☰
用C语言手写Ping:从ICMP报文到原始套接字的完整实现
2026/10/8 19:10:14 网站建设 项目流程

简介:面向网络编程初学者与C语言开发者的Ping功能实现资料,围绕原始套接字和ICMP协议讲解网络连通性检测方法。内容覆盖ICMP回显请求(类型8)与应答(类型0)报文构造、校验和填充、使用socket()创建原始套接字、通过sendto/recvfrom收发数据包,以及基于gettimeofday()计算往返时间(RTT)等关键步骤,并给出网络不可达、权限不足等常见错误的处理思路,可帮助读者从底层理解Ping的工作过程。压缩包共2个文件,包含cpp源代码和txt说明文档,代码与讲解相互对应,便于逐段学习、对照验证;整个包体仅2KB,内容精炼,适合作为课程设计或网络编程入门的实践参考。目前已有414人学习下载,整体代码量小、逻辑清晰,适合在Linux环境下动手编译调试;对于希望掌握原始套接字编程、深入理解网络协议交互的读者,这是一份小而实用的底层网络编程参考资料。

1. 用C语言复刻Ping命令:从ICMP回显请求到RTT计算,这条路比你想的深

排查网络故障时,我几乎条件反射地敲下ping。但一个关键事实是:C语言里没有ping()这个函数能直接调用,你得自己构造ICMP报文、自己管校验和、自己算往返时间。这份资源提供的ip.cpp就是一个完整可编译的C语言Ping实现,配套的说明文档把每一步都写得很直白。适合两类人:一是刚学完socket编程、想用实战把TCP/IP协议栈彻底搞明白的初学者;二是被"ping不通但TCP能连"这类怪问题折磨过、想深入底层排查的从业者。走完这条路,你对套接字、报文校验、超时控制的认知会有质的变化。

2. ICMP报文与Ping协议基础:类型8和类型0,以及为什么普通socket发不了

2.1 ICMP在IP协议栈中的位置

ICMP全称Internet Control Message Protocol,寄生在IP层之上,专门用来传递控制信息和错误报告。网络设备遇到目标不可达、TTL超时等情况时,靠ICMP把消息送回源端。Ping做的是主动探测:发一个回显请求,等一个回显应答。这跟TCP、UDP有本质区别。TCP要三次握手并维护连接状态;UDP虽然无连接但没有应答机制;只有ICMP的回显应答是设计给这种"你还在吗"的探活场景的。

更关键的是,构造ICMP报文这件事,普通socket办不到。你用socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP)创建UDP套接字时,内核会自动加UDP头部;用SOCK_STREAM创建TCP套接字时,内核会维护整个TCP状态机。但你想要的ICMP头、校验和、序列号,内核不会替你做。这就要用到原始套接字,把IP层以下的控制权拿回来。

我在看这份资源的源码时,最先确认的就是这两件事:报文结构定义是否正确、套接字类型是否用了SOCK_RAW。这两点错了,后面全是白搭。

2.2 报文结构拆解

一个ICMP回显请求报文,头部固定8字节,布局依次是:类型(1字节)、代码(1字节)、校验和(2字节)、标识符(2字节)、序列号(2字节)。回显请求的类型值是8,代码是0;回显应答的类型值是0,代码是0。接收方拿到报文后,先看类型就知道这是请求还是应答。校验和覆盖整个ICMP报文,这里既包括头部也包括后面的数据部分,计算前该字段先清零。

标识符用来区分不同的ping实例,序列号用来做丢包统计和乱序检测。你发出的请求序号是1、2、3,回来的应答如果少了某个序号,那一包就是丢了。有一点容易被忽略:类型和代码各占1字节,但C语言里的char在有些平台默认有符号。做数值比较时8会被当成-128吗?其实不会,因为这里只存了unsigned char,但正因为有这个隐患,我在源码里看到的第一个好习惯就是所有报文结构字段都用unsigned类型定义。这种细节,新手很容易在移植到32位嵌入式平台时翻车。数据载荷部分,标准ping会放发送时间戳和随机字节,用于计算RTT和验证报文完整性,这一点到第4章的代码里详细讲。

