☰
用C语言实现Ping程序:ICMP报文构造与原始套接字解析
2026/10/8 19:04:39 网站建设 项目流程

简介:面向C语言网络编程学习者,一份演示如何用C语言实现Ping程序功能的示例代码包。资源共2个文件,含1个cpp源文件与1个txt说明文档,压缩包整体约2KB,轻量易查阅。代码围绕原始套接字和ICMP协议,完整展示构造回显请求、填充校验和、sendto发送、recvfrom接收、解析ICMP头部以及用gettimeofday计算RTT的流程;txt文档补充ICMP类型8/类型0原理、套接字权限及常见错误处理等背景知识,便于代码与理论印证。已有414人学习下载,适合正在学习Socket编程或想深入网络协议栈的开发者。通过调试这份代码,能掌握原始套接字编程要领,理解ICMP报文头字段与校验和算法,并学会使用时间戳计算往返时延;在此基础上可自行扩展带统计输出、丢包率计算或超时重试功能的网络工具,对排查网络故障和进行底层网络编程实践很有帮助。示例虽小,却完整覆盖了从报文构造到RTT测算的关键环节,适合作为课后实验或面试前速刷的参考。

1. 从一条 ping 命令到 C 语言实现:这事的边界在哪

排查网络问题时,很多人第一反应就是 ping——能通代表链路通、路由通、目标主机活着;不能通就得一层层查网卡、查网关、查防火墙。但如果你只会敲ping baidu.com,对 C 语言开发者来说总有个坎迈不过去:ping 到底是怎么实现的?ICMP 报文长什么样?原始套接字为什么需要管理员权限?与其把 ping 当黑匣子用,不如自己用 C 语言实现 Ping 程序功能,把 ICMP 的构造、发送、接收、校验和计算全部在代码里过一遍。写完之后你再看 wireshark 抓包、再看系统 ping 的统计输出,会非常有底气。这条路径适合已经会 socket 编程、想深入网络协议栈的开发者,也适合面试前想把 ICMP 讲透的人。照着本文做,你能拿到一个可运行的实现,以及一套能复用的排错思路。

2. 用原始套接字构造 ICMP 报文:校验和与字节序的细节

2.1 为什么必须用原始套接字,而不是 UDP/TCP

普通开发者写网络程序最先接触的是 UDP 或 TCP,这两类套接字让内核帮你处理传输层头,你做应用层收发就够了。ping 用的是 ICMP 协议,ICMP 直接承载在网络层之上,没有传输层的端口概念。内核提供的SOCK_STREAM和SOCK_DGRAM都不会让你碰到 ICMP 的 type、code 字段,所以你只能创建原始套接字,把 ICMP 报文自己拼出来。

在 Linux 上创建 ICMP 原始套接字的常见做法是:

#include <stdio.h> #include <string.h> #include <unistd.h> #include <errno.h> #include <sys/socket.h> #include <netinet/in.h> #include <netinet/ip.h> #include <netinet/ip_icmp.h> int main(void) { int sockfd = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP); if (sockfd < 0) { perror("socket"); fprintf(stderr, "errno=%d (%s)\n", errno, strerror(errno)); return 1; } struct timeval tv; tv.tv_sec = 1; tv.tv_usec = 0; setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); printf("socket ok: fd=%d\n", sockfd); close(sockfd); return 0; }

这段代码里socket(AF_INET, SOCK_RAW, IPPROTO_ICMP)是核心。SOCK_RAW表示你收发的是网络层包,第三个参数IPPROTO_ICMP告诉内核这个原始套接字只处理 ICMP 协议。如果 socket 返回 -1,最常见的错误是EPERM,也就是没有权限,原因后面避坑章节会细说。

SO_RCVTIMEO设置的是接收超时。ping 程序必须有超时判断,否则调用recvfrom后如果目标一直不回包,程序会永久阻塞在接收调用上,这在实际使用中是绝对不能接受的。

2.2 ICMP 报文长什么样:Echo Request 和 Echo Reply

ping 只关心两种 ICMP 报文。一种是 Echo Request,type 字段为 8、code 为 0,是主动发出的探测请求;另一种是 Echo Reply,type 字段为 0、code 为 0,是目标对请求的回应。报文的格式在 Linux 头文件<netinet/ip_icmp.h>里已经有结构体定义,但理解每个字段的排列方式非常关键。

报文由这几部分组成:type 占 1 字节,code 占 1 字节,checksum 占 2 字节,identifier 占 2 字节,sequence 占 2 字节,后面跟着用户数据。identifier 和 sequence 都是网络字节序。identifier 一般用进程 ID 的低 16 位,用来区分不同 ping 进程;sequence 用来标记同一个进程发出的第几个包。这样即使同时有多个 ping 程序在跑,收包时也不会混淆。

