ICMP协议编程实践:实现ping命令(C语言)

    注解

    程序源码 可在本文末尾复制,或者在 Github 上下载: 。

    ICMP 报文承载在 IP 报文之上,头部结构非常简单:

    注意到, ICMP 头部只有三个固定字段,其余部分因消息类型而异。固定字段如下:

    • type消息类型
    • code代码
    • checksum校验和

    ICMP 报文有很多不同的类型,由 typecode 字段区分。而 命令使用其中两种:

    ../_images/c633276d3679c45943a4f2d7c2b55e05.pngping命令原理

    如上图,机器 A 通过 回显请求 ( Echo Request ) 询问机器 B ;机器 B 收到报文后通过 回显答复 ( Echo Reply ) 响应机器 A 。这两种报文的典型结构如下:

    对应的 type 以及 code 字段值列举如下:

    按照惯例,回显报文除了固定字段,其余部分组织成 3 个字段:

    • 标识符 ( identifier ),一般填写进程 PID 以区分其他 ping 进程;
    • 报文序号 ( sequence number ),用于编号报文序列;
    • 数据 ( data ),可以是任意数据;

    ICMP 规定, 回显答复 报文原封不动回传这些字段。因此,可以将 发送时间 封装在 数据负载 ( payload )中,收到答复后将其取出,用于计算 往返时间 ( round trip time )。

    3 个字段为 ICMP 公共头部;中间 2 个字段为 回显请求回显答复 惯例头部;其余字段为 数据负载 ,包括一个双精度 发送时间戳 以及一个固定的魔性字符串。

    校验和

    ICMP 报文校验和字段需要自行计算,计算步骤如下:

    • 0 为校验和封装一个用于计算的 伪报文
    • 将报文分成两个字节一组,如果总字节数为奇数,则在末尾追加一个零字节;
    • 对所有 双字节 进行按位求和;
    • 将高于 16 位的进位取出相加,直到没有进位;
    • 将校验和按位取反;示例代码如下:
    1. {
    2. uint32_t checksum = 0;
    3. unsigned char* end = buffer + bytes;
    4. // odd bytes add last byte and reset end
    5. if (bytes % 2 == 1) {
    6. end = buffer + bytes - 1;
    7. checksum += (*end) << 8;
    8. }
    9. // add words of two bytes, one by one
    10. while (buffer < end) {
    11. checksum += buffer[0] << 8;
    12. checksum += buffer[1];
    13. buffer += 2;
    14. }
    15. // add carry if any
    16. uint32_t carray = checksum >> 16;
    17. while (carray) {
    18. checksum = (checksum & 0xffff) + carray;
    19. carray = checksum >> 16;
    20. }
    21. // negate it
    22. checksum = ~checksum;
    23. return checksum & 0xffff;
    24. }

    编程实现网络通讯,离不开 套接字 ( ),收发 ICMP 报文当然也不例外:

    1. #include <arpa/inet.h>
    2. int s = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);

    调用 sendto 系统调用发送 ICMP 报文:

    其中,第一个参数为 套接字 ;第二、三个参数为封装好的 ICMP报文长度 ;第四、五个参数为 目的地址 及地址结构体长度。

    调用 系统调用接收 ICMP 报文:

    1. char buffer[MTU];
    2. struct sockaddr_in peer_addr;
    3. int addr_len = sizeof(peer_addr);
    4. recvfrom(s, buffer, MTU, 0, &peer_addr, &addr_len);
    5. struct icmp_echo *icmp = buffer + 20;

    参数为接收缓冲区大小,这里用 1500 刚好是一个典型的 MTU 大小。注意到, recvfrom 系统调用返回 IP 报文,去掉前 20 字节的 IP 头部便得到 ICMP 报文。

    注解

    注意,创建 原始套接字 ( SOCK_RAW )需要超级用户权限。

    程序实现

    掌握基本原理后,便可着手编写代码了。

    1. int send_echo_request(int sock, struct sockaddr_in* addr, int ident, int seq)
    2. {
    3. // allocate memory for icmp packet
    4. bzero(&icmp, sizeof(icmp));
    5. // fill header files
    6. icmp.type = 8;
    7. icmp.code = 0;
    8. icmp.ident = htons(ident);
    9. icmp.seq = htons(seq);
    10. // fill magic string
    11. strncpy(icmp.magic, MAGIC, MAGIC_LEN);
    12. // fill sending timestamp
    13. icmp.sending_ts = get_timestamp();
    14. // calculate and fill checksum
    15. icmp.checksum = htons(
    16. calculate_checksum((unsigned char*)&icmp, sizeof(icmp))
    17. );
    18. // send it
    19. int bytes = sendto(sock, &icmp, sizeof(icmp), 0,
    20. (struct sockaddr*)addr, sizeof(*addr));
    21. if (bytes == -1) {
    22. return -1;
    23. }
    24. return 0;
    25. }

    3-17 行封装用于计算校验和的 伪报文 ,注意到 类型 字段为 8代码 字段为 0校验和 字段为 0标识符 以及 序号 由参数指定;第 10 行调用 calculate_checksum 函数计算 校验和 ;第 25-26sendto 系统调用将报文发送出去。

    对应地,实现 recv_echo_reply 用于接收 ICMP回显答复 报文:

    3-5 行分配用于接收报文的 缓冲区 ;第 9-10 行调用 recvfrom 系统调用 接收 一个 新报文 ;第 13-15 接收报文 超时 ,正常返回;第 21 行从 IP 报文中取出 ICMP 报文;第 24-26 行检查 ICMP报文类型 ;第 29-31 检查 标识符 是否匹配;第 32-38 行计算 往返时间 并打印提示信息。

    最后,实现 ping 函数,循环发送并接收报文:

    1. int ping(const char *ip)
    2. {
    3. // for store destination address
    4. struct sockaddr_in addr;
    5. bzero(&addr, sizeof(addr));
    6. // fill address, set port to 0
    7. addr.sin_family = AF_INET;
    8. addr.sin_port = 0;
    9. if (inet_aton(ip, (struct in_addr*)&addr.sin_addr.s_addr) == 0) {
    10. return -1;
    11. // create raw socket for icmp protocol
    12. int sock = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);
    13. if (sock == -1) {
    14. }
    15. // set socket timeout option
    16. struct timeval tv;
    17. tv.tv_sec = 0;
    18. tv.tv_usec = RECV_TIMEOUT_USEC;
    19. int ret = setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
    20. if (ret == -1) {
    21. return -1;
    22. }
    23. double next_ts = get_timestamp();
    24. int ident = getpid();
    25. int seq = 1;
    26. for (;;) {
    27. // time to send another packet
    28. if (get_timestamp() >= next_ts) {
    29. // send it
    30. ret = send_echo_request(sock, &addr, ident, seq);
    31. if (ret == -1) {
    32. perror("Send failed");
    33. }
    34. // update next sendint timestamp to one second later
    35. next_ts += 1;
    36. // increase sequence number
    37. seq += 1;
    38. }
    39. // try to receive and print reply
    40. ret = recv_echo_reply(sock, ident);
    41. if (ret == -1) {
    42. perror("Receive failed");
    43. }
    44. }
    45. return 0;
    46. }

    3-12 行,初始化 目的地址 结构体;第 14-18 行,创建用于发送、接收 ICMP 报文的 套接字 ;第 20-27 行,将套接字 接收超时时间 设置为 0.1 秒,以便 等待答复报文 的同时有机会 发送请求报文 ;第 30-31 行,获取进程 PID 作为 标识符 、同时初始化报文 序号 ;接着,循环发送并接收报文;第 35-46 行,当前时间达到发送时间则调用 send_echo_request 函数 发送请求报文 ,更新下次发送时间并自增序号;第 48-52 行,调用 recv_echo_reply 函数 接收答复报文

    将以上所有代码片段组装在一起,便得到 ping.c 命令。迫不及待想运行一下:

    1. $ gcc -o ping ping.c
    2. $ sudo ./ping 8.8.8.8
    3. 8.8.8.8 seq=1 25.70ms
    4. 8.8.8.8 seq=2 25.28ms
    5. 8.8.8.8 seq=3 25.26ms

    It works!

    下一步

    本节以 语言为例,演示了 ICMP 编程方法。如果你对其他语言感兴趣,请按需取用:

    小菜学编程