2.3 校验和算法:为什么Ping用的是补码和校验

IP、ICMP、TCP、UDP的校验和算法是同一套,叫ones' complement checksum,即补码和校验。做法是:所有16位字累加,溢出位回卷再加,最终结果取反。核心动机是计算代价极低,硬件上几个加法器就能跑完,适合路由器和网卡做线速处理。这不是加密级的完整性验证,它只能发现传输中的比特翻转,挡不住恶意篡改,但对网络报错场景够用了。

计算时有三个常见错误:一是把IP头也算进去了,ICMP校验和只覆盖ICMP报文本体;二是累加前忘了把校验和字段清零,这会把自己的老值算进去;三是溢出回卷漏了。注意这里的回卷是循环进位,不是简单截断。具体逻辑稍后在代码里会看到:sum = (sum >> 16) + (sum & 0xffff),这行就是把低16位和高16位折叠相加。有这么个细节兜底,你写出来的校验和函数才可能通过Wireshark的合法校验,否则目标主机收到报文,一查校验和不对,直接静默丢弃。

3. 原始套接字是起步避不开的坎:socket()三参数与权限边界

3.1 套接字三参数怎么选

创建原始套接字只需一行关键调用:

int sockfd = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);

第一个参数AF_INET指定IPv4协议族;第二个参数SOCK_RAW是原始套接字标志,明确告诉内核"IP头、ICMP头我来构造,你别管";第三个参数IPPROTO_ICMP让内核只把ICMP协议的数据交付给这个套接字。这里有个取舍:如果第三个参数写成IPPROTO_RAW,你会收到所有IP层流量,过滤起来麻烦很多。我一般用IPPROTO_ICMP,让内核先按协议帮你筛一道。

SOCK_RAW和SOCK_DGRAM的差别是这道坎的关键。SOCK_DGRAM让你发UDP报文时只需要给出payload,内核会垫上UDP头,而UDP头里没有类型、代码、校验和这些ICMP字段,你根本无法用它表达一个ICMP回显请求。这不是能不能收到应答的问题,是发送方根本构造不出合法报文的根源。资源里的ip.cpp,从打开源码时我就重点对照它有没有犯把参数写成SOCK_DGRAM的低级错误。

3.2 权限边界:为什么普通用户创建套接字直接失败

原始套接字之所以需要特权,是因为它能做的事太危险:能伪造源地址、能构造任意协议报文,天然就是攻击面。Linux下创建SOCK_RAW套接字需要CAP_NET_RAW权限,普通用户执行socket()会返回-1,errno是EPERM或EACCES。Windows下逻辑类似,需要管理员权限运行,而且Winsock2里的原始套接字行为和Linux差异不小,比如ICMP头的构造有时会被系统改写。

如果你用这份资源时第一步就卡住,先别怀疑代码,先看权限。我在Linux上一般用sudo运行编译产物;在Windows上则是右键以管理员身份打开命令行再执行。跑源码前先确认这条环境底线,能省掉大量排查时间。

3.3 sendto()和recvfrom()里那些容易被误解的参数

发送ICMP报文用sendto(),第四个参数flags置0即可。第五个参数是目标sockaddr_in结构,里面需要填sin_family=AF_INET、sin_addr.s_addr=目标IP、sin_port传0。ICMP没有端口概念,这个字段在协议栈处理时会被直接忽略。每次发送前把send_buf重新刷一遍,别复用上次构造的旧数据,否则残留的序列号会让统计结果错乱。

接收端用recvfrom(),它返回的数据不只是ICMP报文,而是完整的IP包。你在缓冲区里读到的前20字节是IP头,IP头后面才轮到ICMP头部。所以解析时要先通过IP头的IHL字段确定ICMP的起始偏移,否则读到的全是错位数据。这个偏移计算的坑,我会在第5章避坑记录里单独讲。

