用C语言开发ping命令

但行好事,莫问前程。

—— 《增广贤文》

经过前面学习,我们知道 ping 命令内部通过 ICMP 协议探测目标 IP ,并计算 往返时间 。 本文使用 C 语言开发一个简版 ping 命令, 演示如何通过 套接字 发送和接收 ICMP 协议报文。

报文封装

ICMP 报文同样分为头部和数据,其中头部的结构非常简单:

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

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

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

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

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

名称 类型 代码
回显请求 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. 将报文分成两个字节一组,如果总字节数为奇数,则在末尾追加一个零字节;
  2. 对所有 双字节 进行按位求和;
  3. 将高于 16 位的进位取出相加,直到没有进位;
  4. 将校验和按位取反;

请注意,开始计算前需将原报文校验和字段设为 0 ,再按步骤计算。示例代码如下:

 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
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) + buffer[1];

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

        buffer += 2;
    }

    // negate it
    checksum = ~checksum;

    return checksum & 0xffff;
}

套接字

编程发起网络通信,离不开套接字,收发 ICMP 报文当然也不例外。我们需要创建一个原始套接字,协议类型为 IPPROTO_ICMP :

1
2
3
#include <arpa/inet.h>

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

套接字准备就绪后,即可调用 sendto 系统调用来发送 ICMP 报文了:

1
2
3
4
struct icmp_echo icmp;
struct sockaddr_in peer_addr;

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

其中,第一个参数为 套接字 ; 第二、三个参数为封装好的 ICMP 报文 和它的 长度 ; 第五、六个参数为用于传递 目的地址 的 sockaddr_in 结构体和它的长度。

类似地,调用 recvfrom 系统调用即可接收 ICMP 报文:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#define BUFFER_SIZE_FOR_IP_PACKET 65536

char buffer[BUFFER_SIZE_FOR_IP_PACKET];

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;

其中,第一个参数为 套接字 ; 第二、三个参数为用于接收报文的缓冲区和它的大小;第五、六个参数为用于保存报文源地址的 sockaddr_in 结构体和它的长度。

由于 recvfrom 系统调用返回 IP 包,而不是直接返回 ICMP 报文,因此缓冲区按 IP 包的最大长度来准备。接到 IP 包后,跳过前面的头部(一般为 20 字节),便得到 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
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;
}
  1. 2-16 行封装 ICMP 报文,其中 类型8代码0校验和0标识符序号 由参数指定;
  2. 18-21 行调用 calculate_checksum 函数计算 校验和 ,开始计算前校验和必须先设为 0
  3. 23-28 行调 sendto 系统调用将报文发送出去;

对应地,实现 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
    unsigned char buffer[IP_BUFFER_SIZE];
    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;
    }

    int ip_header_len = (buffer[0] & 0xf) << 2;
    // find icmp packet in ip packet
    struct icmp_echo* icmp = (struct icmp_echo*)(buffer + ip_header_len);

    // 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=%-5d %8.2fms\n",
        inet_ntoa(peer_addr.sin_addr),
        ntohs(icmp->seq),
        (get_timestamp() - icmp->sending_ts) * 1000
    );

    return 0;
}
  1. 2-4 行分配用于接收报文的 缓冲区
  2. 6-9 行调用 recvfrom 系统调用 接收 一个 新报文
  3. 10-17 行接收报文失败,返回 -1 表示出错(如果接收 超时 ,正常返回);
  4. 19-21 行从 IP 报文中取出 ICMP 报文;
  5. 23-26 行检查 ICMP 报文类型
  6. 28-31 行检查 标识符 是否匹配;
  7. 33-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
57
58
59
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) {
        fprintf(stderr, "bad ip address: %s\n", ip);
        return -1;
    };

    // create raw socket for icmp protocol
    int sock = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);
    if (sock == -1) {
        perror("create raw socket");
        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) {
        perror("set socket option");
        return -1;
    }

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

    for (;;) {
        // time to send another packet
        double current_ts = get_timestamp();
        if (current_ts >= 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 = current_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;
}
  1. 2-12 行,初始化 目的地址 结构体;
  2. 14-19 行,创建用于发送、接收 ICMP 报文的 套接字
  3. 21-29 行,将套接字 接收超时时间 设置为 0.1 秒, 以便 等待应答报文 的同时有机会 发送请求报文
  4. 31-33 行,获取进程 PID 作为 标识符 、同时初始化报文 序号
  5. 35-56 行,循环发送并接收报文;
  6. 36-49 行,当前时间达到发送时间则调用 send_echo_request 函数 发送请求报文 , 更新下次发送时间并自增序号;
  7. 51-55 行,调用 recv_echo_reply 函数 接收应答报文

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

1
2
3
4
5
$ 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!

扩展阅读

小菜学网络】系列文章首发于公众号【小菜学编程】,敬请关注:

【小菜学网络】系列文章首发于公众号【小菜学编程】,敬请关注: