3.2 IPv6 的「瑞士军刀」:ICMPv6
上一节我们在 IPv4 的世界里打转,虽然 ICMPv4 也很重要,但在 IPv4 的架构里,它多少有点像个「打补丁」的角色——ARP 负责找地址,IGMP 负责组播,ICMPv4 主要负责报错和诊断。
但在 IPv6 的世界里,事情发生了根本性的变化。ICMPv6 不再只是一个配角,它成了整个舞台的核心。没有它,IPv6 网络几乎连一步都迈不出去。
这就像是你把家里原本分工明确的保姆、司机和管家全辞了,换成了一位全能管家——他既要负责报错(像 ICMPv4),又要负责 ARP 的工作(邻居发现),还要管 IGMP 的工作(组播监听)。这既是设计的优雅之处,也是实现的复杂之处。
3.2.1 不仅仅是 v6 版本的 v4
ICMPv6 定义在 RFC 4443 里(你去看代码注释可能会看到 RFC 1885 或 RFC 2463,它们是旧版本,被 4443 取代了)。如果你直接去搜内核代码,会发现它的实现文件主要有两个:
net/ipv6/icmp.cnet/ipv6/ip6_icmp.c
和 ICMPv4 一样,ICMPv6 也是编译进内核的,你想把它编译成模块?没门。
虽然名字很像,但 ICMPv6 的职责比 v4 重得多。除了保留原有的错误报告和诊断功能(Ping 和 Traceroute),它还接管了两块极其重要的领地:
- ND(Neighbour Discovery,邻居发现):彻底取代了 ARP。你不用再发广播问「谁是 192.168.1.1」,而是用 ICMPv6 的消息来搞定这件事。
- MLD(Multicast Listener Discovery,组播监听发现):接替了 IGMP 的工作,管理组播成员关系。
这也解释了为什么上一节提到的 ping_rcv() 方法会同时在两个文件里出现——因为为了支持 Ping,内核需要处理 IPv4 和 IPv6 两种回显请求。
3.2.2 冰山之下:ICMPv6 初始化
ICMPv6 的初始化流程和 IPv4 非常相似,可以说是「换皮不换骨」,但细节上有微妙的差别。
初始化的核心动作依然是两件事:
- 注册协议处理器(告诉内核「收到 58 号协议的包交给我」)。
- 创建控制用的套接字(给内核自己留个后门发消息)。
来看第一步,定义协议结构体:
static const struct inet6_protocol icmpv6_protocol = {
.handler = icmpv6_rcv,
.err_handler = icmpv6_err,
.flags = INET6_PROTO_NOPOLICY | INET6_PROTO_FINAL,
};
这里有两个标志值得注意:
INET6_PROTO_NOPOLICY:这意味着 ICMPv6 不需要 IPsec 策略检查。这是一个很合理的安全设计——如果你正在处理网络层的错误报告,你绝对不希望因为 IPsec 验证失败而把错误报告本身给丢了,那样你永远不知道网络到底出了什么问题。INET6_PROTO_FINAL:这个标志暗示它是最后的处理者之一。
然后是注册,这一步通过 inet6_add_protocol() 完成:
int __init icmpv6_init(void)
{
int err;
...
if (inet6_add_protocol(&icmpv6_protocol, IPPROTO_ICMPV6) < 0)
goto fail;
return 0;
}
协议号 IPPROTO_ICMPV6 的值是 58。这意味着当 IPv6 层看到 Next Header 字段是 58 时,就会直接把包扔给 icmpv6_rcv()。
第二步是给每个 CPU 都分配一个「专属发报机」:
static int __net_init icmpv6_sk_init(struct net *net)
{
struct sock *sk;
...
for_each_possible_cpu(i) {
err = inet_ctl_sock_create(&sk, PF_INET6,
SOCK_RAW, IPPROTO_ICMPV6, net);
...
net->ipv6.icmp_sk[i] = sk;
...
}
这段代码的逻辑和 v4 完全一致:遍历所有可能的 CPU,为每一个都创建一个 Raw Socket。
为什么要这么做?
还是为了锁竞争。如果所有的 CPU 都共用一个 Socket 来发送 ICMP 报错消息,那么在高并发场景下,发送队列会成为巨大的瓶颈。现在每个 CPU 都有自己的小金库(icmp_sk),谁发的消息谁排队,互不干扰。
3.2.3 报头结构:把错误和信息分开
ICMPv6 的报头结构看起来和 v4 很像,但有一个关键的「位操作」逻辑需要理解。
结构体定义(include/uapi/linux/icmpv6.h):
struct icmp6hdr {
__u8 icmp6_type;
__u8 icmp6_code;
__sum16 icmp6_cksum;
...
};
这三个字段是标准配置:类型、代码、校验和。
但重点在于 icmp6_type 的解释规则。RFC 4443 规定了一个非常聪明的分界线:
- 最高位为 0(0 ~ 127):这是错误消息。
- 最高位为 1(128 ~ 255):这是信息消息。
为了方便判断,内核定义了一个掩码 ICMPV6_INFOMSG_MASK(其实就是 0x80)。只要做一次按位与操作就能知道手里的包是好消息还是坏消息。
常见消息类型速查:
| 类型值 | 内核宏定义 | 类别 | 含义 |
|---|---|---|---|
| 1 | ICMPV6_DEST_UNREACH | Error | 目的不可达 |
| 2 | ICMPV6_PKT_TOOBIG | Error | 包太大(MTU 问题) |
| 3 | ICMPV6_TIME_EXCEED | Error | 超时(类似 v4 的 TTL Exceeded) |
| 4 | ICMPV6_PARAMPROB | Error | 参数问题 |
| 128 | ICMPV6_ECHO_REQUEST | Info | Ping 请求 |
| 129 | ICMPV6_ECHO_REPLY | Info | Ping 回复 |
| 133 | NDISC_ROUTER_SOLICITATION | Info | 路由器请求(ND 协议的一部分) |
| 134 | NDISC_ROUTER_ADVERTISEMENT | Info | 路由器通告 |
| 135 | NDISC_NEIGHBOUR_SOLICITATION | Info | 邻居请求(类似 ARP Request) |
| 136 | NDISC_NEIGHBOUR_ADVERTISEMENT | Info | 邻居通告(类似 ARP Reply) |
你会发现,表的后半截全是 ND(邻居发现)协议的消息。这再次印证了开头的观点:在 IPv6 里,ICMP 就是邻居发现的载体。
3.2.4 接收路径:icmpv6_rcv() 的调度逻辑
当一个 ICMPv6 包到达网卡,经过 IPv6 层的解包后,最终会落到 icmpv6_rcv() 手里。这个函数在 net/ipv6/icmp.c 里,它的逻辑流程图(图 3-4)展示了清晰的「漏斗式」处理。
第一步:安检与统计
包刚进来,先干两件事:
- 更新计数器:
ICMP6_MIB_INMSGS(接收到的 ICMPv6 消息总数)加一。 - 查户口:检查校验和。
如果校验和错了,这包肯定有问题。内核会更新 ICMP6_MIB_INERRORS 计数器,然后直接把包扔掉(kfree_skb)。注意,这里 icmpv6_rcv() 并不会返回错误码,它永远是 0,就像它的 IPv4 前辈 icmp_rcv() 一样。
第二步:类型分发
校验和没问题,接下来就看类型了。和 ICMPv4 那个庞大的 icmp_pointers 分发表不同,ICMPv6 使用了一个巨大的 switch(type) 语句来做分发。
这里有个细节:每个具体的类型被处理时,内核都会通过 ICMP6MSGIN_INC_STATS_BH 宏去更新 /proc/net/snmp6 下对应的统计项。比如收到了 Echo Request,Icmp6InEchos 就会加一;收到了邻居请求,Icmp6InNeighborSolicits 就会加一。
第三步:具体谁管什么?
我们来看看这个 switch 语句里的关键分支:
1. Echo Request (ICMPV6_ECHO_REQUEST) 这是别人 Ping 你。
- 处理函数:
icmpv6_echo_reply()。 - 行为:构造一个 Echo Reply 包发回去。
2. Echo Reply (ICMPV6_ECHO_REPLY) 这是你对别人 Ping 的回复。
- 处理函数:
ping_rcv()。 - 注意:这就是前面提到的「双栈」代码,它位于
net/ipv4/ping.c,同时处理 IPv4 和 IPv6 的 Ping 回复。
3. Packet Too Big (ICMPV6_PKT_TOOBIG) 这是一个 IPv6 特有的、极其重要的消息。
- 触发条件:你发出去的包太大了,中间某个链路的 MTU 装不下。
- 处理:
- 先调用
pskb_may_pull()确保包里有足够的数据供读取。 - 然后调用
icmpv6_notify(),这个函数最终会触发raw6_icmp_error(),把错误通知给相关的 Raw Socket。
- 先调用
4. 邻居发现(ND)消息 这是一组消息,包括 133 到 137 类型。
- 处理函数:全部交给
ndisc_rcv()(位于net/ipv6/ndisc.c)。 - 重要性:我们在第 7 章会详细讨论这个函数,因为它是 IPv6 地址解析的核心。
5. 组播监听(MLD)消息
ICMPV6_MGM_QUERY:查询,交给igmp6_event_query()。ICMPV6_MGM_REPORT:报告,交给igmp6_event_report()。- 注意:第 8 章我们会深入 MLD 的细节。
6. 默认分支(兜底逻辑) 如果进来的包是一个「未知类型」,或者是一些不太常见的类型(比如移动 IPv6 相关的消息),怎么处理?
看这段 switch 的 default 代码,很有意思:
default:
LIMIT_NETDEBUG(KERN_DEBUG "icmpv6: msg of unknown type\n");
/* informational */
if (type & ICMPV6_INFOMSG_MASK)
break;
/*
* error of unknown type.
* must pass to upper level
*/
icmpv6_notify(skb, type, hdr->icmp6_code, hdr->icmp6_mtu);
}
逻辑如下:
- 如果是信息类消息(最高位是 1),那就直接
break。这意味着内核会静默忽略它。为什么?因为收到一个未知的信息消息,通常不影响网络运行,只是多点噪音。 - 如果是错误类消息(最高位是 0),那就不能无视了。必须调用
icmpv6_notify(),把这个错误向上层传递。这符合 RFC 4443 的规定:未知的错误消息也必须报告给上层协议处理(比如 Raw Socket),以防上层协议需要做特殊处理。
3.2.5 发送路径:icmpv6_send() 的时机与限制
发送 ICMPv6 消息主要靠两个函数:
icmpv6_send():通用发送函数,用于发送各种错误消息(不可达、超时等)。icmpv6_echo_reply():专门用于回复 Ping 请求。
icmpv6_send() 的原型长得和 IPv4 版很像:
static void icmpv6_send(struct sk_buff *skb, u8 type, u8 code, __u32 info)
skb:触发这个错误的「罪魁祸首」原始包。type:你要发的 ICMP 类型(比如ICMPV6_TIME_EXCEED)。code:详细的错误码。info:额外信息,最常见的是 MTU 值。
场景 1:Hop Limit 超时(IPv6 版本的 TTL Exceeded)
当路由器转发 IPv6 包时,每经过一跳,Hop Limit 就减 1。一旦减到 0,包就必须被丢弃,并通知发送端。
代码在 ip6_forward() 中(net/ipv6/ip6_output.c):
if (hdr->hop_limit <= 1) {
/* Force OUTPUT device used as source address */
skb->dev = dst->dev;
icmpv6_send(skb, ICMPV6_TIME_EXCEED, ICMPV6_EXC_HOPLIMIT, 0);
IP6_INC_STATS_BH(net, ip6_dst_idev(dst), IPSTATS_MIB_INHDRERRORS);
kfree_skb(skb);
return -ETIMEDOUT;
}
这一步非常关键,因为没有它,数据包就会在路由环路里无限打转,直到耗尽网络带宽。
场景 2:分片重组超时
如果目的主机收到了一堆分片,但在规定时间内没凑齐,或者中间丢了几个,它等不下去了。
代码在 ip6_expire_frag_queue()(net/ipv6/reassembly.c):
void ip6_expire_frag_queue(struct net *net, struct frag_queue *fq,
struct inet_frags *frags)
{
...
icmpv6_send(fq->q.fragments, ICMPV6_TIME_EXCEED, ICMPV6_EXC_FRAGTIME, 0);
...
}
这时候它会告诉发送端:「我放弃治疗了,这包重组超时。」
场景 3:端口不可达(UDP 场景)
这和 ICMPv4 的逻辑一模一样。如果你给一个关闭的 UDP 端口发包,内核会找 Socket;找不到,校验和又正确,那就发个 Port Unreachable 回去。
代码在 __udp6_lib_rcv() 中:
sk = __udp6_lib_lookup_skb(skb, uh->source, uh->dest, udptable);
if (sk != NULL) {
...
}
...
if (udp_lib_checksum_complete(skb))
goto discard;
UDP6_INC_STATS_BH(net, UDP_MIB_NOPORTS, proto == IPPROTO_UDPLITE);
icmpv6_send(skb, ICMPV6_DEST_UNREACH, ICMPV6_PORT_UNREACH, 0);
场景 4:包太大(Packet Too Big)—— PMTU 的关键
这是 IPv6 和 IPv4 最大的区别之一。 在 IPv4 里,如果你发的包太大,路由器会把它分片(除非 DF 位置 1)。 但在 IPv6 里,路由器被禁止分片。分片是发送端自己的事。
所以,当 IPv6 路由器发现一个包比出口 MTU 大,它唯一的选择就是:扔掉它,并给发送端发一个 ICMPV6_PKT_TOOBIG 消息,告诉它「路很窄,车太大,下次开小点来(MTU 是 xxx)」。
注意这里和 v4 的区别:
- IPv4:发
ICMP_DEST_UNREACH+ICMP_FRAG_NEEDED。 - IPv6:发独立的
ICMPV6_PKT_TOOBIG类型。
代码在 ip6_forward() 中:
if ((!skb->local_df && skb->len > mtu && !skb_is_gso(skb)) ||
(IP6CB(skb)->frag_max_size && IP6CB(skb)->frag_max_size > mtu)) {
/* Again, force OUTPUT device used as source address */
skb->dev = dst->dev;
icmpv6_send(skb, ICMPV6_PKT_TOOBIG, 0, mtu);
IP6_INC_STATS_BH(net, ip6_dst_idev(dst), IPSTATS_MIB_INTOOBIGERRORS);
IP6_INC_STATS_BH(net, ip6_dst_idev(dst), IPSTATS_MIB_FRAGFAILS);
kfree_skb(skb);
return -EMSGSIZE;
}
这里的 mtu 变量作为 info 参数传给 icmpv6_send,最终会被填入 ICMPv6 报文的 MTU 字段。发送端收到这个值后,就会调整自己的 Path MTU。
发送前的检查:别把「错误报告」变成「错误炸弹**
在真正调用 icmpv6_send() 之前,内核会做很多检查,其中最关键的是防止「报错的雪崩」。
想象一下,如果某根网线断了,导致海量包无法转发。如果路由器对每个包都发送 ICMP 不可达消息,那么回传的 ICMP 消息可能会再次引发拥塞,甚至导致「ICMP 风暴」。
为了防止这种情况,icmpv6_send() 支持速率限制。它通过调用 icmpv6_xrlim_allow() 来检查是不是发得太频繁了。而这个函数底层其实调用的还是 IPv4 那个通用的 inet_peer_xrlim_allow()。
但是,速率限制不是什么时候都开的。
以下三种情况,icmpv6_send() 会跳过速率限制检查:
- 发送信息类消息(Informational messages),比如 Echo Reply。因为这些通常不是错误,不会触发风暴。
- PMTU 发现相关的消息(
ICMPV6_PKT_TOOBIG)。因为这种消息必须及时传达,如果被限流丢掉了,发送端就永远不知道正确的 MTU 是多少,连接就会彻底断掉。 - 发给 Loopback 设备的消息(本机自己发给自己的)。反正都在内存里转,无所谓拥塞。
除了速率限制,还有一个硬性规定:ICMPv6 错误报文的长度不能超过 1280 字节(IPv6 的最小 MTU,即 IPV6_MIN_MTU)。这是为了确保错误报告本身能顺利通过网络。RFC 4443 要求,错误报文必须尽可能多地包含原始出错的包,但绝不能因此超过最小 MTU。如果原始包太长,内核会无情地截断它。
3.2.6 特殊的访问通道:ICMP Sockets (Ping Sockets)
以前,如果你想 Ping 别人,你的程序必须有 Root 权限。因为创建 Raw Socket (socket(PF_INET, SOCK_RAW, ...)) 需要 CAP_NET_RAW 能力。这就是为什么 /bin/ping 这个程序传统上都有 setuid root 位。
但在 2011 年左右,Linux 内核引入了一个新的安全机制:ICMP Sockets(也叫 Ping Sockets)。
这是一个新的协议类型 IPPROTO_ICMP,但它不是 Raw Socket,而是一种特殊的 Datagram Socket。
创建方式的变化:
- 旧方式:
socket(PF_INET, SOCK_RAW, IPPROTO_ICMP)—— 需要 Root。 - 新方式:
socket(PF_INET, SOCK_DGRAM, IPPROTO_ICMP)—— 不一定需要 Root。
IPv6 也一样,可以用 SOCK_DGRAM 加上 IPPROTO_ICMPV6。
它是怎么实现的?
代码主要在 net/ipv4/ping.c(IPv6 的那部分其实也是调这里的代码,所以叫 dual-stack)。
当一个用户试图创建这种 Socket 时,内核会检查:
- 这个用户所在的组是否在
/proc/sys/net/ipv4/ping_group_range定义的范围内?
这个 procfs 条目默认是 1 0,这意味着「没人能用」。
如果你想允许 GID 为 1000 的用户使用 Ping,可以执行:
echo 1000 1000 > /proc/sys/net/ipv4/ping_group_range
如果你想允许所有人用(除了纯 Root),可以把上限设为最大值:
echo 0 2147483647 > /proc/sys/net/ipv4/ping_group_range
安全性检查
ping_supported() 函数会确保这个 Socket 只能用来发送标准的 Echo 请求:
static inline int ping_supported(int family, int type, int code)
{
return (family == AF_INET && type == ICMP_ECHO && code == 0) ||
(family == AF_INET6 && type == ICMPV6_ECHO_REQUEST && code == 0);
}
也就是说,你不能用这个机制去发送 ICMP Redirect 或者 Destination Unreachable,它只能用来 Ping。这样既给了普通用户诊断网络的权利,又不至于给他们制造 DoS 攻击的武器。
这就是很多现代 Linux 发行版能做到「无 Root Ping」的秘密。
3.2.7 本章回响
回看 ICMPv6,你会发现它不仅仅是 ICMPv4 的一个版本升级。
在 IPv6 的设计哲学里,复杂性是被精心管理的。ARP 被砍掉了,IGMP 被合并了,所有的邻居交互逻辑都被统一到了 ICMPv6 这一层。这使得协议栈本身变得更「薄」了——你只需要维护一个传输层(UDP/TCP)和一个网络层(IPv6),中间的杂活全交给 ICMPv6。
但也正因为这种集大成,ICMPv6 的代码变得相当复杂。它既要处理网络层的报错(如超时、不可达),又要处理链路层的交互(NDP),还要处理传输层的辅助(MLD)。如果 ICMPv6 挂了,IPv6 网络实际上也就瘫痪了。
还记得我们在本章开头提的那个「直觉」吗——Ping 是测试连通性的工具。现在你应该能意识到,Ping 在 IPv6 里测试的不仅仅是「路通不通」,它在测试整个 ICMPv6 子系统是否活着,因为如果没有 ICMPv6,你的邻居表可能是空的,路由可能是错的,MTU 可能是不匹配的。
下一章,我们将深入 IPv4 网络层的实现。虽然我们讨论了很多 ICMP 的细节,但那是「控制平面」的,真正的「数据平面」——那些实实在在的用户数据包是如何被路由、被分片、被转发的——才是网络层最浩瀚的工程。