校验和算法是 RFC 1071 定义的 16 位反码校验和,做一次全量累加再取反。给出一个稳定可用的实现:

static unsigned short in_cksum(unsigned short *addr, int len) { unsigned long sum = 0; while (len > 1) { sum += *addr++; len -= 2; } if (len == 1) { unsigned short last = 0; *(unsigned char *)&last = *(unsigned char *)addr; sum += last; } while (sum >> 16) sum = (sum & 0xFFFF) + (sum >> 16); return (unsigned short)(~sum); }

这个函数的参数是 16 位对齐的缓冲区指针和字节长度。unsigned long累加是为了让中间结果有足够空间容纳多次进位,而不是用一个 16 位变量硬撑,否则进位丢失会让校验和算错。while (sum >> 16)是在做回卷——把进位加到低 16 位上,这是 16 位反码校验和的必要步骤。最后取反得到 checksum。

代码里还有一个细节:当 len 是奇数时,最后一个字节要补 0 再参与累加。网络协议在计算校验和时普遍遵循这个规则,不处理的话某些数据长度下会得到错误结果。这就是后面避坑章节说的“目标不回包”的潜伏原因之一。

2.3 构造 Echo Request 的最小函数

在动手写发送逻辑之前,先写一个构造 Echo Request 的独立函数,方便在 main 里反复调用。我给这个函数加一个 data 长度参数,因为不同的网络测试场景需要不同大小的报文:

#include <netinet/ip_icmp.h> typedef struct { struct icmphdr hdr; char data[1472]; } icmp_pkt_t; int build_echo_request(icmp_pkt_t *pkt, int id, int seq, int data_len) { memset(pkt, 0, sizeof(*pkt)); pkt->hdr.type = ICMP_ECHO; pkt->hdr.code = 0; pkt->hdr.un.echo.id = htons(id); pkt->hdr.un.echo.sequence = htons(seq); memset(pkt->data, 0x41, data_len); pkt->hdr.checksum = 0; int len = sizeof(pkt->hdr) + data_len; pkt->hdr.checksum = in_cksum((unsigned short *)pkt, len); return len; }

ICMP_ECHO是 Linux 头文件里给 Echo Request 定义的常量,值为 8。ICMP_ECHOREPLY是 0。data数组填充 0x41,这是 ASCII 字符A,让包里的用户数据方便在抓包工具里辨认,实际内容随意。htons(id)和htons(seq)把主机字节序转成网络字节序,x86 平台如果不做这一步,wireshark 里看到的 identifier 和 sequence 会是反的,这不算致命错误,但会让排查变难。校验和是在 type、code、id、seq、data 全部填完之后才算,而且必须先把 checksum 字段清 0,这是协议规定的硬性要求。返回的长度是整个 ICMP 报文的字节数,后续sendto要用。

3. 发送与接收循环:超时机制与 id/seq 匹配怎么落地

3.1 发送:sendto 与目标地址填充

发送 ICMP 报文用的是sendto,目标地址要填一个sockaddr_in结构。这里要支持传入 IP 字符串,用inet_pton做转换,它比老旧的inet_addr更安全,能正确处理非法输入:

#include <arpa/inet.h> int send_ping(int sockfd, const char *ip, int id, int seq, int data_len) { struct sockaddr_in dest; memset(&dest, 0, sizeof(dest)); dest.sin_family = AF_INET; if (inet_pton(AF_INET, ip, &dest.sin_addr) != 1) { fprintf(stderr, "invalid IP: %s\n", ip); return -1; } icmp_pkt_t pkt; int len = build_echo_request(&pkt, id, seq, data_len); ssize_t n = sendto(sockfd, &pkt, len, 0, (struct sockaddr *)&dest, sizeof(dest)); if (n < 0) { perror("sendto"); return -1; } return 0; }

inet_pton(AF_INET, ip, &dest.sin_addr)返回 1 表示解析成功,返回 0 表示 IP 格式不对。这里先只支持 IPv4,IPv6 的 ping 需要AF_INET6和不同类型的sockaddr_in6,可以在后续扩展。sendto发送时不需要指定 ICMP 头的 IP 源地址,内核会根据路由表自动填充源 IP,并生成完整的 IP 头。发送成功后整个报文就进入网络栈了,回包要靠后面的recvfrom接收。

3.2 接收:收到的是完整 IP 包,不是 ICMP 报文

