从零实现Ping程序:C语言ICMP协议与原始套接字实战
发布时间:2026/9/28 1:52:57来源:尧图网络
简介这是一份面向网络编程初学者与系统开发者的C语言实战资料聚焦用原始套接字实现Ping命令的核心功能帮助读者理解ICMP协议与底层网络通信机制。资源包共2个文件包含1个cpp源码与1个txt说明文档压缩包约2KB体量轻巧便于快速阅读与本地编译调试。内容围绕ICMP回显请求与应答展开涉及原始套接字创建、ICMP头部填充、数据包构建与收发、校验和解析、往返时间计算及错误处理等关键环节并配有文字说明辅助理解代码逻辑。目前已有414人学习浏览适合希望从套接字层面掌握网络诊断工具原理、提升故障排查与系统编程能力的学习者参考实践。1. 从零写一个 Ping为什么推荐用 C 语言啃下 ICMP 这块硬骨头很多人第一次接触网络编程都是从「怎么用 C 语言实现一个 Ping 程序」这个念头开始的。你打开终端敲下ping www.baidu.com屏幕上跳出一行行64 bytes from ...看起来平平无奇但背后其实是一整套原始套接字、ICMP 协议、校验和计算、超时重传的完整链路。用 C 语言亲手实现一遍你会被迫直面这些平时被库函数封装掉的细节这比看十篇协议讲解都管用。这篇文章面向的是想真正搞懂 Ping 底层逻辑的 C 语言学习者和运维开发人员我会从套接字选型一路讲到校验和、超时、统计输出把每个参数为什么这么设、哪里容易翻车都摊开说清楚。读完你至少能得到一个能在 Linux 和 Windows 上跑通的最小可用版本并且知道它和系统自带ping命令的差距在哪。2. 动手之前ICMP 报文结构和套接字选型2.1 ICMP 报文到底长什么样Ping 的本质是往目标主机发一个 ICMP Echo Request对方回一个 ICMP Echo Reply。ICMP 报文被包在 IP 包里所以你在应用层要自己拼出 ICMP 头。ICMP 头固定 8 字节结构如下字段长度说明Type1 字节请求为 8应答为 0Code1 字节Echo 类型下恒为 0Checksum2 字节覆盖整个 ICMP 报文头数据Identifier2 字节用来匹配请求和应答通常填进程 PIDSequence Number2 字节每发一个包递增Data可变实际负载常见填时间戳Identifier 这个字段是很多人忽略的坑。如果你同时开了两个 Ping 程序不区分 Identifier 就会把别人的应答当成自己的。常见做法是填getpid() 0xFFFF保证同一台机器上不同进程的标识不同。2.2 为什么必须用原始套接字普通 TCP/UDP 套接字由内核帮你封装传输层头但 ICMP 不属于传输层内核不提供「ICMP 套接字」这种抽象。你要拿到 ICMP 层的控制权只能用SOCK_RAW。在 Linux 上创建原始套接字需要 root 权限或者给可执行文件加CAP_NET_RAW能力# 编译后赋予原始套接字能力避免每次 sudo sudo setcap cap_net_rawep ./mypingWindows 上则不同SOCK_RAW需要管理员权限运行而且从 Vista 之后微软限制了原始套接字的发送能力但 ICMP 是例外仍然可以正常收发。这就是为什么同一份代码在 Linux 和 Windows 上行为会有差异。2.3 最小可运行的套接字创建代码#include stdio.h #include stdlib.h #include string.h #include unistd.h #include sys/socket.h #include netinet/ip_icmp.h #include netinet/in.h #include arpa/inet.h #include netdb.h int create_icmp_socket(void) { // AF_INET 表示 IPv4SOCK_RAW 拿到原始套接字 // IPPROTO_ICMP 告诉内核我们只关心 ICMP 协议 int sockfd socket(AF_INET, SOCK_RAW, IPPROTO_ICMP); if (sockfd 0) { perror(socket creation failed); exit(EXIT_FAILURE); } return sockfd; }这段代码做了三件事创建 IPv4 原始套接字、指定协议为 ICMP、失败时打印错误并退出。perror会输出类似socket creation failed: Operation not permitted看到这个基本就是权限不够。参数上AF_INET不能换成AF_INET6因为 ICMPv6 的协议号和报文结构都不同混用会直接报错。3. 把包发出去校验和、发包与收包循环3.1 校验和算法为什么不能照抄网上的ICMP 校验和是 16 位反码求和算法本身不复杂但网上的实现有一半是错的——它们忘了处理奇数字节的情况或者在大端小端上翻车。正确做法是把数据按 16 位为单位累加溢出位回卷最后取反unsigned short checksum(void *buf, int len) { unsigned short *ptr buf; unsigned int sum 0; unsigned short result; // 按 16 位累加注意 len 是字节数 while (len 1) { sum *ptr; len - 2; } // 处理奇数字节把最后一个字节当高字节 if (len 1) { sum *(unsigned char *)ptr; } // 将溢出的高 16 位回卷到低 16 位 sum (sum 16) (sum 0xFFFF); sum (sum 16); result ~sum; return result; }关键点在sum (sum 16) (sum 0xFFFF)这一行。累加过程中可能产生超过 16 位的进位必须回卷。很多实现只回卷一次理论上两次回卷才严谨但实际数据量小的时候一次也够用。len 1的分支处理奇数字节漏掉这个分支在发送奇数长度数据时校验和会错对方直接丢包你只会看到超时排查起来非常痛苦。3.2 构造并发送 Echo Request#define PACKET_SIZE 64 #define DATA_SIZE (PACKET_SIZE - sizeof(struct icmphdr)) int send_echo_request(int sockfd, struct sockaddr_in *dest, int seq) { char packet[PACKET_SIZE]; memset(packet, 0, sizeof(packet)); struct icmphdr *icmp (struct icmphdr *)packet; icmp-type ICMP_ECHO; // 8请求 icmp-code 0; icmp-un.echo.id htons(getpid() 0xFFFF); icmp-un.echo.sequence htons(seq); // 填充数据区写入时间戳用于计算 RTT struct timeval *tv (struct timeval *)(packet sizeof(struct icmphdr)); gettimeofday(tv, NULL); icmp-checksum 0; icmp-checksum checksum(packet, PACKET_SIZE); int sent sendto(sockfd, packet, PACKET_SIZE, 0, (struct sockaddr *)dest, sizeof(*dest)); return sent; }这里有几个参数值得说。PACKET_SIZE设 64 是常见默认值和系统 ping 一致实际数据区就是 56 字节。htons把主机字节序转成网络字节序Identifier 和 Sequence 都必须转否则跨平台通信时对方解析出来的序号是乱的。时间戳写在数据区开头收到应答后直接读出来算 RTT比额外维护一张发送时间表简单得多。3.3 收包循环与超时控制发出去只是第一步收包才是真正考验代码健壮性的地方。原始套接字会收到本机所有 ICMP 包包括别人发的所以必须过滤int recv_echo_reply(int sockfd, int seq, struct timeval *sent_tv) { char buf[1024]; struct sockaddr_in from; socklen_t fromlen sizeof(from); while (1) { int n recvfrom(sockfd, buf, sizeof(buf), 0, (struct sockaddr *)from, fromlen); if (n 0) return -1; struct iphdr *ip (struct iphdr *)buf; int ip_hlen ip-ihl * 4; // IP 头长度以 4 字节为单位 struct icmphdr *icmp (struct icmphdr *)(buf ip_hlen); // 只处理 Echo Reply且 Identifier 和 Sequence 都要匹配 if (icmp-type ! ICMP_ECHOREPLY) continue; if (ntohs(icmp-un.echo.id) ! (getpid() 0xFFFF)) continue; if (ntohs(icmp-un.echo.sequence) ! seq) continue; struct timeval *tv (struct timeval *)(buf ip_hlen sizeof(struct icmphdr)); struct timeval now; gettimeofday(now, NULL); long rtt (now.tv_sec - tv-tv_sec) * 1000 (now.tv_usec - tv-tv_usec) / 1000; printf(64 bytes from %s: seq%d rtt%ld ms\n, inet_ntoa(from.sin_addr), seq, rtt); return 0; } }ip-ihl * 4这一步不能省。IP 头长度是可变的标准是 20 字节但带选项时会更长。直接写死 20 在某些网络环境下会解析错位收到的数据全是乱码。过滤条件里 Identifier 和 Sequence 双重校验能挡掉绝大多数无关包。超时控制靠setsockopt设置SO_RCVTIMEO或者在循环里用select监听可读事件前者简单后者灵活我一般先用SO_RCVTIMEO跑通再考虑优化。4. 避坑与排查那些让你怀疑人生的瞬间4.1 现象程序编译通过但一直超时没有任何输出原因通常是权限不足导致sendto静默失败或者目标地址解析错误。先确认是不是用 root 跑的再看inet_addr或getaddrinfo返回的地址对不对。有个隐蔽情况是防火墙直接丢弃了出站 ICMP这时候你发多少包都是石沉大海用tcpdump icmp抓一下就知道包到底有没有出去。4.2 现象收到大量dup!重复应答这是网络里存在环路或重复路由的典型信号。你的代码层面表现为同一个 Sequence 收到多次 Reply。处理方式是在应用层记录已处理的 Sequence重复的直接丢弃。但更重要的是这本身是网络故障的征兆生产环境出现大量 dup 要立刻查交换机的生成树和路由表。4.3 现象RTT 显示为负数或极大值时间戳字段被覆盖了。检查数据区偏移量计算是否正确sizeof(struct icmphdr)在有些平台上因为对齐会变成 16 而不是 8。稳妥做法是用offsetof或者手动定义 8 字节偏移别直接依赖sizeof。4.4 现象Windows 上能编译但收不到包Windows 的原始套接字收到的数据包含 IP 头Linux 也包含但 Windows 在某些版本上对recvfrom的缓冲区管理更严格。另外 Windows 要求用WSAStartup初始化 Winsock忘了这一步socket直接返回INVALID_SOCKET。跨平台代码建议用宏区分#ifdef _WIN32。4.5 现象ping 域名通ping IP 不通这听起来反直觉但确实会发生。域名解析走的是 DNS可能走了不同的网络路径。如果 IP 不通而域名通说明 DNS 返回的地址和你手动填的不是同一个或者中间有 NAT 做了地址转换。用nslookup确认解析结果再对比路由表。5. 进阶技巧让自研 Ping 更接近生产可用5.1 用 select 实现精确超时和并发SO_RCVTIMEO只能设一个固定超时没法在等待期间做别的事。用select可以同时监听套接字和定时器fd_set readfds; struct timeval timeout {1, 0}; // 1 秒超时 FD_ZERO(readfds); FD_SET(sockfd, readfds); int ret select(sockfd 1, readfds, NULL, NULL, timeout); if (ret 0) { printf(Request timeout for seq%d\n, seq); } else if (ret 0 FD_ISSET(sockfd, readfds)) { recv_echo_reply(sockfd, seq, sent_tv); }select的第一个参数是最大文件描述符加一这个加一经常被忘掉忘了在 Linux 上可能碰巧能跑在 Windows 上直接失败。超时结构体每次调用后会被内核修改循环里必须重新赋值这是个经典陷阱。5.2 统计输出与信号处理一个完整的 Ping 程序要在 CtrlC 时输出统计信息。注册SIGINT处理函数在里面计算丢包率和 RTT 的 min/avg/maxvolatile int running 1; int sent_count 0, recv_count 0; long rtt_min 999999, rtt_max 0, rtt_sum 0; void handle_sigint(int sig) { running 0; } // 主循环结束后 printf(\n--- ping statistics ---\n); printf(%d packets transmitted, %d received, %d%% packet loss\n, sent_count, recv_count, (sent_count - recv_count) * 100 / sent_count); if (recv_count 0) { printf(rtt min/avg/max %ld/%ld/%ld ms\n, rtt_min, rtt_sum / recv_count, rtt_max); }信号处理函数里只能做异步安全操作设置一个volatile标志位是最稳妥的做法别在里面调printf。统计逻辑放在主循环退出后执行这样不会因为信号打断导致数据不一致。5.3 和系统 ping 的差距在哪自己写的版本和系统ping比主要差在IPv6 支持、自适应发包间隔、路径 MTU 发现、时间戳选项、以及各种边界情况的处理。但作为学习项目把 IPv4 这条链路走通已经覆盖了 80% 的核心知识点。我自己的习惯是先用自研版本抓包对照tcpdump的输出确认每个字段都对得上再逐步加功能。这个过程里踩过的坑比直接调库学到的多得多。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网