跳到主要内容

8.9 速查表与内核碎片(Quick Reference)

我们在这本书里经常说:「别死记硬背,要去理解机制。」但在你真正深入内核代码之前,手里有一张准确的「地图」还是很有必要的。这一节就是那张地图。

前面八节我们拆解了 IPv6 的接收、发送、路由、组播和自动配置。现在,我们要把这些散落在 net/ipv6/ 目录各处的函数调用关系收拢一下。这不是那种只给你函数原型的无聊 API 手册——我会尽量把这些函数背后的「上下文」也讲清楚,让你知道它们在整个流程的大棋局里到底坐在哪个位置。

最后,我们会整理几个关键的常量表和几个容易被忽略的「特殊角落」。


核心方法——IPv6 内核的工具箱

先来看看我们在代码里最常碰到的那几个内核方法。它们有的负责摆弄数据结构,有的负责触发关键的协议动作。

1. 地址检查与操作

很多时候你拿到一个 struct in6_addr *,第一件事就是想知道它是不是个「合法的」或者「特殊的」地址。

bool ipv6_addr_any(const struct in6_addr *a)

这是最基础的检查。它判断给定的地址是不是全零地址(::)。

为什么这很重要? 当你初始化一个 socket 或者还没绑定具体地址时,内核经常用全零地址表示「任意地址」或「未指定」。如果你在处理路由查找时传入全零地址,路由子系统会直接崩溃(或者查到一条默认路由,取决于上下文)。所以,拿到地址先问一句「你是 any 吗?」,通常是保命的第一步。

bool ipv6_addr_equal(const struct in6_addr *a1, const struct in6_addr *a2)

直接比较两个 in6_addr 结构体是否相等。这比 memcmp 稍微快一点,而且语义更清晰——它明确告诉读代码的人:这是在比 IPv6 地址。

static inline void ipv6_addr_set(struct in6_addr *addr, __be32 w1, __be32 w2, __be32 w3, __be32 w4)

这是一个「手动挡」函数。in6_addr 本质上是 128 位的一坨数据(通常是 4 个 32 位整数)。如果你想手动拼凑一个地址——比如拼一个 link-local 地址的前缀——这个函数就是用来把这 4 个 u32 塞进去的。

⚠️ 踩坑预警:注意字节序。这里的 w1w4 是网络字节序(__be32)。如果你直接填本地整数,在 PC 上通常没问题(因为 x86 是小端),但移植到某些大端架构或者嵌入式板上时,你会得到一个反过来的地址。

bool ipv6_addr_is_multicast(const struct in6_addr *addr)

判断是不是组播地址。实现的原理非常粗暴:检查前 8 位是不是 0xFF。如果是,返回 true。 不用自己写 (addr->s6_addr[0] == 0xff),用这个宏或函数,不仅代码整洁,而且如果将来标准改了(虽然不太可能),内核改一处就行了。


2. 数据包解剖——从 SKB 中提取 IPv6 信息

一旦数据包到达了协议栈,它就被塞进了 struct sk_buff(SKB)。你需要从 SKB 里把 IPv6 头部「掏」出来。

struct ipv6hdr *ipv6_hdr(const struct sk_buff *skb)

这是最常用的「取头」操作。它返回 SKB 里的 IPv6 头部指针。

注意:这个函数假设 SKB 已经被正确地设置了网络层头指针(skb->network_header)。在 Netfilter 钩子里或者接收路径的早期阶段,这个通常是安全的。但如果你在一个奇怪的角落(比如某个驱动程序的回调里)拿到 SKB,指针可能还没归位,用这个之前最好先确认 skb->network_header 是否合法。

bool ipv6_ext_hdr(u8 nexthdr)

我们在前面讲过,IPv6 的 nexthdr 字段既可能是上层协议(TCP/UDP),也可能是下一个扩展头。这个函数帮你做判断:如果 nexthdr 的值是已知的扩展头类型(比如 Hop-by-Hop, Routing, Fragment 等),它就返回 true

