ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

用C++和Winsock实现Windows版ping:ICMP报文构造与解析

用C++和Winsock实现Windows版ping:ICMP报文构造与解析 简介针对Windows环境下网络连通性检测的实际需求提供的C工程实例完整实现了ping命令的核心功能主要面向掌握基础语法、希望深入理解ICMP协议与原始套接字编程的开发者。项目使用WinSock2库完成Winsock初始化、目标主机域名解析、ICMP回显请求报文构造、序列号与时间戳填充、sendto发送、recvfrom接收应答并依据回应内容计算往返时间同时包含超时判断和错误处理逻辑代码整体脉络清晰。资源为7z压缩包内共5个文件包括两个cpp源文件、一个头文件、一个.pro工程配置及一个.user文件整体体积约5KB很适合在小体积代码包中逐行阅读或扩展实验。针对原始套接字在Windows下运行时可能遇到的管理员权限限制、防火墙拦截等实际问题示例代码和说明中给出了对应提示可帮助读者规避常见坑点。目前已有3033人学习下载尤其适合作为网络编程课程设计、小工具开发或C网络进阶的起步参考。1. 用C在Windows上还原ping命令从ICMP报文到控制台回显排查网络不通时ping是大多数人第一反应摸出来的工具——Windows里一条“ping 192.168.1.1”目标通不通、延迟多少立刻有结果。但如果你和我一样C学到套接字编程这一步大概率会好奇这个命令行背后到底发了什么包、怎么判定超时、RTT又是怎么算出来的这篇笔记把我完整拆解ping命令的过程写下来用C和Winsock的原始套接字SOCK_RAW自己构造ICMP回显请求、解析回显应答在Windows上实现一个功能上和系统ping高度一致的精简版。它解决的不只是“能不能ping通”这个结果而是让你看到ICMP报文里的每个字节、校验和算法、超时重传这些系统ping隐藏起来的细节。适合在学Winsock编程、想深入理解网络协议栈的C开发者。2. ICMP协议与原始套接字ping的底层原理与选型理由2.1 ICMP回显请求与应答的报文结构ping命令工作在TCP/IP协议栈的网络层依赖的是ICMP协议Internet Control Message Protocol互联网控制报文协议。ICMP不承载业务数据专门负责传递网络层的诊断信息和错误报告。ping用到的报文有两类回显请求Echo Request和回显应答Echo Reply。ICMP报文在IP头之后封装IP头里的Protocol字段取值为1。一个标准的回显请求报文结构如下字段长度回显请求值回显应答值Type类型1字节80Code代码1字节00Checksum校验和2字节计算写入由对端计算Identifier标识符2字节自定义与请求一致Sequence Number序列号2字节递增与请求一致Data数据可变时间戳填充原样回传Type和Code是两个独立的字节不是合并的一个字段。刚看报文时很容易搞混Type8且Code0才表示回显请求Type0且Code0表示回显应答Type11且Code0表示TTL超时——tracert工具靠的就是这个。如果你只判断Type等于几后面做应答匹配时会漏掉差错报文给排错带来麻烦。Identifier和Sequence Number不是TCP/UDP里的端口和序号它们的作用是让发送方匹配“哪个应答对应哪个请求”。Windows系统ping默认把Identifier设为当前进程IDSequence从1开始递增。我们自己实现时Identifier可以随意定但Sequence必须每次递增否则并发请求无法区分。2.2 为什么选SOCK_RAW而不是UDP或IcmpSendEcho在Windows上做ping能力我见过三条路调用IcmpSendEcho系列APIicmpapi.h由系统封装全部ICMP细节用SOCK_DGRAM套接字配合IPPROTO_ICMP用SOCK_RAW原始套接字自己构造ICMP报文、自己解析IP头。我最终选SOCK_RAW原因是IcmpSendEcho虽然省事但它是个黑匣子——报文细节看不到数据区内容不能自定义对“想搞懂原理”这个诉求没有帮助。而SOCK_DGRAM配IPPROTO_ICMP在Windows上行为非常诡异Winsock对ICMP这种无法用“连接”抽象的协议支持得残缺sendto能发但接收侧经常收不到完整报文调试成本反而更高。SOCK_RAW的代价是权限从Windows Vista开始系统对原始套接字有严格限制创建套接字和发送ICMP报文都必须以管理员权限运行。这不是偶尔提权是必须提权。不少初学项目就是栽在这一行socket()调用上什么代码都没写就返回INVALID_SOCKET。三条路线的取舍方案权限要求报文可定制性能看IP头复杂度IcmpSendEcho低低否低SOCK_DGRAMIPPROTO_ICMP中中受限中SOCK_RAWIPPROTO_ICMP高高是高一句话总结选型如果你在公司做生产工具、只关心通不通用IcmpSendEcho最稳妥如果你和我一样想把协议吃透或者要自定义报文内容做网络测量老老实实走SOCK_RAW。这篇笔记后面所有代码都基于SOCK_RAW方案。用SOCK_RAW还有额外红利——你能收到Type3目标不可达、Type11TTL超时等差错报文这对区分“网络不通”和“路由黑洞”非常关键是IcmpSendEcho给不了的信息。3. 搭建Windows下的编译环境链接Winsock库与管理员的权限3.1 工程配置ws2_32.lib与头文件的正确顺序用C写Windows网络程序绕不开Winsock。我假设你用的是Visual Studio或者VS Code MinGW-w64两种环境差别不大核心就两件事头文件要包含对、库要链接上。头文件顺序是个老坑winsock2.h必须出现在windows.h之前因为两个头文件都定义了一些同名类型顺序反了会连续报一堆重定义错误。我的固定写法#define WIN32_LEAN_AND_MEAN #include winsock2.h #include ws2tcpip.h #include windows.h #include cstdio #include cstring #pragma comment(lib, ws2_32.lib)第一行#define WIN32_LEAN_AND_MEAN告诉预处理器跳过Windows.h里不常用的大块内容能明显加快编译。#pragma comment(lib, ws2_32.lib)是MSVC专有写法链接器会自动带上Winsock库如果你用MinGW则要在命令行里加-lws2_32或者CMake里写target_link_libraries(ping ws2_32)。提示VC运行库Microsoft Visual C Redistributable在装了Visual Studio的开发机上本来就有不用额外操心。只有把你编译出的exe丢到别的Windows机器上跑时才需要确认目标机装了对应版本的运行库。Winsock还有个区别于Linux socket的强制要求用任何套接字API之前必须先调WSAStartup否则后面socket()直接返回INVALID_SOCKET。这一步的标准代码WSADATA wsaData; int rc WSAStartup(MAKEWORD(2, 2), wsaData); if (rc ! 0) { printf(WSAStartup failed: %d\n, rc); return 1; }MAKEWORD(2, 2)请求加载2.2版Winsock库这是Windows XP以后的标准版本。程序退出前记得调WSACleanup()。3.2 最小骨架创建套接字并设置接收超时配置就绪后第一段能跑的代码是创建ICMP原始套接字SOCKET sock socket(AF_INET, SOCK_RAW, IPPROTO_ICMP); if (sock INVALID_SOCKET) { printf(socket() failed: %d\n, WSAGetLastError()); return 1; }三个参数的含义AF_INET指定IPv4SOCK_RAW表示原始套接字IPPROTO_ICMP限定只处理ICMP报文。第三个参数容易被忽略——没有它原始套接字会收到所有目标为本机的IP包加上它等于让内核只向上层递送ICMP报文。局域网里如果混有大量ARP或广播流量不加这个参数会让recvfrom收到一堆无关数据。接着设置接收超时。ping是“发一个、等一个”目标不回包时不能无限卡在recvfrom里。Windows上最直接的办法是SO_RCVTIMEOint timeoutMs 3000; setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, (const char*)timeoutMs, sizeof(timeoutMs));这里要注意Windows和Linux的差异Windows下SO_RCVTIMEO接受一个int型毫秒数Linux下则是struct timeval。代码如果要从Windows移植到Linux这行必须单独处理不能直接套用。设成3000代表recvfrom最多阻塞3秒超时后返回SOCKET_ERRORWSAGetLastError()得到WSAETIMEDOUT(10060)。不建议设成0——0在Windows上表示无限等待目标不回包程序就永远挂死。到这里一个能创建ICMP套接字、带3秒接收超时的最小骨架就完成了。下一步是真正往套接字里填数据构造ICMP报文。4. 核心逻辑ICMP报文构造、校验和与超时重传4.1 校验和算法为什么“进位回卷”决定对端信不信你ICMP头里最关键也最容易写错的字段是校验和。它的计算规则由RFC 1071规定把报文按16位一组做二进制反码求和再把结果取反写入校验和字段。具体可以拆成三步计算校验和前Checksum字段必须清零从报文开头按16位累加每轮进位超出16位的高位回卷到低16位最终结果按位取反。第3步“进位回卷”是最常被漏掉的地方。很多人写完函数直接return ~sum;就跑了结果发出去的包被对端校验失败后静默丢弃——不是“能通但慢”是根本不通。C实现如下unsigned short calcChecksum(const void* data, int len) { const unsigned short* buf static_castconst unsigned short*(data); unsigned long sum 0; while (len 1) { sum *buf; len - 2; } if (len 1) { sum *(unsigned char*)buf; } while (sum 16) { sum (sum 0xFFFF) (sum 16); } return (unsigned short)(~sum); }这里unsigned long在Windows上占32位每轮累加不会立刻溢出。第二个while (sum 16)就是进位回卷如果第17位上有值把低16位和第17位继续加加到没有进位为止。最后取反得到校验和。提示校验和计算时报文的Type、Code、Identifier、Sequence、Data全部参与唯独Checksum字段本身必须先置0。对端收到后按同样算法对整包重算结果为0xFFFF说明报文完整否则直接丢弃。还有个和字节序有关的细节这段代码以主机序累加16位内存单元最终写入校验和字段时必须转成网络字节序也就是packet.checksum htons(calcChecksum(...))。在x86小端机器上报文数据的内存本身是小端排列的累加时按小端16位字处理最后发送时校验和字段再按网络序存——这套组合和Wireshark抓到的系统ping包校验一致。如果当年我把这笔账记反恐怕会一直卡在“算出来的校验和总是差那么一点”的玄学问题上。4.2 构造回显请求并发送ICMP报文我用结构体按字节流定义关键是#pragma pack(push, 1)禁止编译器对齐填充#pragma pack(push, 1) struct IcmpEchoPacket { unsigned char type; // 8Echo Request unsigned char code; // 0 unsigned short checksum; unsigned short identifier; unsigned short sequence; unsigned long timestamp; // 发送时刻 char data[32]; // 填充数据 }; #pragma pack(pop)#pragma pack(push, 1)让结构体按1字节对齐避免编译器插入空洞。这一行漏掉的后果非常隐蔽报文尺寸和协议对不上校验和永远差几字节的量抓包看半天看不出来。写网络协议结构体时这个宏是标准姿势。填充各字段并发送的完整流程IcmpEchoPacket packet; memset(packet, 0, sizeof(packet)); packet.type 8; packet.code 0; packet.identifier htons(GetCurrentProcessId() 0xFFFF); packet.sequence htons(seq); packet.timestamp htonl(GetTickCount()); packet.checksum htons(calcChecksum(packet, sizeof(packet))); sockaddr_in dest; dest.sin_family AF_INET; inet_pton(AF_INET, targetIp, dest.sin_addr); dest.sin_port 0; int sent sendto(sock, (const char*)packet, sizeof(packet), 0, (sockaddr*)dest, sizeof(dest)); if (sent SOCKET_ERROR) { printf(sendto error: %d\n, WSAGetLastError()); }几个容易踩的点identifier取进程ID低16位是为了模拟系统ping的行为sin_port置0ICMP没有端口概念不要填任何数字。多字节字段先转网络序再参与校验和计算这个顺序不能乱。GetTickCount()返回系统启动以来的毫秒数用htonl转成网络序放进报文——它的用途是对端原样回传我们拿到应答后对比它和当前时刻的差值就是RTT。4.3 接收应答与RTT计算发送后对端如果可达会回一个Echo Reply。注意recvfrom拿到的缓冲里首先是20字节IP头往后才是ICMP头因此接收缓冲要开足一般256字节起步char recvBuf[256]; sockaddr_in from; int fromLen sizeof(from); int bytes recvfrom(sock, recvBuf, sizeof(recvBuf), 0, (sockaddr*)from, fromLen);recvfrom返回后先跳过IP头。IP头第一字节的低4位是IHL首部长度单位是32位字所以IP头长度是(recvBuf[0] 0x0F) * 4字节。多数正常包是20字节但保不准有带选项的包动态算更稳int ipHeaderLen (recvBuf[0] 0x0F) * 4; const IcmpEchoPacket* reply reinterpret_castconst IcmpEchoPacket*(recvBuf ipHeaderLen);取出应答后先校验回显类型是不是0再用之前保存的timestamp计算RTTif (reply-type ! 0) { printf(unexpected ICMP type: %d\n, reply-type); return; } unsigned long sendTime ntohl(reply-timestamp); unsigned long elapse GetTickCount() - sendTime; printf(Reply from %s: bytes%d time%lums\n, targetIp, bytes - ipHeaderLen, elapse);GetTickCount计算本机两个时间点之差是正确的但它有个边界系统运行49.7天后这个32位值会回绕归零。工程化的做法是改用GetTickCount64()它返回64位值回绕问题几乎不存在。如果是给真实环境做工具直接用GetTickCount64替换即可输出时再把差值转成unsigned long。5. 排错避坑Windows下实现ping的五个常见问题5.1 创建套接字直接报WSAEACCES现象socket()返回INVALID_SOCKETWSAGetLastError()返回WSAEACCES(10013)。代码一行没改换个机器就崩。原因Windows Vista之后的UAC机制限制原始套接字非管理员进程没有权限创建SOCK_RAW套接字。这是系统级限制不是代码问题。解决以管理员身份运行。开发期可以在Visual Studio的项目属性里找到“链接器→清单文件→UAC执行级别”改成requireAdministrator编译出的exe每次运行都会弹UAC确认框。命令行调试就在管理员CMD里跑。注意如果你用VS Code的任务栏跑程序那个终端本身也必须是管理员权限的。5.2 sendto成功但recvfrom一直WSAETIMEDOUT现象sendto返回了完整字节数但recvfrom每次都超时同一时间在CMD里用系统ping同一个IP完全正常。原因Windows防火墙拦截了入站ICMP回显请求。你用原始套接字发出去的数据包应答回来时被防火墙视为未知应用的入站流量在门口就被丢弃。解决两个取向。一是临时放行进“高级安全Windows防火墙→入站规则→文件和打印机共享(回显请求-ICMPv4-In)”启用它。二是只给自己的exe加防火墙允许规则。注意调试完把防火墙恢复原状为跑个程序关系统防护不划算。5.3 校验和算错对端静默丢包现象sendto每次都成功对端就是不回包Wireshark抓包能看到请求发出了目标主机侧却没有任何响应。原因校验和计算少了进位回卷或者结构体没做1字节对齐导致报文尺寸和实际不符或者多字节字段忘了转网络字节序。三者任一发生对端计算出来的校验和都不是0xFFFF直接丢弃。解决单独写校验和单测。用Wireshark抓一个系统ping的包把ICMP报文原始字节拷出来用你的calcChecksum算一遍和抓包里的Checksum对比。不一致就依次排查type/code值是否正确、checksum字段是否先清零、多字节是否转换网络序、结构体是否pack对齐。5.4 系统ping通自己程序持续超时现象目标IP用系统ping完全正常自己的程序跑起来全部超时错的还是同一个IP。原因八成是SO_RCVTIMEO设得太小在目标机是无线链路或跨运营商线路时正常RTT可能已经超过你的3秒阈值。另外防火墙对系统ping和自定义exe的放行策略不同——系统ping是受信任程序你的exe不在名单里即使请求发出、应答也回来了recvfrom仍可能等不到数据。解决先把超时调到8000毫秒做对照测试排除时序问题。再用Wireshark在发送端抓包确认请求是否真的出去、应答是否真的回来。如果两步都正常但还是timeout去防火墙里给你的exe加允许规则。5.5 收到应答但RTT异常忽大忽小甚至负值现象time字段偶尔几百毫秒偶尔为负和系统ping的稳定数值对不上。原因最常见的是应答匹配逻辑没写对。如果只判断“是ICMP应答”就直接算RTTWindows上其他进程的ICMP流量也会混进来sequence对不上号自然算出负值或巨值。RTT波动大则是正常的网络现象——无线重传、CPU调度、防火墙QoS都会影响。解决接收逻辑必须同时校验identifier和sequence。identifier过滤其他进程的应答sequence匹配当前发送的请求。收到一个包后先比对sequence不匹配就丢弃、继续等下一个而不是当作本次结果。这也是ICMP报文同时设计这两个字段的原因——它们就是干这个用的。6. 进阶解析TTL、连续探测与丢包率统计6.1 从IP头提取TTL并输出接收缓冲里IP头的第8字节正好是TTL字段。加上它输出格式就贴近系统ping了int ttl recvBuf[8]; int ipHeaderLen (recvBuf[0] 0x0F) * 4;这里recvBuf[8]取的是IP头中偏移8的TTL和IP头长度无关TTL固定在这个位置printf(Reply from %s: bytes%d time%lums TTL%d seq%d\n, targetIp, bytes - ipHeaderLen, elapse, ttl, ntohs(reply-sequence));TTL是很有价值的观测量Windows系统默认TTL是128Linux默认64如果ping百度回来TTL在50左右说明中间经过了多跳路由转发。做网络测量时TTL能辅助判断链路结构。6.2 连续探测丢包率与RTT分布统计单发一个包没有统计意义。工程用的ping都是连续探测统计丢包率和RTT的min/avg/max。方法是在循环里做“发一个、收一个”int sentCount 0, recvCount 0; unsigned long minRtt ~0UL, maxRtt 0, sumRtt 0; for (int i 0; i 100; i) { // 构造请求sequence i sendto(sock, ...); int result recvfrom(sock, ...); if (result ! SOCKET_ERROR) { recvCount; sumRtt elapse; minRtt minRtt elapse ? minRtt : elapse; maxRtt maxRtt elapse ? maxRtt : elapse; } Sleep(500); } double lostRate (sentCount - recvCount) * 100.0 / sentCount; printf(sent%d recv%d lost%.1f%% min/avg/max%lu/%lu/%lu ms\n, sentCount, recvCount, lostRate, minRtt, sumRtt / recvCount, maxRtt);Sleep(500)不是多余的——连续高频发ICMP包容易被系统或中间路由器的限速机制盯上也可能干扰局域网的正常流量。我习惯在测量场景下保持500毫秒以上间隔数据才可信。丢包率有个理解边界要交代清楚这里的“丢包”只代表“本机在超时时间内没等到应答”不能直接定性为目标不可达。路由黑洞、对端防火墙丢弃、中间节点限速都会表现为丢包。想进一步区分就得看差错报文——Type3是目标不可达Type11是TTL超时。这些报文用SOCK_RAW的recvfrom本来就能收到判断返回包的type字段即可这算当初选SOCK_RAW的回报。我把这段逻辑封装成函数后先和系统ping做了对照局域网内同一个目标我的程序跑100次RTT均值与系统ping的差异稳定在±1毫秒内TTL完全一致。这个验证方法值得你也跑一遍——在CMD里先跑系统ping再跑自己程序对比同目标的结果。如果差异明显优先检查timestamp是不是记录的发送时刻、应答匹配逻辑是否漏了sequence校验其次是超时阈值是否太小。从那以后我每次写网络诊断工具都会强制走一遍“系统命令对照测试”同一目标自己的程序和系统工具各跑固定次数对比丢包率和RTT均值。这个习惯帮我抓出过不止一次校验和与字节序的疏漏。希望你也能在自己的环境里把这套代码跑通亲手体验一次从零构造网络探测工具的过程希望帮到你。本文还有配套的精品资源点击获取
返回列表