☰
从零手写Ping程序:Winsock与ICMP协议实战指南
2026/9/30 5:13:30 网站建设 项目流程

简介:这份文档是计算机网络课程设计的完整报告,面向高校计算机与网络工程专业学生,以及正在学习Windows网络编程的开发者,用于完成“用Winsock实现Ping应用程序”这一典型课设任务。报告围绕Winsock编程接口与ICMP协议展开,涵盖绪论、需求分析、详细设计、设计小结、参考文献与附录等章节,系统讲解Ping程序的设计原理与实现流程。压缩包内仅含1个doc文档,大小约333KB,内容以文字与代码说明为主,便于直接阅读和参考。文档中详细梳理了WSAStartup、socket、inet_addr、gethostbyname、recv、sendto等关键函数的用途与调用方式,并分析了ICMP回送请求与应答机制,可帮助读者理解网络层协议与应用层程序之间的交互关系。目前已有235人学习,适合作为课程设计参考、网络编程入门材料或实验报告撰写模板,读者可据此掌握Ping程序的核心逻辑与Winsock编程思路。

1. 从零手写 Ping:为什么 Winsock 课设绕不开 ICMP

很多人做计算机网络课程设计,选题第一反应就是“用 Winsock 写个 Ping 程序”。原因很直接:Ping 这个工具天天用,但真正拆开看,它背后是 ICMP 协议、原始套接字、IP 首部校验和、往返时延统计这一整套东西。把它写出来,等于把网络层和数据链路层之间的边界摸了一遍。你平时敲ping www.baidu.com,看到的是四行回显和几个毫秒数;而课设要你做的,是自己构造 ICMP 回显请求报文,自己算校验和,自己解析回显应答,最后算出最小、最大、平均 RTT 和丢包率。这件事用 Winsock 做,核心就两个 API:socket()和sendto()/recvfrom(),但真正卡人的地方在于原始套接字的权限、字节序、校验和算法和超时处理。这篇笔记面向正在做计算机网络课程设计、需要交一份能跑起来的 Ping 程序的同学,也面向想搞懂“ping 命令与 ICMP 协议分析”到底怎么落到代码里的从业者。我会按“先讲清协议 → 再给可复现代码 → 最后说踩坑”的顺序走,代码用 C++ 加 Winsock,环境是 Windows + Visual Studio,你照着改 IP 和超时参数就能编译运行。

2. ICMP 回显报文长什么样:字段拆解与校验和手算

2.1 ICMP 首部 8 字节的布局

ICMP 回显请求和回显应答共用同一套首部结构,总共 8 字节,后面跟可变长度的数据区。类型字段 1 字节,回显请求是 8,回显应答是 0;代码字段 1 字节,回显场景固定为 0;校验和 2 字节,覆盖整个 ICMP 报文(首部加数据);标识符 2 字节,用来匹配请求和应答,通常填进程 ID;序列号 2 字节,每发一个包加一;数据区一般填 32 字节左右的填充内容,Windows 的 ping 默认就是 32 字节。

字段偏移长度回显请求取值回显应答取值
类型 Type01 字节80
代码 Code11 字节00
校验和 Checksum22 字节计算得出计算得出
标识符 Identifier42 字节进程 ID原样返回
序列号 Sequence62 字节递增原样返回
数据 Data8可变填充字节原样返回

这里有个容易翻车的点:标识符和序列号在网络上传输时是网络字节序(大端),你本机是小端,填进去之前必须用htons()转换。校验和字段本身在计算时要先置 0,算完再填回去。

2.2 校验和算法:16 位反码求和再取反

ICMP 校验和用的是标准的 16 位反码求和。把整个报文按 16 位一组累加,如果总字节数是奇数,最后一字节补一个 0 凑成 16 位;累加过程中如果产生进位,把进位加回低 16 位;最后对结果取反码。下面这段代码可以直接抄:

// 计算 ICMP 校验和,buffer 指向 ICMP 报文起始,len 为报文总长度 unsigned short checksum(unsigned short* buffer, int len) { unsigned long sum = 0; while (len > 1) { sum += *buffer++; // 每次累加 16 位 len -= 2; } if (len == 1) { // 奇数长度,最后一字节当作高 8 位,低 8 位补 0 sum += *(unsigned char*)buffer; } // 将 32 位累加结果的高 16 位折叠到低 16 位 sum = (sum >> 16) + (sum & 0xFFFF); sum += (sum >> 16); return (unsigned short)(~sum); }