原始套接字最容易被误解的地方在这里:你构造的是 ICMP 报文,但recvfrom拿到的缓冲区里是整个 IP 包,也就是从 IP 头开始的所有数据。很多初学者直接把这个缓冲区强转成struct icmphdr,导致解析出来的 type、code 全是错的,看起来像是程序收不到回包,实际上是自己把偏移搞错了。

正确的接收流程是:先解析 IP 头取ihl字段(IP 头长度,单位为 4 字节),计算出 ICMP 头起点,然后从那里开始解析:

#include <sys/time.h> int recv_reply(int sockfd, int id, int seq, double *rtt_ms) { char buf[512]; struct sockaddr_in from; socklen_t fromlen = sizeof(from); struct timeval start, end; gettimeofday(&start, NULL); ssize_t n = recvfrom(sockfd, buf, sizeof(buf), 0, (struct sockaddr *)&from, &fromlen); gettimeofday(&end, NULL); if (n < 0) { if (errno == EAGAIN || errno == EWOULDBLOCK) printf("timeout\n"); else perror("recvfrom"); return -1; } struct iphdr *ip = (struct iphdr *)buf; int ip_hdr_len = ip->ihl * 4; struct icmphdr *icmp = (struct icmphdr *)(buf + ip_hdr_len); if (icmp->type != ICMP_ECHOREPLY) return 0; if (ntohs(icmp->un.echo.id) != id || ntohs(icmp->un.echo.sequence) != seq) return 0; *rtt_ms = (end.tv_sec - start.tv_sec) * 1000.0 + (end.tv_usec - start.tv_usec) / 1000.0; return (int)n; }

gettimeofday在 recvfrom 前后各取一次,差值就是 RTT。注意recvfrom超时由之前SO_RCVTIMEO决定,EAGAIN或EWOULDBLOCK表示超时了——这代表目标没回包,按丢包处理。真正收到响应后再检查 type 是否为 Echo Reply、id 和 seq 是否匹配,这样能过滤掉其他进程的回包和中间路由器发来的错误报文。返回值里的字节数其实可以当报文长度用,如果你后续要做 MTU 分析,这个值很关键。

3.3 把收发组合成最小可运行的循环

发送和接收都准备好后,main 函数要做的事就简单了:创建套接字、设置超时、发一个包、收一个包、循环若干次、最后输出统计。这里把循环次数和超时秒数做成命令行参数,默认发 4 个包,和系统 ping 的默认行为一致:

#include <stdlib.h> int main(int argc, char **argv) { if (argc < 2) { fprintf(stderr, "usage: %s <ip> [count] [timeout_sec]\n", argv[0]); return 1; } int count = 4; int timeout_sec = 1; if (argc >= 3) count = atoi(argv[2]); if (argc >= 4) timeout_sec = atoi(argv[3]); int sockfd = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP); if (sockfd < 0) { perror("socket"); return 1; } struct timeval tv; tv.tv_sec = timeout_sec; tv.tv_usec = 0; setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); int id = getpid() & 0xFFFF; int sent = 0, received = 0; double min_rtt = 1e9, max_rtt = 0, sum_rtt = 0; int data_len = 56; for (int seq = 0; seq < count; seq++) { if (send_ping(sockfd, argv[1], id, seq, data_len) == 0) { sent++; double rtt; if (recv_reply(sockfd, id, seq, &rtt) == 0) { received++; printf("seq=%d rtt=%.2f ms\n", seq, rtt); if (rtt < min_rtt) min_rtt = rtt; if (rtt > max_rtt) max_rtt = rtt; sum_rtt += rtt; } } usleep(200000); } close(sockfd); if (received > 0) { printf("sent=%d received=%d loss=%.0f%% avg=%.2f ms\n", sent, received, (sent - received) * 100.0 / sent, sum_rtt / received); } else { printf("sent=%d received=%d loss=100%%\n", sent, received); } return 0; }

这个循环里有个容易忽略的点:每次recv_reply只读一个包,如果网络栈里已经堆了多个回包(比如上一次超时后回包延迟到达),下一轮循环直接读走的是旧回包,seq 对不上就会被过滤掉。这就是为什么id/seq匹配必须在recv_reply里做,而不是收到什么打印什么。usleep(200000)是发送间隔 200ms,让程序节奏不至于太快;在公网环境建议间隔 1 秒,避免被目标网络的限速策略惩罚。

4. 调整超时、包大小与域名解析:把参数贴近真实网络

4.1 超时阈值和循环次数怎么定

