30、Traceroute实现:使用原始套接字实现traceroute、TTL递增技巧
网络诊断中,traceroute 是个非常实用的工具。它能告诉你,数据包从你的电脑到目标服务器,一路上经过了哪些路由器。说白了,就是帮你画出网络路径的地图。
我个人习惯用 traceroute 来排查网络延迟问题。有一次线上服务响应慢,我怀疑是某个中间节点出了问题。用 traceroute 一查,果然发现某个跳数延迟异常高。嗯,这就是我们今天要自己动手实现的东西。
核心原理:TTL 与 ICMP
实现 traceroute 的关键,在于 IP 协议头里的 TTL(Time To Live) 字段。TTL 是一个 8 位的数字,每经过一个路由器,TTL 就减 1。当 TTL 减到 0 时,路由器会丢弃这个数据包,并给发送方回一个 ICMP 超时消息。
你想想看,如果我们先发一个 TTL=1 的包,第一个路由器收到后 TTL 变 0,它就会回 ICMP 超时。这样我们就知道了第一跳的地址。接着发 TTL=2 的包,第二跳的地址就拿到了。以此类推,直到到达目标。
核心流程:
- 构造 UDP 数据包,设置 TTL = 1
- 发送数据包到目标端口(通常用大端口号,避免被服务响应)
- 接收 ICMP 超时消息,解析出路由器 IP
- TTL 递增,重复步骤 1-3
- 如果收到 ICMP 端口不可达,说明到达目标主机
原始套接字:直接操作 IP 层
要实现 TTL 控制,我们需要使用 原始套接字(Raw Socket)。普通套接字只能操作传输层(TCP/UDP),而原始套接字允许我们直接构造 IP 数据包。
我在项目中遇到过一个问题:用原始套接字发送数据时,系统会自动填充 IP 头的一些字段。如果你自己构造了 IP 头,记得设置 IP_HDRINCL 选项,告诉内核「别帮我填,我自己来」。
创建原始套接字:
int sock = socket(AF_INET, SOCK_RAW, IPPROTO_RAW);
if (sock < 0) {
perror("socket");
exit(1);
}
// 设置 IP_HDRINCL,表示我们自己构造 IP 头
int on = 1;
if (setsockopt(sock, IPPROTO_IP, IP_HDRINCL, &on, sizeof(on)) < 0) {
perror("setsockopt");
exit(1);
}
注意:原始套接字需要 root 权限。如果你在普通用户下运行,会提示权限不足。我建议在开发时用 sudo,但生产环境要注意安全。
TTL 递增技巧
TTL 递增是 traceroute 的核心。每次发送探测包后,TTL 加 1。但这里有个细节:每次发送的 UDP 端口号要不同。为什么?因为 ICMP 回复里会携带原始数据包的部分内容,包括端口号。通过端口号,我们可以匹配哪个探测包对应哪个回复。
我曾经踩过一个坑:连续发送多个 TTL 相同的包,结果 ICMP 回复乱序了,根本分不清哪个回复对应哪个包。后来我改用递增端口号,每个探测包用唯一的端口,问题就解决了。
代码实现
下面是一个简化版的 traceroute 实现。它使用原始套接字发送 UDP 包,并接收 ICMP 回复。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/ip.h>
#include <netinet/ip_icmp.h>
#include <netinet/udp.h>
#include <arpa/inet.h>
#define PACKET_SIZE 4096
#define MAX_HOPS 30
#define TIMEOUT_SEC 2
// 计算校验和
unsigned short checksum(void *b, int len) {
unsigned short *buf = b;
unsigned int sum = 0;
unsigned short result;
for (sum = 0; len > 1; len -= 2)
sum += *buf++;
if (len == 1)
sum += *(unsigned char *)buf;
sum = (sum >> 16) + (sum & 0xFFFF);
sum += (sum >> 16);
result = ~sum;
return result;
}
int main(int argc, char *argv[]) {
if (argc != 2) {
fprintf(stderr, "用法: %s <目标IP>\n", argv[0]);
exit(1);
}
// 创建原始套接字
int sock = socket(AF_INET, SOCK_RAW, IPPROTO_RAW);
if (sock < 0) {
perror("socket");
exit(1);
}
int on = 1;
setsockopt(sock, IPPROTO_IP, IP_HDRINCL, &on, sizeof(on));
// 创建接收套接字,用于接收 ICMP
int recv_sock = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);
if (recv_sock < 0) {
perror("recv socket");
exit(1);
}
struct sockaddr_in dest;
dest.sin_family = AF_INET;
dest.sin_port = htons(33434); // 起始端口
inet_pton(AF_INET, argv[1], &dest.sin_addr);
char packet[PACKET_SIZE];
char recv_buf[PACKET_SIZE];
printf("Traceroute to %s...\n", argv[1]);
for (int ttl = 1; ttl <= MAX_HOPS; ttl++) {
memset(packet, 0, PACKET_SIZE);
// 构造 IP 头
struct iphdr *iph = (struct iphdr *)packet;
iph->version = 4;
iph->ihl = 5;
iph->tos = 0;
iph->tot_len = sizeof(struct iphdr) + sizeof(struct udphdr);
iph->id = htons(ttl);
iph->frag_off = 0;
iph->ttl = ttl;
iph->protocol = IPPROTO_UDP;
iph->check = 0;
iph->saddr = inet_addr("0.0.0.0"); // 由内核填充
iph->daddr = dest.sin_addr.s_addr;
// 构造 UDP 头
struct udphdr *udph = (struct udphdr *)(packet + sizeof(struct iphdr));
udph->source = htons(33434 + ttl); // 递增端口
udph->dest = htons(33434 + ttl);
udph->len = htons(sizeof(struct udphdr));
udph->check = 0;
// 发送数据包
if (sendto(sock, packet, iph->tot_len, 0,
(struct sockaddr *)&dest, sizeof(dest)) < 0) {
perror("sendto");
continue;
}
// 接收 ICMP 回复
struct sockaddr_in from;
socklen_t from_len = sizeof(from);
fd_set fds;
struct timeval tv = {TIMEOUT_SEC, 0};
FD_ZERO(&fds);
FD_SET(recv_sock, &fds);
if (select(recv_sock + 1, &fds, NULL, NULL, &tv) > 0) {
int n = recvfrom(recv_sock, recv_buf, PACKET_SIZE, 0,
(struct sockaddr *)&from, &from_len);
if (n < 0) continue;
// 解析 ICMP 头
struct iphdr *recv_iph = (struct iphdr *)recv_buf;
int iph_len = recv_iph->ihl * 4;
struct icmphdr *icmph = (struct icmphdr *)(recv_buf + iph_len);
char ip_str[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &from.sin_addr, ip_str, sizeof(ip_str));
if (icmph->type == ICMP_TIME_EXCEEDED) {
printf("%2d %s\n", ttl, ip_str);
} else if (icmph->type == ICMP_DEST_UNREACH) {
printf("%2d %s (到达目标)\n", ttl, ip_str);
break;
}
} else {
printf("%2d * (超时)\n", ttl);
}
}
close(sock);
close(recv_sock);
return 0;
}
关键点解析
| 组件 | 作用 | 注意事项 |
|---|---|---|
| 原始套接字 (IPPROTO_RAW) | 发送自定义 IP 数据包 | 需要 root 权限,设置 IP_HDRINCL |
| ICMP 接收套接字 (IPPROTO_ICMP) | 接收 ICMP 超时和端口不可达消息 | 用 select 设置超时,避免阻塞 |
| 递增端口号 | 匹配探测包和回复 | 每个 TTL 使用不同端口,避免混淆 |
| TTL 字段 | 控制数据包经过的路由器数量 | 从 1 开始,每次递增 1 |
避坑指南
我曾经在实现 traceroute 时遇到几个坑,分享给你:
- 校验和问题:IP 头的校验和可以交给内核计算,但 UDP 校验和如果设为 0,某些路由器会丢弃。我建议手动计算 UDP 校验和,或者干脆用 ICMP 代替 UDP 作为探测包。
- 超时处理:网络环境复杂,有些路由器不会回复 ICMP 超时。一定要设置超时,否则程序会卡死。我一般用 2 秒超时,超过就打印 *。
- 端口选择:不要用知名端口(如 80、443),否则目标主机可能会回复正常数据。用 33434 以上的高端口比较安全。
- 多线程问题:如果你要同时追踪多个目标,记得每个追踪用独立的套接字,避免 ICMP 回复混淆。
总结一下:
traceroute 的实现并不复杂,核心就是 TTL 递增 + ICMP 解析。原始套接字给了我们直接操作 IP 层的能力,但也带来了权限和安全问题。在实际项目中,我建议把 traceroute 功能封装成一个独立的模块,用子进程执行,避免影响主程序的稳定性。
嗯,如果你在实现过程中遇到问题,可以检查一下:套接字权限、TTL 设置、ICMP 类型判断。这三个地方最容易出错。
公众号:蓝海资料掘金营,微信deep3321