逻辑说明:sum用unsigned long是为了容纳进位,循环里每次取 16 位累加。奇数长度时把最后一字节当高 8 位处理,这是 RFC 1071 的标准做法。折叠进位的两步不能省,否则结果会偏。最后取反得到校验和。参数方面,buffer必须指向 ICMP 首部第一个字节,len是 ICMP 首部加数据区的总长度,不含 IP 首部。常见误用是传入了 IP 首部长度,导致校验和算错,对方直接丢弃报文,你这边表现为一直超时。

3. 用 Winsock 发一个回显请求:最小可运行代码

3.1 创建原始套接字与设置超时

Windows 下用 Winsock 发 ICMP,必须用SOCK_RAW套接字,协议参数填IPPROTO_ICMP。创建原始套接字需要管理员权限,这是第一个门槛。创建完之后要设置接收超时,否则recvfrom()会一直阻塞。用setsockopt()设置SO_RCVTIMEO,超时值用毫秒。

#include <winsock2.h> #include <ws2tcpip.h> #include <iostream> #pragma comment(lib, "ws2_32.lib") int main() { WSADATA wsa; WSAStartup(MAKEWORD(2, 2), &wsa); // 初始化 Winsock 2.2 // 创建原始套接字,协议为 ICMP SOCKET sock = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP); if (sock == INVALID_SOCKET) { std::cerr << "socket 创建失败,错误码: " << WSAGetLastError() << std::endl; WSACleanup(); return 1; } // 设置接收超时 1000 毫秒 int timeout = 1000; setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, (char*)&timeout, sizeof(timeout)); // 后续发送和接收逻辑... closesocket(sock); WSACleanup(); return 0; }

逻辑说明:WSAStartup是 Winsock 所有调用的前提,不调用后面全失败。socket(AF_INET, SOCK_RAW, IPPROTO_ICMP)创建原始套接字,第三个参数指定 ICMP 协议。setsockopt设置SO_RCVTIMEO后,recvfrom超过 1 秒没数据就返回SOCKET_ERROR,错误码WSAETIMEDOUT,你据此判定丢包。参数方面,超时值根据网络状况调整,局域网 500 毫秒够用,跨公网建议 1000 到 2000 毫秒。注意setsockopt第四个参数在 Windows 下是char*,不是void*,直接传int*编译会报错。

3.2 构造并发送 ICMP 回显请求

构造报文时定义一个结构体,把首部字段和数据区放在一起,填充后算校验和,再sendto出去。目标地址用sockaddr_in指定,端口对 ICMP 无意义,填 0 即可。

// ICMP 回显请求报文结构,首部 8 字节 + 数据 32 字节 struct IcmpPacket { unsigned char type; // 类型:8 为请求,0 为应答 unsigned char code; // 代码:0 unsigned short checksum; // 校验和 unsigned short id; // 标识符 unsigned short seq; // 序列号 char data[32]; // 数据区 }; // 填充并发送一个回显请求 void sendEchoRequest(SOCKET sock, sockaddr_in* dest, int seq) { IcmpPacket packet; packet.type = 8; packet.code = 0; packet.checksum = 0; packet.id = htons(GetCurrentProcessId() & 0xFFFF); // 用进程 ID 低 16 位 packet.seq = htons(seq); memset(packet.data, 'a', sizeof(packet.data)); // 填充 32 字节 // 计算校验和,长度为首部加数据区 packet.checksum = checksum((unsigned short*)&packet, sizeof(packet)); // 发送到目标地址 int ret = sendto(sock, (char*)&packet, sizeof(packet), 0, (sockaddr*)dest, sizeof(*dest)); if (ret == SOCKET_ERROR) { std::cerr << "sendto 失败,错误码: " << WSAGetLastError() << std::endl; } }

逻辑说明:type填 8 表示回显请求,id用进程 ID 低 16 位,seq每次递增。memset填充数据区,内容无所谓,对方会原样返回。校验和计算前checksum字段置 0,算完填入。sendto的第二个参数是报文缓冲区,第三个参数是总长度,第四个参数填 0,后两个是目标地址和地址长度。参数方面,seq从 1 开始递增,id在同一进程内保持不变,用于匹配应答。常见误用是sendto长度传了sizeof(IcmpPacket)但实际数据区长度不同,导致发送多余字节,对方校验和验证失败。

3.3 接收应答并解析 RTT

