用C语言实现Ping程序:ICMP原理与原始套接字实战

发布时间:2026/9/23 2:18:52
用C语言实现Ping程序:ICMP原理与原始套接字实战 简介面向C语言网络编程学习者提供一份用C语言实现Ping命令功能的精简示例以解决ICMP协议报文构造、原始套接字使用及往返时间计算等核心问题可作为计算机网络课程设计、网络实验或底层协议入门的参考适合学生、运维及网络开发人员快速上手。资源共2个文件以cpp源码和txt说明为主压缩包仅2KB其中cpp代码可直接编译测试txt文档对实现步骤与关键函数作了配套讲解。已有414人学习内容涵盖从创建原始套接字到解析应答报文的完整流程包括头部填充、校验和计算、发送接收及超时处理。通过这份代码读者可以掌握socket原始套接字编程、ICMP头部填充与校验和构造方法并能够基于gettimeofday计算往返时间为后续扩展诸如TCP/UDP探测或网络监测工具打下基础。1. 现成的 ping 命令一键就通为什么还有人用 C 语言把它重写一遍接手一台 CentOS 7 虚拟机报错ping: www.baidu.com: temporary failure in name resolution的时候先别急着怪网络这八个字告诉你域名解析挂了而不是 ICMP 不通。换个角度看ping 命令能暴露的问题往往比“丢包”两个字多得多。系统里明明有现成的 ping为什么还有人在学“用 C 语言实现 Ping 程序功能”因为自带 ping 是个黑匣子你看到的是输出行看不到它背后的 ICMP 构造、校验和、超时重传和 RTT 计时。这篇文章就带你把这一层拆开从原理到可编译代码在 Linux 上完整走一遍。这篇适合三类人想把 ping 命令的功能和用法吃透底层原理的 C 语言初学者准备网络编程面试的人以及需要自己造带日志、批量检测工具的一线工程师。不管那个 .rar 包里源码注释多还是少下面这份能落地的实现路径都是可靠的参考。2. 原理先立住ICMP 回显报文结构与原始套接字的选型2.1 为什么 Ping 程序不用 TCP/UDP非要用 ICMP很多第一次写网络程序的 C 语言学习者会惯性去想我要“连”目标机器是不是该用 socket 建 TCP 连接这是常见的误区。Ping 要验证的是网络层和链路层通不通而 TCP/UDP 是传输层协议握手和端口探测会被防火墙策略拦下。你想测路由器通不通可目标端口 80 根本没开TCP SYN 直接被丢你会得到“不可达”但这个“不可达”是端口不可达不是链路不可达信息是混淆的。ICMP 是网络层协议不需要端口路由器转发时也不关心端口号。它让每个收到 Echo Request 的节点都有义务回一个 Echo Reply除非被明确的 ACL 或防火墙策略丢弃。这个“义务”就是 Ping 程序的底层契约。另外 ICMP 报文回程时IP 头里的 TTL 会原样带回来你顺手就能拿到一个关于路径跳数的信息。用 TCP 去做你需要自己处理三次握手、连接超时、端口状态复杂且不准确。所以结论很直接Ping 程序功能实现ICMP Echo 是唯一干净的载体。系统自带的 ping 命令、网络设备上的 ping原理都是这一套。C 语言实现时不需要引入任何第三方库Linux 内核头文件里已经给你定义好了报文结构。2.2 ping 命令原理是什么Echo 请求与应答的字段拆解ping 命令原理是什么一句话概括发送方构造一个 ICMP Echo Request类型 8目标收到后原样回一个 ICMP Echo Reply类型 0发送方根据有没有收到、多久收到来判断连通性和时延。核心字段只有这几个字段长度Echo Request 取值接收方处理方式Type1 字节8目标主机回 Type0Code1 字节00 表示正常回显Checksum2 字节发送前计算校验失败直接丢弃Identifier2 字节自己填常见用 PID原样回显用于匹配进程Sequence2 字节从 0 递增原样回显用于判断丢包和乱序Data可变任意实践里放时间戳回显接收方取回计算 RTT这里最容易踩坑的是 Checksum。ICMP 头部一共 8 字节Type、Code、Checksum、Identifier、Sequence 各占 1/1/2/2/2 字节。计算校验和时要把 Checksum 字段本身先清零再对整个 ICMP 报文按 16 位为单位累加进位回卷后取反。目标主机校验失败会直接丢包表现就是抓包能看到 Request但永远收不到 Reply程序一直 timeout。Identifier 是另一个关键设计。raw socket 在系统里不是独占的同一台机器上可能有多个程序在收 ICMP 报文。如果你不检查 Identifier 是不是自己填的那个值很可能把别的 ping 进程的响应当成自己的打印出完全陌生的 seq 和时延。实践里最常见的坑就是只判断了 ICMP 类型没判断 ID导致程序在机器上有多个 ping 运行时行为全乱。这部分在第四章的避坑里细说。2.3 原始套接字SOCK_RAW 的权限与最小骨架普通 socket 收发的是端口数据而 Ping 程序要自己组装 ICMP 头所以必须用原始套接字。Linux 下创建方式很固定就用socket(AF_INET, SOCK_RAW, IPPROTO_ICMP)。这里的SOCK_RAW告诉你你可以收发没有被内核剥掉的 ICMP 报文但 IP 头依然由内核处理你不用自己拼 IP 头只拼 ICMP 头即可。最小骨架代码如下先验证当前环境能不能创建 raw socket#include stdio.h #include string.h #include unistd.h #include sys/socket.h #include netinet/in.h #include netinet/ip.h #include netinet/ip_icmp.h #include arpa/inet.h int main(int argc, char *argv[]) { if (argc 2) { fprintf(stderr, usage: %s dst-ip\n, argv[0]); return 1; } int sock socket(AF_INET, SOCK_RAW, IPPROTO_ICMP); if (sock 0) { perror(socket); return 1; } printf(raw socket ok: %d\n, sock); close(sock); return 0; }这段代码逻辑很简单创建 raw socket 成功就打印描述符失败则用perror输出原因。它验证的关键点是权限raw socket 需要 root 权限或者CAP_NET_RAW能力普通用户运行大概率会看到socket: Operation not permitted。这个报错不是代码问题是权限问题。后续所有代码都建立在这个 socket 上先跑通这一段后面的收发循环才有意义。编译命令也很常见gcc -Wall -O2 -o sockcheck sockcheck.c然后sudo ./sockcheck 192.168.1.1。能够看到raw socket ok就继续往下走。3. 用 C 写一个最小 Ping套接字初始化、校验和与收发循环3.1 三件前置准备头文件、编译命令、参数约定正式动手前先把习惯立好。很多人拿到这类实现上来就贴一大堆代码结果编译报implicit declaration才知道头文件缺了。这次的核心头文件四件套是固定的#include stdio.h #include string.h #include unistd.h #include sys/socket.h #include sys/time.h #include netinet/in.h #include netinet/ip.h #include netinet/ip_icmp.h #include arpa/inet.hnetinet/ip.h提供 IP 头结构netinet/ip_icmp.h提供 ICMP 头结构sys/time.h提供gettimeofday用来计算 RTT。这个组合在 Ubuntu、CentOS、Debian 上都能直接用不需要额外安装库。编译命令建议带-Wall -O2警告全开优化级别不要用-O0否则checksum里那些循环展开相关的性能差异虽然对小包无感但养成好习惯没坏处。参数约定上命令行最少要传目标 IP 和发包数量超时时间给个默认值 1000 毫秒。用inet_pton做 IP 转换不要用inet_addr后者对255.255.255.255这类地址返回 -1判断起来容易翻车。域名解析先不处理等主流程跑通再加后续单独讲。3.2 校验和实现为什么先清零再计算字节序怎么处理校验和函数是 Ping 程序的第一个核心也是很多 C 语言入门者第一次接触“网络字节序”的地方。它的算法来自 RFC 1071以 16 位为单位累加进位回卷到低位最后取反。实现如下static unsigned short checksum(void *buf, int len) { unsigned short *p buf; unsigned int sum 0; while (len 1) { sum *p; len - 2; } if (len 1) sum *(unsigned char *)p; sum (sum 16) (sum 0xffff); sum (sum 16); return (unsigned short)(~sum); }逻辑说明循环按 2 字节累加如果报文长度是奇数最后剩一个字节补在低位。累加完成后把进位从高 16 位回卷到低 16 位这就是sum 16加sum 0xffff的意义。整个算法对大小端不敏感因为它按网络字节序的字节流原样计算。关键是调用处必须先把 Checksum 字段清零。构造 Echo 报文的函数如下static void build_echo(char *packet, int pkt_size, int seq) { memset(packet, 0, pkt_size); struct icmp *icmp (struct icmp *)packet; icmp-icmp_type ICMP_ECHO; // 8: 回显请求 icmp-icmp_code 0; icmp-icmp_id htons(getpid() 0xffff); icmp-icmp_seq htons(seq); // 数据区前 8 字节放发送时刻对端回显后取回来算 RTT struct timeval *tv (struct timeval *)(packet 8); gettimeofday(tv, NULL); icmp-icmp_cksum 0; icmp-icmp_cksum checksum(packet, pkt_size); }参数说明pkt_size这里固定传 64对应标准 ping 默认荷载。Identifier 用进程 PID 的低 16 位调用htons转成网络字节序这是对端回显时不关心大小端的关键。数据区偏移 8 字节开始放struct timeval因为 ICMP 头固定 8 字节数据区从第 9 字节开始。gettimeofday时间戳会在接收端被取回来做差值这是计算 RTT 最朴素可靠的做法不用依赖单调递增的计数器。3.3 发送与接收循环超时设置、IP 头解析、类型与 ID 双重过滤发送和接收是主循环的核心。发送用sendto目标地址填struct sockaddr_in接收用recvfrom关键点有两个一是必须设置SO_RCVTIMEO否则断网时进程永远阻塞在recvfrom上二是收到的报文带 IP 头要先跳过 IP 头才能拿到 ICMP。代码如下static int ping_once(int sock, struct sockaddr_in *addr, int seq) { char pkt[64]; build_echo(pkt, sizeof(pkt), seq); struct timeval start, end; gettimeofday(start, NULL); if (sendto(sock, pkt, sizeof(pkt), 0, (struct sockaddr *)addr, sizeof(*addr)) 0) { perror(sendto); return -1; } char rbuf[512]; struct sockaddr_in from; socklen_t fromlen sizeof(from); ssize_t n recvfrom(sock, rbuf, sizeof(rbuf), 0, (struct sockaddr *)from, fromlen); if (n 0) { printf(timeout for seq%d\n, seq); return -1; } struct ip *iphdr (struct ip *)rbuf; int iphlen iphdr-ip_hl * 4; // IP 头长度单位是 4 字节 struct icmp *rcv (struct icmp *)(rbuf iphlen); if (rcv-icmp_type ! ICMP_ECHOREPLY) { printf(unexpected icmp type%d\n, rcv-icmp_type); return -1; } if (ntohs(rcv-icmp_id) ! (getpid() 0xffff)) { return -2; // 别的进程的报文直接跳过 } gettimeofday(end, NULL); double ms (end.tv_sec - start.tv_sec) * 1000.0 (end.tv_usec - start.tv_usec) / 1000.0; printf(%zd bytes from %s: icmp_seq%d ttl%d time%.2f ms\n, n - iphlen, inet_ntoa(from.sin_addr), ntohs(rcv-icmp_seq), iphdr-ip_ttl, ms); return 0; }逻辑说明发送后立刻记录start接收成功后再取end差值是完整往返时间。iphdr-ip_hl的单位是 4 字节所以乘 4 才是真实 IP 头长度。打印长度用n - iphlen这对应 ICMP 报文的真实长度。TTL 直接从 IP 头里取这就是系统 ping 命令里ttl64那部分的来源。返回值 -2 表示收到了其他进程的 ICMP 响应并没有超时。当前代码把它当作一次失败统计上会拉高丢包率。严谨的做法是在收到 -2 时继续recvfrom循环读直到匹配自己的 ID 或超时为止。生产级实现会这么做这个简单版保留了边界能跑通但要清楚它的局限。主函数需要设置超时并循环发包int main(int argc, char *argv[]) { if (argc 3) { fprintf(stderr, usage: %s ip count [timeout_ms]\n, argv[0]); return 1; } const char *dst argv[1]; int count atoi(argv[2]); int timeout_ms argc 3 ? atoi(argv[3]) : 1000; struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; if (inet_pton(AF_INET, dst, addr.sin_addr) ! 1) { fprintf(stderr, invalid ip: %s\n, dst); return 1; } int sock socket(AF_INET, SOCK_RAW, IPPROTO_ICMP); if (sock 0) { perror(socket); return 1; } struct timeval tv; tv.tv_sec timeout_ms / 1000; tv.tv_usec (timeout_ms % 1000) * 1000; setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, tv, sizeof(tv)); int ok 0; for (int i 0; i count; i) { int ret ping_once(sock, addr, i); if (ret 0) ok; usleep(300 * 1000); } close(sock); printf(--- %s ping statistics ---\n, dst); printf(%d sent, %d received, %.1f%% loss\n, count, ok, (count - ok) * 100.0 / count); return ok count ? 0 : 1; }主函数的说明usleep(300 * 1000)控制发包节奏避免 1000 个包在几毫秒内全部打出去。这会让每轮循环大约耗时RTT 300ms统计丢包按包数算不按时间算这一点要和系统 ping 区分开。setsockopt 的超时值直接影响丢包率局域网内 200 到 500 毫秒足够跨运营商链路建议 1000 毫秒给足重传空间。3.4 编译运行与结果解读按顺序把上面的函数拼进同一个ping.c编译命令如下gcc -Wall -O2 -o myping ping.c sudo ./myping 192.168.1.1 4输出类似64 bytes from 192.168.1.1: icmp_seq0 ttl64 time0.52 ms 64 bytes from 192.168.1.1: icmp_seq1 ttl64 time0.48 ms 64 bytes from 192.168.1.1: icmp_seq2 ttl64 time0.47 ms 64 bytes from 192.168.1.1: icmp_seq3 ttl64 time0.51 ms --- 192.168.1.1 ping statistics --- 4 sent, 4 received, 0.0% loss这里ttl64说明目标和自己同一个网段中间没有经过路由器如果 TTL 是 51 或者更小说明中间有路由器转发。RTT 从 0.5 毫秒左右可以判断是同网段的正常水平。如果目标是域名比如www.baidu.com编译后直接跑会报invalid ip因为inet_pton只接受点分十进制。常见做法是先调用getaddrinfo把域名解析成 IP再把 IP 填进sockaddr_in。这个逻辑不影响主流程但值得知道生产环境里没人会天天记公网 IP。4. 编译运行与 5 个典型坑从报错到“看起来通但总丢包”4.1 校验和没清零抓包全是请求没有回包现象程序能发出包也能正常退出但收到的全是 timeout。用 tcpdump 抓包时能看到出去的ICMP echo request但对面一个echo reply都不回。原因这是 Ping 程序里最容易翻车的一步。发送前忘了把icmp_cksum先设置为 0或者先调了 checksum 再去填 payload导致校验和把无关数据也算进去了。目标主机校验不过直接把包丢了。还有一种变体是计算长度不对checksum的 len 传了sizeof(struct icmp)但实际报文是 64 字节数据区的 56 个字节没有参与校验。解决严格按先清零、再填充数据、最后调 checksum 的顺序执行。用重放抓包验证终端 1 执行sudo tcpdump -i any icmp -nn终端 2 执行sudo ./myping 192.168.1.1 3。如果抓包显示有echo request但没有echo reply优先怀疑校验和如果有echo reply但程序还报 timeout那是接收解析的过滤条件出了问题。4.2 没设 SO_RCVTIMEO断网时进程彻底卡死现象拔掉网线后运行程序recvfrom一直阻塞按 CtrlC 才能终止进程看起来像死循环。原因raw socket 默认是阻塞模式recvfrom在没有数据时会一直等下去。网络正常时无所谓一旦对端不可达或路由黑洞这个调用永远不返回。大部分初学者写 Ping 程序都会漏掉超时设置因为正常环境测试时根本触发不了。解决在主函数里用setsockopt设置SO_RCVTIMEO超时值建议从 500 毫秒起步。注意struct timeval的精度是秒和微秒别把毫秒直接塞进tv_sec否则 1000 会变成 1000 秒。设置后recvfrom超时返回 -1配合perror或者自己打印 timeout 日志即可。4.3 收到 echo reply 却显示丢包可能是 ID 没匹配上现象抓包看到明确的echo reply目标也回包了本程序却一直报 timeout或者打印出的 seq 和 RTT 跳跃得毫无规律。原因raw socket 收到的 ICMP 报文中可能包含同一台机器上系统 ping、其他自写工具、监控程序发出的请求对应的回包。如果只判断icmp_type ICMP_ECHOREPLY就收下会把别人的响应当成自己的导致统计完全乱掉。解决必须校验ntohs(rcv-icmp_id) (getpid() 0xffff)。更严谨的做法是在收到非匹配 ID 时继续调用recvfrom再读下一个包直到超时。另外提醒一点getpid()低 16 位理论上会在进程 PID 回绕后和其他进程冲突实践里概率极低但如果跑长期监测服务建议用启动时刻的时间戳低 16 位作为 ID 来源。4.4 权限报错 Operation not permitted代码怎么改都白搭现象编译正常运行时报socket: Operation not permitted。原因创建 raw socket 需要CAP_NET_RAW能力普通用户没有。这个和代码逻辑无关纯环境问题。在 Docker 容器里跑时部分容器运行时默认没有授予NET_RAW权限同样报这个错。解决开发调试用sudo ./myping 目标IP 3。生产环境不想给 root 权限可以对编译好的二进制单独提权sudo setcap cap_net_rawep ./myping之后再普通用户跑就不需要 sudo 了。容器里给权限加启动参数--cap-addNET_RAW例如docker run --cap-addNET_RAW ...。另外提醒一下setcap对设置了 setuid 权限的二进制可能不生效调试时优先直接 sudo。4.5 虚拟机或云主机里“能 ping 通网关ping 不通宿主”的玄学现象在 Ubuntu 虚拟机里运行自己写的 Ping 程序ping 网关正常ping Windows 宿主 IP 一直 timeout但同一台虚拟机里用系统/bin/ping也一样不通。看起来像代码问题实际和代码一点关系都没有。原因Windows 防火墙默认拦截了 inbound ICMP Echo Request宿主机根本不会回包。虚拟机走 NAT 还是桥接也会影响路径NAT 模式下虚拟机出网要过一层地址转换部分链路对 ICMP 处理延迟大。解决先做环境排除法。在虚拟机上用系统自带的ping命令试试目标地址如果系统 ping 都不通说明是网络配置或防火墙问题不是自己 C 程序的锅。排查宿主机防火墙临时放行 ICMPv4-In 回显请求或者把虚拟机网络切到桥接模式再试。同理云主机安全组如果入方向没放行 ICMP公网 IP 是 ping 不通的这也能解释为什么ssh 用户名公网ip能通但 ping 不通。5. 从单次 ping 到持续监测断连记录、批量目标与绑定源地址系统自带的 ping 默认只做一轮就退出实际运维里更常用的是持续监测。把上面代码改造成工具型程序有几个顺手就能加的小功能都围绕一个目标让程序能在无人值守时留下日志而不是只在终端里刷输出。记录断连时刻的常见做法是维护一个连续失败计数器。连续 3 次超时判定链路 down恢复后连续 3 次成功判定 recovered这比单次超时就报警可靠得多。伪代码如下int consecutive_fail 0; for (int i 0; i total; i) { int ret ping_once(sock, addr, i); if (ret 0) { if (consecutive_fail 3) { time_t now time(NULL); printf([%s] link recovered\n, ctime(now)); } consecutive_fail 0; } else { consecutive_fail; if (consecutive_fail 3) { time_t now time(NULL); printf([%s] link down\n, ctime(now)); } } }判断逻辑说明ctime返回带换行的字符串打印时不需要再加\n。阈值 3 是按默认 1 秒超时推算的实际部署要根据监控精度调整要秒级告警就连续 2 次判 down要减少误报就连续 5 次。多网卡机器上跑自写 Ping经常会遇到“默认路由选错了网卡”的问题。笔记本同时插着网线和 WiFi 时路由表可能把目标网段指向无线网卡但你想测的是有线链路。系统自带的 ping 有-I参数C 语言里对应的常见做法是SO_BINDTODEVICEif (ifname ! NULL setsockopt(sock, SOL_SOCKET, SO_BINDTODEVICE, ifname, strlen(ifname)) 0) { perror(SO_BINDTODEVICE); }ifname传eth0、wlan0这类字符串。这个操作同样需要 root 权限网卡名不存在时报No such device。调试时先用ip route get 目标IP看一下当前路由会走哪个接口然后决定要不要强行绑定。批量扫描多个 IP 时经验是保持 300 毫秒以上的发包间隔不要一次性并发打上千个包。很多公司网络会扫描 ICMP 流量短时间高频发包容易被安全策略误判为探测攻击。把目标地址放进数组循环调用一份代码就能实现类似“超级 ping”的批量检测工具但发得慢一点活得久一点。最后说一个习惯我现在写 Ping 工具第一件事是先写超时处理和 ID 过滤再写发送逻辑这个顺序帮我避开了不少灵异问题。先保证程序不会卡死、不会接错包剩下的功能都是往上加东西。希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询