用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!

执行权限

如果用普通用户执行开发出来的 ping 程序,系统提示没有权限,这是怎么回事呢?

ping 发送 ICMP 报文,需要创建原始套接字( raw socket )。Linux 创建原始套接字需要 root 权限,这就是 ping 程序报权限错误的原因。那么,如何解决这个问题呢?

我们可以利用 SUID 特性,让普通用户执行 ping 时也能提升到 root 权限。SUIDSet owner User ID up on execution 的缩写,顾名思义就是让程序执行时拥有所有者的权限。

因此,我们可以先将 ping 的所有者改为 root ,并加上 SUID

1
2
3
4
5
# 将ping所有用户改为root,所有组也改为root
chown root:root ping

# SUID
chmod u+s ping

设置完毕后,可以看到文件权限位变成这样:

1
2
$ ls -l ping
-rwsr-xr-x 1 root root 72776 Oct 11 14:15 ping

所有用户的权限位变成 rws ,其中 s 表示 SUID ,执行时自动提升到所有者权限。这样一来,就算普通用户执行 ping ,也是以 root 的权限执行,不会在报错了。

普通用户首先要能执行 ping ,这由所有组或者其他用户的权限位决定。例子中其他用户的权限位是 r-x ,因此任何用户都可以执行这个 ping

SUID 对应的还有 SGID ,顾名思义就是让程序执行时提升到所有组的权限:

1
chmod g+s ping

扩展阅读

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

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