29、Ping程序实现:使用原始套接字实现ping命令、ICMP协议解析

说实话,Ping 这个命令,咱们搞网络的几乎天天用。但你有没有想过,它背后到底是怎么工作的?

我记得刚入行那会儿,有次线上网络抖动,我用 ping 一通猛测,结果同事问我:“你知道 ping 用的是啥协议吗?” 我当时一愣,支支吾吾说“TCP?UDP?” 他笑了笑:“是 ICMP。” 嗯,从那以后,我就把 ICMP 协议和原始套接字彻底啃了一遍。

今天咱们就手写一个迷你版 ping 命令。说白了,就是用原始套接字(Raw Socket)构造 ICMP 回显请求包,然后解析回显应答包。你想想看,这比调现成的库有意思多了。

ICMP 协议基础

ICMP(Internet Control Message Protocol)是 TCP/IP 协议族中的一员。它不传输用户数据,而是传递控制信息和错误报告。Ping 命令用的就是 ICMP 的 回显请求(Type 8)回显应答(Type 0)

ICMP 报文结构其实很简单,就两部分:头部 + 数据。头部固定 8 字节,结构如下:

字段 长度(字节) 说明
Type 1 类型:8 表示请求,0 表示应答
Code 1 代码:对于回显请求/应答,固定为 0
Checksum 2 校验和:覆盖整个 ICMP 报文
Identifier 2 标识符:通常用进程 ID,用于匹配请求和应答
Sequence Number 2 序列号:每发一个包递增,用于检测丢包

数据部分可以任意填充。标准 ping 通常会填充一些时间戳,用来计算 RTT(往返时间)。

核心要点:ICMP 回显请求和应答的 Type 不同,但 Identifier 和 Sequence Number 必须匹配,才能确认是同一个 ping 会话。

原始套接字(Raw Socket)

普通套接字(比如 SOCK_STREAM 或 SOCK_DGRAM)只能操作传输层以上的数据。但原始套接字允许你直接构造和解析 IP 层及以上的协议数据。说白了,就是给你一把“手术刀”,让你能直接操作网络层的报文。

创建原始套接字的代码很简单:

int sock = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);
if (sock < 0) {
    perror("socket");
    exit(1);
}

注意:创建原始套接字需要 root 权限。我在项目中遇到过,有次部署到普通用户环境,程序直接崩溃,查了半天才发现是权限问题。嗯,这个坑你们一定要记住。

权限警告:原始套接字操作需要超级用户权限。运行程序时请使用 sudo,或者设置 setuid 位。

校验和计算

ICMP 报文必须计算校验和。算法很简单:将报文视为 16 位整数的序列,求和后取反码。我直接贴代码,你们拿去就能用:

unsigned short checksum(void *b, int len) {
    unsigned short *buf = b;
    unsigned int sum = 0;
    unsigned short result;

    for (sum = 0; len > 1; len -= 2)
        sum += *buf++;
    if (len == 1)
        sum += *(unsigned char *)buf;
    sum = (sum >> 16) + (sum & 0xFFFF);
    sum += (sum >> 16);
    result = ~sum;
    return result;
}

我曾经在计算校验和时犯过一个低级错误:忘了把主机字节序转换成网络字节序。结果发出去的包校验和全是错的,对方直接丢弃。排查了整整一下午…… 所以,网络编程里,字节序转换一定要刻在脑子里

构造 ICMP 回显请求包

我们定义一个结构体来表示 ICMP 头部:

struct icmp_header {
    uint8_t type;        // 类型
    uint8_t code;        // 代码
    uint16_t checksum;   // 校验和
    uint16_t id;         // 标识符
    uint16_t sequence;   // 序列号
};

然后填充并发送:

void send_ping(int sock, struct sockaddr_in *addr, int seq) {
    char packet[64];
    struct icmp_header *icmp = (struct icmp_header *)packet;

    icmp->type = 8;           // 回显请求
    icmp->code = 0;
    icmp->id = htons(getpid());
    icmp->sequence = htons(seq);
    icmp->checksum = 0;

    // 填充数据部分(这里放时间戳)
    gettimeofday((struct timeval *)(packet + sizeof(struct icmp_header)), NULL);

    // 计算校验和
    icmp->checksum = checksum(packet, sizeof(packet));

    sendto(sock, packet, sizeof(packet), 0,
           (struct sockaddr *)addr, sizeof(*addr));
}

小技巧:数据部分放时间戳,这样收到应答后可以直接计算 RTT,不需要额外维护状态。

解析 ICMP 回显应答包

接收端需要从原始套接字读取数据。注意:原始套接字返回的数据包含 IP 头部,所以我们需要先跳过 IP 头,再解析 ICMP 部分。