4. 完整实现步骤:从填充ICMP头部到计算RTT的关键代码

4.1 定义ICMP头部结构体

源码里的做法是自定义一个头部结构体,字段顺序和网络字节序严格对齐。网络字节序是大端,所以发送前要用htons()把本机字节序转换成网络字节序,x86机器上尤其注意这一点。

typedef struct icmp_header { unsigned char type; /* 8=回显请求,0=回显应答 */ unsigned char code; /* 固定为0 */ unsigned short checksum; /* 校验和,发送前计算 */ unsigned short id; /* 进程ID,区分不同ping实例 */ unsigned short seq; /* 序列号,从1开始递增 */ } icmp_header_t;

这段结构体的关键在字段类型:全部用unsigned族,避开了符号位干扰;checksum放在id和seq之前,因为协议规定校验和字段位置固定,接收方校验时要按这个偏移重新计算。我在其它项目里见过有人把id和seq放在校验和前,结果导致校验和算对了但协议栈根本认不出这个报文。

4.2 校验和函数与数据载荷

ICMP校验和函数是整套代码的命门。RFC 1071给出的是标准做法,下面这个函数就是按补码和校验实现的:

unsigned short in_cksum(unsigned short *addr, int len) { int nleft = len; unsigned short *w = addr; unsigned int sum = 0; unsigned short answer = 0; while (nleft > 1) { sum += *w++; nleft -= 2; } /* 如果报文长度是奇数,最后一个字节补成高位在前 */ if (nleft == 1) { *(unsigned char *)&answer = *(unsigned char *)w; sum += answer; } /* 折叠32位累加和:把高16位反复移到低16位相加 */ sum = (sum >> 16) + (sum & 0xffff); sum += (sum >> 16); return (unsigned short)~sum; }

两个地方容易翻车:一是循环里没处理奇数长度报文,ICMP全长一般是偶数但加了数据载荷后可能变奇,不处理这一步校验和会算错;二是折叠那步必须做够两轮,只做一次的话进位还会残留。计算前要把checksum字段清零,我在源码里看到它是在发送缓冲里先memset,再填充各字段,最后调in_cksum,顺序是对的。

数据载荷我一般放发送时刻的时间戳,用gettimeofday()获取,类型是struct timeval。这样收到应答时直接从载荷里拿出发送时刻,免得单独开全局变量存。时间戳后面再追加一串固定魔数做内容校验,比项目文档里说的"随机字节序列"更实用——你收到应答时要能确认这些数据确实是你自己发出的,而不是某些中间设备缓存的旧报文。要注意payload小于8字节时,部分系统会要求填充到8字节以上,这是防止IP分片的安全边界。

4.3 发送与接收循环:关键代码怎么搭

发送是sendto(),接收是recvfrom(),但真正的技巧在超时控制和RTT打点:

struct timeval tv_out; tv_out.tv_sec = 1; /* 超时1秒 */ tv_out.tv_usec = 0; setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, &tv_out, sizeof(tv_out)); while (seq < count) { /* 构造请求:填type、code、id、seq,计算校验和 */ build_echo_request(send_buf, pid, seq); gettimeofday(&t_send, NULL); /* 发送前打点 */ sendto(sockfd, send_buf, packet_size, 0, (struct sockaddr *)&dest_addr, sizeof(dest_addr)); n = recvfrom(sockfd, recv_buf, sizeof(recv_buf), 0, (struct sockaddr *)&src_addr, &addr_len); gettimeofday(&t_recv, NULL); /* 收到后打点 */ if (n < 0) { /* 超时或出错,记录丢包 */ printf("seq %d: timeout\n", seq); } else { double rtt = (t_recv.tv_sec - t_send.tv_sec) * 1000.0 + (t_recv.tv_usec - t_send.tv_usec) / 1000.0; printf("seq %d: rtt=%.3f ms\n", seq, rtt); } seq++; sleep(1); /* 标准ping每秒钟发一包 */ }