接收时用recvfrom,收到的缓冲区包含 IP 首部加 ICMP 报文。IP 首部长度不固定,需要先读首部长度字段算出偏移,再定位 ICMP 首部。解析出类型、标识符、序列号,匹配上了就计算 RTT。

// 接收应答并返回 RTT(毫秒),失败返回 -1 int recvEchoReply(SOCKET sock, int expectedSeq, DWORD sendTime) { char buffer[1024]; sockaddr_in from; int fromLen = sizeof(from); int ret = recvfrom(sock, buffer, sizeof(buffer), 0, (sockaddr*)&from, &fromLen); if (ret == SOCKET_ERROR) { return -1; // 超时或错误 } // IP 首部长度在低 4 位,单位是 4 字节 int ipHeaderLen = (buffer[0] & 0x0F) * 4; IcmpPacket* reply = (IcmpPacket*)(buffer + ipHeaderLen); // 校验类型、标识符、序列号 if (reply->type != 0) return -1; if (ntohs(reply->id) != (GetCurrentProcessId() & 0xFFFF)) return -1; if (ntohs(reply->seq) != expectedSeq) return -1; DWORD recvTime = GetTickCount(); return (int)(recvTime - sendTime); // 返回 RTT }

逻辑说明:buffer[0] & 0x0F取 IP 首部长度字段,乘以 4 得到字节数,这是定位 ICMP 首部的关键。reply->type必须是 0 才是回显应答。ntohs把网络字节序转回主机字节序再比较。GetTickCount()返回系统启动以来的毫秒数,两次相减得到 RTT。参数方面,expectedSeq是发送时记录的序列号,sendTime是发送前调用的GetTickCount()值。常见误用是直接假设 IP 首部 20 字节,遇到带选项的 IP 首部就解析错位,表现为收到的应答类型字段乱码。

4. 统计与输出:把丢包率和 RTT 算对

4.1 发送四个包并汇总结果

标准 ping 默认发 4 个包,每个间隔 1 秒。发送前记录时间,收到应答算 RTT,超时算丢包。最后统计发送数、接收数、丢包率、最小 RTT、最大 RTT、平均 RTT。

// 主循环:发送 4 个回显请求并统计 int sent = 0, received = 0; int minRtt = 9999, maxRtt = 0, totalRtt = 0; for (int i = 1; i <= 4; i++) { DWORD sendTime = GetTickCount(); sendEchoRequest(sock, &dest, i); sent++; int rtt = recvEchoReply(sock, i, sendTime); if (rtt >= 0) { received++; totalRtt += rtt; if (rtt < minRtt) minRtt = rtt; if (rtt > maxRtt) maxRtt = rtt; std::cout << "来自目标的回复: 字节=32 时间=" << rtt << "ms" << std::endl; } else { std::cout << "请求超时" << std::endl; } Sleep(1000); // 间隔 1 秒 } // 输出统计 std::cout << "\n数据包: 已发送 = " << sent << ",已接收 = " << received << ",丢失 = " << (sent - received) << std::endl; if (received > 0) { std::cout << "往返行程的估计时间(以毫秒为单位):" << std::endl; std::cout << " 最短 = " << minRtt << "ms,最长 = " << maxRtt << "ms,平均 = " << (totalRtt / received) << "ms" << std::endl; }

逻辑说明:循环 4 次,每次发送前记录sendTime,收到应答后算 RTT 并更新统计。Sleep(1000)控制发包间隔,和系统 ping 行为一致。统计部分输出格式模仿系统 ping,方便对照。参数方面,发包数量可以改成变量,间隔时间也可以调整。常见误用是minRtt初始值设 0,导致最小值永远是 0,应该设一个足够大的数比如 9999。

4.2 域名解析:把主机名转成 IP

课设通常要求支持域名,比如ping www.baidu.com。用getaddrinfo()做解析,拿到sockaddr_in结构再传给发送函数。

// 解析主机名到 IPv4 地址 sockaddr_in resolveHost(const char* hostname) { sockaddr_in addr; memset(&addr, 0, sizeof(addr)); addr.sin_family = AF_INET; addrinfo hints, *result; memset(&hints, 0, sizeof(hints)); hints.ai_family = AF_INET; // 只要 IPv4 hints.ai_socktype = SOCK_RAW; // 原始套接字 if (getaddrinfo(hostname, NULL, &hints, &result) != 0) { std::cerr << "域名解析失败: " << hostname << std::endl; return addr; } addr.sin_addr = ((sockaddr_in*)result->ai_addr)->sin_addr; freeaddrinfo(result); return addr; }

