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

众所周知, ping 命令通过 ICMP 协议探测目标 IP 并计算 往返时间 。本文使用 C 语言开发一个 ping 命令,以演示如何通过 套接字**发送接收**ICMP 协议报文。

注解

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

报文封装

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

../_images/476c9d2e44224eaa078f80bdbad440f9.gif

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

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

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

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

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

../_images/31beaa9ddfb5278c7cd98dc4c8624a5b.png

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

表-1 回显报文类型
名称类型”代码“
回显请求80
回显答复00

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

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

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

定义一个结构体用以封装报文:

  1. struct icmp_echo {
  2. // header
  3. uint8_t type;
  4. uint8_t code;
  5. uint16_t checksum;
  6. uint16_t ident;
  7. uint16_t seq;
  8. // data
  9. double sending_ts;
  10. char magic[MAGIC_LEN];
  11. };

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

校验和

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

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

套接字

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

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

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

  1. struct icmp_echo icmp;
  2. struct sockaddr_in peer_addr;
  3. sendto(s, &icmp, sizeof(icmp), 0, peer_addr, sizeof(peer_addr));

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

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

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

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

注解

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

程序实现

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

首先,实现 send_echo_request 函数,用于发送 ICMP回显请求 报文:

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

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

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

  1. int recv_echo_reply(int sock, int ident)
  2. {
  3. // allocate buffer
  4. char buffer[MTU];
  5. struct sockaddr_in peer_addr;
  6. // receive another packet
  7. int addr_len = sizeof(peer_addr);
  8. int bytes = recvfrom(sock, buffer, sizeof(buffer), 0,
  9. (struct sockaddr*)&peer_addr, &addr_len);
  10. if (bytes == -1) {
  11. // normal return when timeout
  12. if (errno == EAGAIN || errno == EWOULDBLOCK) {
  13. return 0;
  14. }
  15. return -1;
  16. }
  17. // find icmp packet in ip packet
  18. struct icmp_echo* icmp = (struct icmp_echo*)(buffer + 20);
  19. // check type
  20. if (icmp->type != 0 || icmp->code != 0) {
  21. return 0;
  22. }
  23. // match identifier
  24. if (ntohs(icmp->ident) != ident) {
  25. return 0;
  26. }
  27. // print info
  28. printf("%s seq=%d %5.2fms\n",
  29. inet_ntoa(peer_addr.sin_addr),
  30. ntohs(icmp->seq),
  31. (get_timestamp() - icmp->sending_ts) * 1000
  32. );
  33. return 0;
  34. }

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. };
  12. // create raw socket for icmp protocol
  13. int sock = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);
  14. if (sock == -1) {
  15. return -1;
  16. }
  17. // set socket timeout option
  18. struct timeval tv;
  19. tv.tv_sec = 0;
  20. tv.tv_usec = RECV_TIMEOUT_USEC;
  21. int ret = setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
  22. if (ret == -1) {
  23. return -1;
  24. }
  25. double next_ts = get_timestamp();
  26. int ident = getpid();
  27. int seq = 1;
  28. for (;;) {
  29. // time to send another packet
  30. if (get_timestamp() >= next_ts) {
  31. // send it
  32. ret = send_echo_request(sock, &addr, ident, seq);
  33. if (ret == -1) {
  34. perror("Send failed");
  35. }
  36. // update next sendint timestamp to one second later
  37. next_ts += 1;
  38. // increase sequence number
  39. seq += 1;
  40. }
  41. // try to receive and print reply
  42. ret = recv_echo_reply(sock, ident);
  43. if (ret == -1) {
  44. perror("Receive failed");
  45. }
  46. }
  47. return 0;
  48. }

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!

