ARTICLE DETAIL

资讯详情

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

Windows下用C++实现ping命令:ICMP协议、Raw Socket与权限避坑指南

Windows下用C++实现ping命令:ICMP协议、Raw Socket与权限避坑指南 简介面向Windows网络编程学习者这份压缩包提供了一套用C与WinSock2实现ping命令功能的完整源码覆盖原始套接字创建、ICMP回显请求构造、发送与接收应答等核心流程。代码包含两个cpp源文件和一个头文件另附Qt工程文件.pro与.pro.user整体仅5KB文件结构紧凑便于直接打开查看和编译调试。资源已有3033人学习/下载验证了其内容对初学者的参考价值。实现中不仅展示了WSAStartup初始化、gethostbyname域名解析、sendto发送自定义ICMP数据包等关键写法也给出了通过recvfrom循环等待回显应答并判断超时的思路同时提醒了管理员权限等Windows限制。读者可借此快速理解ping的工作原理并将其改造为课程设计或网络诊断工具的基础框架。1. 自己用C写一个ping命令到底在写什么同事网络不通第一反应是打开cmd敲一句ping www.baidu.com。但哪天这个工具不在了、要把它嵌进自己的监控程序里、或者领导让你“仿写一个ping”你会发现在Windows上让一个C程序发出并收回第一个ICMP报文要跨过的坑比想象中多校验和算不对没人理你、Raw Socket不开管理员权限直接报错、防火墙默认策略随时让你的包石沉大海。这篇文章会按我实际写ping工具的路径走一遍从ICMP报文结构、校验和算法到两种Windows下的发送方式Raw Socket和IcmpSendEcho2再把我这些年踩过的权限、防火墙、超时、路由选择问题逐个拆开。适合正在做内网监控、自学Windows网络编程或者被作业逼着写一个“仿ping”的人跟着代码跑通一次你就明白这个命令真不是什么黑匣子。2. 从ICMP报文说起回显请求的结构与校验和算法2.1 拆开一个ICMP回显请求字节布局与Identifier/Sequence的约定ping命令的核心不是TCP也不是UDP而是ICMP里的两个类型Echo Request类型8和Echo Reply类型0。你发一个类型8过去对方网络栈回一个类型0回来一次ping就算完成了。整个报文在IP层的数据部分里结构非常紧凑抓一次包就能看明白字节偏移字段长度说明0Type18表示回显请求0表示回显应答11表示超时1Code1请求和应答时为02Checksum2覆盖整个ICMP报文的16位校验和网络字节序4Identifier2用于匹配请求和应答通常填进程ID6Sequence2序号每发包递增8Data可变随便填用于计时和测MTUType、Code、Checksum这8个字节是固定头之后是Identifier和Sequence最后是数据区。Windows命令行里的ping默认发32字节数据总报文48字节你指定-l 1000时数据区就是1000字节总报文1008字节。Identifier为什么要用进程ID因为一台机器上可能同时有多个ping实例在跑每个实例用自己的PID作标识收到Echo Reply时就能分辨是谁的应答。Sequence则是区分同一会话里连续发的多个包配合Identifier一起用能识别丢包和乱序。我当时第一次写的时候偷懒把这两个字段都写死成1结果自己发出去的回显请求和收到的应答确实能匹配上但机器上再多开一个别的ping工具两边就互相串了。老老实实按约定填Identifier取进程IDSequence从1递增。数据区的内容没有硬性要求但我建议不要全填0填点递增的字节比如0x00到0x3f循环这样用Wireshark看报文时一眼能分辨自己的包排查问题方便很多。2.2 校验和算法自己算不对的后果与逐行实现ICMP的Checksum算法来自RFC 1071本质是把报文当作一串16位整数做累加再对累加结果取反。这个算法不是ICMP专属IPv4头、UDP、TCP都用同一套。C实现如下unsigned short icmp_checksum(const void* data, size_t len) { unsigned int sum 0; const unsigned short* p (const unsigned short*)data; while (len 1) { // 每次取16位按网络字节序解释 sum *p; len - 2; } if (len 1) { // 奇数长度时末字节补在高位 sum *(const unsigned char*)p; } while (sum 16) { // 把16位以上部分回卷加到低16位 sum (sum 0xFFFF) (sum 16); } return (unsigned short)(~sum); // 取反 }这个函数有几个点容易出错。第一计算前必须把ICMP报文里的Checksum字段先置0算完再把结果写进去如果带着一个旧值去算结果永远不对而且对端校验失败时会悄悄丢弃报文——表现就是ping一直超时抓包看对方其实收到了。第二指针按unsigned short*累加时字节序由缓冲区里的实际字节序决定我们构造报文时用htons()填好各字段后整个缓冲区已经是网络字节序所以直接累加即可不需要在函数里再做字节序转换。第三进位回卷那步不能少别图省事直接sum (sum 0xFFFF) (sum 16)只做一次while循环是把极端情况下的多轮进位一并处理干净。构造ICMP回显请求的完整函数我一般这样写struct IcmpHeader { unsigned char type; unsigned char code; unsigned short checksum; unsigned short id; unsigned short sequence; }; size_t build_echo_request(char* buffer, size_t size, unsigned short seq) { memset(buffer, 0, size); IcmpHeader* hdr (IcmpHeader*)buffer; hdr-type 8; // Echo Request hdr-code 0; hdr-checksum 0; // 先置零再算 hdr-id htons((unsigned short)GetCurrentProcessId()); hdr-sequence htons(seq); for (size_t i sizeof(IcmpHeader); i size; i) { buffer[i] (char)(i - sizeof(IcmpHeader)); // 填充测试数据 } hdr-checksum icmp_checksum(buffer, size); // 算完回写 return size; }注意id和sequence都经过htons()转换成网络字节序而checksum不需要再转一次校验和函数返回的值本身就是要按网络字节序存到缓冲区里的那个数。这个小细节曾让我的程序在x86小端机器上多调了半个下午后来抓包对比才发现是把同一个值转了两次字节序校验和反而错了。数据区的填充长度决定了你的ping报文大小想测大包就把传入的size调大但别超过IP层的最大负载限制。3. 用Raw Socket实现pingVista后的管理员权限与最小可运行代码3.1 先建Socket再发报文WSAStartup与SOCK_RAW的初始化顺序Windows上最早期的做法是直接创建SOCK_RAW类型的socket指定协议为IPPROTO_ICMP。这个方案的特点是你能完全控制ICMP报文内容但也正因如此Vista之后微软加了硬性限制创建Raw Socket的进程必须具有管理员权限否则WSASocket或socket调用直接返回INVALID_SOCKET错误码是10013WSAEACCES。我一开始不知道写完编译运行程序秒退加了个printf打印错误码才反应过来。下面是最小可运行的发送端代码只负责构造并发出一个ICMP回显请求#include winsock2.h #include ws2tcpip.h #include stdio.h #include stdlib.h #pragma comment(lib, ws2_32.lib) unsigned short icmp_checksum(const void* data, size_t len) { unsigned int sum 0; const unsigned short* p (const unsigned short*)data; while (len 1) { sum *p; len - 2; } if (len 1) { sum *(const unsigned char*)p; } while (sum 16) { sum (sum 0xFFFF) (sum 16); } return (unsigned short)(~sum); } #pragma pack(push, 1) struct IcmpHeader { unsigned char type; unsigned char code; unsigned short checksum; unsigned short id; unsigned short sequence; }; #pragma pack(pop) int main() { WSADATA wsa; WSAStartup(MAKEWORD(2, 2), wsa); SOCKET sock socket(AF_INET, SOCK_RAW, IPPROTO_ICMP); if (sock INVALID_SOCKET) { printf(socket failed, error%d\n, WSAGetLastError()); return 1; } int timeout 3000; setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, (const char*)timeout, sizeof(timeout)); setsockopt(sock, SOL_SOCKET, SO_SNDTIMEO, (const char*)timeout, sizeof(timeout)); const char* target 192.168.1.1; sockaddr_in dest; dest.sin_family AF_INET; dest.sin_addr.s_addr inet_addr(target); char buffer[64]; IcmpHeader* hdr (IcmpHeader*)buffer; memset(buffer, 0, sizeof(buffer)); hdr-type 8; hdr-code 0; hdr-checksum 0; hdr-id htons((unsigned short)GetCurrentProcessId()); hdr-sequence htons(1); for (int i sizeof(IcmpHeader); i (int)sizeof(buffer); i) { buffer[i] (char)(i - sizeof(IcmpHeader)); } hdr-checksum icmp_checksum(buffer, sizeof(buffer)); int ret sendto(sock, buffer, sizeof(buffer), 0, (sockaddr*)dest, sizeof(dest)); if (ret SOCKET_ERROR) { printf(sendto failed, error%d\n, WSAGetLastError()); } else { printf(sent %d bytes to %s\n, ret, target); } closesocket(sock); WSACleanup(); return 0; }代码里有个关键点容易被忽略必须先WSAStartup再创建socket这在任何WinSock程序里都是固定顺序。Raw Socket的第三个参数IPPROTO_ICMP告诉系统这个socket只接收和发送ICMP报文内核会替你过滤掉其他协议的数据。SO_RCVTIMEO和SO_SNDTIMEO分别是收发超时单位是毫秒这里设的3秒对局域网内ping足够宽裕。运行前记得“以管理员身份运行”命令提示符或从VS里以管理员启动调试。如果嫌每次右键麻烦可以在项目里加一个UAC manifest把执行级别设为requireAdministrator这样程序启动时会自动弹UAC提权框。这个权限要求是硬性的和代码怎么写无关。3.2 接收回显应答跳过IP头、超时设置与一次完整循环发出去只算完成一半。收到Echo Reply的关键是理解一件事recvfrom从Raw Socket上读回来的数据不是从ICMP头开始而是从IP头开始。底层网卡收到一个完整IP包后内核会把它原样交给Raw Socket所以你得先解析IP头长度再往后找ICMP头。IP头的第一个字节高4位是版本号IPv4就是4低4位是头部长度以4字节为单位所以(recvBuf[0] 0x0F) * 4就是IP头字节数。char recvBuf[256]; sockaddr_in from; int fromLen sizeof(from); int n recvfrom(sock, recvBuf, sizeof(recvBuf), 0, (sockaddr*)from, fromLen); if (n SOCKET_ERROR) { int err WSAGetLastError(); if (err WSAETIMEDOUT) { printf(Request timed out.\n); } else { printf(recvfrom failed, error%d\n, err); } return 1; } int ipHeaderLen (recvBuf[0] 0x0F) * 4; IcmpHeader* resp (IcmpHeader*)(recvBuf ipHeaderLen); if (resp-type 0 ntohs(resp-id) (unsigned short)GetCurrentProcessId() ntohs(resp-sequence) 1) { long rtt GetTickCount() - startTick; printf(Reply from %s: bytes%d time%ldms\n, inet_ntoa(from.sin_addr), n - ipHeaderLen, rtt); } else if (resp-type 11) { printf(Reply from %s: TTL expired in transit.\n, inet_ntoa(from.sin_addr)); }收到应答后要做三个判断类型必须是0Echo Replyid必须和发送时填的进程ID一致sequence必须匹配当前序号。前两个条件都满足才能确认这个包是“我们的ping的应答”而不是网络上其他ICMP流量。from.sin_addr是对端IP打印出来就能看到响应来源。RTT的计时我习惯用GetTickCount64()它返回毫秒级的系统运行时间比GetTickCount()安全后者约49.7天回绕。计算时在发送前记录一次收到应答后减一下。把发送和接收包在一个for循环里连续跑4次每次sequence加1统计应答次数、平均RTT、丢包数就是一个迷你版ping命令的核心了。循环里每次sendto之前都重新构造ICMP头sequence递增后记得重新算校验和——我见过有人把构造放在循环外结果4个包发出去用的是同一个序号对端回包的序号全对不上收到的应答都被过滤掉了。Raw Socket方案还有一个隐患recvfrom能收到本机所有网卡上符合协议的ICMP流量包括别处发给你的。多网卡机器上如果路由选的出口和你想ping的目标不在同一网段可能出现“发送成功但永远收不到”的怪现象。稳妥的做法是在socket上bind到具体的本地IP让内核只接收绑定网卡的流量。这个问题放在第5章避坑笔记里细说。4. 换一条路用IcmpSendEcho2实现ping省去协议细节4.1 IcmpSendEcho2的最小调用包含头文件、链接库与十行核心代码Raw Socket虽然灵活权限要求和管理成本都偏高。实际工作中我更推荐用Windows自带的ICMP APIIcmpSendEcho2。它封装了报文构造、校验和计算、应答解析、超时管理不需要管理员权限普通用户跑起来的体验和系统自带ping基本一致。这个API属于IP Helper库使用时要引入两个头文件和一个导入库#include winsock2.h #include iphlpapi.h #include icmpapi.h #pragma comment(lib, iphlpapi.lib)winsock2.h必须放在最前面它和windows.h有宏定义冲突的历史问题顺序错了编译报错一大堆属于Windows C开发里最经典的一个坑。下面是完整的最小示例#include winsock2.h #include iphlpapi.h #include icmpapi.h #include stdio.h #include string.h #pragma comment(lib, iphlpapi.lib) #pragma comment(lib, ws2_32.lib) int main() { HANDLE hIcmp IcmpCreateFile(); if (hIcmp INVALID_HANDLE_VALUE) { printf(IcmpCreateFile failed, error%lu\n, GetLastError()); return 1; } unsigned long destAddr inet_addr(192.168.1.1); if (destAddr INADDR_NONE) { printf(invalid IP address\n); return 1; } char requestData[32] { 0 }; memcpy(requestData, hello from icmp api, 19); char replyBuffer[sizeof(ICMP_ECHO_REPLY) sizeof(requestData) 64]; DWORD result IcmpSendEcho2(hIcmp, NULL, NULL, NULL, destAddr, requestData, (WORD)sizeof(requestData), NULL, replyBuffer, sizeof(replyBuffer), 2000); if (result 0) { PICMP_ECHO_REPLY reply (PICMP_ECHO_REPLY)replyBuffer; struct in_addr addr; addr.s_addr reply-Address; printf(Reply from %s: bytes%lu time%lums TTL%u\n, inet_ntoa(addr), reply-DataSize, reply-RoundTripTime, reply-Options.Ttl); } else { printf(ping failed, status%lu\n, GetLastError()); } IcmpCloseHandle(hIcmp); return 0; }IcmpCreateFile虽然名字里有File但它不是打开磁盘文件而是向系统申请一个ICMP会话句柄用完必须IcmpCloseHandle关掉否则句柄泄漏。inet_addr返回的已经是网络字节序可以直接传给API不用再htonl——我见过不少人在这里多做一次转换结果ping的地址完全不对。replyBuffer的大小是另一个关键它必须能容纳ICMP_ECHO_REPLY结构体、你发送的数据副本、可能返回的IP选项信息以及系统预留的填充字节。最小值建议是sizeof(ICMP_ECHO_REPLY) 请求数据长度 64我在代码里就是这样写的宁可多留不要少给。4.2 返回值与报文缓冲看懂PingReply结构体的边界IcmpSendEcho2的返回值是实际收到的回显应答数量1表示成功收到一个应答0表示失败或者超时。失败时千万别直接打印WSAGetLastError要调GetLastError。我第一次用这个API就犯了这个错程序返回0我打印错误码发现是乱码查了半天才意识到这个API不走WinSock的错误通道。成功后replyBuffer在内存里放的不是单个结构体因为Windows API为了支持“一次发多个请求再统一回收”的模式会把多个ICMP_ECHO_REPLY连续摆放。当前场景我们只发了一个请求所以(PICMP_ECHO_REPLY)replyBuffer就是那个唯一应答的结构体地址。常用字段按需取用字段含义使用场景Address应答来源IP网络字节序转成点分十进制打印RoundTripTime往返时间毫秒判断链路质量Options.Ttl应答包的TTL剩余值初值减去它可估算跳数Options.FlagsIP头标志位判断是否分片DataSize返回的数据区字节数与发送大小对照Data指向数据区的指针需要回显内容时读取注意Data字段是指针指向的缓冲区紧跟在结构体之后也就是replyBuffer sizeof(ICMP_ECHO_REPLY)。不要越界访问否则读到的内容完全是垃圾数据。超时时间参数是倒数第二个参数单位毫秒。这个值是“发送后等待应答的总时长”不是“每跳等待时长”。做路由跟踪时如果你给的是2000毫秒而某个中间节点就是不回ICMP超时报文整个程序会干等2秒批量扫描时并发度就上不去了。实际写工具时我会把超时调到500毫秒局域网内足够跨网段再调大。如果发现返回值始终是0而用系统ping命令却正常先查防火墙。IcmpSendEcho2发出的报文和系统ping一样都是ICMP回显请求但某些第三方防火墙软件会按进程路径判断是否放行你自己写的程序不在白名单里就被拦了。第5章会专门讲防火墙排查步骤。5. 避坑笔记自己写的ping在Windows上最容易翻车的5个点5.1 权限、防火墙和校验和三个“发了没回应”的头号嫌疑坑1Raw Socket创建失败错误码10013。现象用SOCK_RAWIPPROTO_ICMP的程序在普通权限下运行socket()返回INVALID_SOCKETWSAGetLastError()是10013WSAEACCES。 原因Vista以后Windows规定创建Raw Socket必须管理员权限普通用户进程直接被拒。 解决开发时用管理员身份运行发布时在项目里嵌入UAC manifest要求requireAdministrator或者干脆改用第4章的IcmpSendEcho2它不需要提权这是我最推荐的做法权限这个坑直接从根上绕开。坑2防火墙拦截程序自己发得出但对方回包进不来。现象代码看起来没问题发送返回成功抓包能看到请求已经出网卡但recvfrom一直超时系统ping却能通。 原因Windows防火墙默认对系统自带ping程序svchost.exe托管的ICMP服务有放行规则但你自己写的exe没有规则入站的ICMP Echo Reply被防火墙丢掉。 解决管理员权限下执行这条命令放行ICMPv4入站netsh advfirewall firewall add rule nameAllow ICMPv4-In protocolicmpv4:8,any dirin actionallow这条规则允许外部向本机发送ICMP回显请求类型8的应答进入。测试完如果不想留规则用delete rule nameAllow ICMPv4-In删掉。开发环境里我一般直接先临时关防火墙验证确认是这个问题后再加规则。坑3校验和算错对端静默丢包。现象发送成功对端也收到了报文但就是不回Wireshark抓包能看到ICMP报文被标记为“bad checksum”。 原因计算校验和前忘了把checksum字段清零或者对已经填好校验和的缓冲区又算了一遍。 解决构造报文时先memset整个缓冲区把checksum字段显式设为0填充完其他字段后最后调用icmp_checksum写回结果。每次sendto前都重新算一遍不要图省事复用上一次的缓冲区。5.2 时通时断、一般故障和接收缓冲三个隐蔽的环境陷阱坑4ping开发板或虚拟机时通时断链路看着没问题就是丢包。现象自己写的程序丢包率很高系统自带ping也一样目标IP在局域网内网线没松动开发板串口打印也正常。 原因常见三类——一是Windows网卡电源管理默认勾选了“允许计算机关闭此设备以节约电源”网卡会不定期休眠二是ARP表项过期后重新解析期间丢包三是开发板固件对ICMP中断处理的优先级低响应慢于你的超时设置。 解决设备管理器里找到使用的网卡属性-电源管理取消勾选节能选项命令行执行arp -d清空ARP缓存程序里把超时从500毫秒放宽到2000毫秒先确认链路再压缩参数。开发板这类嵌入式设备对ICMP的响应速度本来就不稳定超时设太短必然误判为丢包。坑5报错“一般故障General failure”发送直接失败。现象sendto返回SOCKET_ERRORWSAGetLastError()显示10049或10051程序里打印出来的错误描述是“一般故障”有时系统ping也会出现同样的提示。 原因本机路由表里没有到目标地址的可用路径或者当前网卡状态异常禁用、未插线、IP配置错误。Windows找不到合适的出口时就会报这个。 解决先用route print查看路由表确认目标网段有直连路由或默认路由代码层面给socketbind到本机具体IP避免系统选错网卡。bind的地址可以用GetAdaptersInfo枚举网卡后取第一个非回环IP这是多网卡机器上让ping结果可复现的关键一步。坑6接收缓冲区太小应答被截断解析出错。现象程序能收到数据但解析出来的bytes值明显偏小或者respond内容错乱。 原因recvfrom的接收缓冲一是不够大数据被截断二是用了发送缓冲区的大小去接收而实际应答包因为IP头的加入比发送的ICMP报文大几十字节。 解决接收缓冲区固定给到256字节以上不要和发送缓冲区共用同一个变量。解析时严格按“IP头长度 ICMP头 数据区”的顺序偏移IP头长度用(recvBuf[0] 0x0F) * 4算不要假设固定20字节。开启IP选项如记录路由时IP头会超过20字节硬编码偏移是项目里最常见的解析翻车原因。6. 进阶把单次ping扩展成TTL路由跟踪与批量存活探测单次ping能通的设备只能证明“这条路径当前可用”。想把工具做得更有用两个最常见的扩展方向是TTL路由跟踪和批量存活探测。路由跟踪的原理很简单ICMP回显请求的TTL每经过一个路由器就减1减到0时路由器会丢弃该包并回一个ICMP超时报文类型11。所以从TTL1开始递增发送每跳返回的“超时报文”的来源IP就是路径上的路由器地址直到TTL大到能到达目标主机、收到类型0的应答为止。核心代码就一个循环for (int ttl 1; ttl 30; ttl) { setsockopt(sock, IPPROTO_IP, IP_TTL, (const char*)ttl, sizeof(ttl)); // 构造ICMP回显请求sequence填ttl sendto(sock, buffer, sizeof(buffer), 0, (sockaddr*)dest, sizeof(dest)); // 接收并判断类型 int n recvfrom(sock, recvBuf, sizeof(recvBuf), 0, (sockaddr*)from, fromLen); if (n SOCKET_ERROR) { printf(%2d * * *\n, ttl); continue; } int ipHeaderLen (recvBuf[0] 0x0F) * 4; IcmpHeader* resp (IcmpHeader*)(recvBuf ipHeaderLen); if (resp-type 11) { printf(%2d %-20s %ldms\n, ttl, inet_ntoa(from.sin_addr), rtt); } else if (resp-type 0) { printf(%2d %-20s 到达目标\n, ttl, inet_ntoa(from.sin_addr)); break; } }setsockopt的IP_TTL选项必须在每次发送前设置因为TTL值是随包走的设置一次只对下一个包生效。TTL超过30还到不了目标就停避免内网环路里无限循环——实际上公网路径极少超过30跳。这个功能做出来你的工具就带上了“tracert”能力排查跨网段延迟问题时不用再切回cmd。批量存活探测我习惯基于IcmpSendEcho2做把目标IP列表拆成小段每段用一个线程池并发调用单次超时控制在500毫秒。注意IcmpCreateFile的句柄不能跨线程乱共享每个工作线程自己创建、自己关闭避免并发访问同一个句柄引发未知错误。这套东西自己写一遍、跑通一遍、再在真实网络里折腾一番你对“ping”这个命令的理解就彻底不一样了。我现在排查网络问题看到系统ping的结果第一反应已经不是“通或不通”而是“TTL剩多少、时延抖动来自哪一跳、是不是又踩了哪个网卡的节能开关”。这种判断力是命令行永远教不会你的。希望帮到你。本文还有配套的精品资源点击获取
返回列表