Skip to content

第 8 章 当地址不再是稀缺资源 ​

章节引子:历史包袱与新起点 ​

有一个数字像幽灵一样盘旋在网络工程的上空:$2^{32}$。

如果你对网络稍有了解,这个数字不需要解释——它是 IPv4 地址的理论总量。而在现实世界中,由于地址分配的低效和历史的碎片化,我们在 2011 年就正式宣布 IPv4 地址池耗尽了。从那以后,互联网就像是一个靠透析维持生命的病人——靠 NAT(网络地址转换)勉力维持着连接。

但 NAT 本质上是一个 Hack。它打破了互联网端到端的原始设计哲学,让 P2P 通信变得复杂,让协议设计变得笨拙。

本章我们要聊的 IPv6,不仅仅是把地址从 32 位拉长到了 128 位那么简单。它是站在 IPv4 这位巨人(或者说老兵)的肩膀上,吸收了三十年互联网运行经验后的重构。

你会发现,IPv6 的内核代码里充满了熟悉的影子——变量名往往只多了一个 "v6",函数名只是换了个前缀。但这种相似性具有欺骗性。在这份熟悉感之下,IPv6 进行了彻底的底层手术:它抛弃了那些在 IPv4 中不得不保留的陈旧选项,重新定义了数据包的头部结构,甚至从根本上改变了主机接入网络的方式。

这一章,我们不仅仅是要学习“怎么配置 IPv6”,更是要理解在这个新协议下,内核是如何重新思考路由、组播和自动配置的。我们会深入内核源码,看看 Linux 是如何通过扩展头实现灵活性,又是如何通过 MLD 协议管理组播成员的。

理解了这些,你才算真正理解了下一代互联网协议在操作系统内部的脉搏。


8.1 IPv6:不仅仅是更长 ​

进入内核的 IPv6 子系统,你会发现这是一个正在野蛮生长的领域。

说实话,看这部分代码的时候会有一种既视感——仿佛在看 IPv4 代码的重制版。这种感觉很正常。在过去的十年里,IPv6 增加了很多令人兴奋的特性,但其中不少是为了保持兼容性或者过渡期体验而设计的。比如 ICMPv6 sockets、IPv6 组播路由,甚至是最初设计者发誓不用的 IPv6 NAT。

最微妙的是 IPsec(IP 安全层)。在 IPv6 的设计初稿里,IPsec 是强制性的——每个 IPv6 节点都必须支持加密。但在现实世界里,操作系统最终还是在 IPv4 里也实现了 IPsec,而 IPv6 里也保留了作为“可选项”的灵活性。

这告诉我们一件事:工程实践总是比理论设计更复杂。

不过,当我们真的深入内核,剥去那些相似的外壳,你会发现 IPv6 绝不仅仅是在 IPv4 后面加个补丁。开发者们利用这几十年的后见之明,对协议栈进行了不少修正。

  • 头部简化:路由器不再处理分片,所有脏活累活都丢给了源主机。
  • 扩展头部:用链式结构代替了 IPv4 那些死板的选项字段,让协议扩展变得灵活。
  • 自动配置:主机终于可以不靠 DHCP 服务器也能拿到一个全球可路由的地址了。

我们这趟旅程会覆盖这些关键点:扩展头部、MLD 协议、自动配置,以及内核是如何处理 IPv6 数据包的收发。

在深入机制之前,我们得先搞清楚“门牌号”是怎么写的。毕竟,在 IPv6 的世界里,地址空间的量级已经从“一粒沙子”变成了“整个地球上的原子”。

如果你对 inet_sock 这种 IPv4 结构体印象深刻,那么接下来见到的 in6_addr 和 ipv6hdr 会让你感到亲切——但请注意,地址的语义已经发生了本质的变化。

准备好了吗?我们先把那个最著名的概念拆解开来看。


IPv6 地址:从门牌号到坐标 ​

在 IPv4 时代,我们习惯把地址看作 32 位的整数。但在 IPv6 里,地址是一个 128 位的怪兽。光是把它打印出来,就需要 8 组由冒号分隔的十六进制数。

内核里是用什么来装这个大家伙的?

它在 include/uapi/linux/in6.h 里,定义得非常直白:

c
struct in6_addr {
    union {
        __u8    u6_addr8[16];
        __be16  u6_addr16[8];
        __be32  u6_addr32[4];
    } in6_u;
#define s6_addr   in6_u.u6_addr8
#define s6_addr16 in6_u.u6_addr16
#define s6_addr32 in6_u.u6_addr32
};

这是一个教科书式的 Union(联合体) 用法。你会发现内核为了应对不同的操作场景,把这个 128 位的空间切成了三种视角:

  • u6_addr8:当你需要按字节操作(比如拷贝内存)时用。
  • u6_addr16:当你需要处理 16 位分段时用。
  • u6_addr32:当你需要进行按位运算或掩码操作时用。

这种设计在内核网络代码里很常见——为了性能,程序员总是希望能直接操作字长,而不是在那儿做移位运算。

有了数据结构,接下来就是分类。

地址的三种面孔 ​