逻辑说明:getaddrinfo是推荐做法,比老的gethostbyname更安全且支持多线程。hints.ai_family限定 IPv4,ai_socktype填SOCK_RAW。解析结果在result->ai_addr里,转成sockaddr_in取出sin_addr。参数方面,hostname可以是 IP 字符串也可以是域名,getaddrinfo都能处理。常见误用是忘记freeaddrinfo,内存泄漏,长时间运行的程序会出问题。

5. 避坑与排查:原始套接字那些血泪经验

5.1 现象:socket 创建失败,错误码 10013

原因:Windows 下创建SOCK_RAW套接字需要管理员权限,普通用户运行直接返回WSAEACCES(10013)。这是最常见的翻车点,代码没问题,权限不够。

解决:用管理员身份运行 Visual Studio 或编译出的 exe。右键项目属性,链接器清单文件里把UAC 执行级别改成requireAdministrator,这样每次运行自动提权。

5.2 现象:sendto 成功但一直超时,收不到应答

原因:校验和算错,对方收到报文后校验失败直接丢弃,你这边表现为超时。另一个可能是id字段没做字节序转换,对方返回的id和你比较的值不一致。

解决:先用 Wireshark 抓包,看发出的报文校验和字段是否正确。校验和计算函数单独测试,用一个已知报文验证。id和seq发送前用htons转网络字节序,接收后用ntohs转回来再比较。

5.3 现象:recvfrom 收到自己发的包

原因:原始套接字会收到本机发出的所有 ICMP 报文,包括你自己发的请求。如果不做过滤,可能把请求当成应答处理。

解决:解析时先检查type字段,必须是 0 才是应答。再检查id和seq是否匹配。两个条件都满足才认为是有效应答,否则丢弃继续接收。

5.4 现象:RTT 出现负数或异常大

原因:GetTickCount()返回DWORD,相减时如果发生回绕(系统运行 49.7 天后)会出问题。另一个可能是sendTime记录位置不对,在sendto之后才记录,导致 RTT 偏小。

解决:sendTime必须在sendto之前记录。对于回绕问题,用GetTickCount64()替代,返回 64 位值,实际项目中不会遇到回绕。课设场景下GetTickCount()够用,但要知道这个边界。

5.5 现象:解析域名时程序卡住

原因:getaddrinfo是阻塞调用,DNS 服务器不可达时会等待超时,默认可能几十秒。

解决:课设场景可以接受,但最好加一个提示。如果要做得好一点,用getaddrinfo的异步版本或者单独开线程加超时控制。简单做法是先用ping命令确认域名能解析,再跑自己的程序。

6. 进阶技巧:用 Wireshark 对照验证你的报文

写完程序能跑通只是第一步,真正让你信服的是抓包对照。打开 Wireshark,选择对应网卡,过滤条件填icmp,然后运行你的程序。你会看到每发一个包,Wireshark 里出现一行 Echo request,收到应答出现一行 Echo reply。点开 request 报文,展开 Internet Control Message Protocol 部分,对照你代码里填的 type、code、checksum、id、seq 是否一致。这一步能帮你确认校验和算对了没有,字节序转对了没有。

我一般会做三个对照实验。第一,把校验和函数故意改错,观察 Wireshark 里 checksum 字段显示 incorrect,同时程序一直超时,确认校验和的作用。第二,把htons去掉,观察 id 字段在 Wireshark 里显示的值和代码里填的值不一样,确认字节序的影响。第三,把recvfrom的过滤条件去掉,观察程序是否会把请求当成应答,确认过滤的必要性。这三个实验做完,ICMP 和原始套接字的行为就不再是黑匣子了。

还有一个实用技巧:用ping -l参数指定数据区大小,比如ping -l 1024 www.baidu.com,然后在你程序里把数据区改成 1024 字节,观察报文分片。当数据区超过 MTU 时,IP 层会分片,Wireshark 里能看到 Fragment 字段。这是理解 IP 分片的好机会,也是课设报告里可以写的加分项。

最后说一个我踩过的坑:早期版本我用sizeof(IcmpPacket)作为sendto的长度,结构体因为对齐可能比实际报文大,导致发送多余字节。后来改成显式计算长度8 + dataLen,问题消失。这个坑不明显,因为多余字节通常是 0,对方校验和可能仍然通过,但 Wireshark 里能看到报文长度不对。建议你也在代码里显式计算长度,别依赖sizeof。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询