这在你遍历扩展头链表时非常有用——你需要知道什么时候该停(遇到传输层协议),什么时候继续(遇到扩展头)。


3. 协议注册与分发——让内核认得你的协议

如果你想写一个新的协议(比如你自己在 UDP 之上搞了一层),或者你想写一个 Netfilter 模块来精确匹配某个扩展头,你会碰到这些。

int inet6_add_protocol(const struct inet6_protocol *prot, unsigned char protocol)

这是向内核注册协议处理器的核心函数。

  • 如果你写的是 TCPv6,你会把 protocol 设为 IPPROTO_TCP
  • 如果你写的是 ICMPv6,就是 IPPROTO_ICMPV6
  • 但有趣的是,Fragment 扩展头也是通过这个函数注册的(值为 NEXTHDR_FRAGMENT)。

调用这个函数后,内核会把你的处理函数指针填入全局的 inet6_protos 数组。之后,当 IPv6 核心代码解析完头部,发现 nexthdr 是你注册的值时,就会直接 call 你的函数。这是典型的「发布-订阅」模式。


4. MLD 与组播——当主机想要入会

我们在前面花了很多篇幅讲 MLDv2。内核里那些负责把网卡加入组播组,或者向外发送 MLD 报告的逻辑,就在这些函数里。

bool ipv6_is_mld(struct sk_buff *skb, int nexthdr, int offset)

这是一个很贴心的辅助函数。它帮你检查:这个包是不是 ICMPv6?并且,ICMPv6 的类型是不是 MLD 相关的? 它会检查以下四种类型之一,只要命中一个就返回 true

  • ICMPV6_MGM_QUERY (MLD 查询)
  • ICMPV6_MGM_REPORT (MLDv1 报告)
  • ICMPV6_MGM_REDUCTION (离开组播)
  • ICMPV6_MLD2_REPORT (MLDv2 报告)

int ipv6_dev_mc_inc(struct net_device *dev, const struct in6_addr *addr)

这是「让网卡听组播」的底层实现。

  • 当你在用户空间调用 setsockopt(..., IPV6_ADD_MEMBERSHIP, ...) 时,内核最终会走到这里(或者走到它的 socket 变体)。
  • 它会做两件事:
    1. 在网卡硬件层面设置过滤,让网卡接收发往这个组播地址的帧。
    2. 在内核的 inet6_dev 结构里维护一个软件列表,记录这个接口属于哪些组。

int ipv6_sock_mc_join(struct sock *sk, int ifindex, const struct in6_addr *addr)

这是用户空间 IPV6_JOIN_GROUP 的内核入口。它不仅调用上面的 _inc 来更新硬件,还会把这个 socket 挂到该组的成员列表上,这样当组播数据包进来时,内核才知道要把包复制给谁。


5. 接收与转发——数据包的旅途

int ipv6_rcv(struct sk_buff *skb, struct net_device *dev, struct packet_type *pt, struct net_device *orig_dev)

这是 IPv6 协议栈的「总入口」。当驱动程序收到一个 IPv6 包(以太网类型为 0x86DD)并交给内核后,netif_receive_skb() 最终会把这个包扔给 ipv6_rcv

它的主要工作是「安检」:

  1. 版本号是不是 6?
  2. 包长是不是小于基本的 IPv6 头部长度?
  3. 如果有问题,直接丢弃并发送 ICMPv6 Parameter Problem。
  4. 如果没问题,调用 ip6_rcv_finish() 进入下一步处理(路由查找)。

int ip6_forward(struct sk_buff *skb)

这是转发路径的核心。只有当这台机器被配置成路由器(/proc/sys/net/ipv6/conf/all/forwarding = 1)时,数据包才会走到这里。