IPv6 地址的类型划分比 IPv4 要清晰得多,但初学者容易在一个地方卡壳:单播、任播 和 组播。

这不仅仅是名字的区别,它们决定了数据包最终会去哪里。

1. 单播 最常见的情况。一个地址对应一个接口(网卡)。发往单播地址的数据包,目标只有一个——那个唯一的接口。

这就像你给家里寄信,地址精确到门牌号。

2. 任播 这是 IPv6 引入的一个非常聪明的概念。一个地址被分配给一组接口(通常分布在不同的路由器或节点上)。当你发送数据包到一个任播地址时,路由协议会通过度量距离,把这个包送到最近的那个接口。

你可以把任播想象成“连锁店”。你找的是“麦当劳”这个品牌(任播地址),但你只会走进离你最近的那一家(最近的路由器)。

3. 组播 以 ff 开头的地址。这是 IPv4 组播的进化版。发往组播地址的数据包,会被该组内的所有成员接收。

这就像广播电台,只要调到这个频率,所有人都能收到同样的信号。

特殊地址空间 ​

除了这三类,IPv6 还保留了一大片“特区”用于特殊用途。如果你在抓包工具里看到这些前缀,现在你应该知道它们在干什么:

前缀:fe80::/64

这是 IPv6 最重要的“救命稻草”。 即使你没有配置任何 IP,没有连接 DHCP 服务器,只要你的网卡开启了 IPv6,它就会自动给自己生成一个 fe80 开头的地址。

它的规则很死:这种地址仅在当前链路(同一条网线或同一个 Wi-Fi)上有效。

  • 路由器绝不会转发源地址或目标地址是 Link-local 的数据包。
  • 它是邻居发现协议(NDP)和自动配置的基础。

类比回收: 记得之前提到的“连锁店”吗?Link-local 地址就像是这家店里的内部对讲机。你只能用它跟同一家店里的人说话,想打给隔壁街的店?没门。

Global Unicast Address (全球单播地址) ​

这才是 IPv6 的“正规军”。它是全球可路由的,通常由三部分组成,格式定义在 RFC 3587 中:

  1. 全局路由前缀:由你的 ISP 分配,标识你在互联网的大致位置。
  2. 子网 ID:你在本地局域网里划分子网的标识。
  3. 接口 ID:标识具体的网卡接口。

这玩意儿才是用来替代 192.168.x.x 或 8.8.8.8 的东西。

特殊的组播地址 ​

在组播的大类里,有几个地址是内核开发者必须烂熟于心的,因为它们硬编码在协议栈的逻辑里。

Solicited-Node Multicast Address (请求节点组播地址) ​

这是 IPv6 极其精彩的一个设计优化。

在 IPv4 时代,如果你想通过 ARP 问“谁是 192.168.1.1?”,你得广播给整个网段的所有人。这在只有几个设备时没问题,但如果网段里有 10000 台设备呢?每一台都要被这个无聊的问题吵醒。

IPv6 用 Solicited-Node 地址解决了这个效率问题。 格式是:ff02:0:0:0:0:1:ffXX:XXXX。 前面的 104 位是固定的,只有最后 24 位取自目标单播/任播地址。

这意味着什么? 这意味着每个设备只需要监听极少量的组播地址(通常只有一两个)。当你想找某个单播地址对应的 MAC 地址时,你不用喊叫全网,只需要向这个特定的 Solicited-Node 组播地址喊一声即可。噪音大大降低。

链路本地地址的再一次回收: 还记得 fe80 吗?它不仅是自动配置的起点,也是 Solicited-Node 组播的基础——所有的这些“悄悄话”,都是在链路本地地址的范围内进行的。

预定义的全局组播 ​

还有一些保留的组播地址,比如:

  • ff02::1:所有节点地址。路由器和主机都得听。
  • ff02::2:所有路由器地址。只有路由器听,主机忽略。

这也是为什么在 Linux 内核代码里,你会在接口初始化的时候看到类似 ipv6_dev_mc_inc() 这样的调用——它在启动时强行把网卡加入到这些必须监听的组里。

⚠️ 踩坑预警 千万别以为 Link-local 地址可以随便省略。 在配置防火墙或路由规则时,很多人习惯性地只关注 Global 地址,结果发现邻居发现协议挂了,Ping 不通。原因很简单:你把 fe80::/10 的流量给封了。 这就好比你在家里把电话线掐了,却还在奇怪为什么快递员(路由器)打不通你的电话。


IPv6 头部:精简的骨架 ​

我们来看一眼内核眼里的 IPv6 数据包长什么样。这是 include/uapi/linux/ipv6.h 里的定义:

c
struct ipv6hdr {
#if defined(__LITTLE_ENDIAN_BITFIELD)
    __u8            priority:4,
                    version:4;
#elif defined(__BIG_ENDIAN_BITFIELD)
    __u8            version:4,
                    priority:4;
#else
#error  "Please fix <asm/byteorder.h>"
#endif
    __u8            flow_lbl[3];

    __be16          payload_len;
    __u8            nexthdr;
    __u8            hop_limit;

    struct in6_addr saddr;
    struct in6_addr daddr;
};

