3.0 网络的神经系统:为何我们需要 ICMP
想象一下,你正在管理一个庞大的、分布在全球的快递系统。
每天都有数以亿计的包裹在这个网络中流转。作为管理者,你并不需要亲自搬运每一个包裹,但你绝对需要一套机制来告诉你:系统是不是出事了?
如果某个分拣中心突然瘫痪了,如果某条路堵死走不通了,或者如果发件人填错了地址,你需要第一时间知道。不能等包裹凭空消失了才去查,那时候就太晚了。
互联网的设计者面临的是同样的问题。IP 协议本质上是一个「尽力而为」的投递服务——它只管发,不管结果。如果没有一个反馈机制,网络将是不可调试的黑盒:数据包丢了就是丢了,没人知道为什么,也没人知道去哪找。
这就是 ICMP 存在的意义。它是互联网的神经系统,专门用来传递错误报告、诊断信息和控制消息。
你每天都在用它。当你敲下 ping google.com 测试连通性时,你在用 ICMP;当你用 traceroute 追踪数据包经过了哪些路由器时,你也在利用 ICMP 的行为特征。
这一章,我们要把这个「神经系统」拆开来看。我们要看的不是用户工具怎么用,而是内核在收到这些奇怪的数据包时,到底发生了什么。
3.1 ICMPv4:IPv4 的诊断核心
ICMPv4 消息的两副面孔
ICMPv4 的消息可以分为两大类:错误消息 和 信息消息(在 RFC 1812 中也被称为查询消息)。
最著名的工具——ping,本质上就是一个用户空间的测试程序(通常包含在 iputils 这个包里)。它的工作原理非常直接:打开一个 Raw Socket(原始套接字),发送一个 ICMP_ECHO(回显请求)消息,然后等待对方回一个 ICMP_ECHOREPLY(回显应答)。
另一个常用的工具 Traceroute 用来探测主机到目标 IP 之间经过了哪些路径。它的设计非常巧妙,利用了 IP 头部的一个字段——TTL(Time To Live,生存时间)。这个字段代表了数据包还能经过多少个路由器(跳数)。Traceroute 利用了这样一个规则:当转发设备收到一个 TTL 减为 0 的数据包时,必须丢弃它,并回送一个 ICMP_TIME_EXCEEDED(超时)消息。
Traceroute 一开始发送 TTL 设为 1 的包,第一跳路由器收到后 TTL 归零,于是回送 ICMP 超时消息;接着 TTL 设为 2,第二跳路由器回送……以此类推,直到最终目的地返回 ICMP Echo Reply。通过这些中间的 ICMP 报告,Traceroute 就能拼出一张完整的路由图。
虽然在后面的章节里我们会详细聊,但值得一提的是,Linux 内核从 3.0 版本开始引入了一个新玩意儿:ICMP Sockets(Ping Sockets)。这使得普通用户(非 root)也可以通过创建非 Raw Socket 的方式来发送 Ping 请求,这让 ping 工具不再需要设置危险的 setuid root 位。
初始化:inet_init() 与 icmp_sk_init()
ICMPv4 的初始化并不发生在驱动加载时,因为它是内核核心网络栈的一部分,不能被编译成内核模块。这一切都发生在内核启动的早期。
整个流程的起点在 inet_init()。在这个函数里,内核做了两件大事:一是向协议栈注册 ICMP 处理器,二是为 ICMP 消息的发送做准备。
注册协议处理器
就像我们在 TCP/UDP 章节看到的那样,ICMP 也是一个协议,它必须注册到内核的协议分发数组中。这发生在 inet_init() 里:
static const struct net_protocol icmp_protocol = {
.handler = icmp_rcv,
.err_handler = icmp_err,
.no_policy = 1,
.netns_ok = 1,
};
icmp_rcv: 这是核心回调函数。当 IP 层收到一个协议字段为IPPROTO_ICMP (0x1)的数据包时,最终会调用这个函数。no_policy: 这个标志位设为 1,意味着不需要进行 IPsec 策略检查。这是个重要的优化——比如在ip_local_deliver_finish()中,内核看到这个标志就会跳过xfrm4_policy_check(),因为对于 ICMP 控制消息来说,安全策略通常不是首要考虑的。netns_ok: 设为 1 表示这个协议是感知网络命名空间的。如果设为 0,inet_add_protocol()会直接失败并返回-EINVAL。
注册过程很直接:
if (inet_add_protocol(&icmp_protocol, IPPROTO_ICMP) < 0)
pr_crit("%s: Cannot add ICMP protocol\n", __func__);
创建发送用的 Socket
光知道怎么收还不够,内核还得知道怎么发 ICMP 包。这就用到了 icmp_sk_init()。
这里有一个很有意思的设计细节:内核为每一个 CPU 都创建了一个专门的 Raw Socket。
int __net_init icmp_sk_init(struct net *net)
{
. . .
for_each_possible_cpu(i) {
struct sock *sk;
err = inet_ctl_sock_create(&sk, PF_INET,
SOCK_RAW, IPPROTO_ICMP, net);
if (err < 0)
goto fail;
net->ipv4.icmp_sk[i] = sk;
. . .
sock_set_flag(sk, SOCK_USE_WRITE_QUEUE);
inet_sk(sk)->pmtudisc = IP_PMTUDISC_DONT;
}
. . .
}
为什么要这么麻烦?因为在多核系统下,如果所有 CPU 都抢着用一个 Socket 发送数据,锁竞争会非常严重。通过 icmp_sk(struct net *net) 方法,内核可以快速拿到当前 CPU 对应的那个 Socket,直接用于 icmp_push_reply() 进行发送。
这里我们看到了两个配置:
SOCK_USE_WRITE_QUEUE:指示使用写入队列。IP_PMTUDISC_DONT:关闭 PMTU(路径 MTU)发现。因为对于像 ICMP 错误通知这样的控制消息,我们要确保它们能尽可能送达,而不应该因为 MTU 问题被丢弃或分片。
ICMPv4 报文头部:struct icmphdr
每个 ICMP 数据包的头部都是一样的骨架,但肉体各不相同。
头部包含:类型(8 bits)、代码(8 bits)、校验和(16 bits),以及一个 32 位的可变部分。这 32 位的具体内容取决于类型和代码。
struct icmphdr {
__u8 type;
__u8 code;
__sum16 checksum;
union {
struct {
__be16 id;
__be16 sequence;
} echo;
__be32 gateway;
struct {
__be16 __unused;
__be16 mtu;
} frag;
} un;
};
紧跟在这个头部后面的,通常是触发此 ICMP 消息的原始 IP 数据包的头部及其部分载荷。RFC 1812 规定,这部分应该尽可能包含原始数据报的内容,但整个 ICMP 数据报的总长度不能超过 576 字节。这是 IPv4 的最低 MTU 要求(源自 RFC 791),确保任何网络设备都能处理这个大小的包。
分发机制:icmp_pointers 数组
内核怎么知道收到一个 ICMP 包是该给 Ping 看,还是该给路由表看?
答案是查表。
内核定义了一个全局数组 icmp_pointers,它以 ICMP 消息类型为索引,存储了对应的 icmp_control 结构体。
struct icmp_control {
void (*handler)(struct sk_buff *skb);
short error; /* 是否归类为错误消息 */
};
static const struct icmp_control icmp_pointers[NR_ICMP_TYPES+1];
NR_ICMP_TYPES 是最大的 ICMP 类型号(18)。
如果某个类型的 error 字段为 1,说明它是错误消息(比如 Destination Unreachable);如果是 0(隐式),则是信息消息(比如 Echo)。
接下来我们看几个关键的 handler,它们是 ICMP 处理逻辑的灵魂。
1. ping_rcv():不仅仅是回声
在内核 3.0 之前,Ping 的应答(ICMP_ECHOREPLY)是由用户空间的 Raw Socket 直接处理的。那时候,ip_local_deliver_finish() 会先尝试把包投递给 Raw Socket,如果 Raw Socket 吃了,协议层的 handler 就不会再被调用。
但在引入 ICMP Sockets 机制后,情况变了。你不再需要 Root 权限就能创建一个非 Raw 的 ICMP Socket(比如 socket(PF_INET, SOCK_DGRAM, PROT_ICMP))。既然发送者不是 Raw Socket,那回来的应答也就没法匹配到 Raw Socket,自然就没人收了。
为了解决这个问题,icmp_pointers 中专门给 ICMP_ECHOREPLY 挂了一个钩子:ping_rcv()。
这个函数在 net/ipv4/ping.c 里实现。有意思的是,这个文件是双栈 的,也就是说它既处理 IPv4 的 Echo Reply,也处理 IPv6 的(ICMPV6_ECHO_REPLY)。
2. icmp_discard():沉默是金
有些消息,内核收到后不需要做任何事,直接扔掉。
最典型的就是 ICMP_TIMESTAMPREPLY(时间戳应答)。虽然 ICMP 有时间戳功能,但现代网络早就用 NTP(Network Time Protocol) 来做时间同步了,精度更高、功能更强。ICMP Timestamp 更多是历史遗留。
同样被丢弃的还有 Address Mask 相关的消息。以前主机通过 ICMP_ADDRESS 向路由器问子网掩码,现在大家都用 DHCP 了,这玩意儿也就没用了。
3. icmp_unreach()、icmp_redirect() 与其他
- icmp_unreach(): 处理
ICMP_DEST_UNREACH(目标不可达)、ICMP_TIME_EXCEED(超时)、ICMP_PARAMETERPROB(参数问题)等。- TTL 归零:在
ip_forward()中,每次转发 TTL 减 1。一旦归零,路由器就会调用icmp_send(skb, ICMP_TIME_EXCEEDED, ICMP_EXC_TTL, 0),然后丢包。 - 分片超时:如果分片重组超时,
ip_expire()会发送ICMP_EXC_FRAGTIME错误。
- TTL 归零:在
- icmp_redirect(): 处理
ICMP_REDIRECT(重定向)。根据 RFC 1122,主机不应该发送重定向,只有网关才发。以前这里会调用ip_rt_redirect()更新路由表,但从内核 3.6 开始,这个逻辑被移到了协议处理代码中,icmp_redirect()现在只负责校验和分发。这是内核为了应对安全风险(如 ICMP 重定向攻击)所做的架构调整。 - icmp_echo(): 处理
ICMP_ECHO请求。它会把包的类型改成ICMP_ECHOREPLY,然后调用icmp_reply()发回去。除非你设置了sysctl_icmp_echo_ignore_all。
接收流程:icmp_rcv() 的严谨逻辑
所有的 ICMPv4 包最终都会汇聚到 icmp_rcv() 函数。这个函数虽然不直接处理具体的业务逻辑(那是各个 handler 的事),但它做了大量的安检工作。
-
统计与校验: 首先增加
InMsgs计数器。紧接着,校验和必须正确。如果错了,增加InCsumErrors和InErrors,然后直接kfree_skb。这里有个细节:
icmp_rcv()永远返回 0,即使校验和错误也不返回负值。这是因为如果协议层返回错误,内核可能会尝试重传或做其他处理,但对于一个损坏的 ICMP 包,我们只想让它静静消失。 -
类型检查: 检查消息类型是否合法(小于
NR_ICMP_TYPES)。如果是一个未知类型,RFC 1122 要求必须静默丢弃。增加InErrors,丢包。 -
广播与多播的抑制: 如果收到的是一个发往广播或多播地址的包,且是 Echo Request 或 Timestamp Request,内核会检查
sysctl_icmp_echo_ignore_broadcasts。- 这是为了防止网络风暴。如果有人向全网广播发 Ping,每台机器都回应,网络就瘫痪了。默认这个开关是打开的(忽略)。
-
最终分发: 通过
icmp_pointers[type].handler找到对应的回调函数并执行。
发送流程:icmp_send() 与 icmp_reply()
内核发送 ICMP 消息主要有两种途径:
- 主动发送错误报告:比如
icmp_send()。当网络出现异常(如端口不可达、需要分片等)时调用。 - 被动应答:比如
icmp_reply()。用于回复 Echo 或 Timestamp 请求。
无论哪种,最终都会调用到 icmp_push_reply(),并依赖之前提到的 per-CPU Socket 把数据送入 IP 层。
结构体:struct icmp_bxm
在发送之前,内核会用 struct icmp_bxm 来构建待发送的消息。这相当于一个临时的「组装车间」。
struct icmp_bxm {
struct sk_buff *skb; /* 触发消息的原始包 */
int offset; /* 网络头偏移量 */
int data_len; /* 载荷长度 */
struct {
struct icmphdr icmph; /* ICMP 头部 */
__be32 times[3]; /* 时间戳(用于 Timestamp 消息) */
} data;
int head_len; /* 头部总长度 */
struct ip_options_data replyopts; /* IP 选项 */
};
速率限制:别让网络炸了
ICMP 消息如果不加限制地发送,很容易引发「雪崩」。比如某个路由器震荡了,每秒钟收到数万个错误的包,如果每个都回一个 ICMP 不可达,那它自己也就挂了。
内核通过 icmpv4_xrlim_allow() 实现了速率限制。
只有在以下情况下,速率限制才会被跳过:
- 消息类型未知(这很罕见)。
- 是 PMTU 发现消息(
ICMP_FRAG_NEEDED)——这个必须尽快发,否则 TCP 连接会断。 - 设备是 loopback(回环)。
- 该 ICMP 类型没有在速率掩码中开启。
其他情况下,内核会调用 inet_peer_xrlim_allow() 检查是否针对该目标发送了太多的 ICMP 包。
深入发送场景:Destination Unreachable
让我们看几个具体触发 icmp_send() 的场景,这对调试网络问题非常有帮助。
Code 2: Protocol Unreachable (ICMP_PROT_UNREACH)
场景:你发了一个数据包,IP 头里的协议字段写的是 137(假设),但内核根本没有注册协议号为 137 的处理器。
在 ip_local_deliver_finish() 中,内核查表 inet_protos[protocol],发现是 NULL。如果没有 Raw Socket 认领,内核就会触发:
icmp_send(skb, ICMP_DEST_UNREACH, ICMP_PROT_UNREACH, 0);
意思就是:「我想处理这个包,但我找不到能处理它的协议(如 TCP/UDP),所以它不可达。」
Code 3: Port Unreachable (ICMP_PORT_UNREACH)
场景:你发了一个 UDP 包到某台机器的 8888 端口,但那台机器根本没程序监听 8888。
UDP 协议层在 __udp4_lib_rcv() 中查找 Socket,发现找不到。如果校验和是对的,它就会回复:
icmp_send(skb, ICMP_DEST_UNREACH, ICMP_PORT_UNREACH, 0);
这通常是你能见到的最常见的 ICMP 错误报文。
Code 4: Fragmentation Needed (ICMP_FRAG_NEEDED)
场景:PMTU 发现的关键。
你想发一个 9000 字节的大包,但中间经过某个路由器,它的出口 MTU 只有 1500,而且你的包头 DF(Don't Fragment)位是 1。
在 ip_forward() 中,内核会发现 skb->len > dst_mtu(&rt->dst) 且 DF 标志置位。这时候它不能分片,只能丢弃并通知:
icmp_send(skb, ICMP_DEST_UNREACH, ICMP_FRAG_NEEDED,
htonl(dst_mtu(&rt->dst)));
注意这里的最后一个参数:它把正确的 MTU 值告诉了发送端。这就是 PMTU 的工作原理。
Code 5: Source Route Failed (ICMP_SR_FAILED)
场景:使用了 Strict Source Routing(严格源路由)。
如果在 ip_forward() 中发现数据包指定了必须经过网关 A,但路由表显示下一跳确实网关 A,或者配置有冲突,就会触发这个错误。虽然现在源路由用得很少了,但内核依然保留了这个逻辑。
总结
ICMPv4 看起来简单,其实它是网络自我调节的关键一环。
- 它是 IP 层的「异常处理机制」。
- 它是用户诊断工具(Ping/Traceroute)的基石。
- 它通过速率限制和特定的处理逻辑,保护自己不被滥用。
接下来,我们将进入 IPv6 的世界。你会发现,虽然名字很像,但在 IPv6 中,ICMP 被赋予了更核心的职责——它是邻居发现的基础,几乎取代了 ARP 的功能。