它会做几件关键的事:

  • 跳数限制(Hop Limit)检查:如果 TTL 减到了 0,发 ICMPv6 Time Exceeded,然后丢弃包。
  • PMTU 检查:如果包太大,超过了下一跳的 MTU,它会发 ICMPv6 Packet Too Big,并丢弃包(IPv6 中间路由器绝不分片)。
  • 发送 ICMPv6 Redirect:如果它发现源主机选路很蠢(明明走本网段的另一个接口更近),它会调用 ndisc_send_redirect() 喊源主机一声。

6. 路由查找——问路

void ip6_route_input(struct sk_buff *skb)

这是「输入路由查找」。当包是发给本机,或者需要本机转发时,内核需要知道:「这个包到底该去哪?」 它通过调用底层的 fib6_lookup() 在 FIB(Forwarding Information Base)表中搜索,并把结果(一个 dst_entry)挂在 SKB 上。

Note:查找的结果不仅仅是「本地投递」或「转发」;对于转发包,它还决定了下一跳的 MAC 地址是什么。

struct dst_entry *ip6_route_output(struct net *net, const struct sock *sk, struct flowi6 *fl6)

这是「输出路由查找」。当本机(比如 Apache 或者你的客户端程序)要发包时,它会问内核:「我要连这个 IP,该从哪个接口出去?下一跳是谁?」 它同样调用 fib6_lookup(),但是是在发送路径的上下文中。


宏与常量表——协议的通用语

内核代码里充满了各种魔数。为了不让人看疯头,内核用宏把这些魔数包装了一下。以下是我们在处理 IPv6 头部和组播时最常碰到的几个表。

表 8-2:IPv6 扩展头部与 Next Header 值

这是 IPv6 头部里 nexthdr 字段的所有可能取值。注意,扩展头和上层协议(TCP/UDP)共用这个命名空间。

Linux 符号描述
NEXTHDR_HOP0Hop-by-Hop Options header。必须紧跟在 IPv6 头部之后,所有路由器都要看。
NEXTHDR_TCP6TCP segment。老朋友了。
NEXTHDR_UDP17UDP message。DNS 和很多游戏协议都在这上面。
NEXTHDR_IPV641IPv6 in IPv6(隧道协议)。当你看到这个时,说明你剥开了一层 IPv6,里面还有一层 IPv6。
NEXTHDR_ROUTING43Routing header(源路由)。用于指定数据包必须经过的中间节点。
NEXTHDR_FRAGMENT44Fragment header(分片)。IPv6 的分片只能由源发,这个头记录了分片信息。
NEXTHDR_GRE47GRE header。VPN 常用。
NEXTHDR_ESP50Encapsulating Security Payload(IPsec 的一部分)。加密数据。
NEXTHDR_AUTH51Authentication Header(IPsec 的另一部分)。校验数据完整性。
NEXTHDR_ICMP58ICMP for IPv6。NDP 和 MLD 都在这里面。
NEXTHDR_NONE59No next header。这表示后面没东西了。这在某些选项或安全包头中用来表示「到此为止」。
NEXTHDR_DEST60Destination Options header。只有最终目的地才需要处理的信息。

表 8-3:MLDv2 组播地址记录类型

这是 MLDv2 报告消息里用到的类型码,用来精确描述主机对某个组播组的「态度」。

Linux 符号描述
MLD2_MODE_IS_INCLUDE1Include 模式:我只想收 Sources List 里列出的源发来的包。
MLD2_MODE_IS_EXCLUDE2Exclude 模式:我想收这个组的所有包,除了 Sources List 里的。
MLD2_CHANGE_TO_INCLUDE3切换到 Include 模式。
MLD2_CHANGE_TO_EXCLUDE4切换到 Exclude 模式。
MLD2_ALLOW_NEW_SOURCES5允许新源加入(在 Include 模式下追加列表)。
MLD2_BLOCK_OLD_SOURCES6阻断旧源(在 Include 模式下删除列表)。

表 8-4:ICMPv6 Parameter Problem 代码

当内核遇到解析错误时,它会发送「Parameter Problem」消息。这个 Code 字段告诉对方到底哪里错了。