提示:SO_RCVTIMEO的单位是秒和微秒,tv_usec取值范围是0到999999,超出这个范围setsockopt会直接失败。

setsockopt里的SO_RCVTIMEO是整套逻辑的核心:recvfrom()默认是阻塞的,没有这个超时设置,目标主机不回包时进程就永远卡死在recvfrom()里。超时设多少会影响整体节奏,我一般设1秒,跟标准ping的1秒发包间隔吻合。sendto()返回值要检查,如果返回-1且errno为EACCES,说明当前用户确实没有原始套接字权限。

一个容易被行为误导的点:recvfrom()收到数据不代表就是我们的应答。这台主机可能同时收到了别的路由器的ICMP错误消息,或者之前ping实例的延迟应答。所以循环里拿到数据后,必须先校验IP头后的ICMP头部,确认type是0(回显应答)、id匹配当前进程、seq匹配当前发出的序号,才算一次有效应答。如果你不想依赖SO_RCVTIMEO,也可以用select()做超时管理,这里不展开,但原理一致。

4.4 解析应答报文:从IP头偏移到内容验证

recvfrom()读回来的是一个完整的IPv4包,前20字节是IP头。IP头里的IHL字段(4位)表示头部长度,单位是4字节,所以ICMP头的起始偏移是ip->ihl * 4。如果直接拿recv_buf当ICMP头读,你会把IP头的第一个字节当成ICMP的type,那结果自然是错的。

struct iphdr *ip = (struct iphdr *)recv_buf; int ip_header_len = ip->ihl * 4; icmp_header_t *icmp = (icmp_header_t *)(recv_buf + ip_header_len); if (icmp->type == 0 && icmp->id == pid && icmp->seq == seq) { /* 这次应答有效,计算RTT */ }

这段解析是血泪经验,我第一次写的时候就栽在这:没有按IHL做偏移,直接把ICMP头部从缓冲区开头的第一字节读,然后发现每次收到的type都很诡异。另外要警惕收到的报文是其它主机发来的目标不可达,这类报文type是3,直接丢弃按丢包处理即可,不用panic。

5. Ping程序常见坑:校验和翻车、权限失败与几种疑难现象的排查记录

5.1 现象:收不到任何应答,Wireshark显示发出的包checksum错误

原因:校验和计算前没有把checksum字段清零,或者把IP头算进了ICMP的校验范围。我在排查时发现资源的第一个版本就是把sizeof(ip_header)混进了len参数,导致发出的每个ICMP包校验和都比正确值大。目标主机一算校验和不匹配,整个报文直接丢弃,连错误消息都不回。

解决:先把整个发送缓冲区memset为0,再依次填充type、code、id、seq,最后再算校验和。len参数只传ICMP头部加数据载荷的总长度,千万别带上IP头。用Wireshark的checksum校验功能能直接看到红色标记,这个验证手段最靠谱。

5.2 现象:socket()直接返回-1,errno是EPERM或EACCES

原因:当前用户没有CAP_NET_RAW权限。这种情况不是代码问题,Linux普通用户默认不允许创建原始套接字,Windows下则需要管理员提权。防火墙策略也会拦,比如某些安全软件默认阻止原始套接字访问,表现和权限不足完全一样。

解决:Linux下用sudo运行编译产物;Windows下以管理员身份打开命令行再执行程序。排查时要先确认这个边界,别一上来就怀疑代码逻辑。另一个思路是给二进制文件附加cap_net_raw=+ep权限,但那是生产环境的做法,开发调试阶段sudo最直接。

5.3 现象:收到一堆数据,解析出来的type值异常,比如出现8、0以外的值

