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 协议、校验和、字节序、时间戳计算…… 每一个都是网络编程的基石。
#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),该怎么改?嗯,留给你自己试试。