C++实现ping命令:基于ICMP原始套接字的网络连通性探测工具 简介使用C语言在Windows环境下实现Ping命令功能的一份轻量工程适合网络编程入门者以及想深入理解ICMP报文交互的开发人员。压缩包内共五个文件包含两个C源代码文件、一个头文件以及Qt项目配置文件整体压缩后体积仅有五KB代码量精简便于直接阅读和学习目前已有三千零三十三人学习或下载说明其具有一定的参考价值。源码完整演示了初始化Winsock环境、创建原始套接字、解析目标主机地址、构造带序列号和时间戳的ICMP回显请求、使用发送函数发出报文、循环接收应答并校验数据、计算往返时间以及处理超时等关键过程同时注重资源释放和错误处理。由于Windows系统对原始套接字存在安全限制代码注释特别提醒需要以管理员权限运行避免踩坑。通过该工程读者能够快速掌握Ping命令的核心原理并可进一步扩展多主机探测、自定义报文等实际应用是一份简洁实用的网络编程样例。1. C实现ping命令自己动手写一个网络连通性探测工具排查网络故障时第一个在脑子里闪出的命令就是ping。它太常用了以至于大多数人不会去思考它背后发生了什么——直到某一天你在项目里需要自己写一个ping工具可能是内网监控系统需要批量探测上百台机器的存活状态也可能是你需要对ICMP报文的返回时间做统计分析。这种情况下直接调用系统自带的ping.exe往往不够用你需要在自己的C程序里实现同样的探测逻辑。这篇文章会把Windows环境下用C从零实现ping命令的完整过程讲透包括ICMP报文结构、原始套接字的使用方式、超时和TTL处理以及我在实际开发中踩过的几个坑。2. 先弄清协议层ICMP报文结构与原始套接字的选型逻辑在写代码之前有必要先搞清楚一个核心问题ping命令到底在网络上发送了什么它不是TCP连接也不是UDP数据报而是IP层之上的一个特殊协议——ICMPInternet Control Message Protocol。理解它的报文布局和Windows对这类套接字的限制是后面所有代码能跑通的前提。2.1 ICMP报文结构回显请求与回显应答的字节布局ICMP报文封装在IP数据报内部。对ping来说我们用到的就只有两种消息类型类型8表示回显请求Echo Request类型0表示回显应答Echo Reply。头部在Windows上的定义通常写成这样ICMP回显报文不含数据部分 0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 -------------------------------- | 类型(8/0) | 代码(0) | 校验和 | -------------------------------- | 标识符(ID) | 序号(Seq) | -------------------------------- | 数据区(时间戳填充) | --------------------------------类型占1字节代码占1字节校验和占2字节接着是标识符和序号各占2字节最后是变长的数据部分。回显请求和回显应答里标识符和序号的作用是让发送方能区分哪条应答对应哪条请求。数据区通常填入发送时刻的时间戳这样收到应答时能算往返时延剩下的空间填充一些随机字节用来保证探测报文的总长度达到一个约定值。Windows自带的ping默认报文长度是32字节数据区Linux下默认是56字节数据区都算不上大。难点在于校验和ICMP校验和的计算范围是整个ICMP报文头部数据发送前把校验和字段先清零然后以二进制补码的方式进行按字求和再取反。这个算法初看很简单但实现细节容易出错后面避坑章节会专门讲。2.2 Socket选型为什么排除TCP和UDP必须用原始套接字也许有人会问实现类似ping的探测能不能用TCP connect或UDP发包实现可以但那就不是真正意义上的ICMP ping了。TCP探测依赖目标端口是否开放如果目标机器的防火墙把端口过滤了TCP探测结果就是不准确。UDP探测也类似依赖端口状态和ICMP端口不可达消息行为更不可控。ping的初衷是检查IP层是否通——只要目标主机还在线且没有防火墙策略拦截ICMP它就必须回一个ICMP回显应答。这个语义只有ICMP协议能提供。Windows下要发送ICMP报文有两条路供选择。第一条是调用系统封装好的ICMP API即IcmpSendEcho这一族函数微软把构造报文、收发、解析都包好了调用方只需要传目标地址和数据缓冲区。第二条是直接创建原始套接字SOCK_RAW自己动手构造ICMP头、计算校验和、维护序号和超时逻辑。从实现ping命令功能的角度来说原始套接字这条路更底层的逻辑完整也更有学习价值。IcmpSendEcho虽然代码量小但它返回的数据是封装结果调试网络问题时你会感觉像隔了一层黑匣子。我推荐的路线是用原始套接字实现核心逻辑必要时再对照IcmpSendEcho的结果做验证。创建原始套接字的代码在Windows下并不复杂#include winsock2.h #include ws2tcpip.h #include stdio.h #include stdlib.h #pragma comment(lib, ws2_32.lib) SOCKET create_raw_socket() { // 第三个参数 IPPROTO_ICMP 表示我们只接收/发送 ICMP 报文 SOCKET sock socket(AF_INET, SOCK_RAW, IPPROTO_ICMP); if (sock INVALID_SOCKET) { printf(socket() 失败错误码: %d\n, WSAGetLastError()); return INVALID_SOCKET; } return sock; }代码逻辑上socket(AF_INET, SOCK_RAW, IPPROTO_ICMP)创建了一个IPv4地址族下的原始套接字协议类型限定为ICMP。需要强调一个Windows特性SOCK_RAW套接字发送数据时不必自己构造IP头操作系统会自动补齐IP头但接收数据时缓冲区里拿到的是包含IP头在内的完整数据报。这意味着发送和接收的解析逻辑是不对称的新手第一次写时往往在这里犯迷糊。参数方面AF_INET代表IPv4如果要做IPv6的ping就需要用AF_INET6和IPPROTO_ICMPV6但因为IPv6的ICMP校验和依赖伪头部计算复杂度又上一个台阶初学阶段建议先做IPv4版本。3. 核心实现构造ICMP报文、发送与接收的完整流程这一章直接进入代码实现。整个ping循环可以拆成四个阶段构造报文、发送报文、接收应答、解析统计。每一个阶段都有几个值得关注的参数和细节。以一次典型的ping流程为例时间线是这样的程序携带目标IP构建一条64字节的ICMP回显请求记录发送时间戳让出CPU等待应答超时或收到应答后进入下一轮。3.1 构造ICMP回显请求头部字段赋值与数据填充先定义一个紧凑排布的报头结构体注意使用#pragma pack(push, 1)确保结构体不做字节对齐否则在内存中插入的填充字节会破坏ICMP报文格式。#pragma pack(push, 1) typedef struct _ICMP_HEADER { unsigned char type; // 8 回显请求 unsigned char code; // 固定为0 unsigned short checksum; // 校验和 unsigned short id; // 标识符 unsigned short seq; // 序号 } ICMP_HEADER; #pragma pack(pop) // 数据区我们放一个64位时间戳 填充字节 #define DATA_SIZE 32 #define PACKET_SIZE (sizeof(ICMP_HEADER) DATA_SIZE) void build_icmp_packet(char* buffer, int packet_size, unsigned short id, unsigned short seq) { memset(buffer, 0, packet_size); ICMP_HEADER* hdr (ICMP_HEADER*)buffer; hdr-type 8; // Echo Request hdr-code 0; hdr-checksum 0; // 先置0填充完数据之后一起计算 hdr-id htons(id); hdr-seq htons(seq); // 数据区前8字节放当前系统时间微秒级 LARGE_INTEGER tick; QueryPerformanceCounter(tick); memcpy(buffer sizeof(ICMP_HEADER), tick, 8); // 其余字节填充固定模式便于应答核对 for (int i sizeof(ICMP_HEADER) 8; i packet_size; i) { buffer[i] (char)(i 0xFF); } hdr-checksum checksum((unsigned short*)buffer, packet_size); }这段代码把报文头和数据区一起在内存里拼好最后计算整体校验和。id字段一般用进程ID多个ping实例同时运行时不至于混淆seq字段每发一轮递增用于判断应答是否按序返回。数据区使用QueryPerformanceCounter取高精度计数器的当前值它比GetTickCount的毫秒精度高出几个量级测低延迟网络的RTT更准确。容易忽略的一点是id和seq在写入报文前都要调用htons转成网络字节序。x86架构是小端字节序而ICMP报文在网络上统一使用大端字节序不做转换的话对端回显时虽然会原样拷贝但接收方解析时读到的数字会异常导致序号对不上。3.2 校验和计算发送方唯一需要自己算的算法校验和是ICMP里最容易写错的地方。算法本身很直白把整个报文按16位一组求和如果有奇数长度末尾补一个字节继续加最后把进位折叠回去取反后写入校验和字段。unsigned short checksum(void* data, int len) { unsigned short* word (unsigned short*)data; unsigned long sum 0; while (len 1) { sum *word; len - 2; } // 末尾剩一个字节时把它当成高字节继续累加 if (len 1) { sum *(unsigned char*)word; } // 把高16位的进位折叠回低16位直到没有进位 while (sum 16) { sum (sum 0xFFFF) (sum 16); } return (unsigned short)(~sum); }逻辑说明sum是32位无符号整数每轮累加16位数据。如果一个报文长度为奇数实际很少见但算法要兼容末尾字节按高字节为数据、低字节为0的方式累加。折叠进位是二进制补码校验和的精髓——while (sum 16)不断把高16位加回低16位直到高16位归零。最后取反得到的就是ICMP头里的checksum字段。参数上和正确性检查相关的点是这个函数在计算时传入的data必须包含完整的ICMP头和数据区长度len必须与发送长度一致。如果发送缓冲区和计算校验和时用的长度不一致对端验算会直接失败并丢弃这个包表现就是ping永远收不到应答。3.3 发送与接收sendto与recvfrom的超时控制发送报文时需要使用sendto指定目标地址因为原始套接字不像TCP那样有connect后的稳定上下文。接收应答的难点在于要处理超时Windows下原始套接字可以用select或SO_RCVTIMEO实现超时控制。我的建议是用select它能把发送后的等待时间精确控制在毫秒级而且不遗留半关闭状态。int send_ping(SOCKET sock, struct sockaddr_in* dest, char* packet, int len) { int ret sendto(sock, packet, len, 0, (struct sockaddr*)dest, sizeof(*dest)); if (ret SOCKET_ERROR) { printf(sendto 失败错误码: %d\n, WSAGetLastError()); return -1; } return ret; } int recv_ping(SOCKET sock, struct sockaddr_in* src, char* recv_buf, int buf_len, int timeout_ms) { fd_set read_set; FD_ZERO(read_set); FD_SET(sock, read_set); struct timeval tv; tv.tv_sec timeout_ms / 1000; tv.tv_usec (timeout_ms % 1000) * 1000; int sel_ret select(0, read_set, NULL, NULL, tv); if (sel_ret 0) { return 0; // 超时 } if (sel_ret SOCKET_ERROR) { printf(select 失败错误码: %d\n, WSAGetLastError()); return -1; } int src_len sizeof(*src); int recv_len recvfrom(sock, recv_buf, buf_len, 0, (struct sockaddr*)src, src_len); if (recv_len SOCKET_ERROR) { printf(recvfrom 失败错误码: %d\n, WSAGetLastError()); return -1; } return recv_len; }select在这里起的作用相当于给接收操作加了一个定时器timeout_ms是希望等待应答的最大毫秒数。超时返回0时调用方就可以决定是重发还是记录丢包。select的第一个参数在Windows下被忽略传0没问题但在Linux下必须传最大套接字值加1这是跨平台代码要注意的一个差异点。recvfrom拿到缓冲区是带IP头的数据。IP头占20字节无选项时它是变长的初学时最容易掉进去的坑是固定按20字节偏移解析ICMP报文。实际上IP头的长度由IP头中低4位的IHL字段决定IHL乘以4才是真正的头长。稳妥做法是先从IP头里解析出ihl再做偏移。3.4 主循环与结果解析把RTT算准、把丢包率算对ping工具通常连续发送多个探测包我们这里设计成支持循环次数参数。每轮发送后调用recv_ping收到应答解析源IP和ICMP信息未收到则记一次超时丢包。int ping_host(const char* host, int count, int timeout_ms) { // 解析域名 struct hostent* remote gethostbyname(host); if (!remote) { printf(无法解析主机 %s\n, host); return -1; } struct sockaddr_in dest; memset(dest, 0, sizeof(dest)); dest.sin_family AF_INET; dest.sin_addr *(struct in_addr*)remote-h_addr; SOCKET sock create_raw_socket(); if (sock INVALID_SOCKET) return -1; unsigned short pid (unsigned short)GetCurrentProcessId(); int lost 0; for (int seq 0; seq count; seq) { char packet[PACKET_SIZE]; build_icmp_packet(packet, PACKET_SIZE, pid, (unsigned short)seq); // 发送 send_ping(sock, dest, packet, PACKET_SIZE); // 接收 char recv_buf[1024]; struct sockaddr_in src; int recv_len recv_ping(sock, src, recv_buf, sizeof(recv_buf), timeout_ms); if (recv_len 0) { printf(第 %d 包超时\n, seq 1); lost; continue; } if (recv_len 0) break; // 跳过IP头解析ICMP unsigned char ip_ihl (unsigned char)recv_buf[0] 0x0F; int ip_header_len ip_ihl * 4; ICMP_HEADER* icmp (ICMP_HEADER*)(recv_buf ip_header_len); if (icmp-type 0 ntohs(icmp-id) pid) { // 计算RTT LARGE_INTEGER* send_time (LARGE_INTEGER*)(recv_buf ip_header_len sizeof(ICMP_HEADER)); LARGE_INTEGER now, freq; QueryPerformanceCounter(now); QueryPerformanceFrequency(freq); double rtt_ms ((double)(now.QuadPart - send_time-QuadPart)) / freq.QuadPart * 1000.0; printf(来自 %s 的应答: 序号%d RTT%.2fms\n, inet_ntoa(src.sin_addr), ntohs(icmp-seq), rtt_ms); } else { printf(收到非预期应答: type%d\n, icmp-type); } } closesocket(sock); printf(丢包率: %d/%d\n, lost, count); return 0; }这段主循环把前面所有模块串起来。收到应答时先取其IP头拿到IHL后计算IP头长度再偏移到ICMP头。校验序号和id是为了防止同一个socket误收了其他进程的ICMP应答——由于原始套接字收到的是所有发往本机的ICMP包不加过滤很容易被噪音干扰。RTT的计算依赖数据区里存的发送时刻和当前时刻之差所以build_icmp_packet里必须完整拷贝发送时间戳中途不能做截断。4. Windows环境避坑指南权限、字节序与防火墙引发的诡异现象如果你把上面代码原样跑起来有一定概率遇到这样一种情况编译通过运行后socket创建失败错误码10013。这不是代码逻辑写错了而是Windows对原始套接字有一道硬性权限门槛。除此之外还有校验和算错导致的对端静默丢包、防火墙拦截导致的假死、recvfrom收到无关包导致的统计错乱这些都属于需要现场排查才能定位的坑。逐一说明。4.1 创建原始套接字返回10013管理员权限与UWP容器限制现象是socket()返回INVALID_SOCKETWSAGetLastError()给出10013WSAEACCES。在Windows Vista之后的系统上原始套接字的创建强制要求程序以管理员权限运行。普通用户权限下无论代码怎么写都不可能创建成功。原因倒不是微软故意为难开发者而是原始套接字有能力伪造IP头、发送任意协议报文如果放行给普通权限等同于给恶意软件开了后门。解决方案很直接开发调试时用管理员身份运行Visual Studio或右键直接以管理员身份运行生成的exe。交付给终端用户时在程序里附带一个manifest文件声明requestedExecutionLevel为requireAdministrator这样用户双击运行时会自动触发UAC提权。命令行下临时用runas指令打开一个管理员权限的命令行窗口再运行程序。10013还有一个隐蔽触发场景如果程序运行在UWP容器或某些沙箱环境里即使当前用户是管理员容器策略也会拒绝原始套接字的创建。表现在应用商店版本和沙箱调试时常见需要特别留意当前运行上下文。4.2 校验和算错报文发出后对端不回应Wireshark看包全是checksum错误这个坑我印象很深。第一次实现ping时bak校验和函数用的是网上找的片段在Linux下验证过没问题移植到Windows后却发现怎么都收不到应答。用抓包工具看发出的回显请求被目标主机直接忽略。对比协议栈发现问题出在IP头的校验和与ICMP校验和混用原始套接字发送时操作系统会补IP头并算IP头校验和但ICMP校验和必须自己算。这两者算法虽然一样但计算范围完全不同。更隐蔽的是网上有些版本在计算ICMP校验和时把len写成了sizeof(ICMP_HEADER)导致数据区没有纳入校验范围一旦数据区字节发生变化对端验算立即失败。排查方法也很直接抓包看发出报文的ICMP层是否提示incorrect checksum。如果提示就说明验算逻辑有问题。修复后注意观察校验和字段是否随数据区的填充字节变化如果发送不同数据内容时校验和保持不变那基本可以确定计算长度写死了。4.3 防火墙拦截本机ping外网通ping本机却超时现象更玄学同一个程序ping公网地址正常ping本机回环地址127.0.0.1却超时。原因在于Windows防火墙的出站规则和入站规则分离。程序已经以管理员权限运行出站ICMP请求通常能发出但回环应答到达本机时Windows的防火墙过滤层会把它当成入站流量处理而默认入站规则不信任原始套接字发送的ICMP请求于是应答被丢弃。解决方法是调整防火墙规则把程序加入白名单或临时允许ICMPv4入站。命令行操作方式是在管理员终端里执行netsh advfirewall firewall add rule nameAllow ICMPv4 protocolicmpv4:8,any dirin actionallow参数说明protocolicmpv4:8,any表示匹配类型为8的回显请求dirin作用于入站方向actionallow显式允许。调试完毕后如果注意安全也可以把该规则删掉netsh advfirewall firewall delete rule nameAllow ICMPv4这句话在项目演示和现场排障时很有用可以避免为测试环境长期打开ICMP入站导致的安全面扩大。4.4 序号与超时统计错乱recvfrom收到陈旧应答或别人的应答还有一个容易被忽略的细节原始套接字是异步共享的。假设你上一轮发出的请求已经超时但应答在超时之后才姗姗来迟那么下一轮循环的recvfrom会先收到这条迟到的应答再收到本轮应答。如果你在代码里用接收顺序直接对应发送序号统计结果就会错位甚至出现RTT负值。解决办法是每条应答不仅要判定id相等还要判定seq等于当前期望的序号不匹配时继续调用recvfrom再等下一个包直到匹配或超时。不匹配的包还有一种来源本机上其他进程也在发ICMP探测原始套接字会把所有发往本机的ICMP都能收。过滤条件除了id匹配之外最好再检查一下ICMP类型字段确实是0回显应答遇到类型3目标不可达之类的消息应该单独处理并提示用户。5. 进阶实战多线程批量探测与TTL追踪的扩展思路基础版ping已经能用了但放在真实场景里还差点味。比如你有几十台内网机器需要做存活探测串行逐台ping一遍可能耗时太久又比如你想知道目标背后经过了几跳路由基础版也没有提供TTL信息。这两块分别对应了并发设计和协议扩展值得再做一次升级。5.1 多线程批量扫描线程只发不收主线程统一收割如果并发ping的目标数量多每一台都创建一个套接字线程加上独立的超时等待会占用大量句柄且线程切换开销不小。更优雅的做法是生产者-消费者模型工作线程只负责构造报文、发送然后在一个固定容器里登记发送时间和seq接收线程在同一个socket上循环recvfrom根据目标IP和seq把应答写入结果表主线程在全部发送完成后统一等待扫描时间窗结束输出统计。核心伪代码如下// 每个探测目标维护一个结构体 typedef struct _PING_TARGET { struct sockaddr_in addr; unsigned short seq; LARGE_INTEGER send_time; double rtt_ms; BOOL replied; } PING_TARGET;发送线程对每个目标执行sendto并登记send_time后立即切下一个接收线程则阻塞在recvfrom收到数据后解析IP和ICMP用id和seq反查目标表将RTT和回复状态更新到结构体。整个扫描结束时间取决于最慢的一台目标但吞吐量比串行提升了目标数量倍。线程同步上注意一点多个发送线程同时在同一个SOCKET上调用sendto原始套接字的发送是原子的线程安全没问题。但接收线程与主线程对目标表的读写必须加锁否则统计回收时会读到半写状态。5.2 带回显TTL实现一个简化版tracertTTL这一招会告诉你报文走了多少跳。ICMP回显请求的IP头中有个TTL字段每次经过一跳路由器就减1减到0时路由器会给源地址回一个ICMP超时消息类型11。利用这个机制从TTL1开始逐跳发回显请求收到类型11就说明那一跳已经被记录直到目标返回类型0回显应答期间记录源IP和递增的TTL就拼出了一条完整的路由路径。Windows自带tracert用的正是这个思路。在原代码基础上只需要在发送前调用setsockopt设置IP_TTL即可应答解析时判断ICMP类型是11还是0分别记录。int ttl 1; setsockopt(sock, IPPROTO_IP, IP_TTL, (const char*)ttl, sizeof(ttl));每轮探测内部发三次一样的报文取最快响应然后ttl加1继续直到收到类型0或超过最大跳数阈值一般30跳。这种做法相比直接用系统tracert的优势是你可以把路由跳点和延迟写入自己的监控库里做长周期网络路径质量分析。我的习惯是ping主机存活用多线程批量扫描路径探测用TTL逐跳跟踪两个工具配合起来排查一个从丢包率到链路断点的问题就清晰多了。每次写完这类网络工具我都会强制走一遍回环测试→公网测试→跨网段测试三层验证再确认异常时是ICMP被过滤还是程序自身缺陷。希望帮到你。本文还有配套的精品资源点击获取