
简介这套C工程源码演示了在Windows环境下使用WinSock2库实现ping命令的完整流程面向需要掌握ICMP协议、原始套接字编程的网络开发者与计算机专业学生可快速搭建一个具备网络连通性检测功能的命令行工具。压缩包共5个文件包含2个C源文件、1个头文件和Qt工程配置文件整体仅5KB代码结构紧凑清晰便于直接阅读和编译调试。源码涵盖了WSAStartup初始化、创建SOCK_RAW原始套接字、地址解析、ICMP回显请求与应答数据包构造、校验和计算以及超时接收、序列号匹配和往返时间统计等关键步骤另外还针对Windows安全策略下需要管理员权限运行的实际情况给出了必要提醒。已有3033人学习下载对于想学习Windows网络编程、ICMP报文交互或理解ping工作原理的读者是一份精简实用的参考代码也可作为进一步扩展多线程批量探测与参数解析的起点。1. 用C在Windows上实现ping不只是调命令而是把协议握在手里在Windows下写网络监控、做设备巡检或者调试一个刚烧好系统的新开发板第一个想到的验证动作就是ping。但系统自带的ping.exe只能把结果打到控制台你要统计丢包率、把延迟喂给监控面板、控制探查频率它就不好使了。这时候用C自己实现一个ping功能你可以精确控制包大小、超时、TTL和重试策略并把结果直接交给业务代码。这篇文章从ICMP协议讲起带你用Win32 API写一个最少依赖的ping实现再扩展成可用的网络工具。适合网络开发、嵌入式调试和做自动化测试的同行参考。2. 先搞懂ping在Windows下的三种实现路径从ICMP报文到API选型2.1 ICMP回显请求/应答报文一次ping要拆成哪些字段ping命令本质上是发一个ICMP Echo Request类型8报文给目标主机目标主机收到后回一个ICMP Echo Reply类型0。网络层协议号是1。报文结构如下第一字节类型第二字节代码第三和第四字节校验和第五和第六字节标识符Identifier第七和第八字节序列号Sequence Number后面跟着用户可选的Data区。对于C工程师来说最容易翻车的是校验和的计算。它把整个ICMP报文按16位一组累加如果和溢出则回卷最后取反。Windows下IcmpSendEcho已经帮你算了但如果你后续要用原始套接字SOCK_RAW校验和必须自己写。这里给你一份可以直接复用的校验和函数它同时也是后面所有实现的基础unsigned short CalculateChecksum(unsigned short* buffer, int length) { unsigned long sum 0; while (length 1) { sum *buffer; length - 2; } if (length 1) { sum *(unsigned char*)buffer; } while (sum 16) { sum (sum 0xFFFF) (sum 16); } return (unsigned short)(~sum); }这段逻辑并不长但如果你把长度按“字节数”传进去而忘了强制类型转换算出来的校验和不正确抓包看到校验和错误怎么调都是“请求超时”。所以我要强调调用时传的是整个ICMP报文的字节长度不是数据区长度中间累加变量用unsigned long不要用short否则中间溢出会丢掉进位。2.2 三种实现方式对比IcmpSendEcho、Raw Socket、还是第三方库在Windows平台理论上你有三条路。第一调用系统封装好的IcmpSendEcho系列API。它位于icmpapi.h中链接iphlpapi.lib。系统会帮你完成ICMP报文构造、校验和、超时重发、接收和解析你只需要传目标IP、发送数据和接收缓冲区。它的最大优点是运行时不强制需要管理员权限普通用户就能跑非常适合做应用层工具。第二创建SOCK_RAW原始套接字自己收发。这条路自由度最高可以自定义IP头和ICMP字段比如TTL、分片标志、自定义类型。但Windows上原始套接字有诸多限制发送ICMP时系统会补一部分头部接收时需要自己解析IP首部还必须用管理员权限运行。如果你只是做网络层协议研究推荐走这条路但如果是为了快速交付一个可用的工具我一般不选它。第三用开源的跨平台库比如libpcap / WinPcap直接抓包发送。这需要安装底层驱动的服务在用户安装软件时连驱动一起装商业化落地的成本很高只在做特殊网络分析时才考虑。我给一个对比表你可以在决定选型时快速比一比实现方式是否需要管理员权限是否依赖额外驱动/库灵活性适用场景系统API IcmpSendEcho否仅系统自带iphlpapi中监控、巡检、批量探测Raw Socket是否高协议研究、自定义报文、路由追踪libpcap/WinPcap是需要安装驱动高抓包分析、二次封装本文后续代码全部走第一路线。原因很简单实现ping的诉求是用最小成本拿到“目标通不通、延迟多少、丢多少包”这些信息用IcmpSendEcho已经能全部拿到没必要为了炫技引入管理员权限和平台限制。另一个隐藏原因是Raw Socket在Windows的某些安全策略下会被杀毒软件或防火墙拦截调试成本不可预测。2.3 先理解IcmpSendEcho的函数签名在写代码之前我建议你先花一分钟看懂这个API的签名很多常见问题都出在参数含义没搞清DWORD IcmpSendEcho( HANDLE IcmpHandle, // 来自IcmpCreateFile的句柄 IPAddr DestinationAddress, // 目标IPv4地址网络字节序 LPVOID RequestData, // 发送数据缓冲区 WORD RequestSize, // 发送数据长度 PIP_OPTION_INFORMATION RequestOptions, // 可选参数如TTL LPVOID ReplyBuffer, // 接收缓冲区 DWORD ReplySize, // 接收缓冲区长度 DWORD Timeout // 超时时间毫秒 );需要特别注意DestinationAddress是网络字节序的IP地址你可以直接用inet_addr(192.168.1.1)得到但不推荐它不支持IPv6且对于非法输入返回INADDR_NONE后续我会用getaddrinfo解析这样域名和IP都能处理。ReplyBuffer不是一行字节数组它会被系统填成一个或多个ICMP_ECHO_REPLY结构体当RequestData长度太大导致回显被分片时会有多条回复。所以ReplyBuffer的长度要按sizeof(ICMP_ECHO_REPLY)加上你的数据长度来预留只多不少。返回值要分情况看如果返回0说明调用本身失败看GetLastError如果返回非0说明请求被处理了但具体通不通要看ReplyBuffer里的Status字段。这个差异很多人第一次都会搞混我在后面避坑章节会再提。2.4 看懂ICMP状态码从IP_SUCCESS到IP_TTL_EXPIREDIcmpSendEcho返回后ReplyBuffer是一个ICMP_ECHO_REPLY结构体数组第一个成员的Status字段会告诉你这次探测的网络层结果。常见的几个状态码是状态码含义常见诱因IP_SUCCESS收到正确回显目标正常IP_TIMEDOUT超时未收到回显防火墙拦截、设备离线、链路丢包IP_TTL_EXPIREDTTL减少到0路由跳数过多或环路IP_DEST_HOST_UNREACHABLE目标不可达路由表无可用路径IP_BUF_TOO_SMALL接收缓冲区太小ReplyBuffer长度没给够很多人看到IP_TIMEDOUT就认为是网络断了其实更常见的是Windows防火墙没有开启“文件和打印机共享”的入站规则或者目标Linux主机的iptables直接丢弃了ICMP。另一种易混淆的是IP_TTL_EXPIRED它表示你的包被某个路由器的TTL计数器归零丢弃而不是目标主机拒绝回应。在调试路由跟踪时这个状态码反而是有用的信号。3. 最小可运行的C ping用IcmpSendEcho从初始化到回显3.1 初始化WinSock与创建ICMP句柄有了前面的前置知识下面开始写第一个版本。我用VS2019或VS2022新建一个控制台应用Windows SDK版本不要选太老保持默认。如果用的是VS Code先配置好C/C编译环境然后手动链接ws2_32.lib和iphlpapi.lib如果用的是Visual Studio直接在代码里用#pragma comment(lib, ...)就行。首先是初始化与句柄创建#include winsock2.h #include iphlpapi.h #include icmpapi.h #include ws2tcpip.h #include stdio.h #include stdlib.h #include string.h #pragma comment(lib, ws2_32.lib) #pragma comment(lib, iphlpapi.lib) HANDLE InitIcmpHandle() { WSADATA wsaData; if (WSAStartup(MAKEWORD(2, 2), wsaData) ! 0) { printf(WSAStartup failed: %d\n, WSAGetLastError()); return INVALID_HANDLE_VALUE; } HANDLE handle IcmpCreateFile(); if (handle INVALID_HANDLE_VALUE) { printf(IcmpCreateFile failed: %d\n, GetLastError()); WSACleanup(); return INVALID_HANDLE_VALUE; } return handle; }这段代码做了两件事初始化WinSock库以及创建一个ICMP工作句柄。WSAStartup不是“必须”的吗是的IcmpSendEcho内部会用到WinSock相关能力尤其你后面用getaddrinfo解析主机名时不初始化会直接返回WSAEFAULT。IcmpCreateFile每次调用会分配内核资源程序结束时用IcmpCloseHandle释放。如果你写了多个线程务必每个线程各创建自己的句柄不要共用否则在高并发场景下会出现莫名失败。3.2 解析目标地址域名、IPv4、IPv6一次说清这一步很多人喜欢用inet_addr但它有两个问题一是解析不了域名二是对于“255.255.255.255”这种广播地址会返回INADDR_NONE导致误判。我们用getaddrinfo统一处理bool ResolveTarget(const char* host, IPAddr* outAddr) { addrinfo hints {}; hints.ai_family AF_INET; // 只取IPv4ICMP API不支持IPv6 hints.ai_socktype SOCK_RAW; hints.ai_protocol IPPROTO_ICMP; addrinfo* result nullptr; if (getaddrinfo(host, nullptr, hints, result) ! 0) { return false; } sockaddr_in* sa reinterpret_castsockaddr_in*(result-ai_addr); *outAddr sa-sin_addr.S_un.S_addr; freeaddrinfo(result); return true; }这里的hints.ai_socktype写成SOCK_RAW是标识这是一个网络层探测不是必须的但能避免拿到一些奇怪的协议簇。如果你传的目标是IPv6地址getaddrinfo会因为ai_familyAF_INET而解析失败。要支持IPv6得用IcmpSendEcho6接口不同本文先不展开。解析完成后得到的IP地址已经是网络字节序可以直接传给IcmpSendEcho。提示不要试图用这个函数解析“www.baidu.com”后直接打印sin_addr地址是网络字节序要打印需用inet_ntop转成字符串否则数字会让人误解。3.3 发送一次回显请求并解析应答现在把核心函数拼在一起。我直接给一个可以直接跑的完整函数bool PingTarget(const char* host, DWORD timeout, DWORD dataSize, HANDLE hIcmp, DWORD* outRtt) { IPAddr dest 0; if (!ResolveTarget(host, dest)) { printf(Cannot resolve %s\n, host); return false; } char* sendData new char[dataSize]; memset(sendData, A, dataSize); DWORD replySize sizeof(ICMP_ECHO_REPLY) dataSize 8; char* replyBuffer new char[replySize]; memset(replyBuffer, 0, replySize); DWORD ret IcmpSendEcho(hIcmp, dest, sendData, (WORD)dataSize, nullptr, replyBuffer, replySize, timeout); if (ret 0) { DWORD err GetLastError(); printf(IcmpSendEcho error: %lu\n, err); delete[] sendData; delete[] replyBuffer; return false; } ICMP_ECHO_REPLY* reply reinterpret_castICMP_ECHO_REPLY*(replyBuffer); if (reply-Status ! IP_SUCCESS) { printf(Ping %s failed, status%lu\n, host, reply-Status); delete[] sendData; delete[] replyBuffer; return false; } *outRtt reply-RoundTripTime; printf(Reply from %s: bytes%lu time%lums TTL%lu\n, host, dataSize, reply-RoundTripTime, reply-Options.Ttl); delete[] sendData; delete[] replyBuffer; return true; }这里有几个参数必须解释replySize为什么要比sizeof结构体多8因为ICMP_ECHO_REPLY结构体最后是一个长度为1的Data数组系统会把收到的回显数据串联在结构体后面。如果你只给sizeof(ICMP_ECHO_REPLY)那么大会触发IP_BUF_TOO_SMALL错误。多给dataSize8可以确保够用实际上8是ICMP头但很多样例直接加8或加两份dataSize我习惯多留防越界。第二个要说明的是IcmpSendEcho第一次可能失败但分布式网络里首次ARP缓存为空有时需要多发一次。如果你在做慢速设备探测比如开发板启动阶段可能前几秒一直超时稍后恢复。所以不要因为第一次失败就立刻判定目标离线后面会讲重试策略。3.4 命令行参数与循环发送为了更像真正的ping命令我加一个循环入口int main(int argc, char* argv[]) { const char* target argc 1 ? argv[1] : 127.0.0.1; DWORD dataSize argc 2 ? atoi(argv[2]) : 32; DWORD timeout argc 3 ? atoi(argv[3]) : 2000; int count argc 4 ? atoi(argv[4]) : 4; HANDLE hIcmp InitIcmpHandle(); if (hIcmp INVALID_HANDLE_VALUE) return 1; for (int i 0; i count; i) { DWORD rtt 0; bool ok PingTarget(target, timeout, dataSize, hIcmp, rtt); Sleep(500); if (!ok) { printf(Request timed out.\n); } } IcmpCloseHandle(hIcmp); WSACleanup(); return 0; }调用方式pingcli.exe 192.168.1.1 128 1000 10。参数顺序是目标、发送数据字节数、超时毫秒、发送次数。我把Sleep(500)放在两次之间避免连环请求把设备打崩。真实ping.exe默认间隔1秒这里可以改成你需要的频率。如果你需要高频率探测至少把Sleep降为100但要注意目标设备能不能扛得住。4. 把玩具变成工具自定义包大小、解析主机名、连续ping统计4.1 自定义发送包大小与填充数据前面代码中dataSize可以随意改但有一个边界知识以太网帧MTU是1500字节IP头至少20字节ICMP头8字节所以最大可靠的数据部分是1500 - 20 - 8 1472字节。如果你超过1472IP层会分片。分片带来的问题是中间链路如果禁止分片或者某个路由器丢片ping就会超时但目标实际在线。这也是很多人在外网ping大包时会“时通时断”的原因。你可以加大数据量测试一个链路能否承载大包。我一般会先把包大小从32、512、1024、1472这样递增观察RTT是否突然跳变。代码里填充数据时建议把当前时间戳或序列号放进去这样在抓包时能对得上。for (DWORD i 0; i dataSize; i) { sendData[i] (char)(i % 256); }这样填充的好处是接收端可以校验收到的是不是原始数据。但IcmpSendEcho本身不校验数据如果你的回复数据是从目标回显回来的你可以解析reply-Data区域对比。对于Windows目标机它会把你的整个sendData原样回显对于某些路由器或嵌入式设备可能只回显部分内容。所以不要盲目假设返回数据长度一定等于发送长度。4.2 解析主机名如何区分“ping IP”和“ping 端口”第3章我们已经用了getaddrinfo解析域名。这里再补充一个高频问题有人总问“怎么ping服务器的IP和端口”。请记住ICMP没有端口概念端口是TCP/UDP协议的东西。ping一个域名解析后送到的是网络层不经过传输层。如果你想检查某个端口是否开放正确的做法是用TCP connect或TCP SYN。C里可以创建一个SOCK_STREAM套接字调用connect(ip, port)如果返回成功说明端口开放超时则关闭。想在一个工具里同时ping主机和TCP端口你可以先解析目标IP然后各做一套。4.3 连续ping的统计指标丢包率、平均延迟与抖动真正上生产环境不能只看单次响应要看连续统计。我在监控里通常输出三样丢包率、平均延迟、延迟抖动最大和最小差值。代码逻辑不复杂但建议单独封装一个统计结构struct PingStats { DWORD sent; DWORD received; DWORD minRtt; DWORD maxRtt; DWORD totalRtt; }; void UpdateStats(PingStats* stats, DWORD rtt) { stats-sent; if (rtt ! (DWORD)-1) { stats-received; stats-totalRtt rtt; if (rtt stats-minRtt || stats-minRtt 0) stats-minRtt rtt; if (rtt stats-maxRtt) stats-maxRtt rtt; } } void PrintStats(const PingStats stats) { printf(Packets: Sent%lu, Received%lu, Lost%lu (%.2f%% loss)\n, stats.sent, stats.received, stats.sent - stats.received, (stats.sent - stats.received) * 100.0 / stats.sent); if (stats.received 0) { printf(RTT: min%lu ms, avg%lu ms, max%lu ms, jitter%lu ms\n, stats.minRtt, stats.totalRtt / stats.received, stats.maxRtt, stats.maxRtt - stats.minRtt); } }我这里的rtt用-1表示超时。注意DWORD无符号所以判断用了一个特殊值。在循环里每次PingTarget失败时就传入-1。这个统计里没有计算标准差但jittermax-min已经能看出是否抖动剧烈。如果平均延迟正常但jitter很大比如从1ms跳到500ms多半是链路拥塞或设备CPU忙尤其嵌入式设备在刷写Flash时会周期性卡顿。5. 避坑指南为什么你的ping会“一般故障”或时通时断5.1 防火墙拦截导致的“请求超时”现象在Windows上自己用IcmpSendEcho ping一个局域网的Linux主机返回超时用系统ping.exe也超时但两边IP能互相访问其它端口。原因Windows防火墙的入站规则默认只放行部分ICMP类型很多机型把“ICMPv4-In 回显请求”设为阻止。我自己调试时也吃过这个亏一开始以为是代码问题后来发现目标主机从没回应过ping。解决在目标Windows主机执行netsh advfirewall firewall add rule nameICMP Allow protocolicmpv4:8,any dirin actionallow放行回显请求。如果是嵌入式Linux开发板用iptables放行ICMP。注意ping不需要出站规则放行入站回显请求被放行后出站回显应答也会被对应应答规则允许。5.2 IcmpSendEcho返回0和返回非0的判定混淆现象IcmpSendEcho调用后ret变量一直是0GetLastError返回0程序却没有输出任何信息最后把返回值强转成reply pointer直接访问无效内存崩溃。原因IcmpSendEcho返回0表示调用本身失败这时的ReplyBuffer内容未定义不能当结构体用GetLastError为0是常见的“伪错误”很多情况是因为句柄没创建成功但你用了。另一个常见原因是传输功能未初始化。解决先判断hIcmp是否是INVALID_HANDLE_VALUE再判断ret 0时只打印GetLastError并返回。只有ret 0才去访问ReplyBuffer。另外ReplyBuffer必须用有效的堆内存不要放一个栈上小数组就传指针缓冲区太小会返回IP_BUF_TOO_SMALL。5.3 结构体对齐在64位系统上的隐性坑现象同样的代码在32位机上正常拿到64位机上编译后reply-RoundTripTime经常是0或乱值Status也不对。原因Windows头文件中的ICMP_ECHO_REPLY结构体默认对齐是4字节还是8字节取决于编译选项。如果你在包含Windows.h之前定义了#pragma pack(1)整个结构体对齐被打乱成员偏移量变化解析自然错位。解决不要在系统头文件之前改变pack对齐如果有特殊需要只在自定义结构体周围使用#pragma pack(push, 1)和#pragma pack(pop)不要影响系统API头。如果你确实需要更小的结构体来模拟ICMP报文用一个单独的头文件并在所有系统头文件之后include。5.4 缓存或旧ARP导致的“ping开发板IP时通时断”现象ping一块开发板有时通有时超时平均延迟从1ms跳到数百ms甚至中途丢包率20%以上。原因开发板网口PHY工作状态异常、IP冲突或ARP缓存过期。常见原因包括开发板供电不足造成网口偶尔掉线开发板和主机不在同一网段但靠路由器转发更隐蔽的是开发板的MAC地址被另一块板子占用交换机端口学了之后到处漂移。解决先用交叉网线直连排除交换机问题固定开发板IP并在PC上先arp -d *再测试把包大小降低到64字节观察还会不会超时。如果直连情况下依然时通时断基本就是开发板硬件或驱动问题不是你的ping程序问题。我之前调试嵌入式时遇到一个案例是开发板软件启用了“休眠时关网口”的电源策略导致一段时间不通信就会断。5.5 解析域名时拿到IPv6地址导致IcmpSendEcho拒绝现象用getaddrinfo解析一个双栈域名比如localhost在Windows下返回的是IPv6 ::1传给IcmpSendEcho后一直报无法访问而系统ping localhost可以通。原因IcmpSendEcho只支持IPv4不支持IPv6地址。Windows的ping.exe会根据目标自动选择IPv4或IPv6但我们的API并没有自动降级。解决在getaddrinfo的hints里必须设置hints.ai_family AF_INET把返回强制限定为IPv4。如果你的工具要支持IPv6则换用IcmpSendEcho6并传IPv6地址结构。维护双栈支持不是不行但会增加很多代码多数内部工具只用IPv4即可。6. 进阶用异步API和并发探测打造自己的网络巡检小助手6.1 用IcmpSendEcho2解决同步阻塞IcmpSendEcho是同步阻塞的一个目标发一次探测如果超时5秒线程就卡5秒。批量巡检50台设备一次循环最坏可能要250秒。系统给了一个替代品IcmpSendEcho2可以在后台执行通过事件或回调通知结果。基本用法是把参数字段中的EventOrApc置为一个CreateEvent创建的事件句柄然后把函数调用放到工作线程里。写起来比同步版复杂但收益显著可以在数十毫秒内同时发起几十个探测CPU也远低于开几十个线程。这里给一个用事件等待的示意HANDLE hEvent CreateEvent(nullptr, FALSE, FALSE, nullptr); // 每个目标对应一个IcmpSendEcho2调用传入同一个hEvent // 然后WaitForMultipleObjects等待这些事件全部超时我实际项目里是用线程池配合每个目标一个句柄回调返回后更新一个公共统计结构。要注意事件模式用自动重置事件且每次调用前要ResetEvent防止上一次的信号误触发。6.2 三种验证结果的方法写完之后怎么确认自己的程序真能反映网络情况我有三个习惯。第一拿系统自带ping.exe同参数对比包大小、次数、超时都设成一样看平均延迟差值是否在合理范围。第二用Wireshark抓包过滤icmp或icmpv6确认每次程序调用确实产生了ICMP请求和回显应答并且TTL和校验和正确。第三人为制造故障源比如把目标网线拔掉确认程序在超时后能正确进入超时分支而不是卡死。这三点都过了基本可以放心交付。6.3 下一步加上日志和告警才算闭环最后我习惯性给这个工具加两个小功能把每次探测结果写成CSV记录时间戳、目标、RTT、丢包状态如果连续丢包超过3次弹一个系统托盘通知。这样它不仅是一个调试工具能直接当监控看门狗用。我在之前一个网络巡检项目里就是靠这套方案一次性扫描了60多台设备异步版本总耗时在2秒内而同步版本要一分多钟。这个教训告诉我很多性能瓶颈不是协议本身而是没用对Windows已经提供的API。希望这篇笔记能帮你少踩几个坑做出自己的ping工具。本文还有配套的精品资源点击获取