程序源码

  1. /**
  2. * FileName: ping.c
  3. * Author: Fasion Chan
  4. * @contact: fasionchan@gmail.com
  5. * @version: $Id$
  6. *
  7. * Description:
  8. *
  9. * Changelog:
  10. *
  11. **/
  12. #include <arpa/inet.h>
  13. #include <errno.h>
  14. #include <stdint.h>
  15. #include <stdio.h>
  16. #include <string.h>
  17. #include <sys/time.h>
  18. #include <unistd.h>
  19. #define MAGIC "1234567890"
  20. #define MAGIC_LEN 11
  21. #define MTU 1500
  22. #define RECV_TIMEOUT_USEC 100000
  23. struct icmp_echo {
  24. // header
  25. uint8_t type;
  26. uint8_t code;
  27. uint16_t checksum;
  28. uint16_t ident;
  29. uint16_t seq;
  30. // data
  31. double sending_ts;
  32. char magic[MAGIC_LEN];
  33. };
  34. double get_timestamp()
  35. {
  36. struct timeval tv;
  37. gettimeofday(&tv, NULL);
  38. return tv.tv_sec + ((double)tv.tv_usec) / 1000000;
  39. }
  40. uint16_t calculate_checksum(unsigned char* buffer, int bytes)
  41. {
  42. uint32_t checksum = 0;
  43. unsigned char* end = buffer + bytes;
  44. // odd bytes add last byte and reset end
  45. if (bytes % 2 == 1) {
  46. end = buffer + bytes - 1;
  47. checksum += (*end) << 8;
  48. }
  49. // add words of two bytes, one by one
  50. while (buffer < end) {
  51. checksum += buffer[0] << 8;
  52. checksum += buffer[1];
  53. buffer += 2;
  54. }
  55. // add carry if any
  56. uint32_t carray = checksum >> 16;
  57. while (carray) {
  58. checksum = (checksum & 0xffff) + carray;
  59. carray = checksum >> 16;
  60. }
  61. // negate it
  62. checksum = ~checksum;
  63. return checksum & 0xffff;
  64. }
  65. int send_echo_request(int sock, struct sockaddr_in* addr, int ident, int seq)
  66. {
  67. // allocate memory for icmp packet
  68. struct icmp_echo icmp;
  69. bzero(&icmp, sizeof(icmp));
  70. // fill header files
  71. icmp.type = 8;
  72. icmp.code = 0;
  73. icmp.ident = htons(ident);
  74. icmp.seq = htons(seq);
  75. // fill magic string
  76. strncpy(icmp.magic, MAGIC, MAGIC_LEN);
  77. // fill sending timestamp
  78. icmp.sending_ts = get_timestamp();
  79. // calculate and fill checksum
  80. icmp.checksum = htons(
  81. calculate_checksum((unsigned char*)&icmp, sizeof(icmp))
  82. );
  83. // send it
  84. int bytes = sendto(sock, &icmp, sizeof(icmp), 0,
  85. (struct sockaddr*)addr, sizeof(*addr));
  86. if (bytes == -1) {
  87. return -1;
  88. }
  89. return 0;
  90. }
  91. int recv_echo_reply(int sock, int ident)
  92. {
  93. // allocate buffer
  94. char buffer[MTU];
  95. struct sockaddr_in peer_addr;
  96. // receive another packet
  97. int addr_len = sizeof(peer_addr);
  98. int bytes = recvfrom(sock, buffer, sizeof(buffer), 0,
  99. (struct sockaddr*)&peer_addr, &addr_len);
  100. if (bytes == -1) {
  101. // normal return when timeout
  102. if (errno == EAGAIN || errno == EWOULDBLOCK) {
  103. return 0;
  104. }
  105. return -1;
  106. }
  107. // find icmp packet in ip packet
  108. struct icmp_echo* icmp = (struct icmp_echo*)(buffer + 20);
  109. // check type
  110. if (icmp->type != 0 || icmp->code != 0) {
  111. return 0;
  112. }
  113. // match identifier
  114. if (ntohs(icmp->ident) != ident) {
  115. return 0;
  116. }
  117. // print info
  118. printf("%s seq=%d %5.2fms\n",
  119. inet_ntoa(peer_addr.sin_addr),
  120. ntohs(icmp->seq),
  121. (get_timestamp() - icmp->sending_ts) * 1000
  122. );
  123. return 0;
  124. }
  125. int ping(const char *ip)
  126. {
  127. // for store destination address
  128. struct sockaddr_in addr;
  129. bzero(&addr, sizeof(addr));
  130. // fill address, set port to 0
  131. addr.sin_family = AF_INET;
  132. addr.sin_port = 0;
  133. if (inet_aton(ip, (struct in_addr*)&addr.sin_addr.s_addr) == 0) {
  134. return -1;
  135. };
  136. // create raw socket for icmp protocol
  137. int sock = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);
  138. if (sock == -1) {
  139. return -1;
  140. }
  141. // set socket timeout option
  142. struct timeval tv;
  143. tv.tv_sec = 0;
  144. tv.tv_usec = RECV_TIMEOUT_USEC;
  145. int ret = setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
  146. if (ret == -1) {
  147. return -1;
  148. }
  149. double next_ts = get_timestamp();
  150. int ident = getpid();
  151. int seq = 1;
  152. for (;;) {
  153. // time to send another packet
  154. if (get_timestamp() >= next_ts) {
  155. // send it
  156. ret = send_echo_request(sock, &addr, ident, seq);
  157. if (ret == -1) {
  158. perror("Send failed");
  159. }
  160. // update next sendint timestamp to one second later
  161. next_ts += 1;
  162. // increase sequence number
  163. seq += 1;
  164. }
  165. // try to receive and print reply
  166. ret = recv_echo_reply(sock, ident);
  167. if (ret == -1) {
  168. perror("Receive failed");
  169. }
  170. }
  171. return 0;
  172. }
  173. int main(int argc, const char* argv[])
  174. {
  175. return ping(argv[1]);
  176. }

下一步

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

订阅更新,获取更多学习资料,请关注我们的 微信公众号

../_images/wechat-mp-qrcode.png小菜学编程

参考文献