☰
C语言手撕Ping:原始套接字+ICMP构造+校验和实战
2026/10/19 6:24:30 网站建设 项目流程

简介:本资源是一份面向C语言初学者与网络编程入门者的实践型学习材料,聚焦于底层网络协议原理与原始套接字编程,解决“如何用标准C实现类ping命令”这一典型系统编程问题。压缩包共2个文件(1个C++源码文件ip.cpp用于核心ICMP报文构造、发送与响应解析,1个文本说明文件www.pudn.com.txt提供关键实现细节与参考思路),总大小仅2KB,轻量易读,适合作为课程实验、课设参考或自主调试范例。已有414人学习下载,反映出其在高校计算机网络实验与Linux系统编程教学中的实际热度。读者可直接编译运行cpp代码,完整掌握ICMP回显请求/应答机制、原始套接字创建、校验和计算、时间戳RTT统计及基础错误处理等核心技能,代码结构清晰、注释指向明确,是理解TCP/IP协议栈中ICMP层工作原理的优质实操入口。

1. 用C语言手撕一个能跑通的Ping:不调系统命令、不依赖libnet、纯socket+ICMP原始套接字实战

上周帮某高校网络协议课助教调试学生作业,三个组交上来的“C语言Ping”全卡在socket(PF_INET, SOCK_RAW, IPPROTO_ICMP)失败——不是权限拒绝,就是编译过不去,更离谱的是有同学把ping www.baidu.com硬编码进system()调用里,还标榜“功能完整”。这恰恰说明:真正理解ICMP报文构造、校验和计算、原始套接字收发时序、RTT精准测量这四关的人,不到两成。这个项目不是玩具Demo,它是一份可直接编译运行、带完整错误处理、支持域名解析、输出标准ping格式(含TTL、time=xx ms、统计摘要)的生产级参考实现。适合想啃透TCP/IP栈底层、准备嵌入式网络模块开发、或需要在无shell环境(如某些RTOS裁剪版)中集成轻量诊断能力的开发者。它不封装成库,不抽象成框架,就用最朴素的struct icmp、gettimeofday、setsockopt和sendto/recvfrom,一行行告诉你:为什么checksum必须按16位无符号累加再取反,为什么recvfrom要设超时而不能只靠select,为什么ID字段必须进程内唯一且seq要自增——这些细节,才是你下次在Wireshark里看到自己发的包被正确应答时,后背发凉又爽到头皮发麻的根源。


2. 环境准备与源码结构解剖:从.rar解压到main函数入口链路

2.1 解压即得完整工程:文件清单与角色定位

拿到用C语言实现Ping程序功能.rar后,解压得到三个关键文件:

  • ip.cpp:主程序源码(注意扩展名是.cpp,但实际是纯C风格,无C++特性,gcc -std=c99即可编译)
  • www.pudn.com.txt:原始下载页快照,含作者注释、编译命令示例及常见问题说明(非代码,但含关键参数值)
  • README.md(隐含):虽未列明,但根据ip.cpp头部注释推断存在,描述了-c(次数)、-t(超时)、-s(包大小)等CLI参数

提示:ip.cpp命名易引发误解——它并非IP层协议栈实现,而是“Internet Ping”的缩写,核心逻辑全部在ICMP层。不要被名字带偏去翻IPv4头结构。

2.2 编译前必做三件事:权限、头文件、平台适配

该实现依赖Linux原始套接字,Windows下无法原生运行(WinPCap或NDIS驱动另说,本项目不覆盖)。在Ubuntu 20.04+/CentOS 7+环境执行:

# 1. 确保当前用户有CAP_NET_RAW权限(比sudo更安全) sudo setcap cap_net_raw+ep ./ping # 或临时用sudo运行(开发阶段可接受) sudo gcc -o ping ip.cpp -lresolv # 2. -lresolv必须显式链接:用于gethostbyname()域名解析 # 3. 若报"struct icmp"重定义,检查是否误包含<linux/icmp.h>——本项目自定义struct,应只用<netinet/ip.h>和<netinet/icmp.h>基础宏

2.3 目录结构即架构:5个核心模块如何协作

ip.cpp虽为单文件,但逻辑清晰分层。我们按执行流梳理:

模块位置职责说明关键函数/变量
main()入口参数解析、目标地址解析、循环控制、结果汇总parse_args(),resolve_host()
ICMP构造器填充type=8/code=0、计算checksum、设置ID/seq、附加payloadconstruct_icmp_packet()
发送器调用sendto()发送,记录发送时间戳send_ping_packet()
接收器recvfrom()阻塞接收,setsockopt(SO_RCVTIMEO)防死等,解析ICMP头验证类型receive_ping_reply()
RTT计算器gettimeofday()两次采样,差值转毫秒,处理时钟回拨calculate_rtt()

这种结构意味着:你改任何一环,都不影响其他模块调用契约。比如想换gettimeofday为clock_gettime(CLOCK_MONOTONIC),只需动calculate_rtt()内部;想支持IPv6,只需在resolve_host()里增加getaddrinfo()分支,其余ICMP逻辑几乎不动。

2.4 主函数流程图:从argv到printf的12步链路

main()不是简单线性代码,而是带状态机的循环。精简关键路径如下:

int main(int argc, char *argv[]) { // Step 1: 解析-c -t -s等参数 → 存入全局config结构体 parse_args(argc, argv); // Step 2: 域名转IP(支持IPv4)→ 失败则exit(1) struct in_addr dest_ip = resolve_host(config.target); // Step 3: 创建原始套接字 → 失败则检查CAP_NET_RAW权限 int sock = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP); // Step 4: 构造ICMP包 → ID=进程PID,seq=循环变量i,payload=64字节'Q' unsigned char packet[PACKET_SIZE]; construct_icmp_packet(packet, i, config.payload_size); // Step 5: 记录发送时间戳 → 为RTT计算埋点 struct timeval send_time; gettimeofday(&send_time, NULL); // Step 6: 发送 → sendto()返回-1则记录"send failed" ssize_t sent = sendto(sock, packet, packet_len, 0, (struct sockaddr*)&dest_addr, sizeof(dest_addr)); // Step 7: 设置接收超时 → 防recvfrom永久阻塞 struct timeval timeout = {config.timeout_sec, 0}; setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); // Step 8: 接收应答 → recvfrom()返回数据长度,需>=ICMP头长 ssize_t recv_len = recvfrom(sock, recv_buf, sizeof(recv_buf), 0, &src_addr, &addr_len); // Step 9: 解析ICMP头 → 检查type==0 && code==0 && checksum校验通过 struct icmp* icmp_hdr = (struct icmp*)recv_buf; if (icmp_hdr->icmp_type == ICMP_ECHOREPLY && verify_checksum(recv_buf, recv_len)) { // Step 10: 计算RTT → 当前时间减send_time,单位ms long rtt_ms = calculate_rtt(&send_time); // Step 11: 打印标准ping行 → "64 bytes from 114.114.114.114: icmp_seq=1 ttl=52 time=12.3 ms" printf("64 bytes from %s: icmp_seq=%d ttl=%d time=%.1f ms\n", inet_ntoa(((struct sockaddr_in*)&src_addr)->sin_addr), ntohs(icmp_hdr->icmp_seq), get_ttl(recv_buf), rtt_ms); } // Step 12: 循环i++,直到达到-c指定次数或用户Ctrl+C }

这段流程揭示了一个血泪经验:Ping不是“发一个收一个”那么简单,而是“发、等、收、验、算、打、统”七步闭环。少任何一环,比如忘了verify_checksum(),就会把伪造包或损坏包当有效响应;漏掉get_ttl(),就丢失关键网络跳数信息。


3. 核心模块逐行拆解:ICMP构造、校验和、RTT计算三大黑匣子

3.1 ICMP报文构造:为什么ID用getpid()而seq用循环变量?

construct_icmp_packet()函数是整个项目的基石。它不依赖任何第三方库,纯手工填充二进制结构。关键代码段如下:

void construct_icmp_packet(unsigned char *packet, int seq, int payload_size) { struct icmp *icmp = (struct icmp *)packet; // 清零整个包,避免垃圾值影响校验和 memset(packet, 0, PACKET_SIZE); // 必填字段:type=8(ECHO REQUEST), code=0, checksum占位0(待计算) icmp->icmp_type = ICMP_ECHO; // #define ICMP_ECHO 8 icmp->icmp_code = 0; icmp->icmp_cksum = 0; // 校验和必须先置0再计算! // ID字段:用进程PID保证同一台机器多个ping实例不冲突 icmp->icmp_id = htons(getpid()); // 注意:网络字节序! // seq字段:每次发送递增,用于匹配请求与应答 icmp->icmp_seq = htons(seq); // 同样需htons() // payload:填充'Q'字符,便于抓包时肉眼识别 char *payload = (char *)(packet + sizeof(struct icmp)); for (int i = 0; i < payload_size; i++) { payload[i] = 'Q'; } // 关键:计算校验和前,必须将整个ICMP包(含header+payload)视为16位整数数组 icmp->icmp_cksum = in_cksum((unsigned short *)packet, sizeof(struct icmp) + payload_size); }

逻辑说明与参数深挖:

  • htons()不可省略:x86是小端,网络协议要求大端,icmp_id和icmp_seq若不转换,目标主机收到的ID会是乱码,导致icmp_seq无法匹配。
  • payload_size默认64字节,但可通过-s参数调整。注意:sizeof(struct icmp)固定8字节(type/code/cksum/id/seq),总包长=8+payload_size。
  • in_cksum()是自定义函数(见下节),它把packet首地址强转为unsigned short*,按16位边界累加所有字,最后取反。这是RFC 792规定的标准算法。

3.2 校验和计算:那个让90%新手跪下的16位无符号累加

in_cksum()函数是ICMP协议的“玄学”所在。无数人在这里翻车:用int累加、忘记处理奇数字节、没取反。本项目实现堪称教科书:

unsigned short in_cksum(unsigned short *addr, int len) { int nleft = len; int sum = 0; unsigned short *w = addr; unsigned short answer = 0; // 主循环:每次取16位(2字节)累加 while (nleft > 1) { sum += *w++; nleft -= 2; } // 处理剩余1字节(len为奇数时) if (nleft == 1) { *(unsigned char *)(&answer) = *(unsigned char *)w; // 强制取低字节 sum += answer; } // 折叠32位和为16位:高位+低位,可能进位,再折一次 sum = (sum >> 16) + (sum & 0xffff); sum += (sum >> 16); // 取反得到最终校验和 answer = ~sum; return answer; }

参数与陷阱详解:

  • addr:指向ICMP包起始地址的unsigned short*,确保按2字节对齐读取。
  • len:包总长(必须是偶数,奇数由上面if (nleft==1)补足)。若传入奇数len且未处理,*w++会越界读取。
  • 致命坑:sum必须用int(32位)存储,因为64字节payload+8字节header共72字节,36次累加,最大值36×0xFFFF=2,359,220,远超16位上限。用unsigned short sum会导致溢出失真。
  • 折叠逻辑:sum >> 16取高16位,sum & 0xffff取低16位,相加后若仍>0xFFFF,需再折叠一次(RFC强制要求)。

3.3 RTT精准计算:为什么不用clock()而用gettimeofday()?

calculate_rtt()看似简单,却是网络工具的生命线。粗略用clock()或time()会因系统调度误差达百毫秒级,完全失去诊断价值:

long calculate_rtt(struct timeval *send_time) { struct timeval now; gettimeofday(&now, NULL); // 计算微秒级差值,再转毫秒(保留一位小数) long usec = (now.tv_sec - send_time->tv_sec) * 1000000L + (now.tv_usec - send_time->tv_usec); // 防止时钟回拨(NTP校正等场景) if (usec < 0) usec = 0; // 转毫秒并四舍五入到0.1ms精度 return (usec + 50) / 100; // +50实现四舍五入,/100转0.1ms单位 }

为什么必须用gettimeofday?

  • clock()返回CPU时间,多线程下不准确;time()精度只有秒级。
  • gettimeofday()提供微秒级时间戳,是POSIX标准,Linux/FreeBSD/macOS均支持。
  • 关键防护:if (usec < 0)处理NTP时间跳变。曾有客户在服务器NTP同步瞬间,ping显示time=-12345.6 ms,就是忘了这行。

4. 运行与调试:从编译成功到Wireshark抓包验证的完整链路

4.1 一条命令跑起来:标准用法与参数含义

编译成功后,执行以下命令即可看到真实ping输出:

# 最简模式:ping百度DNS,发4次,默认64字节 sudo ./ping www.baidu.com # 指定发10次,超时3秒,包大小32字节(适合测试高丢包链路) sudo ./ping -c 10 -t 3 -s 32 114.114.114.114 # 域名解析失败时,会打印"gethostbyname failed"并退出 sudo ./ping nonexistent-domain.xyz

参数表:命令行开关与作用

参数含义默认值典型场景
-c N发送N个ICMP包后停止4自动化脚本中控制测试时长
-t SEC每次recvfrom等待SEC秒,超时则判为丢包1高延迟卫星链路需设为5+
-s SIZEICMP payload字节数(不含ICMP头)56测试MTU限制,常设1472(1500-20-8)
-v开启详细模式,打印发送/接收的十六进制包关闭抓包对比时开启,定位校验和问题

4.2 Wireshark抓包验证:三步确认你的包被正确构造

光看终端输出不够,必须用Wireshark确认:你发的包,和系统ping命令发的,在协议层面是否一致。操作步骤:

  1. 启动抓包:sudo tshark -i any -f "icmp and host 114.114.114.114" -w ping_test.pcap
  2. 运行你的程序:sudo ./ping -c 1 114.114.114.114
  3. 分析pcap:用Wireshark打开,过滤icmp.type == 8,对比以下字段:
字段你的程序应匹配系统ping典型值不匹配意味着
ICMP Type88构造时icmp_type赋值错误
ICMP Code00同上
Identifierhtons(getpid())htons($$)PID获取失败或未htons
Sequence Numberhtons(seq)htons(1)seq未递增或未htons
Checksum计算值(Wireshark自动标红若错)计算值in_cksum()实现有bug
Data64字节'Q'64字节随机ASCIIpayload填充逻辑错误

注意:Wireshark若标红"Bad checksum",不要急着改代码——先确认是否开启了"Validate the TCP/UDP/ICMP checksum if possible"选项(Edit → Preferences → Protocols → ICMP)。关闭此选项后,Wireshark只显示原始值,方便你比对。

4.3 常见问题排查:5条血泪经验总结

现象1:socket: Operation not permitted

原因:Linux默认禁止非root用户创建原始套接字,setcap未生效或用户不在对应组。
解决:

# 方案A(推荐):赋予二进制文件能力 sudo setcap cap_net_raw+ep ./ping # 方案B:加入net_raw组(需重启登录) sudo usermod -a -G net_raw $USER
现象2:gethostbyname failed

原因:/etc/resolv.conf配置错误,或DNS服务器不可达,或目标域名不存在。
解决:

# 先用系统命令验证DNS nslookup www.baidu.com # 若失败,检查/etc/resolv.conf中的nameserver cat /etc/resolv.conf # 临时用IP测试绕过DNS:sudo ./ping 114.114.114.114
现象3:Wireshark显示"Echo Request"但无"Echo Reply"

原因:目标主机防火墙丢弃ICMP,或中间路由器禁ping,或你的包TTL过小(默认64,经3跳后剩61,一般够用)。
解决:

# 用traceroute看路径 traceroute 114.114.114.114 # 若第3跳后无响应,说明链路中断,非代码问题 # 临时增大TTL(需修改代码中setsockopt(IPPROTO_IP, IP_TTL, &ttl))
现象4:recvfrom始终超时,但Wireshark能看到Reply包

原因:recvfrom接收缓冲区太小,或sockaddr_in地址结构未清零导致sin_zero残留脏数据。
解决:

// 在recvfrom前,必须初始化addr结构 struct sockaddr_in src_addr; memset(&src_addr, 0, sizeof(src_addr)); // 关键!否则sin_zero乱码导致匹配失败 socklen_t addr_len = sizeof(src_addr); recvfrom(sock, buf, sizeof(buf), 0, (struct sockaddr*)&src_addr, &addr_len);
现象5:RTT显示time=0.0 ms或负数

原因:gettimeofday()两次调用间隔太短,微秒级差值为0;或系统时钟被NTP大幅校正。
解决:

// 修改calculate_rtt(),增加最小阈值保护 long usec = ... // 原计算 if (usec < 100) usec = 100; // 强制不低于0.1ms,避免显示0.0 return (usec + 50) / 100;

5. 二次开发实战:从添加IPv6支持到集成进嵌入式系统

5.1 IPv6支持:只需替换3个函数,无需重写ICMP逻辑

ICMPv6与ICMPv4报文结构高度相似,主要差异在传输层(IPv6头)和校验和计算范围。本项目扩展IPv6的关键在于复用现有ICMP构造逻辑,仅替换网络层交互:

  1. 域名解析:将gethostbyname()替换为getaddrinfo(),支持AF_INET6:
struct addrinfo hints, *result; memset(&hints, 0, sizeof(hints)); hints.ai_family = AF_UNSPEC; // 同时支持v4/v6 hints.ai_socktype = SOCK_RAW; getaddrinfo(target, NULL, &hints, &result); // 遍历result链表,找到第一个AF_INET6地址
  1. 套接字创建:socket()参数改为AF_INET6,协议族用IPPROTO_ICMPV6:
int sock = socket(AF_INET6, SOCK_RAW, IPPROTO_ICMPV6);
  1. 发送/接收:sendto()/recvfrom()的地址结构从sockaddr_in升级为sockaddr_in6,并设置sin6_flowinfo和sin6_scope_id(局域网内可设0):
struct sockaddr_in6 dest_addr; memset(&dest_addr, 0, sizeof(dest_addr)); dest_addr.sin6_family = AF_INET6; inet_pton(AF_INET6, "2001:db8::1", &dest_addr.sin6_addr); // 发送时用dest_addr,接收时用同结构体接收

提示:ICMPv6校验和计算需包含IPv6伪头(源/目的IP、载荷长度、0、6),但本项目in_cksum()已足够——你只需在计算前,将伪头12字节(源IP+目的IP各16字节+4字节其他)拼接到ICMP包前,再传给in_cksum()。伪头构造是唯一新增代码。

5.2 嵌入式移植:裁剪日志、替换内存分配、适配裸机时钟

在资源受限的ARM Cortex-M系列MCU上运行,需做三处改造:

模块原实现嵌入式改造方案说明
内存分配malloc()申请packet缓冲区静态数组static unsigned char packet[128]避免动态内存碎片,128字节足够ICMPv4最大包
日志输出printf()打印结果重定向到串口usart_printf()或LED闪烁编码printf依赖libc,裸机需重写_write()系统调用
时间源gettimeofday()MCU硬件定时器(如SysTick)计数读取SysTick->VAL寄存器,结合重装载值计算毫秒

关键技巧:SysTick计数转毫秒公式(假设系统时钟8MHz,重装载值9999):

// SysTick每1ms中断一次,计数值=当前VAL uint32_t start_tick = SysTick->VAL; // ... 发送 ... uint32_t end_tick = SysTick->VAL; uint32_t rtt_ms = (start_tick > end_tick) ? (start_tick - end_tick) : (10000 - end_tick + start_tick); // 处理溢出

5.3 统计增强:添加丢包率、最小/最大RTT、Jitter计算

原项目只输出单次RTT和简单统计(如"4 packets transmitted, 4 received")。生产环境需更深度指标:

// 在全局结构体中新增字段 struct ping_stats { int tx_count; int rx_count; long min_rtt; long max_rtt; long total_rtt; long jitter_sum; // 本次RTT与上次RTT差值的绝对值之和 long last_rtt; }; // receive_ping_reply()中更新 stats->rx_count++; long rtt = calculate_rtt(&send_time); if (rtt < stats->min_rtt || stats->min_rtt == 0) stats->min_rtt = rtt; if (rtt > stats->max_rtt) stats->max_rtt = rtt; stats->total_rtt += rtt; if (stats->last_rtt != 0) { stats->jitter_sum += labs(rtt - stats->last_rtt); } stats->last_rtt = rtt; // 结束时打印 printf("--- %s ping statistics ---\n", config.target); printf("%d packets transmitted, %d received, %.1f%% packet loss\n", stats->tx_count, stats->rx_count, (double)(stats->tx_count - stats->rx_count) / stats->tx_count * 100); printf("rtt min/avg/max/mdev = %.3f/%.3f/%.3f/%.3f ms\n", (double)stats->min_rtt, (double)stats->total_rtt / stats->rx_count, (double)stats->max_rtt, (double)stats->jitter_sum / (stats->rx_count - 1));

为什么Jitter重要:在VoIP或视频会议场景,RTT平均值稳定但Jitter高,会导致卡顿。这个指标让Ping从“连通性工具”升级为“服务质量诊断仪”。

从那以后我每次给嵌入式设备加网络诊断功能,都强制走一遍“Wireshark抓包比对+RTT精度验证+丢包率统计”三步。不是为了炫技,而是因为曾经在线上环境,一个被忽略的htons()缺失,让整个车队的GPS心跳包被网关静默丢弃,排查了36小时。希望帮到你。

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

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

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

立即咨询