Linux 符号描述
ICMPV6_HDR_FIELD0Erroneous header field。也就是头部字段里有烂货。
ICMPV6_UNK_NEXTHDR1Unrecognized Next Header type。内核收到了一个它不认识的 nexthdr 类型,不知道该怎么剥下一层。
ICMPV6_UNK_OPTION2Unrecognized IPv6 option。在解析 Hop-by-Hop 或 Destination Options 时,遇到了未知的 Option。

特殊地址——那些「不能动」的常量

内核用几个全局的 struct in6_addr 实例来定义那些几乎在每个网卡上都会出现的特殊地址。你可以直接引用它们,而不需要自己填数组。

注意:这些实例都是只读的,直接用来比较或赋值即可。

  • in6addr_any:全零地址 ::。代表「任意地址」或「未指定」。监听 socket 时常用。
  • in6addr_loopback:环回地址 ::1。永远代表本机。
  • in6addr_linklocal_allnodes:链路本地所有节点地址 ff02::1。发给这个地址,本链路所有启用 IPv6 的主机都能收到。
  • in6addr_linklocal_allrouters:链路本地所有路由器地址 ff02::2。只有路由器才监听这个。
  • in6addr_interfacelocal_allnodes:接口本地所有节点 ff01::1
  • in6addr_interfacelocal_allrouters:接口本地所有路由器 ff01::2
  • in6addr_sitelocal_allrouters:站点本地所有路由器 ff05::2。注意,Site-Local 地址(fec0::/10)已经被 RFC 3879 废弃了,但为了兼容性,内核里还留着这些常量。

路由表管理——用户空间命令如何落进内核

我们在 Linux 上配置路由,一般用两个工具:古老的 route (net-tools) 和现代的 iproute2 (ip 命令)。这两个工具虽然界面不同,但在内核底层的殊途同归。

iproute2 添加/删除路由

这是现在推荐的方式。它通过 Netlink Socket 与内核通信。

  1. 添加路由 (ip -6 route add ...):

    • 用户态发送 Netlink 消息。
    • 内核侧 net/ipv6/route.c 收到消息,调用 inet6_rtm_newroute()
    • inet6_rtm_newroute() 解析参数,最终调用 ip6_route_add() 把路由条目塞进 FIB 表。
  2. 删除路由 (ip -6 route del ...):

    • 用户态发送 Netlink 消息。
    • 内核侧调用 inet6_rtm_delroute()
    • 最终调用 ip6_route_del() 去掉那条路由。
  3. 查看路由 (ip -6 route show):

    • 内核侧调用 inet6_dump_fib(),把整个 FIB 表遍历一遍,通过 Netlink 把每一条目吐给用户空间。

route 命令(老派做法)

这是经典的 ioctl 方式。虽然 route 命令本身已经很老了,但内核为了兼容性还保留着这部分代码。

  1. 添加路由 (route -A inet6 add ...):

    • route 工具调用 ioctl(sockfd, SIOCADDRT, &rt)
    • 内核的 ipv6_route_ioctl() 捕获这个请求。
    • 它内部依然是调用 ip6_route_add() 做正事。
  2. 删除路由 (route -A inet6 del ...):

    • 调用 ioctl(sockfd, SIOCDELRT, &rt)
    • ipv6_route_ioctl() 处理,内部调用 ip6_route_del()

结论:不管你用哪种工具,最底层干活的那两个函数永远是一样的。这再次印证了 Linux 内核设计的分层思想——用户接口可以变,但核心逻辑不动。


本章回响

好了,我们终于走完了 IPv6 这一整章——从最初的 128 位地址设计,到邻居发现的那些细碎报文,再到组播的源过滤和今天的这份速查表。

这章真正留给你的东西,应该不是这几十个函数的名字,而是对 IPv6 「大一统」 设计的体会。 你看,IPv4 里的 ARP、RARP、ICMP Redirect、IGMP……这些散落在各处的协议,在 IPv6 里统统被归拢到了 ICMPv6 这一面大旗下。NDP 是 ICMPv6,MLD 是 ICMPv6,连报错都是 ICMPv6。这种统一让协议栈变得更瘦、更高效,但也让每一个 ICMPv6 包的解析变得更加关键——漏看一个类型,可能就意味着丢了一个关键的邻居信息。

