第 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 里,定义得非常直白:
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 还保留了一大片“特区”用于特殊用途。如果你在抓包工具里看到这些前缀,现在你应该知道它们在干什么:
Link-local Address (链路本地地址)
前缀:fe80::/64
这是 IPv6 最重要的“救命稻草”。
即使你没有配置任何 IP,没有连接 DHCP 服务器,只要你的网卡开启了 IPv6,它就会自动给自己生成一个 fe80 开头的地址。
它的规则很死:这种地址仅在当前链路(同一条网线或同一个 Wi-Fi)上有效。
- 路由器绝不会转发源地址或目标地址是 Link-local 的数据包。
- 它是邻居发现协议(NDP)和自动配置的基础。
类比回收: 记得之前提到的“连锁店”吗?Link-local 地址就像是这家店里的内部对讲机。你只能用它跟同一家店里的人说话,想打给隔壁街的店?没门。
Global Unicast Address (全球单播地址)
这才是 IPv6 的“正规军”。它是全球可路由的,通常由三部分组成,格式定义在 RFC 3587 中:
- 全局路由前缀:由你的 ISP 分配,标识你在互联网的大致位置。
- 子网 ID:你在本地局域网里划分子网的标识。
- 接口 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 里的定义:
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) 对比一下,你会发现几个明显的变化:
- Version:依然是 4 位,值为 6。
- Priority:后来被重新定义为 Traffic Class (流量类别),用于 QoS。
- Flow Label (20 bit):
flow_lbl。这是 IPv6 新增的字段,用于标记数据包流,让路由器能识别属于同一个 TCP 连接的包并给予相同处理(不检查上层头部的快速转发)。 - Payload Len:载荷长度。注意,这不再包括头部本身。
- Next Header:这是最关键的变化。它取代了 IPv4 的 Protocol 字段,但它更灵活。
- 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 ]
这种设计带来了两个巨大的好处:
- 路由器很轻松:除了极个别的扩展头(如 Hop-by-Hop),路由器根本不需要解析这些中间头部,直接跳过去。这就大大提升了路由器的转发速度。
- 无限扩展性:理论上你可以串任意长的头部链。
常见的几种扩展头部:
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 位空间里找到下一站的。