100 行 C 代码拆解 traceroute 的 TTL 把戏
目录
原文作者和我一样,用了多年 traceroute 却从没想过它怎么知道每一跳的 IP 地址。原理其实很简单,用百来行 C 语言就能实现(原文用 Rust 实现,被我改写成C语言)。
traceroute 在做什么
先看一下 traceroute 的典型输出。
$ traceroute -m 15 -w 2 8.8.8.8 traceroute to 8.8.8.8 (8.8.8.8), 15 hops max, 60 byte packets 1 DESKTOP-UTK6RNQ (172.30.208.1) 1.085 ms 1.066 ms 0.730 ms 2 192.168.23.254 (192.168.23.254) 6.107 ms 5.933 ms 5.884 ms 3 192.168.250.1 (192.168.250.1) 8.985 ms 8.976 ms 8.963 ms 4 * * * 5 14.216.139.1 (14.216.139.1) 8.100 ms 8.074 ms 8.028 ms 6 14.147.147.138 (14.147.147.138) 14.214 ms 16.849 ms 14.147.147.130 (14.147.147.130) 9.348 ms 7 14.147.147.133 (14.147.147.133) 8.598 ms 5.568 ms * 8 * 219.132.200.237 (219.132.200.237) 8.044 ms 121.12.142.49 (121.12.142.49) 18.740 ms 9 202.97.93.77 (202.97.93.77) 8.058 ms 202.97.93.45 (202.97.93.45) 8.071 ms 202.97.93.85 (202.97.93.85) 8.070 ms 10 202.97.94.102 (202.97.94.102) 8.064 ms 202.97.12.37 (202.97.12.37) 8.206 ms 202.97.65.93 (202.97.65.93) 14.591 ms 11 202.97.95.170 (202.97.95.170) 13.774 ms 13.748 ms 202.97.57.77 (202.97.57.77) 13.815 ms 12 203.22.178.197 (203.22.178.197) 16.613 ms 16.607 ms 16.324 ms 13 203.22.178.22 (203.22.178.22) 178.898 ms 178.892 ms 178.170 ms 14 * * 203.131.250.82 (203.131.250.82) 187.172 ms 15 * * *
每一行显示一跳的信息:序号、路由器 IP、三个延迟采样。星号( * )表示那一跳没有回应。
注意,这条路径和后面我们自己实现的版本不同,15 跳也没走到 8.8.8.8。两次运行走了不同路线,后面的负载均衡一节会解释原因。
traceroute 看着像在逐个询问路径上每个路由器的身份,其实不是。
traceroute 从头到尾没问过谁。它干的是发送注定死在途中的数据包,然后听路由器怎么回报这个死讯。
核心原理:TTL 把戏
每个 IP 包的头部都有一个 TTL(Time To Live,生存时间)字段,初始值通常是 64。它的工作方式很简单。
- 每经过一个路由器转发,TTL 减 1
- 当某个路由器把 TTL 减到 0 时,它丢弃这个包,同时向发送方回一个 ICMP(Internet Control Message Protocol,互联网控制消息协议)「Time Exceeded」(超时)错误消息
- 这个 ICMP 错误消息里包含了该路由器的 IP 地址
traceroute 利用的就是这个机制:依次发送 TTL=1、TTL=2、TTL=3 的探测包。TTL=1 的包到第一个路由器就被丢弃并触发回报,TTL=2 的包到第二个路由器才被丢弃,以此类推。每个路由器在「杀死」探测包时,顺带就把自己暴露了。
一句话总结就是,traceroute 发送的是设计好要死在每一跳的包,然后听死讯。
第一个探测
原理清楚了,代码就不复杂。先用 C 写个最简版本,发送一个指定 TTL 的 UDP 包,等 ICMP 回应,提取回应者的 IP。
为什么用 UDP 而不是 TCP?因为这些探测包本来就是设计好要死在途中的,不需要 TCP 的握手和可靠传输。资源消耗少。
#include <stdio.h> #include <string.h> #include <unistd.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <sys/time.h> /* 发送一个 TTL=ttl 的 UDP 探测包,返回应答路由器的 IP */ /* 返回 0 表示超时(无应答) */ uint32_t probe(uint32_t target, int ttl) { /* UDP socket 发送探测包,设置 TTL 是关键 */ int send_sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); setsockopt(send_sock, IPPROTO_IP, IP_TTL, &ttl, sizeof(ttl)); /* 原始 ICMP socket 接收路由器的错误回报 */ /* SOCK_RAW 需要 root 权限。系统的 traceroute 靠 setuid 位绕过这个限制,后面会解释 */ int recv_sock = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP); struct timeval tv = {.tv_sec = 2}; setsockopt(recv_sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); /* 往目标 33434 端口发一个 32 字节的 UDP 包 */ /* 33434 是 Van Jacobson 原版 traceroute 选的起始端口,沿用至今 */ /* 选这个端口是因为几乎没有服务监听它,目标会回 ICMP Port Unreachable */ /* 如果碰巧有服务在听,目标正常收下 UDP 包,不会回 ICMP,traceroute 就不知道到了 */ struct sockaddr_in dest = { .sin_family = AF_INET, .sin_addr.s_addr = target, .sin_port = htons(33434), }; char payload[32] = {0}; sendto(send_sock, payload, sizeof(payload), 0, (struct sockaddr *)&dest, sizeof(dest)); /* 等待 ICMP 应答(2 秒超时) */ /* 原始 socket 上 recv 一次返回一个完整数据报,不会读半截 */ unsigned char buf[512]; int n = recv(recv_sock, buf, sizeof(buf), 0); close(send_sock); close(recv_sock); if (n < 20) return 0; /* 超时或数据包太短 */ /* 从 IP 头部提取源 IP(字节 12-15,网络字节序) */ uint32_t src_ip; memcpy(&src_ip, buf + 12, 4); return src_ip; } int main(void) { uint32_t target = inet_addr("8.8.8.8"); /* Google DNS */ for (int ttl = 1; ttl <= 15; ttl++) { uint32_t hop = probe(target, ttl); if (hop == 0) { printf("%2d *\n", ttl); } else { struct in_addr addr = {.s_addr = hop}; printf("%2d %s\n", ttl, inet_ntoa(addr)); } } return 0; }
下面逐段说说关键部分。
socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP) 创建一个普通的 UDP socket。紧接着的 setsockopt(..., IP_TTL, &ttl, ...) 是整个 traceroute 的核心,它把这个 socket 发出的所有包的 TTL 设为我们指定的值。
socket(AF_INET, SOCK_RAW, IPPROTO_ICMP) 创建第二个 socket,这次是原始 ICMP socket。它监听到达本机的所有 ICMP 包,包括路由器发回的「Time Exceeded」错误回报。=SOCK_RAW= 需要 root 权限,因为原始 socket 能嗅探任意网络流量。
往端口 33434 发送 32 字节的空数据。数据内容无所谓,重要的是这个包带着我们设好的 TTL 往前跑。33434 端口没有服务监听,所以当包最终到达目标时,目标会回复 ICMP 「Port Unreachable」(端口不可达)而不是「Time Exceeded」,这恰好告诉 traceroute 「到了」。
等待 ICMP 回应。原始 socket 收到的是完整的 IP 包(含 IP 头部),IP 头部的第 12 到 15 字节是源 IP 地址,直接提取出来就是回应路由器的地址。
编译运行。
$ gcc -o traceroute traceroute.c $ sudo ./traceroute 1 172.30.208.1 2 192.168.23.254 3 192.168.250.1 4 * 5 27.44.88.1 6 120.80.149.81 7 * 8 * 9 * 10 219.158.3.190 11 202.77.23.30 12 162.245.124.254 13 203.131.241.220 14 203.131.250.82 15 *
能跑了。本地网关、运营商骨干网、国际出口都看得见。不过有两个问题,它不知道何时到达目标(一直跑到 TTL 15),而且每跳只有一次探测,没有延迟信息。
ICMP 是什么
ICMP 全称 Internet Control Message Protocol(互联网控制消息协议)。它是互联网的「错误报告」协议,不搬运数据,只传递网络状态信息。
你其实已经见过 ICMP 消息,只是可能没意识到。=ping= 报的「Destination Host Unreachable」(目标不可达)就是 ICMP Type 3。traceroute 依赖的「Time Exceeded」(超时)是 ICMP Type 11。
ICMP 回应包在原始 socket 上收到的样子大概是这样。
字节 0-11 字节 12-15 字节 16-19 字节 20 +---------------+--------------+--------------+--------+-------- | IP 头部其他 | 源 IP 地址 | 目标 IP 地址 | ICMP | ICMP | 字段(版本、 | (回应路由器 | (你的机器) | Type | 数据... | TTL、长度等) | 的地址) | | | +---------------+--------------+--------------+--------+-------- \_____________________ IP 头部 (前 20 字节) _______/ \_ ICMP _/
整个 IP 头部占前 20 字节,其中字节 0-11 是版本、TTL、总长度等控制字段,字节 12-15 是源 IP,字节 16-19 是目标 IP。ICMP 消息从第 20 字节开始,第一个字节是类型码。我们这里忽略了 ICMP 类型,只从 IP 头部提取了源 IP,所以程序不知道什么时候到目标。
知道何时到达目标
traceroute 通过检查 ICMP 类型来确认是否到达目的地。Type 11(Time Exceeded)表示途中的路由器丢弃了包,Type 3(Destination Unreachable)表示到达了目标但目标没人监听那个端口。
第一步,把 uint32_t 返回值换成一个能区分三种状态的结构。
typedef enum { PROBE_HOP, /* Type 11:超时,还在途中 */ PROBE_REACHED, /* Type 3:端口不可达,到达目标 */ PROBE_TIMEOUT, /* 无应答 */ } probe_type_t; typedef struct { probe_type_t type; uint32_t ip; } probe_result_t;
接着改 ICMP 解析部分。IP 头部的第一个字节的低 4 位是 IHL(Internet Header Length),乘以 4 得到头部长度。ICMP 类型紧跟在 IP 头部之后。
int ip_hdr_len = (buf[0] & 0x0f) * 4; /* 通常为 20 */ if (n < ip_hdr_len + 1) return (probe_result_t){PROBE_TIMEOUT, 0}; uint32_t src_ip; memcpy(&src_ip, buf + 12, 4); switch (buf[ip_hdr_len]) { /* ICMP 类型码 */ case 11: /* Time Exceeded:途中的路由器 */ return (probe_result_t){PROBE_HOP, src_ip}; case 3: /* Destination Unreachable */ if (src_ip == target) return (probe_result_t){PROBE_REACHED, src_ip}; /* 源 IP 不是目标,说明是途中某台机器发的 Type 3 */ return (probe_result_t){PROBE_HOP, src_ip}; default: return (probe_result_t){PROBE_TIMEOUT, 0}; }
注意 case 3 里的 if (src_ip = target)= 判断。第一版代码没做这个检查,只要收到 Type 3 就认为到达了目标。但实际运行时会发现,途中某些内网设备也会发 Type 3 消息,让你误以为到了。加上这个检查后,只有源 IP 和目标 IP 一致时才算真正到达。
main 循环也跟着更新,到达后立即退出。这里用 break 只能跳出 switch=,跳不出外面的 =for 循环,所以加个标志位。
int reached = 0; for (int ttl = 1; ttl <= 15; ttl++) { probe_result_t r = probe(target, ttl); switch (r.type) { case PROBE_HOP: printf("%2d %s\n", ttl, inet_ntoa((struct in_addr){r.ip})); break; case PROBE_REACHED: printf("%2d %s\n", ttl, inet_ntoa((struct in_addr){r.ip})); reached = 1; break; case PROBE_TIMEOUT: printf("%2d *\n", ttl); break; } if (reached) break; }
加上计时
真正的 traceroute 会显示每跳的往返时间(RTT)。实现起来不复杂,发送前记个时间戳,收到应答后算差值。
需要增加 #include <time.h> 和在结构体中加一个字段。
typedef struct { probe_type_t type; uint32_t ip; double rtt_ms; /* 往返延迟,毫秒 */ } probe_result_t;
在 probe() 函数中包裹计时。
struct timespec t_start, t_end; clock_gettime(CLOCK_MONOTONIC, &t_start); sendto(send_sock, payload, sizeof(payload), 0, (struct sockaddr *)&dest, sizeof(dest)); /* ... recv 逻辑 ... */ clock_gettime(CLOCK_MONOTONIC, &t_end); double rtt = (t_end.tv_sec - t_start.tv_sec) * 1000.0 + (t_end.tv_nsec - t_start.tv_nsec) / 1e6;
clock_gettime(CLOCK_MONOTONIC, ...) 用的是单调时钟。「单调」就是只增不减,不会被 NTP 校准或手动改时间往回拨,测时间间隔正好。如果用 =CLOCK_REALTIME=,系统时间一调,算出来的 RTT 就可能变成负数。
加上计时后的输出。
$ sudo ./traceroute 1 172.30.208.1 1.148 ms 2 192.168.23.254 3.934 ms 3 192.168.250.1 28.519 ms 4 * 5 27.44.88.1 11.272 ms 6 120.80.149.81 7.925 ms 7 * 8 * 9 * 10 219.158.3.190 14.546 ms 11 202.77.23.30 13.397 ms 12 162.245.124.254 12.053 ms 13 203.131.241.220 335.569 ms 14 203.131.250.82 189.175 ms 15 *
从第 6 跳的约 8 ms(运营商网络)到第 10 跳的约 14 ms(骨干网),延迟在慢慢爬。真正的跳变在第 13 跳:从 12 ms 飙到 335 ms,那就是数据包跨太平洋的时刻。
每跳三次探测
traceroute 在每个 TTL 值上发送三个探测包,所以你看到每行有三个延迟值。发三个不是随便发的,有几个用处。
*方差感知*。网络延迟是波动的,单次测量可能是异常值,三次测量能看到一致性。如果三个值分别是 10 ms、12 ms、11 ms,你知道这跳延迟稳定;如果是 5 ms、200 ms、8 ms,说明这跳拥堵或路径不稳定。
*可靠性*。如果一次探测超时但另外两次收到回应,你仍然能看到这一跳。一个 * 混在真实数值中,意思是「不稳定」,不是「不存在」。
*负载均衡检测*。如果同一个 TTL 值的三次探测收到了不同的 IP 地址,说明路径上存在负载均衡。原文作者最初用 github.com 作为目标时就遇到了这个问题,反复命中不同的负载均衡节点。
代码上,只需在现有的 TTL 循环内加一层内循环。为了输出整洁,只在 IP 变化时才打印 IP,否则只打印时间。
int reached = 0; uint32_t last_ip = 0; printf("%2d ", ttl); for (int i = 0; i < 3; i++) { probe_result_t r = probe(target, ttl); switch (r.type) { case PROBE_HOP: case PROBE_REACHED: if (last_ip != r.ip) { struct in_addr addr = {.s_addr = r.ip}; printf("%s ", inet_ntoa(addr)); last_ip = r.ip; } printf("%.3f ms ", r.rtt_ms); if (r.type == PROBE_REACHED) reached = 1; break; case PROBE_TIMEOUT: printf("* "); break; } } printf("\n"); if (reached) break;
运行效果。
$ sudo ./traceroute 8.8.8.8 1 172.30.208.1 1.148 ms 0.232 ms 0.169 ms 2 192.168.23.254 3.934 ms 3.761 ms 2.850 ms 3 192.168.250.1 28.519 ms 4.876 ms 5.607 ms 4 * * * 5 27.44.88.1 11.272 ms 7.522 ms 8.049 ms 6 120.80.149.81 7.925 ms 120.80.149.73 12.396 ms 7.235 ms 7 * * * 8 * * 219.158.103.218 11.132 ms 9 * * 219.158.24.138 10.484 ms 10 219.158.3.190 14.546 ms 219.158.3.106 15.134 ms 219.158.3.190 19.326 ms 11 202.77.23.30 13.397 ms 11.701 ms 12.370 ms 12 162.245.124.254 12.053 ms 103.239.176.113 13.936 ms 103.239.176.109 14.021 ms 13 203.131.241.220 335.569 ms * * 14 203.131.250.82 189.175 ms 188.894 ms 196.809 ms 15 * * * 16 8.8.8.8 200.987 ms 183.870 ms 186.769 ms
开始像真正的 traceroute 了。
和真正的 traceroute 对比
| 功能 | 真正的 traceroute | 本文实现 |
|---|---|---|
| TTL 递增 | 是 | 是 |
| ICMP 类型检查 | 是 | 是 |
| 计时(RTT) | 是 | 是 |
| 每跳三次探测 | 是 | 是 |
| DNS 反向解析 | 是(如 =dns.google=) | 否 |
| 端口递增 | 是(33434, 33435...) | 否(固定 33434) |
| ICMP Echo 模式(=-I=) | 是 | 否(仅 UDP) |
| TCP 模式(=-T=) | 是 | 否 |
| IPv6 支持 | 是(=traceroute6=) | 否 |
真正的 traceroute 每次探测会递增目标端口(33434, 33435, 33436...),用来把 ICMP 应答匹配到具体的探测包,因为 ICMP 应答里包含了原始 UDP 包的头部。它还支持 TCP 模式(=-T=),对付 UDP 被防火墙拦截但 TCP 能通过的网络环境。原理都一样,设低 TTL,让包死在途中,读 ICMP 错误。
traceroute 看不到的东西
到这一步,traceroute 的基本原理已经清楚了。不过别被它的输出骗了。输出里每跳有精确的 IP 和毫秒级延迟,看着像一张网络地图。但实际上这些 IP 可能不是真正的路径,延迟也可能不代表真实往返时间,有些路由器干脆隐身。地图谈不上,更像草图。下面是四条常见的误解。
*非对称返回路径*。ICMP 错误消息从路由器返回你的机器时,走的不一定是去程的同一条路。网络路由是动态的,返回路径可能完全不同。traceroute 显示的是去程路径上每个路由器的 IP,但你不知道 ICMP 回复是怎么回来的。
*MPLS 隧道*。MPLS(Multi-Protocol Label Switching,多协议标签交换)是运营商骨干网常用的技术。数据包可以在一个 MPLS 隧道中穿过多个路由器,但 traceroute 只看到隧道的入口和出口,中间的路由器被隐藏了。一行输出背后可能藏着十几个路由器。
*负载均衡路径分裂*。同一个 TTL 的三次探测可能走完全不同的路径。很多运营商在骨干网入口部署 ECMP(Equal-Cost Multi-Path,等价多路径)路由,把流量分散到多条链路上。traceroute 显示的「路径」其实不是任何一个数据包真正走过的路,它是多次探测拼出来的混合产物。
*ICMP 限速*。那些 * 不一定是死路由器。很多路由器为了节省 CPU 会降低 ICMP 消息的生成优先级,甚至直接丢弃 ICMP 回复。数据包正常穿过这些路由器,只是路由器懒得告诉你它在那儿。
为什么有星号
* 是 traceroute 输出里最让人犯迷糊的部分。这玩意到底在说什么?
简单说, * 的意思是「这次探测没收到回应」,不代表「那里没东西」。具体原因有五种。
- *ICMP 限速*,路由器正常转发数据包,只是减少了 ICMP 回复的生成频率。这是最常见的原因。
- *防火墙拦截 ICMP*,路由器或前面的防火墙直接丢弃所有 ICMP 消息。企业和云防火墙经常这样做。
- *防火墙拦截 UDP 33434*,路由器在 TTL 还没减到 0 之前就丢弃了探测包,包根本没有机会「自然死亡」并触发回报。
- *超时*,路由器确实回复了,但回复在路上花了超过 2 秒(我们设的超时时间)。现代网络上很少见。
- *回复迷路*,ICMP 回复发出了,但走了另一条路,没能到达我们这。
密码就在输出里,第 4 跳全是 * ,但第 5 跳又能看到路由器了。第 7 跳也是一样,全是 * ,数据包照样穿过它到达了后面的路由器。这说明那些「沉默」的路由器在正常转发数据包,只是不愿意回 ICMP。
为什么需要 sudo
每次运行都要加 sudo 很烦。系统的 traceroute 命令不需要 =sudo=,为什么我们的需要?
因为我们的代码打开了一个 SOCK_RAW 类型的 ICMP socket 来直接读取原始网络数据。原始 socket 能嗅探到达本机的任意网络流量,这种操作内核只允许 root 干。
系统的 traceroute 绕过了这个限制,靠的是安装时设置的 setuid 位。运行 ls -la $(which traceroute) 能看到类似 -rwsr-xr-x 的权限,那个 s 表示,不管谁来执行这个二进制,都以文件所有者(root)的权限跑。
macOS 上还有第三种选择,内核允许非特权用户用 SOCK_DGRAM 类型的 ICMP socket(而不是 SOCK_RAW=),功能受限,但跑个基本的 =ping 和 traceroute 够了。
完整代码
以下是最终版本,114 行 C 代码(含注释和空行)。
#include <stdio.h> #include <string.h> #include <unistd.h> #include <time.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <sys/time.h> #define TARGET_PORT 33434 #define MAX_TTL 30 #define PROBES_PER_HOP 3 #define RECV_TIMEOUT 2 /* 秒 */ typedef enum { PROBE_HOP, /* ICMP Type 11:超时,途中的路由器 */ PROBE_REACHED, /* ICMP Type 3:端口不可达,到达目标 */ PROBE_TIMEOUT, /* 无应答 */ } probe_type_t; typedef struct { probe_type_t type; uint32_t ip; double rtt_ms; } probe_result_t; static probe_result_t probe(uint32_t target, int ttl) { int send_sock = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); setsockopt(send_sock, IPPROTO_IP, IP_TTL, &ttl, sizeof(ttl)); int recv_sock = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP); struct timeval tv = {.tv_sec = RECV_TIMEOUT}; setsockopt(recv_sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); struct sockaddr_in dest = { .sin_family = AF_INET, .sin_addr.s_addr = target, .sin_port = htons(TARGET_PORT), }; char payload[32] = {0}; struct timespec t_start, t_end; clock_gettime(CLOCK_MONOTONIC, &t_start); sendto(send_sock, payload, sizeof(payload), 0, (struct sockaddr *)&dest, sizeof(dest)); unsigned char buf[512]; int n = recv(recv_sock, buf, sizeof(buf), 0); clock_gettime(CLOCK_MONOTONIC, &t_end); close(send_sock); close(recv_sock); double rtt = (t_end.tv_sec - t_start.tv_sec) * 1000.0 + (t_end.tv_nsec - t_start.tv_nsec) / 1e6; if (n < 20) return (probe_result_t){PROBE_TIMEOUT, 0, 0}; int ip_hdr_len = (buf[0] & 0x0f) * 4; if (n < ip_hdr_len + 1) return (probe_result_t){PROBE_TIMEOUT, 0, 0}; uint32_t src_ip; memcpy(&src_ip, buf + 12, 4); switch (buf[ip_hdr_len]) { case 11: return (probe_result_t){PROBE_HOP, src_ip, rtt}; case 3: if (src_ip == target) return (probe_result_t){PROBE_REACHED, src_ip, rtt}; return (probe_result_t){PROBE_HOP, src_ip, rtt}; default: return (probe_result_t){PROBE_TIMEOUT, 0, 0}; } } int main(int argc, char *argv[]) { const char *target_str = (argc > 1) ? argv[1] : "8.8.8.8"; uint32_t target = inet_addr(target_str); for (int ttl = 1; ttl <= MAX_TTL; ttl++) { int reached = 0; uint32_t last_ip = 0; printf("%2d ", ttl); for (int i = 0; i < PROBES_PER_HOP; i++) { probe_result_t r = probe(target, ttl); switch (r.type) { case PROBE_HOP: case PROBE_REACHED: if (last_ip != r.ip) { struct in_addr addr = {.s_addr = r.ip}; printf("%s ", inet_ntoa(addr)); last_ip = r.ip; } printf("%.3f ms ", r.rtt_ms); if (r.type == PROBE_REACHED) reached = 1; break; case PROBE_TIMEOUT: printf("* "); break; } } printf("\n"); if (reached) break; } return 0; }
编译和运行。
gcc -o traceroute traceroute.c sudo ./traceroute 8.8.8.8
参考资料:Van Jacobson 的原始 traceroute 实现、RFC 792(ICMP 标准)。