跟 struct iphdr (IPv4) 对比一下,你会发现几个明显的变化:

  1. Version:依然是 4 位,值为 6。
  2. Priority:后来被重新定义为 Traffic Class (流量类别),用于 QoS。
  3. Flow Label (20 bit):flow_lbl。这是 IPv6 新增的字段,用于标记数据包流,让路由器能识别属于同一个 TCP 连接的包并给予相同处理(不检查上层头部的快速转发)。
  4. Payload Len:载荷长度。注意,这不再包括头部本身。
  5. Next Header:这是最关键的变化。它取代了 IPv4 的 Protocol 字段,但它更灵活。
  6. Hop Limit:相当于 IPv4 的 TTL,改了个更准确的名字。

为什么说 nexthdr 是核心?

在 IPv4 里,如果我想加个安全选项,我得塞到 IPv4 头部的 Options 字段里,路由器得费劲地把选项解析出来才能确认下一站是谁。这很慢。

IPv6 说:别搞那么复杂。 nexthdr 直接指着下一个“头部”的类型。它可能是 TCP(6),可能是 UDP(17),也可能是我们要讲到的 Extension Header(扩展头部)。

扩展头部:链式处理的智慧 ​

这是 IPv6 设计中最具“工程美感”的部分之一。

IPv6 头部被固定为 40 字节。如果你需要额外功能怎么办? 比如:

  • 你要分片。
  • 你要指定必经之路。
  • 你要加密(IPsec)。

IPv6 的答案是:把这些功能做成独立的“扩展头部”,一个接一个地串起来。

每一个扩展头部的第一个字节都是 Next Header 字段,指向下一个头部的类型。最后一个扩展头部的 Next Header 指向上层协议(如 TCP)。

[ IPv6 Header (NextHdr=Routing) ]
    -> [ Routing Header (NextHdr=TCP) ]
        -> [ TCP Segment ]

这种设计带来了两个巨大的好处:

  1. 路由器很轻松:除了极个别的扩展头(如 Hop-by-Hop),路由器根本不需要解析这些中间头部,直接跳过去。这就大大提升了路由器的转发速度。
  2. 无限扩展性:理论上你可以串任意长的头部链。

常见的几种扩展头部:

1. Hop-by-Hop Options Header ​

  • Next Header:通常指向 TCP/UDP 或其他扩展头。
  • 特点:唯一必须被路径上每个路由器都处理的扩展头。
  • 用途:常用于 Router Alert(告诉路由器“嘿,别光转发,看看这个包”)或 Jumbo Payload(超大载荷)。因为它强制所有路由器处理,所以性能开销很大,用得很少。

2. Routing Header ​

  • 类比:类似于 IPv4 的 Loose Source Route(宽松源站选路)。
  • 功能:指定数据包必须经过的中间路由器地址。
  • Type 0 被废弃:早期的 Routing Header Type 0 因为安全问题(可以用于反射攻击)已经被 RFC 5095 废弃。现在的 Routing Header Type 2 主要用于移动 IPv6。

3. Fragment Header ​

这是 IPv6 里最容易让人踩坑的地方。

重大变化:在 IPv6 中,中间路由器绝对不会分片。 如果路由器收到一个比 MTU 大的包,它不会像 IPv4 那样好心帮你切碎,而是直接扔掉,并回一个 ICMPv6 "Packet Too Big" 消息。

分片必须由源主机完成。 Fragment Header 包含了分片偏移、标识符等信息,跟 IPv4 类似,但它作为一个独立的扩展头部存在。

⚠️ 踩坑预警: MTU 问题是 IPv6 网络故障的头号杀手。 很多人把 IPv4 网络切到 IPv6 后,发现大包总是丢,小包能通。为什么? 因为他们在中间路由器上配置了错误的 MTU,或者在防火墙里把 ICMPv6 给禁了(导致 "Packet Too Big" 消息回不来)。源主机以为一路畅通,一直发大包,结果全在半路被无声丢弃。 千万别封杀 ICMPv6,这是 IPv6 正常工作的呼吸通道。

4. Destination Options Header ​

这个头很有趣。它只给最终目的地看。如果中间有 Routing Header,它可以出现在 Routing Header 之前;如果不需要分片,它直接跟在 IPv6 头部后面。 通常用于携带某些只有目标主机才关心的参数。


本节回响 ​

这一节我们建立了 IPv6 的基本视图:从那个巨大的 in6_addr 结构体,到三种主要的通信模式(单播、任播、组播),再到通过 nexthdr 链接起来的扩展头部机制。

看起来只是字段排列的重新组合,但内核的设计哲学在这里发生了转变:IPv6 假设路由器是聪明的,但也是懒惰的——它只做最基本的转发,把复杂的逻辑交给端点去处理。 这种“端到端”原则的回归,是 IPv6 相比于满是补丁的 IPv4 最本质的进步。

但要真正让这些地址和头部跑起来,内核还需要做一件事:路由。

下一节,我们将深入内核的路由子系统,看看 Linux 是如何通过 fib6_lookup() 在那个庞大的 128 位空间里找到下一站的。

基于 VitePress 构建