原因:recvfrom()返回的是完整IP包,没做IHL偏移就解析ICMP头。IP头长度不一定是20字节,可能带选项字段变成24甚至更多字节,直接把整个缓冲区当ICMP头读,必然错位。另外内核可能把其它协议的报文也交给了这个套接字,比如IGMP报文,它的头结构和ICMP完全不同。

解决:第一步做IP头偏移(ip->ihl * 4);第二步校验IP头的protocol字段是否为IPPROTO_ICMP;第三步再读ICMP头部。三步校验都通过了再谈业务处理。我在实践中还加了一层保险:校验icmp->id == pid,因为同一台机器上可能有多个ping实例并存。

5.4 现象:程序卡死在recvfrom(),Ctrl+C都杀不干净

原因:没设置SO_RCVTIMEO超时,或设置了但被UNIX信号打断了。ICMP应答丢失时,recvfrom()会一直阻塞等下去,进程看起来像假死。这在"ping内网显示一般故障"这类场景里特别明显——本机防火墙对类型8的报文做静默丢弃时,不发应答但也不发错误消息,recvfrom就傻等。

解决:每次循环开始前重新设置一次SO_RCVTIMEO;同时处理EINTR错误返回值,因为信号会打断系统调用,这时errno会是EINTR而不是EAGAIN,continue之前先判断errno类型。我在源码里看到它在每次循环前都重设了超时,这个习惯值得学习。

5.5 现象:多实例同时运行,收到彼此的应答,统计结果全是乱的

原因:id字段没有用真正的进程ID,或固定成某个常量了。ICMP协议的id就是设计用来区分不同ping实例的,两个程序用同一个id发出的请求,应答会互相串,收到id对不上的包就全丢了。

解决:用getpid()作为id传给build_echo_request(),接收端校验icmp->id == getpid()。这是从ip.cpp里看到的处理方式,也是我后来写所有网络探测程序时默认遵守的约定。

另外提一个常见但经常被误判的现象:ping baidu.com报temporary failure in name resolution。这个报错里连ICMP报文发送环节都没走到——它不是网络不可达,是DNS解析失败。在你自己的Ping程序里,这个错误对应getaddrinfo()返回非0值,要在发ICMP前就检查主机名解析结果,单独报DNS解析失败而不是报目标无应答。否则排障方向上就全错了。

6. 进阶验证技巧:用Wireshark校准报文格式,再把单次Ping改造成持续统计工具

代码能跑通只是第一步,报文格式对不对,必须用Wireshark抓包验证。开着Wireshark的icmp过滤条件,发出第一波ICMP请求,观察几个关键字段:type=8、code=0、checksum没有红色标记、id是你用的进程号、seq从1开始递增。对端的应答回来后,检查type=0、id和seq和请求匹配、RTT时间合理。这些字段全部对上,你的协议实现才算真正合法,否则就是"能收到但全靠运气"的玄学状态。

验证通过后,我建议把这份单次发几包的代码改造成持续运行的工具。核心改动在循环那块:丢掉count上限,改用Ctrl+C中断;每次统计模块记录总发送数、总收到数、累计RTT,退出前打印丢包率和平均RTT。这里有个容易被忽略的细节:平均RTT要用中位数而不是算术平均,因为ICMP受网络抖动影响极大,几个异常大值就能把平均值拉得很离谱。你可以在代码里维护一个RTT数组,排序后取中间值,或者用pthread_mutex保护一个计数结构,搞成线程安全的统计模块。

我在做这个改造时还有一个习惯:同一段代码分别对两个目标主机跑,对比中位数RTT和丢包率。这个对比能快速定位是本地网络问题还是远端问题。举例来说,如果ping内网网关丢包率为0,ping外网域名丢包率却很高,那就是运营商链路或出口防火墙的问题,跟你本机配置关系不大。这种多地ping对比的思路,比单独看一个目标的数字有用得多。

从那以后我每次写完网络协议相关代码,都强制先跑一遍Wireshark确认报文格式,再谈业务逻辑,这个习惯救过我太多次了。希望帮到你。

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

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

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

立即咨询