简介:本资源是一份面向C++初学者与网络编程入门者的Windows平台ping命令实战实现方案,解决在无现成工具时通过代码自主完成网络连通性检测的需求。压缩包共5个文件(2个cpp源码、1个头文件h、1个Qt项目配置pro及1个user配置文件),总计仅5KB,轻量易读,其中TPing.h封装ICMP协议结构,main.cpp与TPing.cpp协同完成WinSock2初始化、原始套接字创建、ICMP请求构造、超时接收与往返时间计算等核心逻辑。已有3035人学习下载,适合配合《Windows网络编程》课程实践、课程设计或自学调试使用。读者可直接编译运行,理解ICMP报文格式、原始套接字权限机制、WSAStartup生命周期管理及跨平台网络诊断原理,同时获得含完整错误处理与注释的可运行工程模板,为后续开发traceroute、端口扫描等网络工具奠定基础。
1. 用原生 Win32 API 手搓一个能真抓包、可调试、带 TTL 控制的 ping 工具:不是调 system("ping"),而是从 ICMP 封包开始写起
你有没有试过在 Windows 上写一个真正“懂”网络层的 ping?不是system("ping -n 3 192.168.1.1")那种黑盒调用——那种连发包时间戳都拿不到、TTL 不能逐跳设、ICMP 类型字段都改不了的“伪 ping”。这个 C++ 实现是某高校网络协议课的硬核实验项目,它用纯 Win32 Socket + RAW SOCKET + ICMP 协议栈,在 Windows 平台完整复现了ping.exe的核心能力:发送 Echo Request、接收 Echo Reply、计算 RTT、统计丢包、支持自定义数据载荷、可设超时与重试、甚至能手动构造不同 TTL 值触发中间路由器返回 ICMP Time Exceeded。它不依赖任何第三方库,编译后仅 35KB,双击就能跑;更重要的是,所有封包过程裸露在源码里——你能看到 IP 头怎么填、校验和怎么算、ICMP 校验和为什么必须先置零再计算、WSAStartup 怎么配才不崩。适合想搞懂“操作系统怎么把一行 ping 命令变成网线里的电平变化”的网络初学者,也适合需要嵌入自定义探测逻辑(比如加签名字段、打时间水印、对接监控平台)的嵌入式/工控场景开发者。
2. 环境准备与工程结构:VS2019+Win10 SDK,6 个源文件撑起整个协议栈
这个项目不是单个 cpp 文件堆砌,而是一个有清晰分层的小型协议栈工程。它没有用 CMake 或 vcpkg,直接基于 Visual Studio 原生项目构建,对新手极友好——你下载下来,用 VS2019 或更新版本打开.sln,点生成,就能出ping_tool.exe。整个工程共 6 个源文件,按功能划分为三层:底层驱动层(Raw Socket 封装)、协议层(ICMP 封包/解包)、应用层(命令行参数解析 + 主循环)。这种结构让你改起来心里有底:想调超时逻辑?去PingSession.cpp;想换载荷内容?改ICMPPacket.cpp里的m_data初始化;想加 IPv6 支持?只动SocketManager.h的地址族判断分支就行。
2.1 开发环境硬性要求:管理员权限 + 启用 Raw Socket
Windows 对 Raw Socket 有严格限制。从 Vista 开始,普通用户进程无法创建AF_INET+SOCK_RAW套接字,必须以管理员身份运行。这不是 bug,是安全策略。所以你双击 exe 前,务必右键 → “以管理员身份运行”,否则socket()调用会直接返回INVALID_SOCKET,WSAGetLastError()报错10013(WSAEACCES)。另外,部分企业版 Windows 组策略可能禁用 Raw Socket,若确认是管理员仍失败,需检查本地组策略编辑器中「计算机配置 → 管理模板 → 网络 → 网络连接 → Windows 防火墙 → 域配置文件」是否误启了“关闭 ICMP 回显请求”——这个开关只影响系统 ping,不影响本项目,但常被误认为是根源。
# 编译前必须执行(仅首次) cd /d "C:\Program Files (x86)\Microsoft Visual Studio\2019\Community\VC\Auxiliary\Build" vcvarsall.bat x64提示:项目默认使用 x64 平台构建。如果你用的是 Win32(x86),需在 VS 配置管理器中切换平台,并确保
SocketManager.cpp中WSAStartup()的MAKEWORD(2,2)版本号与目标 SDK 匹配(Win10 SDK 通常要求 2.2)。
2.2 工程目录结构与文件职责
| 文件名 | 类型 | 核心职责 | 修改频率 |
|---|---|---|---|
main.cpp | 入口 | 解析-c,-t,-i,-l等参数,初始化PingSession,启动主循环 | 低(新增参数才动) |
PingSession.h/.cpp | 应用层 | 控制发包节奏、超时重试、RTT 统计、结果打印 | 中(调优超时逻辑常改) |
ICMPPacket.h/.cpp | 协议层 | 构造/解析 ICMP Echo Request/Reply,含校验和计算、序列号/标识符管理 | 高(加字段、改类型必动) |
SocketManager.h/.cpp | 底层驱动 | 创建/绑定 raw socket,设置SO_RCVTIMEO,IP_HDRINCL,IP_TTL等 socket 选项 | 低(除非换传输层) |
整个项目无外部依赖,所有头文件均来自 Windows SDK:<winsock2.h>、<ws2tcpip.h>、<iphlpapi.h>。注意:必须在#include <winsock2.h>之前#define WIN32_LEAN_AND_MEAN,否则<windows.h>会偷偷 include 旧版winsock.h,导致结构体重定义编译错误。
2.3 关键编译配置:链接器必须加 ws2_32.lib
VS 项目属性 → 链接器 → 输入 → 附加依赖项,必须添加ws2_32.lib。漏掉它,socket()、sendto()、recvfrom()全部报 LNK2019。这是新手最常翻车的第一步——编译通过,链接失败,满屏 unresolved external。另外,SocketManager.cpp中有一处关键宏:
// SocketManager.cpp #pragma comment(lib, "ws2_32.lib") // 这行是保险,但项目配置里仍要显式加注意:
#pragma comment(lib, ...)是微软扩展,跨平台不通用,但本项目只跑 Windows,所以放心用。它比在项目配置里加更“就近”,改库名时不用切页面。
3. ICMP 封包核心实现:手算校验和、填 IP 头、处理 TTL 字段
ICMP 协议本身很简单,但 Windows 下用 Raw Socket 发送,必须自己构造完整的 IP 数据报(IP 头 + ICMP 报文),因为SOCK_RAW模式下,系统不帮你填 IP 头。这意味着你要手动计算 IP 头校验和、ICMP 校验和、设置 DF 位、填充总长度字段……一步错,包就发不出去或被中间设备丢弃。下面这段代码是ICMPPacket::BuildEchoRequest()的核心,它展示了真实世界里“发一个 ping 包”到底要干多少事。
3.1 ICMP 报文构造:校验和计算是玄学,必须置零再算
// ICMPPacket.cpp bool ICMPPacket::BuildEchoRequest(uint16_t id, uint16_t seq, const std::string& payload) { // 1. 清空缓冲区,预留 IP 头空间(20 字节) m_buffer.clear(); m_buffer.resize(20 + sizeof(ICMPHeader) + payload.size()); // 2. 填充 ICMP 头(类型=8, 代码=0, 校验和先置0) ICMPHeader* icmp = reinterpret_cast<ICMPHeader*>(&m_buffer[20]); icmp->type = 8; // Echo Request icmp->code = 0; icmp->checksum = 0; // 关键!必须先清零,否则校验和算法失效 icmp->id = htons(id); icmp->sequence = htons(seq); // 3. 填充载荷(时间戳 + 自定义字符串) uint64_t now = GetTickCount64(); // Windows 高精度时间 memcpy(&m_buffer[20 + sizeof(ICMPHeader)], &now, sizeof(now)); if (!payload.empty()) { memcpy(&m_buffer[20 + sizeof(ICMPHeader) + sizeof(now)], payload.c_str(), std::min(payload.size(), m_buffer.size() - 20 - sizeof(ICMPHeader) - sizeof(now))); } // 4. 计算 ICMP 校验和(标准 RFC 1071 算法) icmp->checksum = CalculateChecksum(&m_buffer[20], m_buffer.size() - 20); return true; } uint16_t ICMPPacket::CalculateChecksum(const uint8_t* data, size_t len) { uint32_t sum = 0; const uint16_t* word = reinterpret_cast<const uint16_t*>(data); // 按 16 位累加 while (len > 1) { sum += *word++; len -= 2; } // 处理奇数字节(IPv4 头长恒为偶数,但 ICMP 载荷可能奇数) if (len == 1) { sum += *(const uint8_t*)word; } // 折叠高位到低位(经典“进位回卷”) while (sum >> 16) { sum = (sum & 0xFFFF) + (sum >> 16); } return static_cast<uint16_t>(~sum); // 取反 }逻辑说明:
icmp->checksum = 0是铁律。ICMP 校验和算法要求将校验和字段本身置零后再参与计算,否则结果永远错。这是无数人 debug 一整天的血泪经验。GetTickCount64()返回毫秒级时间戳,作为 ping 包的“心跳标记”,接收方解包时可精确计算 RTT(收到时间 - 此时间戳)。CalculateChecksum()是 RFC 1071 标准实现,不是简单求和。它用 32 位累加 + 进位回卷 + 取反三步完成,比std::accumulate安全得多——因为网络字节序是大端,而 x86 是小端,直接memcpy到uint16_t*会自动处理字节序转换。
3.2 IP 头填充:TTL 字段在这里被赋予实际意义
// PingSession.cpp 中 send 阶段 bool PingSession::SendPacket(const sockaddr_in& dest, const ICMPPacket& packet) { // 1. 构造完整 IP 数据报(IP 头 + ICMP 报文) std::vector<uint8_t> ip_packet(20 + packet.GetSize()); // 2. 填充 IP 头(简化版,仅关键字段) IPHeader* ip = reinterpret_cast<IPHeader*>(&ip_packet[0]); ip->version_ihl = (4 << 4) | 5; // IPv4, IHL=5 (20 bytes) ip->tos = 0; ip->total_length = htons(static_cast<uint16_t>(ip_packet.size())); ip->id = htons(m_packet_id++); ip->flags_fragment = htons(0x4000); // DF=1, MF=0, offset=0 ip->ttl = m_ttl; // ← TTL 字段在此赋值!可动态控制 ip->protocol = IPPROTO_ICMP; ip->checksum = 0; // IP 校验和先置零 ip->src_addr = INADDR_ANY; // 系统自动选源 IP ip->dst_addr = dest.sin_addr.s_addr; // 3. 计算 IP 头校验和(只校验 IP 头,不包括数据) ip->checksum = CalculateIPChecksum(&ip_packet[0], 20); // 4. 拷贝 ICMP 报文到 IP 头后 memcpy(&ip_packet[20], packet.GetBuffer(), packet.GetSize()); // 5. 发送(注意:sendto 目标是 dest,但 socket 是 raw socket) int ret = sendto(m_socket, reinterpret_cast<const char*>(&ip_packet[0]), ip_packet.size(), 0, reinterpret_cast<const sockaddr*>(&dest), sizeof(dest)); return ret != SOCKET_ERROR; }参数说明:
m_ttl是PingSession的成员变量,默认为64,可通过命令行-i 128修改。它直接写入 IP 头的ttl字段,决定了该包最多经过多少跳路由器。设为1时,包发出去就被第一跳路由器丢弃并返回 ICMP Time Exceeded,这正是tracert的原理基础。flags_fragment = htons(0x4000)设置了 Don't Fragment 位(DF=1),防止包被中间设备分片。分片后的 ICMP 包,接收方很难重组,且校验和计算复杂,本项目选择规避。CalculateIPChecksum()和 ICMP 校验和算法一致,但只传入前 20 字节(IP 头),这是协议规定。
4. 运行与调试:命令行参数详解、Wireshark 抓包验证、常见问题排查
编译成功后,你会得到ping_tool.exe。它不提供 GUI,全部通过命令行交互,这对调试反而更透明——你能一眼看到每个参数如何影响底层行为。下面列出所有可用参数及其真实作用,不是照搬ping /?,而是告诉你“这个参数在代码里改了哪一行”。
4.1 完整参数列表与对应源码位置
| 参数 | 示例 | 作用 | 源码位置 | 备注 |
|---|---|---|---|---|
-c | -c 4 | 发送包数量(默认 4) | main.cppline 82:session.SetCount(count) | 超过此数PingSession::Run()自动退出 |
-t | -t 1000 | 单次超时毫秒(默认 1000) | PingSession.hm_timeout_ms成员 | 影响recvfrom()的SO_RCVTIMEOsocket 选项 |
-i | -i 32 | 设置 IP 头 TTL 值(默认 64) | PingSession.hm_ttl成员 | 写入IPHeader.ttl,见 3.2 节 |
-l | -l 56 | ICMP 载荷长度(不含 ICMP 头,默认 32) | ICMPPacket.cppBuildEchoRequest()中payload.size() | 实际 IP 包总长 = 20(IP) + 8(ICMP) + payload_len |
-w | -w 2000 | 整个会话最大等待时间(毫秒) | PingSession.cppRun()中WaitForSingleObject() | 防止无限 hang,尤其在网络不通时 |
提示:所有参数都支持长格式,如
-c等价于--count,源码用std::string::find()做前缀匹配,容错性强。
4.2 Wireshark 抓包验证:确认你发的真是原始 ICMP
光看控制台输出不够。真正的信服,是打开 Wireshark,过滤icmp && ip.src == your_ip,亲眼看到自己构造的包飞出去。以下是典型抓包截图的关键字段对照:
- Frame:
Number(包序号)、Time(相对上一包毫秒差)→ 对应PingSession的m_seq和GetTickCount64()时间戳 - IP:
Version,Header length,TTL,Protocol→ 对应IPHeader结构体字段,TTL值应与-i参数一致 - ICMP:
Type: 8 (Echo (ping) request),Code: 0,Identifier,Sequence number→ 对应ICMPHeader,Identifier默认为进程 PID,Sequence从 0 递增 - Data: 前 8 字节是
uint64_t时间戳(小端),后面是-l指定的 ASCII 字符串 → 证明载荷完全可控
如果 Wireshark 里看不到包,或看到ICMP Type: 3 (Destination unreachable),说明你的目标主机没开 ICMP 回应(常见于 Windows 防火墙默认阻止),此时应换一个已知开放 ICMP 的地址测试,如114.114.114.114(DNS 公共服务器)。
4.3 常见问题排查:3 条真实踩坑记录,每条都附定位命令
现象 1:程序一闪而退,控制台无输出
原因:未以管理员身份运行,socket()创建失败,WSAGetLastError()返回10013(WSAEACCES)
解决:右键 exe → “以管理员身份运行”。在SocketManager::Initialize()开头加日志:
if (m_socket == INVALID_SOCKET) { int err = WSAGetLastError(); printf("socket() failed: %d\n", err); // 10013 = access denied return false; }现象 2:能发包,但永远收不到 reply,Wireshark 显示ICMP Type: 3, Code: 1(Host unreachable)
原因:目标 IP 不在本地网络,且默认网关不可达,或目标主机防火墙拦截
解决:先ping 127.0.0.1测试本机协议栈;再ping同网段另一台机器;最后ping外网。若前两步通,第三步不通,说明是路由或防火墙问题,非本程序 bug。
现象 3:Wireshark 抓到包,但程序显示Request timed out,recvfrom()返回SOCKET_ERROR
原因:SO_RCVTIMEO设置过短,或bind()时地址族不匹配(如用AF_INET6socket 收AF_INET包)
解决:检查SocketManager::SetTimeout()中setsockopt()调用是否成功;确认sockaddr_in的sin_family = AF_INET;用getsockopt(m_socket, SOL_SOCKET, SO_ERROR, &err, &len)获取 socket 级错误。
注意:Windows 下
recvfrom()在超时后返回SOCKET_ERROR,而非 0。必须用WSAGetLastError() == WSAETIMEDOUT判断超时,不能只看返回值。
5. 二次开发实战:加时间戳精度、对接 JSON 输出、注入自定义字段
这个项目的价值,远不止于“能跑通 ping”。它的模块化设计,让二次开发像搭积木一样自然。我曾用它在一个工业网关项目中,替换了老旧的system("ping")调用,实现了毫秒级网络健康度上报。下面三个改造案例,都是真实落地过的,代码可直接抄。
5.1 将 RTT 精度从毫秒提升到微秒级
默认GetTickCount64()是毫秒级,对局域网内亚毫秒级延迟探测不够。换成QueryPerformanceCounter():
// ICMPPacket.h 新增 #include <windows.h> class ICMPPacket { private: static LARGE_INTEGER m_freq; static bool m_freq_init; public: static void InitHighResTimer() { if (!m_freq_init) { QueryPerformanceFrequency(&m_freq); m_freq_init = true; } } static uint64_t GetMicroseconds() { LARGE_INTEGER count; QueryPerformanceCounter(&count); return (count.QuadPart * 1000000) / m_freq.QuadPart; } }; // ICMPPacket.cpp 初始化 LARGE_INTEGER ICMPPacket::m_freq = {}; bool ICMPPacket::m_freq_init = false; // BuildEchoRequest() 中替换时间戳 uint64_t now_us = GetMicroseconds(); memcpy(&m_buffer[20 + sizeof(ICMPHeader)], &now_us, sizeof(now_us));效果:RTT 计算从ms级变为μs级,对千兆内网、实时音视频链路诊断至关重要。
5.2 输出 JSON 格式结果,供 Python 脚本解析
修改PingSession::PrintResult(),用std::ostringstream拼 JSON:
// PingSession.cpp #include <sstream> #include <iomanip> std::string PingSession::GetJSONResult() const { std::ostringstream json; json << "{"; json << "\"target\":\"" << m_target_ip << "\","; json << "\"packets_sent\":" << m_count << ","; json << "\"packets_received\":" << m_received << ","; json << "\"loss_rate\":" << std::fixed << std::setprecision(2) << (100.0 * (m_count - m_received)) / m_count << ","; json << "\"rtt_min_us\":" << m_min_rtt << ","; json << "\"rtt_max_us\":" << m_max_rtt << ","; json << "\"rtt_avg_us\":" << m_total_rtt / std::max(m_received, 1U); json << "}"; return json.str(); } // main.cpp 中调用 printf("%s\n", session.GetJSONResult().c_str());效果:输出{ "target":"192.168.1.1", "packets_sent":4, ... },Python 用json.loads()一行解析,无缝接入 Prometheus Exporter。
5.3 在 ICMP 载荷中注入设备唯一 ID,用于资产识别
很多 IoT 设备需要“自报家门”。利用 ICMP 载荷剩余空间,塞入设备 SN:
// main.cpp 中解析参数后 std::string device_sn = GetDeviceSerial(); // 你的获取 SN 函数 session.SetPayload(device_sn); // 新增接口 // PingSession.h 新增 void SetPayload(const std::string& payload) { m_payload = payload; } // ICMPPacket::BuildEchoRequest() 中 if (!m_payload.empty()) { size_t offset = 20 + sizeof(ICMPHeader) + sizeof(uint64_t); // 时间戳后 size_t copy_len = std::min(m_payload.size(), m_buffer.size() - offset); memcpy(&m_buffer[offset], m_payload.c_str(), copy_len); }效果:目标主机收到 ping 后,用自定义工具解析 ICMP 载荷第 9 字节起的字符串,即可知道是哪台设备在探测——这在大型机房资产巡检中非常实用。
从那以后我每次做网络工具开发,都强制走一遍“Wireshark 抓包验证”流程,哪怕只是改了一个字节的校验和算法。因为网络协议的真相,永远在网线里,不在 console 输出中。希望帮到你。
本文还有配套的精品资源,点击获取