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.png

ping命令原理

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

../_images/31beaa9ddfb5278c7cd98dc4c8624a5b.png

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

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

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

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

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

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
struct icmp_echo {
    // header
    uint8_t type;
    uint8_t code;
    uint16_t checksum;

    uint16_t ident;
    uint16_t seq;

    // data
    double sending_ts;
    char magic[MAGIC_LEN];
};

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

校验和

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

  1. 0 为校验和封装一个用于计算的 伪报文
  2. 将报文分成两个字节一组,如果总字节数为奇数,则在末尾追加一个零字节;
  3. 对所有 双字节 进行按位求和;
  4. 将高于 16 位的进位取出相加,直到没有进位;
  5. 将校验和按位取反;

示例代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
uint16_t calculate_checksum(unsigned char* buffer, int bytes)
{
    uint32_t checksum = 0;
    unsigned char* end = buffer + bytes;

    // odd bytes add last byte and reset end
    if (bytes % 2 == 1) {
        end = buffer + bytes - 1;
        checksum += (*end) << 8;
    }

    // add words of two bytes, one by one
    while (buffer < end) {
        checksum += buffer[0] << 8;
        checksum += buffer[1];
        buffer += 2;
    }

    // add carry if any
    uint32_t carray = checksum >> 16;
    while (carray) {
        checksum = (checksum & 0xffff) + carray;
        carray = checksum >> 16;
    }

    // negate it
    checksum = ~checksum;

    return checksum & 0xffff;
}

套接字

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

#include <arpa/inet.h>

int s = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);

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

struct icmp_echo icmp;
struct sockaddr_in peer_addr;

sendto(s, &icmp, sizeof(icmp), 0, peer_addr, sizeof(peer_addr));

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

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

#define MTU 1500

char buffer[MTU];
struct sockaddr_in peer_addr;
int addr_len = sizeof(peer_addr);

recvfrom(s, buffer, MTU, 0, &peer_addr, &addr_len);

struct icmp_echo *icmp = buffer + 20;

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

注解

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

程序实现

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

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
int send_echo_request(int sock, struct sockaddr_in* addr, int ident, int seq)
{
    // allocate memory for icmp packet
    struct icmp_echo icmp;
    bzero(&icmp, sizeof(icmp));

    // fill header files
    icmp.type = 8;
    icmp.code = 0;
    icmp.ident = htons(ident);
    icmp.seq = htons(seq);

    // fill magic string
    strncpy(icmp.magic, MAGIC, MAGIC_LEN);

    // fill sending timestamp
    icmp.sending_ts = get_timestamp();

    // calculate and fill checksum
    icmp.checksum = htons(
        calculate_checksum((unsigned char*)&icmp, sizeof(icmp))
    );

    // send it
    int bytes = sendto(sock, &icmp, sizeof(icmp), 0,
        (struct sockaddr*)addr, sizeof(*addr));
    if (bytes == -1) {
        return -1;
    }

    return 0;
}

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

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
int recv_echo_reply(int sock, int ident)
{
    // allocate buffer
    char buffer[MTU];
    struct sockaddr_in peer_addr;

    // receive another packet
    int addr_len = sizeof(peer_addr);
    int bytes = recvfrom(sock, buffer, sizeof(buffer), 0,
        (struct sockaddr*)&peer_addr, &addr_len);
    if (bytes == -1) {
        // normal return when timeout
        if (errno == EAGAIN || errno == EWOULDBLOCK) {
            return 0;
        }

        return -1;
    }

    // find icmp packet in ip packet
    struct icmp_echo* icmp = (struct icmp_echo*)(buffer + 20);

    // check type
    if (icmp->type != 0 || icmp->code != 0) {
        return 0;
    }

    // match identifier
    if (ntohs(icmp->ident) != ident) {
        return 0;
    }

    // print info
    printf("%s seq=%d %5.2fms\n",
        inet_ntoa(peer_addr.sin_addr),
        ntohs(icmp->seq),
        (get_timestamp() - icmp->sending_ts) * 1000
    );

    return 0;
}

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

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
int ping(const char *ip)
{
    // for store destination address
    struct sockaddr_in addr;
    bzero(&addr, sizeof(addr));

    // fill address, set port to 0
    addr.sin_family = AF_INET;
    addr.sin_port = 0;
    if (inet_aton(ip, (struct in_addr*)&addr.sin_addr.s_addr) == 0) {
        return -1;
    };

    // create raw socket for icmp protocol
    int sock = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);
    if (sock == -1) {
        return -1;
    }

    // set socket timeout option
    struct timeval tv;
    tv.tv_sec = 0;
    tv.tv_usec = RECV_TIMEOUT_USEC;
    int ret = setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
    if (ret == -1) {
        return -1;
    }

    double next_ts = get_timestamp();
    int ident = getpid();
    int seq = 1;

    for (;;) {
        // time to send another packet
        if (get_timestamp() >= next_ts) {
            // send it
            ret = send_echo_request(sock, &addr, ident, seq);
            if (ret == -1) {
                perror("Send failed");
            }

            // update next sendint timestamp to one second later
            next_ts += 1;
            // increase sequence number
            seq += 1;
        }

        // try to receive and print reply
        ret = recv_echo_reply(sock, ident);
        if (ret == -1) {
            perror("Receive failed");
        }
    }

    return 0;
}

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

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

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