int recv_ping(int sock, struct sockaddr_in *addr, int seq) {
    char buffer[256];
    socklen_t addr_len = sizeof(*addr);
    struct iphdr *ip;
    struct icmp_header *icmp;
    struct timeval *tv_send, tv_recv;
    float rtt;

    int n = recvfrom(sock, buffer, sizeof(buffer), 0,
                     (struct sockaddr *)addr, &addr_len);
    if (n < 0) return -1;

    ip = (struct iphdr *)buffer;
    icmp = (struct icmp_header *)(buffer + (ip->ihl * 4));

    if (icmp->type != 0) return -1;  // 不是回显应答
    if (icmp->id != htons(getpid())) return -1;  // 不是我们的包
    if (icmp->sequence != htons(seq)) return -1; // 序列号不匹配

    // 计算 RTT
    tv_send = (struct timeval *)(buffer + (ip->ihl * 4) + sizeof(struct icmp_header));
    gettimeofday(&tv_recv, NULL);
    rtt = (tv_recv.tv_sec - tv_send->tv_sec) * 1000.0 +
          (tv_recv.tv_usec - tv_send->tv_usec) / 1000.0;

    printf("Reply from %s: seq=%d time=%.2f ms\n",
           inet_ntoa(addr->sin_addr), seq, rtt);
    return 0;
}

这里有个细节:IP 头部的长度是可变的(因为有选项字段),所以必须用 ip->ihl * 4 来获取真正的 IP 头部长度。我见过有人直接写死 20 字节,结果遇到带选项的 IP 包就解析错了。

完整流程与核心逻辑

下面这张图展示了整个 ping 程序的核心流程,从构造请求到解析应答:

Ping 程序核心流程 创建原始套接字 构造 ICMP 请求包 发送请求包 接收并解析应答 计算 RTT 并输出 关键点说明 • 需要 root 权限 • 校验和必须正确 • 注意字节序转换 • 跳过 IP 头部 • 匹配 ID 和序列号 • 数据部分放时间戳 • 超时处理要加 • 建议用 select 实现 • 多线程可并发 ping

完整示例代码

把上面几段代码拼起来,就是一个能用的迷你 ping 了。我建议你动手敲一遍,别光看。代码不长,但涉及的知识点很密集:原始套接字、ICMP 协议、校验和、字节序、时间戳计算…… 每一个都是网络编程的基石。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/ip.h>
#include <netinet/ip_icmp.h>
#include <arpa/inet.h>
#include <sys/time.h>

// 校验和计算(前面已给出)
unsigned short checksum(void *b, int len) {
    unsigned short *buf = b;
    unsigned int sum = 0;
    unsigned short result;
    for (sum = 0; len > 1; len -= 2)
        sum += *buf++;
    if (len == 1)
        sum += *(unsigned char *)buf;
    sum = (sum >> 16) + (sum & 0xFFFF);
    sum += (sum >> 16);
    result = ~sum;
    return result;
}

int main(int argc, char *argv[]) {
    if (argc != 2) {
        fprintf(stderr, "Usage: %s <target_ip>\n", argv[0]);
        exit(1);
    }

    int sock = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);
    if (sock < 0) {
        perror("socket");
        exit(1);
    }

    struct sockaddr_in addr;
    addr.sin_family = AF_INET;
    inet_pton(AF_INET, argv[1], &addr.sin_addr);

    printf("PING %s: 64 bytes of data.\n", argv[1]);

    for (int seq = 0; seq < 4; seq++) {
        send_ping(sock, &addr, seq);
        recv_ping(sock, &addr, seq);
        sleep(1);
    }

    close(sock);
    return 0;
}

重要提醒:上面的代码为了简洁,省略了超时处理和错误恢复。实际生产环境中,一定要用 select()setsockopt() 设置接收超时,否则程序可能会卡死。

避坑指南

  • 权限问题:我曾经在 CI 环境里跑这个程序,结果一直报错。查了半天才发现是 Docker 容器默认没有 raw socket 权限。解决方案:加 --cap-add=NET_RAW
  • 校验和错误:如果对方没回复,90% 是校验和算错了。建议先用 Wireshark 抓包对比一下。
  • 字节序:Identifier 和 Sequence Number 一定要用 htons() 转换。我见过有人直接赋值,结果 ID 对不上,所有应答都被丢弃。
  • IP 头部长度:别写死 20 字节。用 ip->ihl * 4 才是正确做法。

好了,这就是一个迷你 ping 的完整实现。你想想看,从创建原始套接字到构造 ICMP 包,再到解析应答,每一步都清清楚楚。下次再有人问你 ping 的原理,你就可以直接甩代码给他看了。

扩展思考:如果你想让这个 ping 支持域名解析、并发 ping 多个目标、或者输出统计信息(丢包率、平均 RTT),该怎么改?嗯,留给你自己试试。

公众号:蓝海资料掘金营,微信deep3321