ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

用C语言实现Ping程序:ICMP报文构造与原始套接字实战解析

用C语言实现Ping程序:ICMP报文构造与原始套接字实战解析 简介用C语言实现Ping程序功能的代码示例包内含完整示例代码与说明文档面向网络编程初学者、高校计算机网络课程学生及需要排查网络连通性的开发人员。资源共2个文件其中cpp源文件演示了基于原始套接字的ICMP回显请求构造、发送与应答解析流程txt文档为pudn平台配套说明整体仅2KB体积轻量、便于快速阅读。已有414人浏览学习。示例围绕ICMP协议展开涵盖类型8回显请求与类型0回显应答的处理完整走通套接字创建、ICMP头部填充、数据包构建、sendto发送、recvfrom接收、头部解析及往返时间计算等关键环节可帮助读者理解ping命令底层的网络协议协作机制也可作为课程实验或自学参考直接修改复用。1. 用 C 语言实现 Ping 程序功能为什么自己写一个比系统 ping 更能看清网络用 C 语言实现 Ping 程序功能是网络编程课最经典的课程设计也是排查“网络到底通不通”时最顺手的一把刀。表面看系统自带 ping但自己写一遍才能真正看到 ICMP 报文怎么构造、原始套接字怎么收发、超时和重传如何设计。它适合刚学完 socket 想动手验证的人也适合需要在嵌入式设备或内网环境里做连通性探测的人。自己写的版本能自由控制发包频率、包大小、超时阈值还能把 RTT 数据直接喂给监控脚本。做完这个项目后面看 TCP 协议栈、抓包工具都会顺畅很多。2. 从协议到代码ICMP 报文构造与校验和的三种写法2.1 ICMP Echo 报文结构8 字节头部 变长数据type/code/id/seq 各管什么Ping 的核心是 ICMP Echo。ICMP 是 IP 层协议协议号 1不经过 TCP/UDP 端口所以写 C 语言版 ping 时不能像 HTTP 那样拿 SOCK_STREAM而是要走原始套接字。一次 Echo Request 报文由 8 字节 ICMP 头加数据区组成数据区通常放时间戳和填充内容长度由命令行参数决定。字段偏移长度含义本程序取值type018 表示 Echo Request0 表示 Echo Reply8code11请求/回显均为 00checksum22覆盖整个 ICMP 报文的校验和计算后回填id42标识发送进程回包要匹配getpid() 0xffffseq62序号用于重传和丢包统计自增id 用进程号是惯例避免本机多个 ping 程序互相收到对方的回包。seq 每次发包自增如果某个 seq 超时重发重发时 seq 保持不变等下一次新请求才递增。数据区长度可以自定义最常见的是 56 字节这样整个 ICMP 报文就是 64 字节8 字节头 56 字节数据所以系统 ping 打印出来的回复长度是 64 bytes。我在构造时更建议用字节数组直接操作而不是强转struct icmp结构体因为结构体在 32 位和 64 位编译条件下可能产生对齐填充填进去的字段偏移全乱。2.2 校验和计算三种写法的差别与两个翻车点ICMP 校验和是网上教程写错的重灾区。算法本身不复杂把校验和字段先置 0按 16 位一组做补码求和出现进位就循环回加最后按位取反。但很多人直接拿本机字节序去加或者忘记了进位循环导致发出去了对方直接丢弃。下面是我常用的显式大端写法不依赖主机字节序#include stdint.h static uint16_t icmp_checksum(const void *data, int len) { const uint8_t *p (const uint8_t *)data; uint32_t sum 0; while (len 1) { sum (uint16_t)((p[0] 8) | p[1]); /* 大端组装 16 位字 */ p 2; len - 2; } if (len 1) /* 长度为奇数补一个 0 字节 */ sum (uint16_t)p[0] 8; while (sum 16) /* 循环进位直到高 16 位为 0 */ sum (sum 0xffff) (sum 16); return (uint16_t)~sum; }这段代码的计算顺序和教科书完全一致先把报文按字节流的网络字节序组装成 16 位字累加后处理进位最后取反。注意第 8 行必须先把p[0] 8转成 16 位再做或运算否则在 int 提升后可能出现符号问题。回填时直接把返回值的高字节放pkt[2]、低字节放pkt[3]不要再调htons因为字节流本身就是大端。第二种写法是用struct icmphdr结构体字段填完后调用内核头文件里类似的校验函数。这种写法在 x86 小端机器上常在id、seq上忘记htons结构体对齐又把 8 字节头撑大属于最容易翻车的方案。我一般只在小端平台做临时验证时用交付代码不碰它。第三种写法是字节数组加编译期断言先定义一个紧凑结构体描述 ICMP 头用_Static_assert(sizeof(...) 8, icmp header size must be 8)挡住对齐问题。这种做法适合团队协作结构体清晰但初学者不容易理解断言的意义。校验和函数建议始终用上面那种纯字节写法至少不会被平台字节序干扰。如果抓包发现 checksum wrong先检查计算前有没有把校验字段清零再看有没有做循环进位这两个点占了我遇到问题的九成。2.3 原始套接字与第一个包socket(AF_INET, SOCK_RAW, IPPROTO_ICMP) 的最小命令发送 ICMP 需要原始套接字。最小可用代码#include sys/socket.h #include netinet/in.h #include arpa/inet.h #include string.h int sock socket(AF_INET, SOCK_RAW, IPPROTO_ICMP); if (sock 0) { perror(socket); return -1; } int ttl 64; setsockopt(sock, IPPROTO_IP, IP_TTL, ttl, sizeof(ttl)); struct sockaddr_in dst; memset(dst, 0, sizeof(dst)); dst.sin_family AF_INET; dst.sin_port 0; /* ICMP 没有端口号 */ inet_pton(AF_INET, 223.5.5.5, dst.sin_addr); unsigned char pkt[64]; memset(pkt, 0, sizeof(pkt)); pkt[0] 8; /* type: Echo Request */ pkt[1] 0; /* code: 0 */ pkt[4] (id 8) 0xff; pkt[5] id 0xff; pkt[6] (seq 8) 0xff; pkt[7] seq 0xff; int sent sendto(sock, pkt, sizeof(pkt), 0, (struct sockaddr *)dst, sizeof(dst)); if (sent 0) { perror(sendto); }这段代码里我没有设置IP_HDRINCL所以内核会自动构造 IP 头ICMP 头完全由自己负责。id和seq拆成高字节低字节填入是标准的大端网络序写法不必额外调用htons。sendto的第三个参数是 ICMP 报文总长这里发 64 字节回包长度也应该是 64。如果sent返回值比pkt_len小说明发包失败原始套接字不存在 TCP 那种部分发送。这个阶段最容易出现的现象是socket直接失败并提示Operation not permitted原因是普通用户没有CAP_NET_RAW权限后面避坑章会专门展开。另一个常见现象是sendto成功但收不到任何回包这时候先别怀疑代码用系统 ping 看目标是否允许 ICMP很多云主机默认屏蔽 ICMP发出去就是有去无回。3. 接收、超时与重传让 C 语言 Ping 程序有“心跳”3.1 收包不读头recvfrom 返回的数据里 IP 头还在发包只是第一步接收回包才是最容易稀里糊涂的地方。很多人以为recvfrom拿到的就是从 ICMP 头开始的数据实际上对于SOCK_RAWLinux 返回的是完整的 IP 包开头是 IP 头。如果直接把 buffer 当 ICMP 头解析type、checksum、id 全对不上统计结果就是一堆乱码。我一般这样处理#define BUFSIZE 512 unsigned char buf[BUFSIZE]; struct sockaddr_in from; socklen_t fromlen sizeof(from); int n recvfrom(sock, buf, sizeof(buf), 0, (struct sockaddr *)from, fromlen); if (n 0) { perror(recvfrom); return -1; } /* 这里按 IPv4 头解析IP 头长度可变取 ihl*4 才是 ICMP 起点 */ struct iphdr *iph (struct iphdr *)buf; int iphlen iph-ihl * 4; unsigned char *icmp buf iphlen; if (icmp[0] ! 0) /* type 0 才是 Echo Reply */ continue; if (ntohs(*(uint16_t *)(icmp 4)) ! my_id) continue;注意代码里对 IP 头做了ihl * 4的偏移而不是固定写死 20。IPv4 头在带选项时可以到 60 字节固定 20 在绝大多数场景能跑但一旦遇到带选项的中间设备或特殊网卡解析就会错位。ntohs的目的也很明确id 字段在网络字节序里存储拿到本机比较前要转成主机序。如果这里不做ntohs你会发现收到的 id 总像是 256 倍关系这是典型的字节序问题不是 pid 不匹配。recvfrom返回的n是整个 IP 包长度想要打印系统 ping 那种“64 bytes from x.x.x.x”应该用n - iphlen代表 ICMP 报文长度。发送时如果数据区是 56 字节这里算出来就是 64和系统 ping 输出一致。3.2 用 select 做超时控制1 秒还是 3 秒timeval 会被改写单纯阻塞在recvfrom上一旦丢包程序就永远卡死。常见做法是用select给接收加超时这是 ping 程序的标准心跳设计struct timeval tv; fd_set rset; tv.tv_sec 1; tv.tv_usec 0; FD_ZERO(rset); FD_SET(sock, rset); int ret select(sock 1, rset, NULL, NULL, tv); if (ret 0) { printf(Request timeout for icmp_seq%d\n, seq); continue; } else if (ret 0) { perror(select); break; }select 第一个参数要传sock 1不是sock本身这个细节很多人记错但写错并不会立刻报错只会让 select 行为变得不可预知。超时值的选择同网段网关一般给 1 秒跨运营商或卫星链路给 3 秒。1 秒对局域网很充裕对跨网却容易误判3 秒能覆盖绝大多数公网路径但不会让你等太久。不要给 00 表示完全不等待select 会立刻返回程序就变成空转。还有一个必须记住的坑select 返回后timeval会被内核改写成剩余时间。循环里如果复用同一个结构体第二次 select 的等待时间就不是你预设的值。正确做法是每次进入循环前重新赋值tv.tv_sec和tv.tv_usec。这个看起来是玄学实际是 POSIX 明确写的行为不重置就会出现「第一次等 3 秒第二次等 0 秒」的间歇性抽风。3.3 丢包率与 RTT 统计重传不一定是坏事但别把超时算成 RTT有了 select 超时接下来就是统计逻辑。Ping 的统计模型很简单每秒钟发一个包等回复超时就记录丢包继续下一个。发送时间和接收时间之间取毫秒差作为 RTT。关键点在于只有成功收到回包才累计 RTT超时的包不能把超时值当 RTT否则平均值会被严重拉高。double rtt_ms 0; struct timeval t0, t1; gettimeofday(t0, NULL); sendto(...); /* select 等回包 */ gettimeofday(t1, NULL); rtt_ms (t1.tv_sec - t0.tv_sec) * 1000.0 (t1.tv_usec - t0.tv_usec) / 1000.0; total_rtt rtt_ms; rtt_count;统计输出可以按这个模板printf(%d packets transmitted, %d received, %.1f%% packet loss\n, sent, received, (sent - received) * 100.0 / sent); if (received 0) printf(rtt min/avg/max %.3f/%.3f/%.3f ms\n, rtt_min, total_rtt / received, rtt_max);seq 回绕是容易被忽略的问题。uint16_t的 seq 到 65535 后会变回 0如果程序长时间运行会出现两个相同 seq 的包导致旧回包匹配到新请求。解决办法是用一个更大的计数变量做真正序号只在报文里放低 16 位。统计用的 sent/received 用int或long不要用uint16_t。id 用getpid() 0xffff虽然足够区分进程但如果父进程 fork 后 pid 相同而子进程各自建 socket还是可能串包。4. 地址解析与格式化输出从 IP 到主机名从 ms 到可读结果4.1 域名参数与 getaddrinfoEAI_AGAIN 的失败分支不能跳过命令行收到的目标可能是 IP也可能是域名。写 C 语言 ping 时最常见做法是用getaddrinfo做解析它能把域名一次性转成sockaddr_in还天然处理了 IPv4/IPv6 选择struct addrinfo hints, *res; memset(hints, 0, sizeof(hints)); hints.ai_family AF_INET; /* 只取 IPv4简化处理 */ hints.ai_socktype SOCK_RAW; int rc getaddrinfo(argv[1], NULL, hints, res); if (rc ! 0) { fprintf(stderr, ping: %s: %s\n, argv[1], gai_strerror(rc)); return 1; } memcpy(dst.sin_addr, ((struct sockaddr_in *)res-ai_addr)-sin_addr, sizeof(struct in_addr)); freeaddrinfo(res);EAI_AGAIN是临时解析失败代表 DNS 服务器暂时不可达不是域名不存在。很多程序遇到EAI_AGAIN直接退出这在工程上是不可接受的。正确的处理是重试两三次退避 1 秒、2 秒、4 秒重试仍失败再报错。系统 ping 输出里的ping: baidu.com: temporary failure in name resolution就是这一分支的体现本质是解析器没拿到应答不一定是目标主机宕机。inet_addr这个老函数也可以解析 IPv4 点分字符串但它不支持inet_pton对返回值合法性做的完整校验而且255.255.255.255会被映射成INADDR_NONE造成误判。现在写新代码我更推荐直接inet_pton(AF_INET, target, dst.sin_addr)解析失败就说明用户输入的不是合法 IPv4再走getaddrinfo域名分支。4.2 反向 DNSgetnameinfo 要不要放主循环阈值设多少系统 ping 默认会尝试把回包源 IP 反查成主机名显示成www.baidu.com (110.242.68.66)。自己实现时我最不推荐在主循环里同步做反向解析因为 DNS 反查可能卡住几百毫秒甚至更久直接影响下一次发包的节奏导致 RTT 统计失真。getnameinfo 的典型用法char host[NI_MAXHOST]; int rc getnameinfo((struct sockaddr *)from, sizeof(from), host, sizeof(host), NULL, 0, NI_NAMEREQD); if (rc 0) { printf(%s (%s), host, ip_str); } else { printf(%s, ip_str); }NI_NAMEREQD表示只有能解析出名字才用名字否则保留 IP。这样代码不会出现www.unknown.com这种半成品结果。工程上更稳的做法是默认不发反查只有加-a参数时才启用并且把反查结果缓存起来同一个 IP 只查一次。如果不想缓存就把反查放到单独线程里主循环只负责发包和收包。系统 ping 的输出整洁是因为它内部做了缓存和异步处理我们自己写的 C 语言版没必要在第一步就追求同样的显示效果先保证 RTT 数据干净。4.3 输出对齐与时间戳用 clock_gettime 还是 gettimeofday输出格式直接决定这个工具好不好用。我通常对齐系统 ping 的经典格式printf(%d bytes from %s: icmp_seq%u ttl%d time%.3f ms\n, icmp_len, ip_str, seq, ttl, rtt_ms);这里icmp_len是去掉 IP 头后的 ICMP 报文长度。ip_str用inet_ntop生成不要用inet_ntoa后者返回静态缓冲区连续打印两次会互相覆盖。时间戳方面gettimeofday精度到微秒足够算毫秒级 RTTclock_gettime(CLOCK_MONOTONIC)更好因为单调时钟不会受系统时间调整影响。我自己在写监控脚本时一定用CLOCK_MONOTONIC否则 NTP 校时瞬间会把 RTT 算成负值或巨大值。打印时要注意timeout和正常回包走的是不同分支。超时打印Request timeout即可不要打印 fake 的 RTT。统计汇总建议在程序结束和SIGINT中断时各打一次按 CtrlC 时也要能看到丢包率和均值。实现方式就是注册一个signal(SIGINT, handler)在 handler 里打印统计后exit(0)这是很多命令行工具都会做的体验优化代码量不大但很实用。5. Ping 程序避坑指南5 个让新手抓狂的案例与修复参数5.1 权限不足socket 返回 Operation not permitted不是代码写错现象socket(AF_INET, SOCK_RAW, IPPROTO_ICMP)返回 -1perror输出Operation not permitted。原因Linux 下创建原始套接字需要CAP_NET_RAW普通用户默认没有这个能力Windows 下则要求进程以管理员权限运行。解决开发调试时用sudo ./myping临时提权部署给其他同事用时sudo setcap cap_net_rawep ./myping可以给可执行文件单独授予能力避免每次都要 sudo。容器场景更不能忘docker 默认会丢弃很多 capabilities要在 docker run 时加--cap-addNET_RAW否则在容器里编译好的程序到宿主机能跑进容器就崩。这个错误不是代码逻辑问题但最容易在一开始挫伤信心。5.2 域名解析临时失败ping: baidu.com: temporary failure in name resolution 的三步定位现象用域名当目标时程序直接报错退出或者在系统 ping 里出现temporary failure in name resolution。原因getaddrinfo 返回EAI_AGAIN通常是 DNS 服务器不可达、/etc/resolv.conf 配置异常或网络暂时断开。解决先不查代码用 IP 地址代替域名发包比如直接 ping 223.5.5.5。如果 IP 通了说明 ICMP 收发逻辑没问题问题在 DNS 解析环节如果 IP 也不通排查网络和防火墙。第二步检查 /etc/resolv.conf 里的 nameserver 是否可达很多 CentOS 7 环境出现这个报错都是因为 DNS 指向了内网已下线的服务器。第三步在程序里做重试EAI_AGAIN时循环解析间隔 1 秒、2 秒、4 秒分别试一次仍失败再报错。把解析失败和网络不可达分开打印日志里才分得清到底是哪一段断了。5.3 ping 内网报“一般故障”多网卡绑定源地址才能救回来现象Windows 下系统 ping 内网其它机器提示“一般故障General failure”自己写的 C 语言程序 sendto 也可能返回Network is unreachable。原因目标地址所在网段和当前默认路由不匹配或者机器有多块网卡内核选错了出口。笔记本接网线又开 WiFi 时最容易出现。解决在程序里支持指定出口网卡或源地址。Linux 上可以用SO_BINDTODEVICE把 socket 绑到指定接口int rc setsockopt(sock, SOL_SOCKET, SO_BINDTODEVICE, eth1, strlen(eth1)); if (rc 0) perror(bind to device);也可以绑定源 IP用bind把 socket 关联到指定地址这样路由表会优先走这个源地址所在的网段。注意SO_BINDTODEVICE需要 root普通用户会得到Operation not permitted所以这个参数一般做成可选默认不启用。虚拟机环境里 NAT 网卡和桥接网卡并存这个坑几乎是必踩的。5.4 TTL 恒为 0 或 128读回包时跳过 IP 头偏移别用死值现象打印出来的 TTL 永远等于 0或者固定等于 128换目标也不变。原因TTL 字段在 IP 头里你读的却是 ICMP 头的 type 位置或者直接把buf[8]当 TTL在公网环境里偶尔碰巧看起来正常但语义全错。解决按 IP 头结构解析用iph-ihl * 4计算偏移再取 TTLstruct iphdr *iph (struct iphdr *)buf; if (iph-protocol ! IPPROTO_ICMP) continue; /* 只收 ICMP过滤其它协议 */ unsigned char *icmp buf iph-ihl * 4; int ttl iph-ttl;如果开了IP_HDRINCL自己构造 IP 头回包仍然会带 IP 头解析方式不变。TTL 只减不加从 64 起步的包经过 5 跳后回来显示 59这是正常现象如果显示 128说明目标或中间设备是 Windows 系默认 TTL不是 bug。读错偏移的典型特征是代码里全是buf[20]、buf[21]这种魔法数一旦遇到带 IP 选项的包就错位改成协议头结构体解析是一劳永逸的办法。5.5 回包 ID 对不上按 pid 过滤否则统计就是黑匣子现象程序统计乱跳收到的回包数量比发送数量还多或者 RTT 出现负数和超大值。原因同一台机器上多个 ping 进程共享同一类 raw socket内核会把 ICMP 回包广播给所有匹配协议的 socket不按 id 过滤就会收到别人家的包。解决在解析出 ICMP 头后比较 id 字段uint16_t rcv_id (icmp[4] 8) | icmp[5]; if (rcv_id ! my_id) continue; /* 不是发给自己的回包 */my_id在程序启动时用getpid() 0xffff取一次后面保持不变。不要每轮发包前重新取 pid虽然 pid 不会变但代码语义会让人误以为它可能变化。过滤完 id 再过滤 seq两者都匹配才进入统计逻辑。这一步不做程序在开发机上跑没问题到了多人共用的服务器上就会收到一堆其他用户的 ping 回包统计结果完全不可信。6. 进阶把 Ping 程序改成 RTT 抖动探测器Jitter监控不再只看丢包率丢包率能反映链路是不是断了但反映不出“时好时坏”。项目里更常见的问题丢包率 0%视频却卡顿语音断断续续。这时候要看的是 RTT 抖动也就是相邻两个回包 RTT 的绝对差。把 C 语言 ping 程序稍加改造让它连续发包并把每次 RTT 存下来就能算抖动double jitter 0; for (int i 2; i count; i) { double diff rtt[i] - rtt[i - 1]; if (diff 0) diff -diff; jitter diff - jitter / 16.0; }这里用的是 RFC 3550 里抖动的指数平均算法/16.0是建议的衰减因子值越大对历史越敏感越小越跟随瞬时变化。我一般同时打印原始 RTT 序列和 jitter 值因为只看平均值会掩盖“周期性抽风”的问题。配套做法是把每次回包的时间戳和 RTT 追加到本地文件事后用 gdb 或脚本分析而不是只留一个统计汇总。发到官网上线的版本里我保留了-i间隔参数并支持多目标轮流发包每轮只发一个包、等待超时后再发下一个避免同时打开多个 socket 造成负载。连续探测超过 5 分钟时我还加了一个内存上限只保留最近 1000 个 RTT 样本。这个方向投入不大却让我在好几次线上问题里先于业务发现抖动源头。回想起来我自己第一次做的版本只盯着平均 RTT丢了原始样本结果现场拿不出证据。后来养成的习惯是每一个包的时间戳、seq、TTL 都落盘宁可日志冗余也不要事后没有后悔药。希望帮到你。本文还有配套的精品资源点击获取
返回列表