简介:面向Windows平台网络编程初学者与希望理解ICMP协议底层实现的中级开发者,这份资源以C++结合WinSock2库完整演示了ping命令的核心逻辑,涵盖Winsock初始化、原始套接字创建、目标主机地址解析、ICMP回显请求报文构造与发送、应答接收与超时判定,以及套接字关闭与资源清理等关键环节,可帮助读者打通从协议原理到代码落地的链路。压缩包共5个文件,约5KB,以2个cpp源文件与1个h头文件为主体承载核心实现,另含1个pro工程文件与1个user配置,便于直接导入Qt环境编译调试。目前已有3035人学习下载,适合作为网络诊断工具开发、课程设计或协议分析练习的参考样例,读者可从中获得可运行的工程骨架、报文结构处理思路与常见错误处理范式,并据此扩展命令行参数解析、多线程探测与权限适配等细节。
1. 从一条 ICMP 回显说起:这个 C++ 资源到底能干什么
很多人第一次接触网络编程,不是写 TCP 回显服务器,而是想搞明白ping到底怎么把「对方在不在」这件事问出来的。命令行里敲一句ping 8.8.8.8,几行延迟数字就出来了,看着像玄学,其实背后是一套非常朴素的机制:构造 ICMP Echo Request,发出去,等 Echo Reply,算往返时间。这个 C++ 资源干的就是把这条链路用 Winsock 亲手搭一遍,让你不再把ping当黑匣子。
它面向的是 Windows 平台,用原始套接字(raw socket)直接收发 ICMP 报文,自己算校验和、自己解析 IP 头,最终输出和系统ping类似的统计结果。适合两类人:一是刚学完 socket API、想找个能跑通的小项目练手的;二是做网络诊断工具、需要把连通性探测嵌进自己程序里的从业者。下面按「原理立住 → 代码复现 → 参数与坑」的顺序拆,能抄的地方直接给代码。
2. 原理先立住:ICMP 报文结构与 Winsock 原始套接字
在动手写代码之前,得先搞清楚两件事:ICMP 报文长什么样,以及 Windows 下怎么拿到发原始报文的权限。这两点没弄明白,后面调试基本靠猜。
2.1 ICMP Echo 报文的字段布局
ICMP 报文紧跟在 IP 头后面,Echo Request 和 Echo Reply 共用同一套结构,靠 Type 字段区分。一个最小的 Echo 报文只有 8 字节头加可变数据区:
| 字段 | 长度 | 说明 |
|---|---|---|
| Type | 1 字节 | 请求填 8,回复是 0 |
| Code | 1 字节 | Echo 类型固定为 0 |
| Checksum | 2 字节 | 覆盖整个 ICMP 报文的反码和 |
| Identifier | 2 字节 | 用来匹配请求与回复,通常填进程 ID |
| Sequence | 2 字节 | 序号,每发一个加一 |
| Data | 可变 | 载荷,常填时间戳或固定填充 |
Identifier 和 Sequence 是配对的钥匙。你发出去一串请求,回来的回复可能乱序甚至丢失,靠这两个字段才能把「哪个回复对应哪次发送」对上号,进而算出每一次的 RTT。数据区里塞一个发送时间戳是常见做法,收到回复时直接读出来做差,比在本地维护一张发送时间表更省事,也不怕程序中途状态错乱。
校验和这块是新手第一个翻车点。ICMP 校验和是 16 位反码和:把报文按 16 位分组累加,进位回卷,最后取反。注意计算时 Checksum 字段本身要当作 0 参与运算,算完再填回去。网上不少示例代码漏了「回卷」这一步,报文发出去对方直接丢弃,你还以为是网络不通。
2.2 Windows 下 raw socket 的权限与限制
Windows 的原始套接字和 Linux 差别不小。创建时用socket(AF_INET, SOCK_RAW, IPPROTO_ICMP),但有个硬性前提:必须以管理员权限运行,否则socket()直接返回INVALID_SOCKET,WSAGetLastError()给的是WSAEACCES。这一点和很多人「普通用户也能 ping」的直觉相反——系统自带的ping.exe能跑,是因为它内部走了别的路径,你自己写 raw socket 就得老老实实提权。
另一个容易忽略的点是接收缓冲区。raw socket 收到的数据包含 IP 头,也就是说recvfrom拿到的前 20 字节(无选项时)是 IP 首部,ICMP 报文从第 20 字节开始。解析时如果直接从缓冲区第 0 字节读 Type,读到的其实是 IP 头的版本号字段,值恒为 0x45,你会得到一堆莫名其妙的「未知类型」。正确做法是先看 IP 头里的首部长度字段(低 4 位乘以 4),跳过它再解析 ICMP。
还有超时设置。recvfrom默认是阻塞的,网络不通时会一直挂着。用setsockopt配SO_RCVTIMEO设一个接收超时,比如 1 到 2 秒,超时后recvfrom返回SOCKET_ERROR且错误码为WSAETIMEDOUT,这时就判定该次请求丢包。这套「发一个、等一个、超时算丢」的节奏,是整个程序的主循环骨架。
3. 把代码跑起来:校验和、发包与收包解析
原理清楚了,落到代码上分三块:初始化 Winsock 并建 socket、构造并发送 Echo Request、循环接收并解析 Echo Reply。下面按这个顺序给可复现的实现,每段都能直接拼进同一个.cpp文件。
3.1 初始化与 socket 创建
#include <winsock2.h> #include <ws2tcpip.h> #include <iostream> #include <cstring> #pragma comment(lib, "ws2_32.lib") // 全局只初始化一次 Winsock bool initWinsock() { WSADATA wsa; int ret = WSAStartup(MAKEWORD(2, 2), &wsa); // 请求 2.2 版本 if (ret != 0) { std::cerr << "WSAStartup failed: " << ret << std::endl; return false; } return true; } SOCKET createIcmpSocket() { SOCKET s = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP); if (s == INVALID_SOCKET) { // 常见原因:没以管理员身份运行,错误码 WSAEACCES std::cerr << "socket failed: " << WSAGetLastError() << std::endl; } return s; }WSAStartup是 Windows 网络编程的入场券,不调用后面所有 socket 函数都会失败,版本号填MAKEWORD(2, 2)即可。createIcmpSocket里如果返回INVALID_SOCKET,先别怀疑代码,九成是权限问题,用管理员终端重跑一次。把错误码打出来是关键习惯,WSAGetLastError()比任何猜测都直接。
3.2 校验和计算与报文构造
// 16 位反码和,buf 按 16 位为单位累加,奇数长度补 0 unsigned short checksum(unsigned short* buf, int len) { unsigned long sum = 0; while (len > 1) { sum += *buf++; len -= 2; } if (len == 1) { sum += *(unsigned char*)buf; // 末尾单字节按高字节处理 } // 回卷:把高 16 位的进位加回低 16 位 while (sum >> 16) { sum = (sum & 0xFFFF) + (sum >> 16); } return (unsigned short)(~sum); } // 构造一个 Echo Request,返回报文长度 int buildEchoRequest(char* packet, int payloadSize, unsigned short seq) { ICMP_HEADER* icmp = (ICMP_HEADER*)packet; icmp->type = 8; // Echo Request icmp->code = 0; icmp->checksum = 0; // 先置 0 再算 icmp->id = (unsigned short)GetCurrentProcessId(); icmp->seq = seq; // 数据区填发送时间戳,便于回包时算 RTT char* data = packet + sizeof(ICMP_HEADER); unsigned long long sendTime = GetTickCount64(); memcpy(data, &sendTime, sizeof(sendTime)); memset(data + sizeof(sendTime), 'A', payloadSize - sizeof(sendTime)); int totalLen = sizeof(ICMP_HEADER) + payloadSize; icmp->checksum = checksum((unsigned short*)packet, totalLen); return totalLen; }ICMP_HEADER是自己定义的结构体,字段顺序必须和协议一致:type、code、checksum、id、seq,各占对应字节,注意结构体对齐别让编译器插填充,必要时加#pragma pack(1)。checksum函数里那个while (sum >> 16)就是回卷,漏了它报文必被丢弃。数据区塞GetTickCount64()的返回值,回包时读出来做差就是 RTT,精度到毫秒,够用。
3.3 发送、接收与解析主循环
void pingLoop(SOCKET s, sockaddr_in* dest, int count, int payloadSize) { char sendBuf[1024]; char recvBuf[1024]; int timeout = 2000; // 2 秒接收超时 setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, (char*)&timeout, sizeof(timeout)); for (int i = 0; i < count; ++i) { int sendLen = buildEchoRequest(sendBuf, payloadSize, (unsigned short)i); if (sendto(s, sendBuf, sendLen, 0, (sockaddr*)dest, sizeof(*dest)) == SOCKET_ERROR) { std::cerr << "sendto failed: " << WSAGetLastError() << std::endl; continue; } sockaddr_in from; int fromLen = sizeof(from); int recvLen = recvfrom(s, recvBuf, sizeof(recvBuf), 0, (sockaddr*)&from, &fromLen); if (recvLen == SOCKET_ERROR) { if (WSAGetLastError() == WSAETIMEDOUT) { std::cout << "seq=" << i << " timeout" << std::endl; } continue; } // 跳过 IP 头:首部长度字段低 4 位 * 4 int ipHeaderLen = (recvBuf[0] & 0x0F) * 4; ICMP_HEADER* reply = (ICMP_HEADER*)(recvBuf + ipHeaderLen); if (reply->type != 0) continue; // 只处理 Echo Reply unsigned long long sendTime; memcpy(&sendTime, recvBuf + ipHeaderLen + sizeof(ICMP_HEADER), sizeof(sendTime)); unsigned long long rtt = GetTickCount64() - sendTime; std::cout << "reply from " << inet_ntoa(from.sin_addr) << " seq=" << reply->seq << " time=" << rtt << "ms" << std::endl; } }主循环的逻辑是「发一个等一个」,简单但足够说明问题。SO_RCVTIMEO设 2 秒,超时就打印 timeout 继续下一个,不会卡死。解析时(recvBuf[0] & 0x0F) * 4算出 IP 头长度,这是跳过 IP 头的通用写法,比硬编码 20 更稳,因为带选项的 IP 头会变长。reply->type != 0过滤掉非回复报文,避免把别的 ICMP 消息误当回复。RTT 用GetTickCount64算,避免 32 位溢出。
4. 避坑与排查:五个让程序「看着能跑其实不对」的细节
代码拼起来能编译,不代表结果可信。下面这几条是实际调试时最容易踩的,每条按现象、原因、解决写清楚。
现象一:程序一启动就报 socket 创建失败,错误码 10013。原因是没以管理员权限运行,raw socket 在 Windows 上需要提权。解决办法是用管理员身份打开终端再跑,或者在项目属性里把调试器设为以管理员启动。别去改代码绕,这个权限绕不过去。
现象二:能收到包,但解析出来的 Type 永远是 69(0x45)。原因是没跳过 IP 头,直接把缓冲区首字节当 ICMP Type 读了,0x45 正是 IPv4 首部的版本加首部长度字段。解决是按(recvBuf[0] & 0x0F) * 4算出偏移再解析,别硬编码 20。
现象三:报文发出去了,对方就是不回,抓包看请求格式正常。十有八九是校验和算错,最常见的是漏了进位回卷,或者计算时没把 checksum 字段先置 0。解决是把checksum函数单独拿出来,用已知正确的报文做单元测试,确认输出对得上再往下走。
现象四:RTT 偶尔出现负数或者几万毫秒的离谱值。原因是时间戳用了 32 位的GetTickCount,运行久了溢出回绕。解决是统一换成GetTickCount64,数据区里存 8 字节,读写两端类型一致。
现象五:连续 ping 时回复的 seq 对不上,统计全乱。原因是没校验 Identifier,把本机其他程序(或系统自身)的 ICMP 回复也收进来了。解决是在解析时比对reply->id和自己填的进程 ID,不一致的直接丢弃。
提示:调试 raw socket 时,同时开一个抓包工具对照看,能省掉大量「到底是没发出去还是没收到」的猜测。
5. 进阶收尾:把单发包改成并发探测与统计输出
单发单收的版本跑通之后,真正能拿去用的工具还得解决两件事:一是并发探测多个目标时不互相阻塞,二是输出一份像样的统计。这里给一个把主循环改成「先批量发、再统一收」的思路,以及统计口径怎么定。
批量发送的核心是把发送和接收解耦:先在一个循环里把所有 Echo Request 发出去,每个带不同 seq,然后进入接收循环,按超时窗口收集回复,用 seq 做匹配。这样 N 个目标的探测总耗时接近单个超时窗口,而不是 N 倍。接收端维护一个std::map<unsigned short, unsigned long long>记录每个 seq 的发送时刻,收到回复就查表算 RTT 并标记已回,超时窗口结束后没标记的就是丢包。
统计输出建议至少包含四项:发送数、接收数、丢包率、最小/平均/最大 RTT。平均 RTT 别用整数累加再除,容易在样本多时丢精度,用double累加。丢包率按(发送 - 接收) / 发送 * 100%算,注意除零保护。下面这段是统计结构的骨架:
struct PingStats { int sent = 0; int received = 0; double rttMin = 1e9; double rttMax = 0; double rttSum = 0; void addReply(double rtt) { ++received; rttSum += rtt; if (rtt < rttMin) rttMin = rtt; if (rtt > rttMax) rttMax = rtt; } void print() const { double loss = sent ? (sent - received) * 100.0 / sent : 0.0; double avg = received ? rttSum / received : 0.0; std::cout << "sent=" << sent << " recv=" << received << " loss=" << loss << "%" << " min/avg/max=" << rttMin << "/" << avg << "/" << rttMax << "ms" << std::endl; } };还有一个容易被忽略的边界:目标如果是域名,得先用getaddrinfo解析成sockaddr_in,解析失败要单独报错,别和「ping 不通」混为一谈。另外,raw socket 在某些安全软件环境下会被拦截,表现为sendto返回错误但错误码不直观,遇到这种先关掉第三方防护再试,确认是环境问题还是代码问题。
我自己的习惯是,每次改完收发逻辑,先用本机回环地址127.0.0.1跑一遍——回环一定通,能快速验证报文构造和解析是否正确,再去测外网目标。这个顺序能帮你把「代码错」和「网络不通」两类问题彻底分开,省下大量来回折腾的时间。希望帮到你。
本文还有配套的精品资源,点击获取