我们花了很多时间在 inet6_devmc_listfib6_table 这些结构体里打转,是因为网络协议不只是理论上的状态机,它是实实在在的内核内存结构。如果你不知道组播过滤器挂在哪个链表上,当你调试丢包问题时,你就只会对着 tcpdump 抓下来的报文发呆,而不知道去内核代码里的哪个 if 语句里加断点。

下一章,我们将跨过协议栈的处理层,进入那个传说中的「网络大过滤器」——Netfilter。那是内核网络栈里最险峻的一段路,也是防火墙、NAT、Conntrack 这些神奇功能的发生地。你会看到,当一个数据包终于被解析完 IPv6 头部,正准备欢天喜地交给 socket 时,是如何被 Netfilter 这只「上帝之手」一把抓住,审判生死的。

那将是一场关于拦截与改写的游戏。


练习题

练习 1:understanding

题目:在 IPv6 协议栈的初始化过程中,内核需要将特定的协议处理逻辑(如 TCPv6、UDPv6 或扩展头处理)注册到系统中。假设你是内核开发者,需要编写一个模块来处理一个新的 IPv6 扩展头(Next Header 号为 N)。你应该调用哪个函数来完成注册?该函数的签名是什么,以及它会将处理逻辑存储在内核的哪个全局数据结构中?

答案与解析

答案:应调用 inet6_add_protocol() 函数。其签名为 int inet6_add_protocol(const struct inet6_protocol *prot, unsigned char protocol)。该处理逻辑会被存储在全局数组 inet6_protos[] 中。

解析:根据知识点 inet6_add_protocol(),这是内核用于注册 IPv6 协议处理器(包括传输层协议和扩展头)的标准函数。注册时,协议号(如 IPPROTO_UDP 或扩展头标识)作为索引,将 struct inet6_protocol 指针存入 inet6_protos 数组。这样当 ipv6_rcv 解析数据包时,就能通过 nexthdr 字段找到对应的处理回调函数。

练习 2:application

题目:一个主机刚刚生成了一个链路本地 IPv6 地址。在进行重复地址检测(DAD)通过之前,该地址处于“临时”状态。为了验证该地址的唯一性,邻居发现协议需要使用一种特殊的组播地址。如果主机的链路本地地址是 fe80::2aa:ff:fe3f:4a21(忽略 ff:fe 插入位),那么它应该加入哪个请求节点组播地址来进行 DAD 验证?请写出计算过程和结果。

答案与解析

答案:结果为 ff02::1:ff3f:4a21。计算过程:取单播地址的低 24 位(3f:4a:21),加上前缀 ff02:0:0:0:0:1:ff00::/104

解析:根据知识点 Solicited-Node Multicast Address,请求节点组播地址用于邻居发现和 DAD。其生成规则是将单播/任播地址的低位 24 位拼接到固定前缀 ff02:0:0:0:0:1:ff00::/104 上。题目中地址低位 24 位为 3f:4a:21,拼接后即得 ff02::1:ff3f:4a21。主机发送邻居请求报文到此地址,若有其他主机拥有该地址,会响应邻居通告。

练习 3:thinking

题目:IPv6 的“自动配置”允许主机无状态生成 IP 地址。然而,标准的 EUI-64 接口 ID 生成方式(基于 MAC 地址)可能导致用户隐私被追踪。Linux 内核提供了一种名为“Privacy Extensions”的机制来解决这个问题。请结合 RFC 4941 分析,为什么基于 MAC 地址生成 IPv6 接口 ID 存在隐私风险?内核是如何通过 Privacy Extensions 机制缓解这一风险的?

答案与解析