ping 的“超时时间”是最考验经验的参数。在局域网内,RTT 通常在 1 到 5 毫秒;跨运营商公网链路,RTT 可能是 30 到 80 毫秒;国际链路 150 到 300 毫秒也很常见。如果把超时固定设成 100 毫秒,去 ping 一个欧洲服务器,大部分包都会误报丢包。常见做法是默认给 1 秒,这个值能覆盖绝大多数场景。如果目标确实很慢,可以通过命令行参数传入 2 或 3 秒。

循环次数决定了测试的耗时。默认 4 次在公网测试时只有 4 个样本,统计意义不大,我一般做连通性测试会发 20 个包,算出来的丢包率和平均 RTT 才有参考价值。这里顺带说一个运维里常见的坑:系统自带的 ping 命令在部分 Linux 发行版上默认是无限循环,需要-c参数限制次数,自己做工具时一定要把 count 做成可配置的,否则自动化脚本里一个 ping 能把 CI 任务挂死。

4.2 包大小对网络质量测试的影响

很多人写 ping 程序只复制 56 字节 data,这个大小测连通性没问题,但测网络质量不够。普通业务流量包通常在 100 到 1400 字节之间,如果你用 56 字节的小包测出来延迟正常,换成大包后可能延迟飙升甚至丢包,这在弱网环境下非常典型。把build_echo_request的data_len参数暴露出来,测试时就能灵活切换。

测试场景data 长度总报文长度说明
连通性探测5684与系统 ping 默认一致
常见业务模拟10001028贴近多数应用层包
MTU 边界测试14721500以太网 MTU 上限,超过则需要分片

如果 data_len 设为 1472,整个 IP 包正好 1500 字节,这是标准以太网 MTU 的极限。再大的话内核或中间设备会做分片,ping 的结果就不好解释了。做 MTU 测试时建议配合抓包工具看有没有 “Fragmentation needed” 类型的 ICMP 错误报文——这类错误包的 type 是 3、code 是 4,正常情况下不会被 Echo Reply 分支处理,你需要单独写一个分支来解析它。我在实际测试中见过不少路由器在收到超过 MTU 且不允许分片的包时,回的是 type 3 code 4,不是 Echo Reply,初学者往往以为是超时丢包,其实是把控制报文和探测回包搞混了。

4.3 域名解析:让程序别再报 “temporary failure in name resolution”

热搜词里那个ping: baidu.com: temporary failure in name resolution是典型的使用场景。这个错误往往不是 ping 程序本身的问题,而是运行环境的 DNS 配置有问题。但也不排除另一种情况:你的 C 语言 ping 程序只接收 IP 字符串,用户输入baidu.com后程序直接报 invalid IP。这是接口设计缺陷。常见做法是在 IP 解析失败后用getaddrinfo做域名兜底:

#include <netdb.h> int resolve_addr(const char *host, struct sockaddr_in *out) { struct addrinfo hints, *res = NULL; memset(&hints, 0, sizeof(hints)); hints.ai_family = AF_INET; hints.ai_socktype = SOCK_DGRAM; if (getaddrinfo(host, NULL, &hints, &res) != 0) return -1; memcpy(out, res->ai_addr, sizeof(*out)); freeaddrinfo(res); return 0; }

然后在send_ping里把原来的inet_pton失败分支改成调用resolve_addr。这样程序既能处理220.181.38.148这种 IP,也能处理baidu.com这样的域名。注意getaddrinfo本身依赖 DNS 服务,如果 DNS 没配置好,resolve_addr 会失败,这时候的错误信息就应该是提醒用户检查/etc/resolv.conf和网络连通性,而不是笼统地说 invalid IP。把“IP 格式错误”和“域名解析失败”区分开,用户才能根据错误信息做正确的排查。

5. 避坑:为什么你的 ping 程序收不到回包

5.1 socket 创建失败:Operation not permitted

现象:程序编译通过,运行时socket函数返回 -1,perror 输出socket: Operation not permitted。

原因:SOCK_RAW需要 root 权限或CAP_NET_RAW能力。系统自带的 ping 命令普通用户能用,是因为发行版给/usr/bin/ping设置了cap_net_raw+ep能力位,而你刚编译出来的二进制文件没有这个位。

解决:临时验证就用sudo ./ping 1.1.1.1。长期使用建议用sudo setcap cap_net_raw+ep ./ping给二进制文件单独授权。不推荐chmod u+s方式,suid 配合未知漏洞的安全风险太大。

5.2 程序收不到回包,但 tcpdump 能看到

现象:一边跑自己的程序,一边用 tcpdump 或 wireshark 抓包,发现目标确实回了 Echo Reply,但程序完全没有输出。

