ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用C语言实现Ping:ICMP原始套接字与校验和深度解析

用C语言实现Ping:ICMP原始套接字与校验和深度解析 简介面向计算机网络课程设计这份《Ping 程序的设计与实现》报告覆盖了完整的实现原理与代码设计适合计算机科学与技术、网络工程等专业学生参考。报告系统讲解原始套接字技术涵盖 Winsock 初始化与注销、Socket 创建关闭、ICMP 报文封装、校验和计算、sendto 与 recvfrom 收发数据包等关键环节并给出了 ping -t 功能的实现思路与界面美化方案。压缩包内仅含 1 个 doc 文档大小约 189KB体量紧凑但内容完整可直接作为课程报告模板或设计蓝本使用。该资源已有 404 人学习浏览。文档中还附有课程设计任务书、工作进度计划、ICMP_HDR 结构定义及 checksum 函数源码读者可据此快速搭建自己的 Ping 程序框架加深对原始套接字与 ICMP 协议的理解。1. 这份 Ping 课程设计源码帮你把 ICMP 和原始套接字一次弄透收到这份《ping 程序的设计与实现》计算机网络课程设计时我第一反应是又是一份学生报告。但翻完源码后发现它其实把 Ping 的核心链路完整走了一遍——用 C 语言在 Windows 上通过原始套接字SOCK_RAW构造 ICMP 回显请求、计算校验和、发送并接收应答、统计往返时延还带了一个简化版的-t无限 Ping 参数。对正在做计网课程设计、或者想搞懂「Ping 到底怎么实现的」的人来说这是一份能直接照着敲的代码包不是那种只有截图没有源码的纸面报告。适合三类人要交计网课设的本科生、刚接触 Winsock 编程的初学者、想快速理解 ICMP 与原始套接字关系的读者。下面我按「协议原理 → 校验和算法 → 核心流程 → 踩坑记录 → 改造建议」的顺序拆给你看。2. 先搞清楚 Ping 的底层原始套接字与 ICMP 报文2.1 为什么 Ping 必须用原始套接字普通 socket 编程里我们常用两种套接字SOCK_STREAM流式TCP和SOCK_DGRAM数据报UDP。但 Ping 用的是 ICMP 协议它既不是 TCP 也不是 UDP而是直接跑在 IP 层之上的控制协议。Windows 的 Winsock 库默认不会帮你封装 ICMP 报文所以必须用原始套接字让程序自己构造 ICMP 头部并交给 IP 层发送。创建原始套接字的核心代码只有一行SOCKET rawSocket socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);这里三个参数分别是AF_INET表示使用 IPv4 地址族SOCK_RAW表示创建原始套接字而非流式或数据报套接字IPPROTO_ICMP指定 IP 头中的协议字段为 ICMP。注意SOCK_RAW类型的套接字在 Windows 上只有管理员组成员才能成功创建否则socket()调用会返回SOCKET_ERRORWSAGetLastError()通常是WSAEACCES权限被拒绝。原始套接字接收数据时有一个非常重要的特性无论你发的是 ICMP 还是别的协议收到的数据包都会包含完整的 IP 头这也是后面代码里ECHOREPLY结构体要把IPHDR放在最前面的原因。2.2 ICMP 报文格式类型、代码、校验和ICMP 报文封装在 IP 数据报中格式很固定。前四个字节是公共部分后面跟具体类型相关的字段。代码里用到的只有两种类型ICMP 类型值含义方向8Echo Request回显请求本机 → 目标主机0Echo Reply回显应答目标主机 → 本机ICMP_ECHOREQ 8和ICMP_ECHOREPLY 0这两个宏就是从这个表里来的。类型 8 的请求报文里还要带标识符ID和序列号Seq用来匹配应答校验和Checksum覆盖整个 ICMP 报文数据部分随便填Windows 的 ping 默认填 32 字节代码里的REQ_DATASIZE 32就是这个值。2.3 把报文格式翻译成 C 结构体课程设计源码里自定义了三个结构体我帮你拆开看// IP 首部格式 typedef struct _IPHeader { u_char VIHL; // 版本和首部长度 u_char ToS; // 服务类型 u_short TotalLen; // 总长度 u_short ID; // 标识号 u_short Frag_Flags; // 片偏移量 u_char TTL; // 生存时间 u_char Protocol; // 协议 u_short Checksum; // 首部校验和 struct in_addr SrcIP; // 源 IP 地址 struct in_addr DestIP;// 目的地址 } IPHDR, *PIPHDR; // ICMP 首部格式 typedef struct _ICMPHeader { u_char Type; // 类型 u_char Code; // 代码 u_short Checksum; // 首部校验和 u_short ID; // 标识 u_short Seq; // 序列号 char Data; // 数据 } ICMPHDR, *PICMPHDR; // ICMP 回应请求 typedef struct _ECHOREQUEST { ICMPHDR icmpHdr; DWORD dwTime; char cData[REQ_DATASIZE]; } ECHOREQUEST, *PECHOREQUEST;需要说明的是ICMPHeader结构体末尾的char Data并不是真的只有一个字节的数据它在这里的作用是占位真正发送时后面紧跟着ECHOREQUEST里的dwTime和cData。而recvfrom收到的原始数据包含 IP 头所以ECHOREPLY结构体把IPHDR放在第一位。你可能会发现这个 IP 头结构体没有按常见的__declspec(align)做字节对齐在 VC 6.0 默认的#pragma pack(8)下结构体大小会膨胀。实际测试时建议在结构体定义前后加上#pragma pack(1)否则sizeof(ECHOREQUEST)会比你预期的多出几个填充字节发送出去的报文大小就不对了。这个问题我在第五节会再强调。3. 把 ICMP 校验和算对16 位累加算法走读3.1 校验和的数学原理ICMP 校验和的计算方法是把整个 ICMP 报文按 16 位2 字节为一个单位拆开依次累加到一个 32 位累加器中如果报文总长度是奇数最后一个字节要补一个0x00扩展成 16 位再累加。累加过程中产生的进位不要丢弃而是回卷加到低位上去这叫「16 位反馈计算」。最终把累加和的高 16 位与低 16 位相加再对结果取反就得到校验和。发送方把算好的校验和填入 ICMP 头的 Checksum 字段接收方收到后对整个报文重新做一次同样的计算如果结果为0xFFFF则说明数据在传输过程中没有被破坏。UDP 的校验和也是类似算法区别在于 UDP 还额外加了一个伪头部。3.2 逐行读源代码源码里的checksum()函数就是上面算法的直接实现u_short checksum(u_short *buffer, int len) { register int nleft len; register u_short *w buffer; register u_short answer; register int sum 0; // 使用 32 位累加器,进行 16 位的反馈计算 while (nleft 1) { sum *w; nleft - 2; } // 补全奇数位 if (nleft 1) { u_short u 0; *(u_char *)(u) *(u_char*)w; sum u; } // 将反馈的 16 位从高位移到低位 sum (sum 16) (sum 0xffff); sum (sum 16); answer ~sum; return (answer); }几个关键点也是你写代码时最容易出错的地方while (nleft 1)而不是因为每轮要消耗 2 个字节。如果循环条件写错奇数长度报文就会越界读取。奇数长度补位不能直接sum *w因为w是u_short*多读一个字节就是越界。正确做法是只取低地址那个字节高字节补零。第一次sum (sum 16) (sum 0xffff)做完后理论上还可能产生一次进位比如结果等于0x1FFFF所以要再执行一次sum (sum 16)。这是很多简化版教程会漏掉的步骤。answer ~sum取反时要保证结果是 16 位u_short在 Windows 下是unsigned short正好 16 位不会多出高位。3.3 校验和算错了会怎样校验和错误在 ICMP 场景下有几种表现我实际调试时都遇到过发送方算错了对端收到后校验失败直接丢弃表现为「Ping 请求超时收不到任何回复」。字节序搞反了Windows 是小端网络字节序是大端。如果你把ID和Seq直接按主机字节序填进去对端可能不认但校验和本身是通的表现比较隐蔽一般是「偶尔能通偶尔超时」。sizeof用错checksum((u_short*)echoReq, sizeof(ECHOREQUEST))这里如果结构体有 paddingsizeof会把填充字节也算进去。填充字节未初始化每次算出来的校验和还不一样。表现是「同一台机器有时候通有时候不通」。一个快速验证方法把算出的校验和打印出来用 Wireshark 抓包对比标准 ping 的 ICMP 报文 Checksum 值。Windows 系统自带 ping 的 ICMP 报文类型、ID、序列号都是确定的照着比对很快能发现问题。4. 核心流程实现建套接字、发请求、收应答、出统计4.1 初始化 Winsock 与解析目标主机所有 Winsock 程序的第一步都是WSAStartup没初始化就调用其他函数会直接失败。代码里在主函数中完成初始化并且只请求了 1.1 版本的 WinsockWSADATA wsd; if (WSAStartup(MAKEWORD(1, 1), wsd) ! 0) { printf(加载 Winsock 失败!\n); }这里MAKEWORD(1, 1)表示请求 Winsock 1.1对于 ICMP 回显这种简单功能完全够用。要注意的是每次WSAStartup成功调用后程序结束前必须对应调用WSACleanup()释放资源源码里每次循环结束后都调用了。如果WSAStartup和WSACleanup次数不匹配后面的套接字操作可能收到WSANOTINITIALISED错误。目标主机解析用gethostbynamelpHost gethostbyname(pstrHost); if (lpHost NULL) { printf(Host not found:%s\n, pstrHost); return; }这个函数同时支持 IP 地址字符串和域名返回的是一个HOSTENT结构体指针。取 IP 时用*((u_long FAR*)(lpHost-h_addr))拿到第一个地址填入sockaddr_in的sin_addr.s_addr。如果解析失败最常见的原因是域名拼写错误、DNS 配置不对或者目标主机根本不存在。注意gethostbyname是阻塞的解析超时可能长达十几秒这个点后面避坑章节会再提。4.2 发送 ICMP 回显请求发送函数的核心逻辑是填充ECHOREQUEST结构体再调用sendtoint SendEchoRequest(SOCKET s, struct sockaddr_in *lpstToAddr) { static ECHOREQUEST echoReq; static int nId 1; static int nSeq 1; int nRet; // 填充回应请求消息 echoReq.icmpHdr.Type ICMP_ECHOREQ; echoReq.icmpHdr.Code 0; echoReq.icmpHdr.Checksum 0; echoReq.icmpHdr.ID nId; echoReq.icmpHdr.Seq nSeq; // 填充要发送的数据 for (nRet 0; nRet REQ_DATASIZE; nRet) { echoReq.cData[nRet] 1 nRet; } // 存储发送的时间 echoReq.dwTime GetTickCount(); // 计算回应请求的校验和 echoReq.icmpHdr.Checksum checksum((u_short*)echoReq, sizeof(ECHOREQUEST)); // 发送回应请求 nRet sendto(s, (LPSTR)echoReq, sizeof(ECHOREQUEST), 0, (struct sockaddr*)lpstToAddr, sizeof(SOCKADDR_IN)); if (nRet SOCKET_ERROR) { printf(send to() error:%d\n, WSAGetLastError()); } return (nRet); }这里有两个细节值得注意。第一ID和Seq用的是static局部变量每次调用自动递增保证多轮 ping 之间序列号不重复。第二Checksum先置 0 再计算这是必须的——如果把上一次发包的校验和留在里面整个报文的校验和就永远不会正确。dwTime用GetTickCount()记录的是系统启动以来的毫秒数不是 Unix 时间戳。接收方拿到这个值后再次调用GetTickCount()做差就是单次往返时延。这种方式很简单但GetTickCount本身有约 1016ms 的精度误差在低延迟局域网里测出的时延经常是 0ms 或 1ms这是正常现象。4.3 用 select 实现应答超时recvfrom在没有数据到达时会阻塞线程Ping 程序需要在一段时间内没收到回复就判定超时所以必须用select做超时控制int WaitForEchoReply(SOCKET s) { struct timeval timeout; fd_set readfds; readfds.fd_count 1; readfds.fd_array[0] s; timeout.tv_sec 1; timeout.tv_usec 0; return(select(1, readfds, NULL, NULL, timeout)); }这段代码的关键是fd_set结构和timeval超时设置。select返回 1 表示套接字可读、有数据到达返回 0 表示超时返回SOCKET_ERROR表示出错。Windows 版本的select第一个参数是nfds实际上被忽略填多少都行但为了可读性建议填 1。fd_set在这里用的是fd_count和fd_array这种 Windows 专有实现Linux 下是FD_SET宏加fd_set结构两套写法不同如果之后把代码迁到 Linux 需要改。超时时间设为 1 秒是因为struct timeval的tv_sec是秒、tv_usec是微秒。如果你想把超时改成 2.5 秒就写tv_sec 2; tv_usec 500000;。4.4 接收应答、计算 RTT 与统计输出接收函数在select返回可读后调用从原始套接字上接收数据DWORD RecvEchoReply(SOCKET s, LPSOCKADDR_IN lpsaFrom, u_char *pTTL) { ECHOREPLY echoReply; int nRet; int nAddrLen sizeof(struct sockaddr_in); // 接收应答回复 nRet recvfrom(s, (LPSTR)echoReply, sizeof(ECHOREPLY), 0, (LPSOCKADDR)lpsaFrom, nAddrLen); // 检验接收结果 if (nRet SOCKET_ERROR) { printf(recvfrom() error:%d\n, WSAGetLastError()); } // 记录返回的 TTL *pTTL echoReply.ipHdr.TTL; // 返回应答时间 return(echoReply.echoRequest.dwTime); }recvfrom的缓冲区大小是sizeof(ECHOREPLY)里面包含 IP 头 ICMP 头 数据 256 字节填充。收到的 IP 头会直接覆盖echoReply.ipHdrICMP 头和数据覆盖后面的字段。通过echoReply.ipHdr.TTL能拿到对端返回的 TTL 值echoReply.echoRequest.dwTime拿到的是发送时写入的时间戳。注意这里没有校验 ICMP 类型是不是ICMP_ECHOREPLY如果目标不可达对端返回的是类型 3 的 ICMP 报文代码会把它当成一次「成功应答」处理这是原版代码的一个缺陷第五节会讲怎么改进。主循环里Ping()函数按固定 4 次发包每次间隔Sleep(1000)同时维护sent/received/lost三个计数器最后输出丢包率和最小/最大/平均时延printf(Ping statistics for %s:\n, inet_ntoa(srcIP.sin_addr)); printf( Packets: Sent %d, Received %d, Lost %d (%.f%% loss),\n, sent, reveived, lost, (float)(lost*1.0/sent)*100);%.f在 C 里表示不输出小数部分这一点容易误写成%.1f导致百分比格式不对。如果你的编译器对%.f支持不好建议改成%.1f%%保留一位小数更符合习惯。5. 避坑与排查权限、超时、缓冲区和 -t 参数四个重点5.1 创建原始套接字失败报错 WSAEACCES现象socket(AF_INET, SOCK_RAW, IPPROTO_ICMP)返回SOCKET_ERRORWSAGetLastError()为 WSAEACCES。原因从 Windows NT 开始只有 Administrators 组成员才能创建SOCK_RAW套接字。普通用户权限下调用直接失败。这不是代码问题是操作系统安全策略。解决以管理员身份运行程序。在 Visual C 6.0 里调试时把 VC 6.0 本身用「以管理员身份运行」启动命令行运行时右键选择「以管理员身份运行」。另外Windows XP 之后的系统对原始套接字还有更多限制不能发送 TCP/UDP 流量只能发 ICMP这正是本课程设计场景刚好够用。5.2 select 超时后 fd_set 失效现象Ping 连续超时两三次后后面所有请求都收不到回复甚至select一直返回 0。原因Windows 的select调用会修改传入的fd_set结构标记哪些套接字可读。如果select返回 0超时fd_set内容会变成空集。下一次循环如果直接复用同一个fd_set而不重新初始化等于没监听任何套接字。解决把fd_set readfds的声明和初始化放到WaitForEchoReply函数内部每次调用时重建这正是源码里的做法。如果你想把超时逻辑外提每次循环前必须重新设置fd_count和fd_array[0]。这个坑在 Linux 下同样存在select的fd_set也是写回式的这是多线程 ping 工具中最常见的隐性 bug。5.3 recvfrom 缓冲区过小导致丢包现象能发不能收recvfrom返回SOCKET_ERROR错误码WSAEMSGSIZE报文太长。原因原始套接字收到的数据包含 IP 头IP 头默认 20 字节。如果缓冲区只留了sizeof(ECHOREQUEST)的大小ICMP 应答报文IP 头 ICMP 头 32 字节数据 时间戳总长超过缓冲区系统直接报错丢包。解决接收缓冲区必须用sizeof(ECHOREPLY)它以IPHDR开头、带 256 字节cFiller填充能容纳最坏情况。我一般改成char recvBuf[1024]然后用指针强转彻底避免结构体大小问题。另外发送缓冲区同理——sendto的第二个参数是(LPSTR)echoReqsizeof(ECHOREQUEST)要和你结构体实际大小严格一致最好打印出来确认。5.4 -t 参数解析的边界问题现象输入ping -t 127.0.0.1时程序行为异常或者报错。原因源码处理-t的方式是if(strstr(opt1, -t) ! NULL){ log true; strncpy(ptr, opt10, strlen(opt1)-3); ptr[strlen(opt1)-2] 0; }这个逻辑是从输入字符串中查找-t子串然后把末尾 3 个字符截掉。它假设输入格式固定是域名 -t如果用户按标准 ping 的语法输入ping -t 域名截取出来的地址就错了。而且strstr只做子串匹配域名里如果碰巧包含-t也会误判。解决我建议不用字符串截取改成拆分空格char cmd[100], host[100]; sscanf(opt1, %s %s, cmd, host); if (strcmp(cmd, -t) 0 host[0] ! \0) { log true; strcpy(ptr, host); } else { strcpy(ptr, opt1); }这样不管-t在前面还是后面都能正确解析也规避了strncpy不写结尾\0的隐患。源码里ptr[strlen(opt1)-2] 0;的下标其实算得不对截取后字符串结尾会多一个空格字符发给gethostbyname时经常解析失败。5.5 目标不可达时被误判为成功现象Ping 一个不存在的 IP程序没有报超时反而显示Reply from ...时延还特别小。原因recvfrom收到任何 ICMP 报文都会触发select可读包括类型 3目的不可达、类型 11TTL 超时。原代码没有检查echoReply.icmpHdr.Type是不是ICMP_ECHOREPLY导致错误报文被当成回显应答。解决在RecvEchoReply里收到数据后先判定响应长度是否大于 IP 头长度再从 IP 头偏移处取 ICMP 类型。简单判断可以写成int nIcmpType echoReply.echoRequest.icmpHdr.Type; // 注意 echoReply 布局更严谨的做法是用char buf[1024]接收然后按内存布局取出 IP 头长度字段(buf[0] 0x0f) * 4跳过 IP 头后读 ICMP 类型等于 8 才是回显应答。6. 让课程设计可交差也能跑改造 Ping 的三个小技巧6.1 把超时时间做成命令行参数源码里超时固定 1 秒发 4 次。这个参数在很多场景要调校园网内网延迟 1~2ms1 秒超时纯属浪费跨运营商链路抖动大1 秒又不够。我改成了-w参数int timeout_ms 1000; if (strstr(opt1, -w) ! NULL) { timeout_ms atoi(strstr(opt1, -w) 2); }把timeout.tv_sec timeout_ms / 1000; timeout.tv_usec (timeout_ms % 1000) * 1000;填进select就能自由控制等待时长。这个改动的价值不仅是功能完整答辩时老师问「超时时间怎么实现的」你可以从select的timeval讲到毫秒怎么拆分成秒和微秒这是一个很好的加分点。6.2 把丢包统计和退出码连起来做自动化课程设计一般做到界面输出就结束了但 Ping 程序最常见的工程用途是写进批处理做连通性检测。我把Ping()的主循环改成返回一个退出码丢包为 0 返回 0丢包率大于 0 返回 1完全不可达返回 2。这样在批处理脚本里就能ping_test.exe -w 2000 192.168.1.1 if errorlevel 1 echo 目标不可达请检查网络这个修改不需要动协议部分只是在统计完lost之后加一个返回值十几分钟就能完成但对「Ping 程序的实际用途」理解会深一层。6.3 自己的验证清单每次改完都跑一遍我拿到任何一份 Ping 源码都会按这个清单测一遍再写报告ping 127.0.0.1回环地址验证程序自身链路时延应为 0msTTL 为 128。ping 网关IP验证局域网链路必须通TTL 一般也是 128。ping 不存在的IP如 192.168.1.250必须超时不能误报成功。ping -t 域名先验证域名解析再验证无限循环用 CtrlC 中断。ping www.baidu.com验证公网链路观察 TTL 是否变小说明中间经过了多次路由转发。这套验证清单我建议你直接抄进课程设计报告的需求分析里比空谈「实现了 ping 功能」更有说服力。从收到这份源码到现在我每次拿到网络相关的课程设计代码都会强制走一遍「看协议 → 看数据结构 → 跑通 → 故意改坏验证异常路径」的顺序。校验和、超时控制、原始套接字权限这三个坑属于那种代码跑通了也未必理解、但理解了就再也不会忘的知识点。这份源码的价值不在代码本身有多精巧而在于它把 ICMP、原始套接字和 Winsock 三个零散概念串成了一条完整的可运行链路希望帮到你。本文还有配套的精品资源点击获取
返回列表