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 塞进去的。
⚠️ 踩坑预警:注意字节序。这里的
w1到w4是网络字节序(__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 变体)。 - 它会做两件事:
- 在网卡硬件层面设置过滤,让网卡接收发往这个组播地址的帧。
- 在内核的
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。
它的主要工作是「安检」:
- 版本号是不是 6?
- 包长是不是小于基本的 IPv6 头部长度?
- 如果有问题,直接丢弃并发送 ICMPv6 Parameter Problem。
- 如果没问题,调用
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_HOP | 0 | Hop-by-Hop Options header。必须紧跟在 IPv6 头部之后,所有路由器都要看。 |
NEXTHDR_TCP | 6 | TCP segment。老朋友了。 |
NEXTHDR_UDP | 17 | UDP message。DNS 和很多游戏协议都在这上面。 |
NEXTHDR_IPV6 | 41 | IPv6 in IPv6(隧道协议)。当你看到这个时,说明你剥开了一层 IPv6,里面还有一层 IPv6。 |
NEXTHDR_ROUTING | 43 | Routing header(源路由)。用于指定数据包必须经过的中间节点。 |
NEXTHDR_FRAGMENT | 44 | Fragment header(分片)。IPv6 的分片只能由源发,这个头记录了分片信息。 |
NEXTHDR_GRE | 47 | GRE header。VPN 常用。 |
NEXTHDR_ESP | 50 | Encapsulating Security Payload(IPsec 的一部分)。加密数据。 |
NEXTHDR_AUTH | 51 | Authentication Header(IPsec 的另一部分)。校验数据完整性。 |
NEXTHDR_ICMP | 58 | ICMP for IPv6。NDP 和 MLD 都在这里面。 |
NEXTHDR_NONE | 59 | No next header。这表示后面没东西了。这在某些选项或安全包头中用来表示「到此为止」。 |
NEXTHDR_DEST | 60 | Destination Options header。只有最终目的地才需要处理的信息。 |
表 8-3:MLDv2 组播地址记录类型
这是 MLDv2 报告消息里用到的类型码,用来精确描述主机对某个组播组的「态度」。
| Linux 符号 | 值 | 描述 |
|---|---|---|
MLD2_MODE_IS_INCLUDE | 1 | Include 模式:我只想收 Sources List 里列出的源发来的包。 |
MLD2_MODE_IS_EXCLUDE | 2 | Exclude 模式:我想收这个组的所有包,除了 Sources List 里的。 |
MLD2_CHANGE_TO_INCLUDE | 3 | 切换到 Include 模式。 |
MLD2_CHANGE_TO_EXCLUDE | 4 | 切换到 Exclude 模式。 |
MLD2_ALLOW_NEW_SOURCES | 5 | 允许新源加入(在 Include 模式下追加列表)。 |
MLD2_BLOCK_OLD_SOURCES | 6 | 阻断旧源(在 Include 模式下删除列表)。 |
表 8-4:ICMPv6 Parameter Problem 代码
当内核遇到解析错误时,它会发送「Parameter Problem」消息。这个 Code 字段告诉对方到底哪里错了。
| Linux 符号 | 值 | 描述 |
|---|---|---|
ICMPV6_HDR_FIELD | 0 | Erroneous header field。也就是头部字段里有烂货。 |
ICMPV6_UNK_NEXTHDR | 1 | Unrecognized Next Header type。内核收到了一个它不认识的 nexthdr 类型,不知道该怎么剥下一层。 |
ICMPV6_UNK_OPTION | 2 | Unrecognized 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 与内核通信。
-
添加路由 (
ip -6 route add ...):- 用户态发送 Netlink 消息。
- 内核侧
net/ipv6/route.c收到消息,调用inet6_rtm_newroute()。 inet6_rtm_newroute()解析参数,最终调用ip6_route_add()把路由条目塞进 FIB 表。
-
删除路由 (
ip -6 route del ...):- 用户态发送 Netlink 消息。
- 内核侧调用
inet6_rtm_delroute()。 - 最终调用
ip6_route_del()去掉那条路由。
-
查看路由 (
ip -6 route show):- 内核侧调用
inet6_dump_fib(),把整个 FIB 表遍历一遍,通过 Netlink 把每一条目吐给用户空间。
- 内核侧调用
用 route 命令(老派做法)
这是经典的 ioctl 方式。虽然 route 命令本身已经很老了,但内核为了兼容性还保留着这部分代码。
-
添加路由 (
route -A inet6 add ...):route工具调用ioctl(sockfd, SIOCADDRT, &rt)。- 内核的
ipv6_route_ioctl()捕获这个请求。 - 它内部依然是调用
ip6_route_add()做正事。
-
删除路由 (
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_dev、mc_list 和 fib6_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 网络运维中最常见的故障点。