答案:基于 MAC 地址生成接口 ID 会导致接口 ID 固定不变,且 MAC 地址是全球唯一的硬件标识。只要用户接入网络,其 IPv6 地址的后半部分始终不变,使得跨网络的用户行为追踪变得容易。Privacy Extensions(RFC 4941)机制通过生成随机的接口 ID(而非基于 MAC)来创建临时地址,并且这些临时地址会定期过期和更新,从而增加了追踪难度,保护了用户隐私。

解析:此题考察对“知识点 Autoconfiguration”和“Privacy Extensions”的深度理解。IPv6 的无状态自动配置通常结合 MAC 地址生成接口 ID(如 EUI-64),这虽然在地址唯一性上很方便,但也暴露了设备身份。Privacy Extensions 的核心在于“去关联性”,通过随机化接口 ID 并引入生命周期(Preferred Lifetime),使得同一个设备在不同时间段或不同网络中使用不同的 IP 地址,从而防止被持续监控。这是在保持网络层连通性同时,提升用户安全性的典型设计。


要点提炼

IPv6 的内核实现建立在数据结构 in6_addr 和全新的地址分类体系之上。通过使用 Union(联合体)将 128 位地址划分为 8 位、16 位和 32 位视图,内核能够高效地处理不同粒度的内存操作与位运算。同时,IPv6 彻底摒弃了广播,转而采用单播、任播和组播三种模式。特别是引入了“被请求节点组播地址”(Solicited-Node Multicast Address),它通过将地址映射到特定的 ff02::1:ffxx:xxxx 组播组,使得邻居发现过程只在极小范围内进行,从而极大地解决了 IPv4 时代 ARP 广报带来的“以太网噪音”问题。

协议头部设计体现了 IPv6 “精简骨架、灵活扩展” 的工程哲学。IPv6 头部被固定为 40 字节,去除了校验和(Checksum)以减轻路由器负担,并将所有可选功能移出。通过 nexthdr 字段,IPv6 引入了“扩展头部”的链式处理机制(如 Hop-by-Hop、Routing、Fragment),每个头部像链表节点一样串联。这种设计使得中间路由器只需处理 IPv6 主头和极个别的逐跳选项,极大地提升了转发效率,实现了从“复杂变长结构”到“固定快速处理”的回归。

自动配置(Autoconfiguration)机制赋予了主机“即插即用”的能力,无需 DHCP 服务器即可生成全球路由地址。这一过程分为四个阶段:首先生成链路本地地址并进行重复地址检测(DAD);接着发送路由器请求(RS);然后接收包含前缀信息的路由器公告(RA);最后结合接口 ID(通常基于 MAC 地址或随机生成的隐私扩展)合成全球单播地址。配合 Valid Lifetime 和 Preferred Lifetime 机制,网络管理员可以通过调整 RA 参数实现全网前缀的平滑无缝重编号。

在数据包接收路径 ipv6_rcv() 中,内核通过严格的“安检”规则来维护协议的纯洁性。函数首先会丢弃版本号错误、目标地址为回环地址或源地址为组播地址的非法数据包,并强制解析 Hop-by-Hop 扩展头。随后,数据包通过 Netfilter 钩子进入 ip6_rcv_finish(),在这里执行路由查找(fib6_lookup)。根据查找结果,内核决定数据包的命运:是交给本地协议栈(ip6_input)进行扩展头剥洋葱式解析并分发至上层,还是进行转发(ip6_forward)。

分片处理机制的变革是 IPv6 区别于 IPv4 的关键特性之一,同时也带来了潜在的 MTU 陷阱。IPv6 禁止中间路由器进行分片,所有分片行为必须在源主机完成。如果在传输路径上遇到 MTU 更小的链路,路由器会直接丢弃数据包并回传 ICMPv6 “Packet Too Big” 消息,强制源主机进行 Path MTU Discovery(PMTUD)。因此,在配置防火墙或路由时,必须确保不阻断 ICMPv6 流量,否则会导致大包连通性静默失败,这是 IPv6 网络运维中最常见的故障点。