ARTICLE DETAIL

资讯详情

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

C++调用Windows ICMP API实现自己的ping命令

C++调用Windows ICMP API实现自己的ping命令 简介这份资源面向需要在 Windows 环境下手写网络诊断工具的 C 开发者解决如何以 WinSock2 与原始套接字实现 ping 命令的核心问题。内容围绕 ICMP 回显请求与应答的构造、发送和接收展开包含套接字初始化、地址解析、报文封装、超时处理及资源清理等关键环节并涉及管理员权限等实际运行注意点。压缩包共 5 个文件含 2 个 cpp 源文件、1 个头文件、1 个工程文件及 1 个用户配置文件整体仅 5KB结构精简适合作为小型网络编程示例直接阅读。已有 3033 人学习下载。读者可获得可直接编译运行的 ping 实现源码从中理解原始套接字编程流程、ICMP 报文格式与往返时间计算思路也能为后续扩展多线程、参数解析或跨平台适配提供参考。整体示例短小完整更适合入门级网络编程学习者对照上手。1. C 实现 ping 命令的功能Windows不是把系统 ping 包一层壳很多 C 新人拿到这个需求第一反应是去读取系统 ping 的 stdout 来解析字符串或者干脆撸一个SOCK_RAW原始套接字自己构造 ICMP 报文。这两条路在 Windows 上都不聪明前者等于把黑匣子当工具后者要管理员权限还要跟校验和、字节序、超时机制死磕。Windows 实际早就把 ICMP 回显探测封装成了IcmpSendEcho系列 API你要做的只是传参、收结果、解析数据。这篇文章就用这套 API 从零写一个能用于局域网连通性检查和远程探测的 C ping 工具把超时、TTL、丢包率、批量扫描这些细节摊开讲。跟着代码走一遍你就能在 Visual Studio 或命令行环境里编译出属于自己的 ping 命令。2. 先搞懂 ping 的底层逻辑ICMP 报文与 Windows 的两个实现岔路2.1 ping 到底在做什么echo request 与 echo reply 的往返ping 命令的核心是 ICMP 协议中的回显请求Echo RequestType 8和回显应答Echo ReplyType 0。发送方往目标 IP 丢一个 ICMP 报文目标主机的协议栈在收到后原样返回应答发送方根据「发出去的时间」和「收到应答的时间」之差算出往返时间 RTT。这个过程中ICMP 报文里还带着标识号Identifier和序号Sequence Number用于把同一批探测请求和应答配对。真正决定「通不通」的是三层连通性不是传输层端口。所以 ping 能测出网络通不通、延迟高不高但测不出某个 Web 服务是否在运行这也解释了为什么很多网络排障第一步先 ping 网关而不是直接测端口。在 C 里实现这个功能不是你打印一个字符串再假装成功而是要真实地发一个 Type 8 报文再收一个 Type 0 报文然后把时间差、TTL 等信息暴露给调用方。2.2 原始套接字与 ICMP API为什么我劝你别自己拼报文在 Windows 上实现 ping 有两条技术路线。第一条是 Winsock 原始套接字代码大致是socket(AF_INET, SOCK_RAW, IPPROTO_ICMP)然后自己创建ICMP_HEADER填充 Type、Code、Checksum、Identifier、Sequence再用sendto/recvfrom收发。这条路线不是不能跑但有几个现实问题Windows 对原始套接字有很大限制收发 ICMP 需要以管理员身份运行你得自己计算 ICMP 校验和注意网络字节序recvfrom收到的数据还带着 IP 头你需要用WSARecvMsg或者研究IP_HDRINCL选项解析否则很容易在 64 位系统上翻车。第二条路线是 Windows 提供的 ICMP API入口是IcmpCreateFile和IcmpSendEcho实现在icmp.dll/iphlpapi.dll中。这些 API 把报文构造、校验和计算、超时等待、应答解析全部封装好了你只需要提供一个合法的 IPv4 地址、一段发送数据、一个足够大的回复缓冲区。最关键的收益是权限普通用户也能调用IcmpSendEcho不需要管理员权限。我在生产环境做网络巡检工具时明确选择 ICMP API因为代理进程跑在普通服务账户下用原始套接字会加重部署复杂度。3. 用 IcmpSendEcho 写出第一个可用 ping核心代码与参数3.1 初始化 ICMP 句柄与封装报文查询先写一个最小可运行版本。新建一个ping_sample.cpp代码如下#include winsock2.h #include ws2tcpip.h #include iphlpapi.h #include icmpapi.h #include stdio.h #include string.h #pragma comment(lib, ws2_32.lib) #pragma comment(lib, iphlpapi.lib) int main(int argc, char* argv[]) { WSADATA wsa; if (WSAStartup(MAKEWORD(2, 2), wsa) ! 0) { printf(WSAStartup失败\n); return 1; } if (argc 2) { printf(用法: %s ip [超时毫秒] [数据大小]\n, argv[0]); WSACleanup(); return 1; } // 注意: inet_addr 只接受点分十进制IPv4地址, 不解析域名 unsigned long dest_ip inet_addr(argv[1]); if (dest_ip INADDR_NONE) { printf(无效的IPv4地址: %s\n, argv[1]); WSACleanup(); return 1; } int timeout argc 2 ? atoi(argv[2]) : 2000; int data_size argc 3 ? atoi(argv[3]) : 32; if (data_size 1) data_size 1; if (data_size 65500) data_size 65500; HANDLE hIcmp IcmpCreateFile(); if (hIcmp INVALID_HANDLE_VALUE) { printf(IcmpCreateFile失败, 错误码 %lu\n, GetLastError()); WSACleanup(); return 1; } // 构造发送数据, 内容可以随意, 通常填一个固定字节 char* send_data new char[data_size]; memset(send_data, a, data_size); // 回复缓冲区必须能同时容纳 ICMP_ECHO_REPLY 结构体和回显数据 unsigned char reply_buf[sizeof(ICMP_ECHO_REPLY) 65500]; DWORD reply_size sizeof(ICMP_ECHO_REPLY) data_size; IP_OPTION_INFORMATION options { 0 }; options.Ttl 64; // 相当于系统ping里的TTL设置 options.Flags 0; DWORD ret IcmpSendEcho( hIcmp, dest_ip, send_data, (WORD)data_size, options, reply_buf, reply_size, (DWORD)timeout ); if (ret ! 0) { PICMP_ECHO_REPLY reply (PICMP_ECHO_REPLY)reply_buf; if (reply-Status IP_SUCCESS) { struct in_addr addr; addr.s_addr reply-Address; char src[INET_ADDRSTRLEN] { 0 }; inet_ntop(AF_INET, addr, src, sizeof(src)); printf(来自 %s 的回复: 字节%u 时间%u ms TTL%u\n, src, reply-DataSize, reply-RoundTripTime, reply-Options.Ttl); } else { printf(目标可达但状态码异常: %lu\n, reply-Status); } } else { DWORD err GetLastError(); if (err IP_REQ_TIMED_OUT) { printf(请求超时: %s\n, argv[1]); } else { printf(IcmpSendEcho失败, 错误码 %lu\n, err); } } delete[] send_data; IcmpCloseHandle(hIcmp); WSACleanup(); return 0; }代码逻辑分成几块WSAStartup初始化 Winsock 环境IcmpCreateFile创建 ICMP 句柄然后填充回复缓冲区和IP_OPTION_INFORMATION结构最后调用IcmpSendEcho同步发送。IcmpSendEcho的返回值是成功匹配的应答数量而不是布尔值返回 0 时要通过GetLastError获取失败原因。参数方面有几个容易忽略的点。options.Ttl表示发送报文的存活时间设为 64 可以覆盖绝大多数局域网和本地路由场景如果探测跨越多个运营商节点系统 ping 默认 TTL 可能是 128但你可以在代码里自由调整。reply_size必须是sizeof(ICMP_ECHO_REPLY) data_size而不是填整个数组大小否则 Windows 会直接返回参数错误。data_size建议上限 65500超过后 ICMP payload 会被分片很多中间设备会直接丢弃表现为超时。3.2 解析应答数据往返时间、TTL 与状态上面代码里有一个重点不要用ret非零就断定 ping 通了。IcmpSendEcho返回 1 表示收到了一条 ICMP 应答但应答本身可能是目标不可达Type 3或超时Type 11这些状态会体现在ICMP_ECHO_REPLY结构体的Status字段中。真正判定通常用IP_SUCCESS这是一个值为 0 的常量。TTL 的读取路径也是新手高发区。ICMP_ECHO_REPLY结构体里没有直接叫Ttl的字段TTL 放在Options子结构里也就是reply-Options.Ttl。它代表应答报文从远端主机回到本机时IP 头里剩余的跳数。举个例子如果远端是 Linux 默认 TTL 64又经过 3 跳回来你看到的 TTL 就是 61。通过对比这个数值你可以判断目标主机是什么操作系统或者链路经过了多少跳。RTT 用的是reply-RoundTripTime单位是毫秒。这个值是 Windows 内部在发送和接收之间计算好的比你自己用GetTickCount掐表更准也规避了线程调度带来的误差。标准系统 ping 显示的时间也是类似逻辑所以拿你的程序和系统 ping 对比时通常在 1~2ms 内波动都算正常。3.3 命令行参数目标地址、超时、报文大小怎么传上面的代码只做了一个最小 CLI参数从左到右是 IP、超时毫秒、数据大小。如果你直接编译运行ping_sample.exe 192.168.1.1默认超时 2000ms数据大小 32 字节行为已经非常接近系统 ping 的默认形态。在编译环境上如果你用 Visual Studio直接新建一个 C 空项目把代码粘贴进去就行。#pragma comment已经帮你链接了ws2_32.lib和iphlpapi.lib不需要到属性页手工配置。如果你习惯命令行用 MSVC 环境cl /EHsc ping_sample.cpp /link ws2_32.lib iphlpapi.lib如果使用 CMake则需要在target_link_libraries中加上ws2_32和iphlpapi。还有一个边角问题代码里用了inet_ntop这个函数从 Vista 开始才稳定可用如果你的目标环境是 Windows XP 或更老需要退回inet_ntoa不过都 2025 年了正常不需要考虑 XP。这里把参数设置整理成一张表方便你后续扩展参数项含义建议值说明目标 IP点分十进制 IPv4 地址如 192.168.1.1只接受地址不解析域名超时单次等待应答的毫秒数局域网 1000广域网 3000设太大排查慢设太小误报超时数据大小回显报文的 payload 字节数默认 32测 MTU 时可调 1472 或更大TTL发送报文的存活跳数64 或 128探测链路质量时可按需调整4. 把玩具变成工具连续 ping、批量探测与结果统计4.1 循环发送与丢包统计每轮间隔如何控制系统 ping 的默认行为是以一秒为间隔连续发送四条然后输出丢包率。你用同步IcmpSendEcho做同样的事时最容易犯的错误就是每轮结束后直接Sleep(1000)。因为IcmpSendEcho本身是会阻塞等待的如果目标超时它已经等够了 2000ms再Sleep(1000)实际间隔就是 3 秒。正确的间隔控制是记录本轮IcmpSendEcho的实际耗时再用1000 - 耗时作为Sleep的入参。如果耗时已经超过 1000ms那就不要睡了直接进入下一轮。这样可以保证每轮发送的起点大致对齐秒边界。下面的循环结构可以直接抄#include windows.h void ping_loop(const char* ip, int rounds, int timeout, int data_size) { HANDLE hIcmp IcmpCreateFile(); unsigned long dest_ip inet_addr(ip); char* data new char[data_size]; memset(data, x, data_size); unsigned char reply_buf[sizeof(ICMP_ECHO_REPLY) 65500]; DWORD reply_size sizeof(ICMP_ECHO_REPLY) data_size; int received 0; unsigned long total_rtt 0; for (int i 0; i rounds; i) { DWORD start GetTickCount(); DWORD ret IcmpSendEcho(hIcmp, dest_ip, data, (WORD)data_size, NULL, reply_buf, reply_size, timeout); DWORD elapsed GetTickCount() - start; if (ret ! 0) { PICMP_ECHO_REPLY reply (PICMP_ECHO_REPLY)reply_buf; if (reply-Status IP_SUCCESS) { received; total_rtt reply-RoundTripTime; printf(第 %d 轮: %u ms TTL%u\n, i 1, reply-RoundTripTime, reply-Options.Ttl); } } if (i rounds - 1) { int wait_ms 1000 - (int)elapsed; if (wait_ms 0) Sleep(wait_ms); } } printf(丢包率: %.1f%% 平均RTT: %.1f ms\n, (rounds - received) * 100.0 / rounds, received 0 ? (double)total_rtt / received : 0.0); IcmpCloseHandle(hIcmp); }这个函数完全没有做线程同步适合顺序执行。elapsed是GetTickCount的粗略时间可能被系统调度拉开一两毫秒但对比 RTT 的百毫秒级别完全够用。细心的读者会发现这里没有保存每一轮的 RTT而是只累加平均第六章会给出完整统计的方案。4.2 探测网段逐台扫描时怎样复用句柄批量扫描一个 C 段是运维场景的常客。常见的需求是192.168.1.1到192.168.1.254逐台探测把在线的主机列出来。这里有一个效率原则IcmpCreateFile的句柄可以复用不要每台主机都创建和关闭一次否则扫描 254 个地址会浪费大量时间在句柄切换上。扫描代码可以非常朴素HANDLE hIcmp IcmpCreateFile(); for (int i 1; i 254; i) { char target[32]; sprintf(target, 192.168.1.%d, i); unsigned long dest_ip inet_addr(target); unsigned char reply_buf[sizeof(ICMP_ECHO_REPLY) 32]; DWORD ret IcmpSendEcho(hIcmp, dest_ip, scan, 4, NULL, reply_buf, sizeof(reply_buf), 1000); if (ret ! 0) { PICMP_ECHO_REPLY reply (PICMP_ECHO_REPLY)reply_buf; if (reply-Status IP_SUCCESS) { printf(%s 在线, RTT%u ms\n, target, reply-RoundTripTime); } } } IcmpCloseHandle(hIcmp);注意这段代码是串行扫描254 个地址如果每个超时 1 秒最坏情况要等 254 秒不实用。因此批量扫描时建议把超时调到 300~500ms同时只对同一广播域内的主机使用。真正要扫描多个网段时应该给每个地址开一个工作线程再用IcmpSendEcho2异步发送。同步IcmpSendEcho毕竟是对 API 最简单直接的封装适合小规模场景大规模并发可以看第六章的思路。4.3 输出格式像系统 ping 一样可读工具一旦要交给别人用输出格式就得向系统 ping 看齐。我的经验是最少包含四类信息来源 IP、数据大小、往返时间、TTL。如果还要做监控建议增加时间戳方便后续喂给图表系统。比如可以改成这种格式[2025-04-01 14:22:31] 192.168.1.1 的通达信息: 字节32 时间2ms TTL61时间戳用GetLocalTime获取然后格式化到前缀。注意不要直接打印 ANSI 转义序列来加颜色Windows 控制台在不同终端下的表现不一致普通日志保持纯文本最稳。输出还要区分「超时」和「不可达」超时通常是链路丢包或目标静默不可达往往是目标主机不存在或路由黑洞。你可以通过reply-Status区分IP_DEST_HOST_UNREACHABLE目标主机不可达和IP_REQ_TIMED_OUT请求超时对应不同的处理逻辑。虽然这是监控脚本的细节但对判断故障点很有价值。5. 常见问题与避坑从错误码到防火墙的排查记录5.1 域名直接传进 inet_addr解析失败导致全部超时现象代码写成inet_addr(www.baidu.com)结果dest_ip变成INADDR_NONE程序要么报无效地址要么一路超时。原因inet_addr只做点分十进制到二进制 IPv4 地址的转换不做域名解析。传入域名会返回 32 位全 1 的INADDR_NONE这本身是一个合法地址 255.255.255.255往它发 ICMP 大概率没有回应于是表现为超时。解决先用getaddrinfo把主机名解析成sockaddr_in再取出sin_addr.s_addr传给IcmpSendEcho。如果你的工具最终要支持域名参数这一步绕不开。5.2 ReplyBuffer 太小返回 ERROR_INVALID_PARAMETER 而不是结果现象把reply_buf开到char[64]调用IcmpSendEcho后返回值是 0GetLastError得到 87也就是ERROR_INVALID_PARAMETER。原因Windows 要求回复缓冲区必须能容纳至少一个ICMP_ECHO_REPLY结构体再加上你发出的数据长度。结构体内部包含IPAddr和IP_OPTION_INFORMATION实际大小在 64 位系统上是 40 字节左右你只给 64 字节回显数据根本没有地方放。解决统一用sizeof(ICMP_ECHO_REPLY) data_size计算不要拍脑袋固定一个数组长度。另外不要用自定义结构体去模拟ICMP_ECHO_REPLY不同 Windows SDK 版本结构定义可能不同用系统头文件里的定义最安全。5.3 ret 0 不代表通漏看 ICMP_ECHO_REPLY.Status现象目标主机不可达时IcmpSendEcho返回 1程序直接打印来自 x.x.x.x 的回复但实际根本 ping 不通。原因只要收到任何一条进入本机的 ICMP 报文包括类型 3 的目标不可达报文IcmpSendEcho都会认为收到了回复。判断网络是否通畅必须看Status字段是不是IP_SUCCESS而不能只看 ret。解决在拿到reply指针后加上if (reply-Status IP_SUCCESS)分支非成功状态一律走异常分支。实践中我会把状态码转成可读字符串比如IP_DEST_NET_UNREACHABLE、IP_TTL_EXPIRED这样日志里能直接看出是网络不通还是 TTL 耗尽。5.4 防火墙和安全组丢包程序与系统 ping 行为不一致现象系统 ping 某个内置防火墙的目标机器能通你的程序 ping 却超时或者反过来程序能通、系统 ping 超时。原因Windows 防火墙默认对 ICMP 有自己的入站规则有些机器只允许ping.exe通过有些规则只对系统服务生效。如果你在云服务器上还需要检查安全组是否放行 ICMP 协议。你的 C 程序虽然调用了系统 API但在防火墙眼里它仍然是一个新的可执行文件首次运行时可能被默认策略拦下。解决先跑一次ping -t 目标IP确认系统命令行能通如果系统 ping 通而你的程序不通说明防火墙没有放行你这个 exe去「高级安全 Windows Defender 防火墙」里为它添加允许 ICMPv4 入站规则。如果两边都不通再排查安全组和网络路由。写代码本身解决不了防火墙策略但排查到这一步至少不会怀疑是自己的逻辑有 bug。5.5 连续 ping 间隔漂移同步超时和 Sleep 叠加现象程序每隔 1 秒打一行日志但实际用 Wireshark 看抓包发现 ICMP 请求间隔是 2 秒甚至 3 秒而且越跑越偏。原因同步IcmpSendEcho在目标超时时会等待满整个 timeout。你又在循环末尾无条件Sleep(1000)导致实际间隔变成 timeout 1000。丢包率高的链路会很快放大这个漂移。解决按 4.1 节的方式用GetTickCount记录单轮耗时再让Sleep吃掉剩余时间。如果一轮已经超过 1000ms连Sleep都不要调。这样做出来的发送频率才稳定也方便计算真实的丢包率。6. 让结果更接近系统 ping多轮统计与并发探测技巧6.1 算平均值前先做三轮基线系统 ping 默认只发 4 次但对于抖动较大的公网链路4 次的平均值容易受某一个尖刺影响。我一般会在工具里增加一个-n参数支持连续 10 次以上的探测同时把每一轮的 RTT 存进数组最后统一计算最短、最长和平均。示例代码#define MAX_ROUNDS 64 unsigned long rtt[MAX_ROUNDS]; DWORD rounds 4; // 可由命令行传入 for (DWORD i 0; i rounds; i) { DWORD ret IcmpSendEcho(...); if (ret ! 0 ((PICMP_ECHO_REPLY)reply_buf)-Status IP_SUCCESS) { rtt[i] ((PICMP_ECHO_REPLY)reply_buf)-RoundTripTime; } else { rtt[i] 0; } // 间隔控制略 } // 计算统计 unsigned long best UINT32_MAX, worst 0, sum 0, received 0; for (DWORD i 0; i rounds; i) { if (rtt[i] 0) continue; received; sum rtt[i]; if (rtt[i] best) best rtt[i]; if (rtt[i] worst) worst rtt[i]; } printf(已发送 %lu, 已接收 %lu, 丢包率 %.1f%%, 最短 %lu ms, 最长 %lu ms, 平均 %.1f ms\n, rounds, received, (rounds - received) * 100.0 / rounds, best, worst, received 0 ? (double)sum / received : 0.0);这样的输出可以直接丢给监控平台。把best和worst一并展示能比单看平均更快发现网络抖动。6.2 用 IcmpSendEcho2 做并发探测同步版本的瓶颈在于一次只能等一个目标扫描 100 台主机要串行几百秒。Windows 提供了IcmpSendEcho2可以传入OVERLAPPED结构或回调函数实现异步批量探测。常见做法是给每个目标创建一个事件对象把事件句柄塞进OVERLAPPED然后调用IcmpSendEcho2后立刻返回线程等待事件再解析结果。代码骨架大致是HANDLE hEvent CreateEvent(NULL, TRUE, FALSE, NULL); OVERLAPPED ov { 0 }; ov.hEvent hEvent; IcmpSendEcho2(hIcmp, hEvent, NULL, NULL, dest_ip, data, data_size, NULL, reply_buf, reply_size, timeout); WaitForSingleObject(hEvent, timeout 100); PICMP_ECHO_REPLY reply (PICMP_ECHO_REPLY)IcmpParseReplies(reply_buf, reply_size);注意IcmpParseReplies必须拿原始缓冲区解析不能在事件返回后直接强转否则可能读到半初始化数据。这个 API 的原理和同步版不同它把应答整理成连续的内存块因此别再沿用同步版的手工指针。异步并发可以显著降低扫网段的耗时但代码复杂度也会上升建议先把 4.1 的同步循环跑通再升级到异步。我自己现在写这类工具的习惯是第一版永远先用同步 API 跑通逻辑对照系统 ping 至少验证三轮再考虑并发优化。Windows 的 ICMP API 已经替我们挡掉了校验和、字节序这些脏活最大风险反而是对结构体和状态码想当然。保持最朴素的调用方式先让输出可信再去追性能。希望帮到你。本文还有配套的精品资源点击获取
返回列表