It works!

程序源码

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
/**
 * FileName:   ping.c
 * Author:     Fasion Chan
 * @contact:   fasionchan@gmail.com
 * @version:   $Id$
 *
 * Description:
 *
 * Changelog:
 *
 **/

#include <arpa/inet.h>
#include <errno.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <sys/time.h>
#include <unistd.h>

#define MAGIC "1234567890"
#define MAGIC_LEN 11
#define MTU 1500
#define RECV_TIMEOUT_USEC 100000

struct icmp_echo {
    // header
    uint8_t type;
    uint8_t code;
    uint16_t checksum;

    uint16_t ident;
    uint16_t seq;

    // data
    double sending_ts;
    char magic[MAGIC_LEN];
};

double get_timestamp()
{
    struct timeval tv;
    gettimeofday(&tv, NULL);
    return tv.tv_sec + ((double)tv.tv_usec) / 1000000;
}

uint16_t calculate_checksum(unsigned char* buffer, int bytes)
{
    uint32_t checksum = 0;
    unsigned char* end = buffer + bytes;

    // odd bytes add last byte and reset end
    if (bytes % 2 == 1) {
        end = buffer + bytes - 1;
        checksum += (*end) << 8;
    }

    // add words of two bytes, one by one
    while (buffer < end) {
        checksum += buffer[0] << 8;
        checksum += buffer[1];
        buffer += 2;
    }

    // add carry if any
    uint32_t carray = checksum >> 16;
    while (carray) {
        checksum = (checksum & 0xffff) + carray;
        carray = checksum >> 16;
    }

    // negate it
    checksum = ~checksum;

    return checksum & 0xffff;
}

int send_echo_request(int sock, struct sockaddr_in* addr, int ident, int seq)
{
    // allocate memory for icmp packet
    struct icmp_echo icmp;
    bzero(&icmp, sizeof(icmp));

    // fill header files
    icmp.type = 8;
    icmp.code = 0;
    icmp.ident = htons(ident);
    icmp.seq = htons(seq);

    // fill magic string
    strncpy(icmp.magic, MAGIC, MAGIC_LEN);

    // fill sending timestamp
    icmp.sending_ts = get_timestamp();

    // calculate and fill checksum
    icmp.checksum = htons(
        calculate_checksum((unsigned char*)&icmp, sizeof(icmp))
    );

    // send it
    int bytes = sendto(sock, &icmp, sizeof(icmp), 0,
        (struct sockaddr*)addr, sizeof(*addr));
    if (bytes == -1) {
        return -1;
    }

    return 0;
}

int recv_echo_reply(int sock, int ident)
{
    // allocate buffer
    char buffer[MTU];
    struct sockaddr_in peer_addr;

    // receive another packet
    int addr_len = sizeof(peer_addr);
    int bytes = recvfrom(sock, buffer, sizeof(buffer), 0,
        (struct sockaddr*)&peer_addr, &addr_len);
    if (bytes == -1) {
        // normal return when timeout
        if (errno == EAGAIN || errno == EWOULDBLOCK) {
            return 0;
        }

        return -1;
    }

    // find icmp packet in ip packet
    struct icmp_echo* icmp = (struct icmp_echo*)(buffer + 20);

    // check type
    if (icmp->type != 0 || icmp->code != 0) {
        return 0;
    }

    // match identifier
    if (ntohs(icmp->ident) != ident) {
        return 0;
    }

    // print info
    printf("%s seq=%d %5.2fms\n",
        inet_ntoa(peer_addr.sin_addr),
        ntohs(icmp->seq),
        (get_timestamp() - icmp->sending_ts) * 1000
    );

    return 0;
}

int ping(const char *ip)
{
    // for store destination address
    struct sockaddr_in addr;
    bzero(&addr, sizeof(addr));

    // fill address, set port to 0
    addr.sin_family = AF_INET;
    addr.sin_port = 0;
    if (inet_aton(ip, (struct in_addr*)&addr.sin_addr.s_addr) == 0) {
        return -1;
    };

    // create raw socket for icmp protocol
    int sock = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);
    if (sock == -1) {
        return -1;
    }

    // set socket timeout option
    struct timeval tv;
    tv.tv_sec = 0;
    tv.tv_usec = RECV_TIMEOUT_USEC;
    int ret = setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
    if (ret == -1) {
        return -1;
    }

    double next_ts = get_timestamp();
    int ident = getpid();
    int seq = 1;

    for (;;) {
        // time to send another packet
        if (get_timestamp() >= next_ts) {
            // send it
            ret = send_echo_request(sock, &addr, ident, seq);
            if (ret == -1) {
                perror("Send failed");
            }

            // update next sendint timestamp to one second later
            next_ts += 1;
            // increase sequence number
            seq += 1;
        }

        // try to receive and print reply
        ret = recv_echo_reply(sock, ident);
        if (ret == -1) {
            perror("Receive failed");
        }
    }

    return 0;
}

int main(int argc, const char* argv[])
{
    return ping(argv[1]);
}

下一步

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

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

../_images/wechat-mp-qrcode.png

小菜学编程