Skip to content

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.c
  • net/ipv6/ip6_icmp.c

和 ICMPv4 一样,ICMPv6 也是编译进内核的,你想把它编译成模块?没门。

虽然名字很像,但 ICMPv6 的职责比 v4 重得多。除了保留原有的错误报告和诊断功能(Ping 和 Traceroute),它还接管了两块极其重要的领地:

  1. ND(Neighbour Discovery,邻居发现):彻底取代了 ARP。你不用再发广播问「谁是 192.168.1.1」,而是用 ICMPv6 的消息来搞定这件事。
  2. MLD(Multicast Listener Discovery,组播监听发现):接替了 IGMP 的工作,管理组播成员关系。

这也解释了为什么上一节提到的 ping_rcv() 方法会同时在两个文件里出现——因为为了支持 Ping,内核需要处理 IPv4 和 IPv6 两种回显请求。

3.2.2 冰山之下:ICMPv6 初始化 ​

ICMPv6 的初始化流程和 IPv4 非常相似,可以说是「换皮不换骨」,但细节上有微妙的差别。

初始化的核心动作依然是两件事:

  1. 注册协议处理器(告诉内核「收到 58 号协议的包交给我」)。
  2. 创建控制用的套接字(给内核自己留个后门发消息)。

来看第一步,定义协议结构体:

c
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() 完成:

c
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 都分配一个「专属发报机」:

c
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):

c
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)。只要做一次按位与操作就能知道手里的包是好消息还是坏消息。

常见消息类型速查:

类型值内核宏定义类别含义
1ICMPV6_DEST_UNREACHError目的不可达
2ICMPV6_PKT_TOOBIGError包太大(MTU 问题)
3ICMPV6_TIME_EXCEEDError超时(类似 v4 的 TTL Exceeded)
4ICMPV6_PARAMPROBError参数问题
128ICMPV6_ECHO_REQUESTInfoPing 请求
129ICMPV6_ECHO_REPLYInfoPing 回复
133NDISC_ROUTER_SOLICITATIONInfo路由器请求(ND 协议的一部分)
134NDISC_ROUTER_ADVERTISEMENTInfo路由器通告
135NDISC_NEIGHBOUR_SOLICITATIONInfo邻居请求(类似 ARP Request)
136NDISC_NEIGHBOUR_ADVERTISEMENTInfo邻居通告(类似 ARP Reply)

你会发现,表的后半截全是 ND(邻居发现)协议的消息。这再次印证了开头的观点:在 IPv6 里,ICMP 就是邻居发现的载体。

3.2.4 接收路径:icmpv6_rcv() 的调度逻辑 ​

当一个 ICMPv6 包到达网卡,经过 IPv6 层的解包后,最终会落到 icmpv6_rcv() 手里。这个函数在 net/ipv6/icmp.c 里,它的逻辑流程图(图 3-4)展示了清晰的「漏斗式」处理。

第一步:安检与统计 ​

包刚进来,先干两件事:

  1. 更新计数器:ICMP6_MIB_INMSGS(接收到的 ICMPv6 消息总数)加一。
  2. 查户口:检查校验和。

如果校验和错了,这包肯定有问题。内核会更新 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 装不下。
  • 处理:
    1. 先调用 pskb_may_pull() 确保包里有足够的数据供读取。
    2. 然后调用 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 代码,很有意思:

c
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 消息主要靠两个函数:

  1. icmpv6_send():通用发送函数,用于发送各种错误消息(不可达、超时等)。
  2. icmpv6_echo_reply():专门用于回复 Ping 请求。

icmpv6_send() 的原型长得和 IPv4 版很像:

c
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):

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):

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() 中:

c
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() 中:

c
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() 会跳过速率限制检查:

  1. 发送信息类消息(Informational messages),比如 Echo Reply。因为这些通常不是错误,不会触发风暴。
  2. PMTU 发现相关的消息(ICMPV6_PKT_TOOBIG)。因为这种消息必须及时传达,如果被限流丢掉了,发送端就永远不知道正确的 MTU 是多少,连接就会彻底断掉。
  3. 发给 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 时,内核会检查:

  1. 这个用户所在的组是否在 /proc/sys/net/ipv4/ping_group_range 定义的范围内?

这个 procfs 条目默认是 1 0,这意味着「没人能用」。 如果你想允许 GID 为 1000 的用户使用 Ping,可以执行:

bash
echo 1000 1000 > /proc/sys/net/ipv4/ping_group_range

如果你想允许所有人用(除了纯 Root),可以把上限设为最大值:

bash
echo 0 2147483647 > /proc/sys/net/ipv4/ping_group_range

安全性检查 ​

ping_supported() 函数会确保这个 Socket 只能用来发送标准的 Echo 请求:

c
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 的细节,但那是「控制平面」的,真正的「数据平面」——那些实实在在的用户数据包是如何被路由、被分片、被转发的——才是网络层最浩瀚的工程。

基于 VitePress 构建