原因:recvfrom的缓冲区起点错了。前面说过原始套接字收到的是完整 IP 包,直接强转icmphdr会把 IP 头第一个字节当成 type 字段去判断,结果 type 永远不匹配,回包被当成无关数据丢弃了。

解决:先按struct iphdr解析,用ip->ihl * 4算出 IP 头长度,再偏移到 ICMP 头。判断ihl是否为 5(20 字节)是最常见的默认值,但遇到带选项的 IP 头时容易翻车,所以代码里一定要用动态偏移。

5.3 目标收到报文但不回包:校验和算错了

现象:tcpdump 能看到请求包发出去,目标也在线,但对方完全不应答。把抓包文件导入 wireshark,包被标记为 “Checksum incorrect”。

原因:校验和计算错误。常见有三处:一是累加变量只用了 16 位,进位回卷时丢掉高位的进位;二是计算前没有把 checksum 字段清零,旧值参与累加;三是报文长度为奇数时没有补 0 字节。

解决:统一用前面给出的in_cksum实现,中间累加用unsigned long,每次构造报文时先memset清零整个结构体,再填字段,最后才调校验和函数。这三个细节做到位,校验和基本不会出错。

5.4 同一个 id 重复使用:两条不同进程的 ping 互相干扰

现象:程序从第二次运行开始,偶尔打印出来的 rtt 比实际大很多,或者收到延迟到达的旧回包。

原因:我用getpid() & 0xFFFF做 id,进程退出后 PID 可能被复用。如果上一个进程的超时回包在网络栈里没被读走,新进程 id 又恰好相同,就会读到旧包。

解决:在 main 里可以考虑随机化 id,或者每次运行用time(NULL) & 0xFFFF与 PID 组合。实际上只要保持 id/seq 双重匹配,误收概率就极低了,但不要为了省事去掉 seq 判断。

5.5 “一般故障”和“temporary failure”不是一回事

现象:Windows 上 ping 内网 IP 报“一般故障”,Linux 上 ping 域名报temporary failure in name resolution。不少初学者把这两种情况混在一起查错,折腾半天。

原因:Windows 的“一般故障”多数是网卡被禁用、IP 地址冲突,或者本机防火墙把 ICMP 出站流量拦了;Linux 的temporary failure in name resolution则是 DNS 解析阶段就失败了,压根没走到 ICMP 发送那一步。

解决:按错误来源分两类排查。sendto阶段出现的ENETUNREACH、EHOSTUNREACH表示路由或网关有问题,重点看ip route和网卡状态;recvfrom超时才是真正的丢包,重点看目标防火墙是否丢弃 ICMP,以及链路质量。把这个区分清楚,能省去大量瞎换参数的功夫。

6. 进阶验证:与系统 ping 对比,再做一个 crontab 连通性巡检

程序能跑通之后,先别急着收工。我每次写完这种底层工具都会做一轮对比验证:同一个目标,同时跑系统 ping 和自己写的 ping,看 RTT 的均值是否在同一个量级。正常情况下误差应该在 0.1 毫秒以内,如果自己的程序明显偏慢,先检查是不是在recv_reply里多做了耗时的printf,或者在发送循环里忘了用gettimeofday只测了接收段——发送本身消耗的时间在局域网里可以忽略,但如果在接收前加了一堆打印,RTT 就会被污染。

验证通过后,这个工具的价值就从“练手”变成了“可用”。把可执行文件放到/usr/local/bin/myping,再配合 shell 脚本做定时巡检。下面这个脚本每分钟对目标主机做一次 3 包检测,结果写进日志文件:

#!/bin/bash target="220.181.38.148" log="$HOME/ping-monitor.log" result=$(/usr/local/bin/myping "$target" 3 2) echo "$(date '+%F %T') $result" >> "$log"

脚本的关键是myping的第二个参数 3 表示只发 3 个包,第三个参数 2 表示超时 2 秒,保证单次检测最长耗时不超过 6 秒左右,不会拖垮计划任务。配合 crontab 里的一行* * * * * /path/to/check.sh,就能做成一个最简的链路质量监控。如果某次检测丢包率超过阈值,可以在脚本里加一行 grep 判断,触发告警逻辑。

这个过程中我也踩过不少回,比如一开始把超时设成 1 秒、发包间隔设成 1 秒,结果 20 个包的检测要跑 40 秒,crontab 周期全乱了。后来把参数拆开,让调用方自己控制频率,问题才解决。自己实现过一次 ping 后,再看到“网络抖动”“丢包率 5%”这类描述,脑子里会自动浮现出 ICMP 报文往返的时序图,排查问题时的方向感完全不一样。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询