ARTICLE DETAIL

资讯详情

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

基于原始套接字的Tracert程序设计:ICMP协议与VC6.0实战

基于原始套接字的Tracert程序设计:ICMP协议与VC6.0实战 简介这份Tracert程序设计报告面向计算机网络课程学习者与网络编程初学者围绕原始套接字编程与ICMP协议展开帮助读者理解路由跟踪的底层原理并完成课程设计或实验报告撰写。报告内容涵盖设计目的与要求、系统详细设计、程序流程与主要函数说明并附有基于Windows Socket API的源代码片段涉及TTL递增探测、ICMP超时报文解析、Ping连通性检测等关键实现思路可对照学习VC6.0环境下的调试与运行方法。资源包内共1个doc文档压缩后约194KB篇幅紧凑、结构完整适合作为实验参考或课程作业模板。目前已有168人学习浏览对于希望掌握Tracert工作原理、提升网络故障排查与协议分析能力的读者这份报告提供了从原理到代码实现的完整参考路径。1. 从一份 Tracert 程序设计报告说起原始套接字到底能干什么很多人第一次接触 Tracert是在命令行里敲下tracert www.example.com看着一行行跳数刷出来觉得这东西挺神奇但也就止步于“会用”。直到某天网络不通ping 目标地址没反应你才意识到光知道通不通远远不够——你得知道在哪一跳断的。这份 Tracert 程序设计报告.doc 的价值就在这儿它不是教你敲命令而是把 Tracert 拆到 ICMP 协议和原始套接字这一层让你自己写一个出来。报告里覆盖了设计目的、系统详细设计、完整 C 源码、校验和算法、ICMP 报文解码逻辑以及实验数据分析。适合谁正在学计算机网络课程设计的学生、想搞懂原始套接字编程的开发者、需要从协议层理解路由跟踪原理的运维人员。如果你只想要一个能跑的 exe网上工具一大把但如果你想弄明白 TTL 怎么被路由器递减、ICMP 超时报文里为什么嵌着原始 IP 头、校验和为什么必须自己算这份报告值得逐行读。2. ICMP 协议与原始套接字Tracert 的底层逻辑拆解2.1 为什么 Tracert 必须用原始套接字普通 Socket 编程你用的是 TCP 或 UDP操作系统帮你封装好 IP 头和传输层头你只管往里面塞数据。但 Tracert 要干的事情不一样它需要自己构造 ICMP 报文自己设置 IP 头的 TTL 字段还要能收到中间路由器发回来的 ICMP 超时报文。这些操作流式套接字根本做不到。原始套接字SOCK_RAW的核心区别在于它让你直接访问 IP 层及以上的数据。在 Windows 下创建原始套接字的典型写法是SOCKET sockRaw WSASocket(AF_INET, SOCK_RAW, IPPROTO_ICMP, NULL, 0, WSA_FLAG_OVERLAPPED); if (sockRaw INVALID_SOCKET) { cerr Failed to create raw socket, error: WSAGetLastError() endl; WSACleanup(); return -1; }这里三个参数决定了一切AF_INET指定 IPv4 协议族SOCK_RAW声明原始套接字类型IPPROTO_ICMP告诉系统我们只关心 ICMP 协议的数据包。最后一个参数WSA_FLAG_OVERLAPPED在 VC6.0 环境下通常写 0 也能跑但报告里用了重叠标志说明作者考虑过异步接收的场景。创建成功后你拿到的是一个能收发 ICMP 报文的句柄。但注意原始套接字收到的不只是你想要的 ICMP 回显应答还可能收到各种中间路由器发来的超时报文、目的不可达报文甚至其他进程产生的 ICMP 包。所以接收端必须做过滤——这就是报告里DecodeIcmpResponse函数存在的原因。2.2 TTL 递减机制路由跟踪的核心TTLTime To Live是 IP 头里的一个 8 位字段设计初衷是防止数据包在网络中无限循环。每经过一个路由器TTL 减 1减到 0 时路由器丢弃该包并向源地址发回一个 ICMP 超时报文Type 11Code 0。Tracert 就是利用了这个机制。它先发一个 TTL1 的 ICMP 回显请求包第一个路由器收到后 TTL 减为 0返回超时报文源主机就知道第一跳的 IP 了。然后发 TTL2 的包第二个路由器返回超时依此类推直到数据包到达目的主机目的主机返回 ICMP 回显应答Type 0跟踪结束。报告里的核心循环逻辑是这样的int iTTL 1; int iMaxHop DEF_MAX_HOP; // 通常设为 30 while (!bReachDestHost iMaxHop--) { // 设置当前数据包的 TTL setsockopt(sockRaw, IPPROTO_IP, IP_TTL, (char*)iTTL, sizeof(iTTL)); // 填充 ICMP 头 ((ICMP_HEADER*)IcmpSendBuf)-cksum 0; ((ICMP_HEADER*)IcmpSendBuf)-seq htons(usSeqNo); ((ICMP_HEADER*)IcmpSendBuf)-cksum GenerateChecksum( (USHORT*)IcmpSendBuf, sizeof(ICMP_HEADER) DEF_ICMP_DATA_SIZE); // 记录发送时间用于计算 RTT stDecodeResult.dwRoundTripTime GetTickCount(); // 发送 sendto(sockRaw, IcmpSendBuf, sizeof(IcmpSendBuf), 0, (sockaddr*)destSockAddr, sizeof(destSockAddr)); // 接收并解码... iTTL; }这段代码有几个关键点值得展开。第一setsockopt设置IP_TTL必须在每次sendto之前调用因为 TTL 是逐包生效的不是一次设置永久有效。第二校验和计算前必须先把cksum字段清零算完再填回去这是 RFC 1071 的标准做法。第三GetTickCount()返回的是系统启动以来的毫秒数精度足够 Tracert 用但如果你要做微秒级 RTT 测量得换QueryPerformanceCounter。2.3 ICMP 超时报文里藏着什么当路由器返回 ICMP 超时报文时这个报文的结构比普通的回显应答复杂得多。它的 ICMP 数据区里嵌着原始数据包的 IP 头和至少前 8 个字节的 ICMP 头。为什么要嵌这些因为源主机需要知道是哪个数据包触发了超时——通过匹配 ICMP 头里的 ID 和序列号。报告里的解码函数处理了两种情况BOOL DecodeIcmpResponse(char* pBuf, int iPacketSize, DECODE_RESULT stDecodeResult) { IP_HEADER* pIpHdr (IP_HEADER*)pBuf; int iIpHdrLen pIpHdr-hdr_len * 4; // 大小合法性检查 if (iPacketSize (int)(iIpHdrLen sizeof(ICMP_HEADER))) return FALSE; ICMP_HEADER* pIcmpHdr (ICMP_HEADER*)(pBuf iIpHdrLen); USHORT usID, usSquNo; if (pIcmpHdr-type ICMP_ECHO_REPLY) { // 目的主机返回的回显应答 usID pIcmpHdr-id; usSquNo pIcmpHdr-seq; } else if (pIcmpHdr-type ICMP_TIMEOUT) { // 中间路由器返回的超时报文 char* pInnerIpHdr pBuf iIpHdrLen sizeof(ICMP_HEADER); int iInnerIPHdrLen ((IP_HEADER*)pInnerIpHdr)-hdr_len * 4; ICMP_HEADER* pInnerIcmpHdr (ICMP_HEADER*)( pInnerIpHdr iInnerIPHdrLen); usID pInnerIcmpHdr-id; usSquNo pInnerIcmpHdr-seq; } else { return FALSE; // 不认识的 ICMP 类型丢弃 } // 用进程 ID 和序列号双重过滤 if (usID ! (USHORT)GetCurrentProcessId() || usSquNo ! stDecodeResult.usSeqNo) return FALSE; // 提取源 IP 和 RTT stDecodeResult.dwIPaddr.s_addr pIpHdr-sourceIP; stDecodeResult.dwRoundTripTime GetTickCount() - stDecodeResult.dwRoundTripTime; return TRUE; }这段代码里最容易被忽略的是hdr_len * 4。IP 头的长度字段单位是 4 字节所以实际字节数要乘以 4。很多初学者在这里翻车算出来的偏移量不对解码出来的全是乱码。另一个坑是ICMP_TIMEOUT分支里要二次解析内层 IP 头因为外层 IP 头的源地址是路由器的内层才是你原始发送的那个包。2.4 校验和算法别小看这十几行ICMP 校验和用的是反码求和算法报告里的实现是标准写法USHORT GenerateChecksum(USHORT* pBuf, int iSize) { unsigned long cksum 0; while (iSize 1) { cksum *pBuf; iSize - sizeof(USHORT); } if (iSize) // 奇数个字节补一个字节 cksum *(UCHAR*)pBuf; cksum (cksum 16) (cksum 0xffff); cksum (cksum 16); // 处理进位 return (USHORT)(~cksum); }逻辑说明把数据按 16 位累加溢出的高位回卷加到低位最后取反。参数pBuf是待校验数据的指针iSize是字节数。注意iSize为奇数时最后一个字节要单独处理否则校验和会算错。这个算法在 VC6.0 下跑没问题但如果你移植到 64 位平台unsigned long的长度变了得改成unsigned int或uint32_t。3. 在 VC6.0 下把 Tracert 跑起来环境、编译与调试3.1 环境搭建VC6.0 的坑比代码还多报告明确写了运行环境是 VC6.0这不是随便选的。VC6.0 自带的 Platform SDK 里有winsock2.h和ws2tcpip.h但默认安装可能不全。你需要确认两件事第一winsock2.h的版本要支持WSASocket和IP_TTL第二链接器要加上ws2_32.lib。在 VC6.0 里配置链接库的步骤Project → Settings → Link 选项卡 → Object/library modules 里加上ws2_32.lib。如果编译时报unresolved external symbol __imp__WSAStartup8就是没加这个库。另一个常见问题是头文件包含顺序。winsock2.h必须在windows.h之前包含否则会有一堆重定义错误。报告里的顺序是#include iostream.h #include iomanip.h #include winsock2.h #include ws2tcpip.h #include itracert.h注意iostream.h和iomanip.h是带.h的老式头文件这是 VC6.0 的标准写法。如果你用 VS2019 或更高版本得改成iostream和iomanip并加上using namespace std;。3.2 自定义头文件 itracert.h 里该放什么报告源码里引用了itracert.h但没给出完整内容。根据代码里用到的符号这个头文件至少需要定义以下内容#ifndef ITRACERT_H #define ITRACERT_H #define DEF_MAX_HOP 30 // 最大跳数 #define DEF_ICMP_TIMEOUT 3000 // 收发超时毫秒 #define DEF_ICMP_DATA_SIZE 32 // ICMP 数据区大小 #define MAX_ICMP_PACKET_SIZE 1024 // 接收缓冲区大小 #define ICMP_ECHO_REPLY 0 // 回显应答 #define ICMP_ECHO_REQUEST 8 // 回显请求 #define ICMP_TIMEOUT 11 // 超时报文 // ICMP 头结构 typedef struct _ICMP_HEADER { BYTE type; BYTE code; USHORT cksum; USHORT id; USHORT seq; } ICMP_HEADER; // IP 头结构简化版 typedef struct _IP_HEADER { BYTE hdr_len; // 首部长度4 位版本 4 位长度 BYTE tos; USHORT total_len; USHORT id; USHORT flags; BYTE ttl; BYTE protocol; USHORT checksum; ULONG sourceIP; ULONG destIP; } IP_HEADER; // 解码结果 typedef struct _DECODE_RESULT { USHORT usSeqNo; DWORD dwRoundTripTime; in_addr dwIPaddr; } DECODE_RESULT; USHORT GenerateChecksum(USHORT* pBuf, int iSize); BOOL DecodeIcmpResponse(char* pBuf, int iPacketSize, DECODE_RESULT stDecodeResult); #endif参数说明DEF_MAX_HOP设为 30 是惯例Windows 自带 tracert 默认也是 30 跳。DEF_ICMP_TIMEOUT设为 3000 毫秒意味着每跳最多等 3 秒超过就打印星号。DEF_ICMP_DATA_SIZE设为 32 字节这是 ICMP 回显请求的典型载荷大小Windows ping 默认也是 32 字节。3.3 编译、运行与结果解读编译通过后在命令行里运行itracert www.baidu.com预期输出格式Tracing route to www.baidu.com [110.242.68.66] with a maximum of 30 hops. 1 1 ms 1 ms 1 ms 192.168.1.1 2 5 ms 4 ms 6 ms 10.0.0.1 3 12 ms 11 ms 13 ms 202.99.96.1 4 15 ms 14 ms 16 ms 110.242.68.66 Trace complete.每一列的含义第一列是跳数TTL 值第二到第四列是三次探测的 RTT报告里只做了一次你可以改成循环三次取平均最后一列是该跳路由器的 IP 地址。如果某跳显示* Request timed out.说明该路由器不返回 ICMP 超时报文或者超时时间设得太短。报告里只发了一次探测包所以每跳只有一个 RTT 值。实际使用中Windows 自带 tracert 每跳发三个包取三次结果这样能看出网络抖动的程度。你可以把发送逻辑改成循环三次每次用不同的序列号接收端匹配后分别记录 RTT。3.4 用 Wireshark 验证 ICMP 报文写完程序跑通了怎么确认发出去的包和收回来的包跟预期一致开 Wireshark 抓包是最直接的办法。过滤条件写icmp然后运行你的 itracert。你会看到两类包一类是你发出去的 ICMP Echo RequestTTL 值从 1 开始递增另一类是中间路由器返回的 ICMP Time-to-live exceeded以及目的主机返回的 ICMP Echo Reply。点开超时报文的详情在 ICMP 载荷里能看到你原始发送的 IP 头和 ICMP 头ID 字段应该等于你的进程 ID序列号跟你设置的一致。如果 Wireshark 里只看到发出去的包没有回来的检查三个地方防火墙是不是拦了 ICMP、目标地址是不是真的可达、接收超时是不是设得太短。如果回来的包 ID 对不上检查GetCurrentProcessId()的返回值是不是被截断了——它返回DWORD而 ICMP 头的 ID 字段是USHORT强转时只保留低 16 位这是正常的但过滤时也要用同样的强转结果去比。4. 避坑与排查Tracert 程序设计的五个血泪教训4.1 坑一原始套接字创建失败错误码 10013现象WSASocket返回INVALID_SOCKETWSAGetLastError()返回 10013WSAEACCES。原因Windows XP SP2 之后微软限制了原始套接字的创建权限。管理员账户可以创建普通用户不行。另外某些安全软件会拦截原始套接字调用。解决用管理员身份运行程序。如果还是不行检查安全软件的拦截日志把编译器或生成的 exe 加入白名单。在 VC6.0 里调试时需要以管理员身份启动 VC6.0 本身否则调试模式下创建的原始套接字也会失败。4.2 坑二收到的包 ID 和序列号对不上现象程序一直在接收循环里打转明明 Wireshark 里看到了回包但DecodeIcmpResponse总是返回 FALSE。原因ICMP 超时报文里嵌的原始 ICMP 头其 ID 字段是你发送时填的值。如果你填的是GetCurrentProcessId()的强转结果过滤时也必须用同样的值去比。另一个可能是序列号用了htons转换但比较时忘了转换回来。解决在DecodeIcmpResponse里加调试输出把收到的usID和usSquNo打印出来跟发送时设置的值对比。注意网络字节序和主机字节序的转换要一致发送时htons(usSeqNo)接收比较时也要ntohs回来或者两边都不转只要一致就行。4.3 坑三RTT 显示为 0 或者负数现象输出的 RTT 有时是 0ms有时是很大的负数。原因GetTickCount()返回DWORD两个DWORD相减如果接收时间早于发送时间理论上不应该但多线程或系统时间调整时可能发生结果会变成一个很大的正数。显示 0ms 则是因为GetTickCount精度只有 15.6ms局域网内 RTT 小于这个值时就显示 0。解决报告里的处理是if (stDecodeResult.dwRoundTripTime)判断非零才显示具体值否则显示1 ms。这个处理是合理的。如果要更高精度换QueryPerformanceCounter和QueryPerformanceFrequency能到微秒级。4.4 坑四程序在某个跳数卡住不动现象跑到第 N 跳后程序停在那里既不输出超时也不继续下一跳。原因recvfrom设置了SO_RCVTIMEO但超时时间设得太长比如 10 秒而那个路由器确实不返回 ICMP 超时报文。或者recvfrom的循环逻辑有问题收到不匹配的包后没有继续等待而是直接退出了。解决确认setsockopt设置SO_RCVTIMEO的代码在recvfrom之前执行了。检查接收循环收到不匹配的包应该continue继续接收而不是break跳出。报告里的逻辑是while(1)循环只有解码成功或超时才跳出这个结构是对的。如果还是卡住把超时时间改短到 1000ms 试试。4.5 坑五VC6.0 编译报错“无法打开 ws2tcpip.h”现象编译时提示Cannot open include file: ws2tcpip.h。原因VC6.0 默认安装的 Platform SDK 版本较老可能没有ws2tcpip.h。这个头文件是后来才加入的里面定义了getaddrinfo等新函数。解决报告里的代码其实只用了gethostbyname不需要ws2tcpip.h。直接把这行#include ws2tcpip.h注释掉就能编译通过。如果你确实需要新函数去微软官网下载最新的 Platform SDK 并配置 VC6.0 的包含路径。但更推荐的做法是换用 VS2019 或 VS2022把代码里的老式头文件和iostream.h改成标准 C 写法。5. 从能跑到好用给 Tracert 加三个实用改进5.1 每跳发三次探测取平均Windows 自带 tracert 每跳发三个包这样能看出网络抖动。改法很简单把发送和接收逻辑包一层循环for (int probe 0; probe 3; probe) { ((ICMP_HEADER*)IcmpSendBuf)-seq htons(usSeqNo); ((ICMP_HEADER*)IcmpSendBuf)-cksum 0; ((ICMP_HEADER*)IcmpSendBuf)-cksum GenerateChecksum( (USHORT*)IcmpSendBuf, sizeof(ICMP_HEADER) DEF_ICMP_DATA_SIZE); stDecodeResult.dwRoundTripTime GetTickCount(); sendto(sockRaw, IcmpSendBuf, sizeof(IcmpSendBuf), 0, (sockaddr*)destSockAddr, sizeof(destSockAddr)); // 接收逻辑同上收到后打印 RTT // 如果超时打印星号 }注意每次发送前要重新算校验和因为序列号变了。接收端匹配序列号时三个包的序列号不同要分别匹配。这样输出的格式就跟系统自带 tracert 一样每跳三列 RTT。5.2 反向 DNS 解析把 IP 变成域名系统自带 tracert 会尝试把每一跳的 IP 反解成域名显示成路由器名 [IP]的格式。加这个功能用gethostbyaddrhostent* pHost gethostbyaddr( (char*)stDecodeResult.dwIPaddr, sizeof(stDecodeResult.dwIPaddr), AF_INET); if (pHost) cout pHost-h_name [ inet_ntoa(stDecodeResult.dwIPaddr) ] endl; else cout inet_ntoa(stDecodeResult.dwIPaddr) endl;参数说明第一个参数是in_addr结构的指针第二个是长度第三个是协议族。反解失败时直接打印 IP不要报错退出因为很多路由器没有配置反向 DNS 记录。5.3 超时时间自适应固定 3000ms 超时在某些网络环境下太短在另一些环境下又太长。可以根据上一跳的 RTT 动态调整如果上一跳 RTT 是 10ms这一跳超时设 500ms 就够了如果上一跳 RTT 是 200ms超时至少设 2000ms。int iTimeout max(500, (int)(stDecodeResult.dwRoundTripTime * 3)); setsockopt(sockRaw, SOL_SOCKET, SO_RCVTIMEO, (char*)iTimeout, sizeof(iTimeout));这个逻辑放在每次sendto之前执行。注意SO_RCVTIMEO设置后对后续所有recvfrom生效不需要每次重设但如果你在循环里改了超时值下一次recvfrom就会用新值。5.4 一个容易被忽略的细节ICMP 数据区填充报告里用memset(IcmpSendBufsizeof(ICMP_HEADER), E, DEF_ICMP_DATA_SIZE)把数据区填成字符 E。这个填充内容其实可以自定义Windows ping 默认填的是字母表序列。填充的作用是让 ICMP 包达到最小长度要求同时方便在抓包时识别。如果你填全零某些路由器可能会丢弃。建议填成递增的字节序列这样在 Wireshark 里一眼就能认出是你的程序发的包。从那以后我每次写网络诊断工具都会先用 Wireshark 抓一遍包确认发出去的字节和收回来的字节跟代码里写的一致再去看业务逻辑。这个习惯帮我省了无数个对着代码发呆的下午。希望帮到你。本文还有配套的精品资源点击获取
返回列表