
简介这份文档是计算机网络课程设计的完整报告面向高校计算机与网络工程专业学生以及正在学习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 做核心就两个 APIsocket()和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限定 IPv4ai_socktype填SOCK_RAW。解析结果在result-ai_addr里转成sockaddr_in取出sin_addr。参数方面hostname可以是 IP 字符串也可以是域名getaddrinfo都能处理。常见误用是忘记freeaddrinfo内存泄漏长时间运行的程序会出问题。5. 避坑与排查原始套接字那些血泪经验5.1 现象socket 创建失败错误码 10013原因Windows 下创建SOCK_RAW套接字需要管理员权限普通用户运行直接返回WSAEACCES10013。这是最常见的翻车点代码没问题权限不够。解决用管理员身份运行 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。希望帮到你。本文还有配套的精品资源点击获取