去年底我给自己定了一个任务:把 Linux 6.19 的 net/ipv6/addrconf.c 完整读一遍。说实话这个文件我早就想啃,但一直没下定决心,因为地址配置这块涉及的状态机、定时器、netlink 回调纠缠在一起,光看代码很容易绕晕。后来我借助 DeepSeek 先做了一轮函数关系梳理,再回到主线源码逐段核实,才把整个文件串了起来。这篇博客就是这次源码分析沉淀下来的笔记,核心关注 IPv6 地址从生成、检测、使用到回收的完整链路。适合的人群有两类:一是想搞懂 SLAAC、DAD、临时地址这些概念在内核里到底怎么实现的读者,二是准备做网络排障却总在 IPv6 地址状态上卡壳的工程师。
1. addrconf.c在内核协议栈中的位置:它不是“配地址”而是“管地址”
1.1 从SLAAC说起:地址可以不用人管
IPv4 时代,地址基本靠静态配置和 DHCP,内核不需要关心地址如何“诞生”。IPv6 引入了 SLAAC(无状态地址自动配置)机制,设备收到路由器通告 RA 之后,可以自己生成全球单播地址。这带来一个本质变化:地址是“自己长出来的”,内核必须承担养地址的责任——什么时候做 DAD、地址什么时候过期、冲突了怎么办、临时地址要不要换新的。这就是 addrconf.c 存在的根本原因。这个文件放在 net/ipv6/addrconf.c,是 IPv6 协议栈中体量最大的文件之一,6.19 主线版本超过 5000 行,核心逻辑全部围绕“IPv6 地址的生命周期管理”展开。
1.2 三个核心数据结构
要读懂 addrconf.c,三个结构体必须拿下。
第一是struct inet6_dev,每个网络设备对应一个。它挂在 net_device 的 ip6_ptr 指针上,里面维护了 IPv6 地址链表、各类 sysctl 配置(比如 dad_transmits、accept_ra),以及链路本地地址的生成状态。可以把它理解为“设备级的 IPv6 控制块”。
第二是struct inet6_ifaddr,这是单个 IPv6 地址的完整描述。里面不仅有地址本身和前缀长度,还有 family、scope、flags、preferred_lft、valid_lft、引用计数,以及 DAD 定时器。整个文件的一半篇幅都在围绕这个结构体怎么创建、怎么转换状态、怎么销毁。
第三是struct ifa_cacheinfo,它的定义在 UAPI 头文件中,是内核向用户态暴露地址寿命信息的标准格式。字段包括 ifa_prefered、ifa_valid、ifa_cstamp 和 ifa_tstamp,后两者用于记录创建时间和状态变化时间。ip -6 addr show显示的 preferred_lft、valid_lft 直接来自这里。
1.3 与周边模块的协作关系
addrconf.c 不是孤岛。它与 ndisc.c(邻居发现)、route.c(路由表)、fib6(IPv6 转发表)、netlink(rtnetlink 处理)有大量交叉调用。以网卡启动为例,dev_open 触发 NETDEV_UP 事件后,addrconf_notify 被调用,它会为设备创建 inet6_dev,尝试生成链路本地地址,然后触发路由器和前缀发现。这个流程中,addrconf.c 是核心调度者,但具体发送 NS/NA 报文的是 ndisc.c,插入路由的是 route.c,向用户态发通知的是 rtnetlink 机制。
为了清楚展示协作关系,列一下常见的模块间调用:
| 当前文件 | 调用对象 | 典型场景 |
|---|---|---|
| addrconf_notify | ndisc_send_ns / inet6_ifa_notify | 设备 ready 后触发链路本地地址 DAD |
| ipv6_add_addr | ip6_ins_rt / addrconf_join_solict | 地址添加后插入 local 路由、加入多播组 |
| inet6_rtm_newaddr | ipv6_add_addr / ipv6_del_addr | 用户态通过 netlink 增删地址 |
| ndisc_recv_na | addrconf_dad_failure | 收到 NA 后发现重复地址 |
这个表格想表达的意思是:读 addrconf.c 不能只盯这个文件,至少要把 ndisc.c 的 NS/NA 处理部分和 netlink 的 rtnetlink_rcv_msg 流程一并打开。
2. 地址状态机:从TENTATIVE到PREFERRED再到DEPRECATED
2.1 状态标记不是枚举而是位掩码
第一次看 include/uapi/linux/if_addr.h 时,你会觉得奇怪:IPv6 地址状态为什么是一堆位掩码?IFA_F_TENTATIVE、IFA_F_DADFAILED、IFA_F_DEPRECATED 这些不是互斥的枚举值,而是可以组合的位。
关键要区分“状态”和“策略”:
- TENTATIVE、DADFAILED、DEPRECATED 是状态
- OPTIMISTIC 是策略,表示允许在 DAD 完成前使用
- NODAD 也是策略,表示跳过检测
- TEMPORARY、STABLE_PRIVACY、MANAGETEMPADDR 属于地址属性
同一地址可以同时带 TENTATIVE 和 OPTIMISTIC,这在 RFC 4429 中就是允许的。如果把这些标志当成互斥状态去理解,后面看 DAD 逻辑会绕不过来。
| 标志位 | 含义 | 典型组合 |
|---|---|---|
| IFA_F_TENTATIVE | 正在 DAD 检测 | 几乎总是和 DAD 流程绑定 |
| IFA_F_OPTIMISTIC | 乐观模式,检测前可用 | 与 TENTATIVE 同置 |
| IFA_F_DADFAILED | DAD 失败 | 手动添加地址保留时出现 |
| IFA_F_DEPRECATED | 过了首选期 | 可与 TEMPORARY 组合 |
| IFA_F_TEMPORARY | 临时地址 | 通常由公共地址派生 |
| IFA_F_NODAD | 跳过 DAD | 链路本地或隧道场景常见 |
2.2 地址诞生和初始状态流转
一个 IPv6 地址进入内核有三条路:
- 用户态通过 netlink 下发 RTM_NEWADDR,最后走到 inet6_rtm_newaddr
- 收到 RA 消息后由 addrconf_prefix_rcv 解析出前缀,再调用 ipv6_add_addr 生成 SLAAC 地址
- 设备使能时自动创建链路本地地址,fe80::/10,由 addrconf_dev_config 启动
这三条路最终都会聚到 ipv6_add_addr 函数,经过一系列重复检查、scope 整理、生命周期赋值之后,把新的 inet6_ifaddr 挂到 idev->addr_list。需要注意的是,在这个阶段地址通常会被标上 IFA_F_TENTATIVE,然后通过 addrconf_dad_start 进入 DAD 流程。
DAD 流程有几种结局:
- 发送 NS 后没有收到 NA,DAD 成功,标志位清除,地址进入 PREFERRED 状态,并向用户态发送 RTM_NEWADDR 通知
- 收到 NA,DAD 失败,地址被标记 IFA_F_DADFAILED,自动配置的地址最终会被清理
- 地址带有 NODAD 或设备不支持 ARP,DAD 直接跳过,视同成功
2.3 生命周期定时器:addrconf_verify_rtable 的职责
PREFERRED 和 DEPRECATED 的切换靠的是一个延迟工作队列 addrconf_verify_work,每隔一段时间运行一次 addrconf_verify_rtable。它遍历所有 idev 的地址列表,判断是否超过 preferred_lft 或 valid_lft,触发状态更新或删除。
其实这个机制在老版本内核里是简单的定时器,每几秒扫描一次,但随着 IPv6 地址数量增加,全量扫描的代价越来越大。后来内核引入了按“最近到期时间”排序的等待队列,每次只需要处理最快到期的地址,处理完再根据下一个到期时间设置定时器。这种改法很值得学习:同样一件事,不考虑成本时可以先扫描,但一旦地址量上来,就必须改成“下一次唤醒时间由最近到期事件决定”的按需调度模型。
2.4 DAD完成前为什么不发RTM_NEWADDR
实际使用ip -6 monitor时,你会发现地址出现在用户态的时间比创建时间要晚。原因很简单:TENTATIVE 状态下内核不确定地址是否冲突,不能直接告诉用户态“这个地址能用”。只有 DAD 完成后才通过 inet6_ifa_notify 发送通知。这个“延迟上报”的细节非常容易踩坑,特别在调试 SLAAC 地址时,如果只盯着 netlink 事件发现地址迟迟不出现,先检查 DAD 是否正常完成。
3. DAD重复地址检测:addrconf.c里最精巧的一条路径
3.1 什么时候跳过DAD
DAD 并不是在所有场景都会执行。addrconf 中会做几个判断:回环地址不做、IFA_F_NODAD 显式跳过、设备标识 IFF_NOARP 时跳过。再往下,SLAAC 地址和管理员手动添加的地址,在重传次数上还有不同的参考配置。内核同时保留了两个 sysctl:dad_transmits 和 dup_addr_detect_transmits,前者主要影响手动配置的地址,后者影响 SLAAC 自动生成地址。dmesg 中经常出现的“IPv6 address ... causes duplicate address”日志就来自这个路径。
3.2 发送NS和重试的机制
DAD 的核心动作是往目标地址的 solicited-node 多播地址发送 NS 报文,这个 NS 报文的源地址是未指定地址::,目标地址是请求节点的多播地址,同时在选项里带上被检测地址。因为源地址是::,收到 NA 的一方就可以判定是重复地址。
addrconf_dad_begin 负责发起 DAD。它先检查重传次数,若为 0 则直接完成检测,否则构造 NS 并启动 dad_timer 定时器。dad_timer 超时后,如果还没有收到确权结果,会再次发送 NS,直到重试次数用尽,才进入 addrconf_dad_completed 把地址从 TENTATIVE 状态放行。
简化逻辑如下(去掉锁和错误分支后的主干):
static void addrconf_dad_begin(struct inet6_ifaddr *ifp) { if (!dad_count) { addrconf_dad_completed(ifp); return; } if (ifp->flags & IFA_F_OPTIMISTIC) addrconf_leave_anycast(ifp); ndisc_send_ns(dev, &ifp->addr, &ifp->addr, NULL); mod_timer(&ifp->dad_timer, jiffies + ...); }这段代码做了裁剪,真实的 6.19 版本里还包含对多播组的操作、对 sysctl 读取的并发保护,但主干就是这样:先发 NS,再挂定时器。
3.3 收到NA后怎么办
重复地址裁决不在 addrconf.c,而是在 ndisc_recv_na 里。它收到针对目标地址的 NA 后,会检查该地址是否存在且处于 TENTATIVE 状态,如果条件满足就调用 addrconf_dad_failure。
addrconf_dad_failure 执行的动作比较多:
- 给地址打上 IFA_F_DADFAILED 标记
- 打印内核日志,通常包含重复地址的具体 IP
- 如果是自动生成的 SLAAC 地址,或者当前没有管理性标志保护,则安排删除
- 触发 NETDEV_CHANGEADDR 事件,让上层应用得知接口地址发生变化
有一种情况值得一提:管理员手动添加的地址发生 DAD 失败,内核会保留这个地址(打上 DADFAILED 标记),因为它不能确定用户是不是故意配置了一个本端只用入方向流量的地址。而 SLAAC 自动生成的地址没有这种“人工干预”背景,所以失败后会被删除,等待下次 RA 刷新。
3.4 Enhanced DAD与RFC 7527
6.19 的 addrconf.c 里也包含了针对 RFC 7527 的增强 DAD 逻辑。传统 DAD 中,如果网段里有两台设备同时启动并互相做 DAD,有可能都以为地址唯一。增强 DAD 通过在 NS 中携带 nonce 选项,在收到 NA 并回复 ECHO 请求后做二次确认,进一步降低冲突误判概率。这个逻辑在代码里比较隐蔽,因为它跨越了 ndisc.c 和 addrconf.c 两个文件,所以我建议阅读 DAD 路径时不要只看 addrconf_dad_begin,还要把 ndisc_recv_na、ndisc_recv_ns 的回调一起看。
4. netlink加法与用户态交互:一条ip命令在addrconf.c里做了什么
4.1 inet6_rtm_newaddr入口校验
当用户执行ip -6 addr add 2001:db8:1::10/64 dev eth0,ip 命令构造一条 RTM_NEWADDR netlink 消息,属性里带 IFA_LOCAL 或 IFA_ADDRESS、IFA_PREFIXLEN、IFA_CACHEINFO、IFA_FLAGS。内核 rtnetlink 在收到后根据地址族的 rtnl_link_ops 分发到 inet6_rtm_newaddr。
这个函数要做的事情包括:
- 根据 ifindex 找到对应 net_device,并强制转换成 inet6_dev
- 解析地址属性,确认地址本身是合法的 IPv6 地址
- 从前缀长度推导 scope,如果没有显式声明,链路本地地址自动使用 LINK_LOCAL scope
- 读取 IFA_CACHEINFO 里的 preferred_lft 和 valid_lft,缺省时用无限期
- 读取 IFA_FLAGS,得到用户要求的标志位
以代码片段示意关键判断:
static int inet6_rtm_newaddr(struct sk_buff *skb, struct nlmsghdr *nlh, struct netlink_ext_ack *extack) { ... if (ifm->ifa_prefixlen > 128) return -EINVAL; ... return ipv6_add_addr(idev, &addr, NULL, cfg.ifa_prefixlen, scope, &cfg); }4.2 ipv6_add_addr里到底发生了什么
ipv6_add_addr 是地址入链的核心,也是整个文件最值得逐行看的地方。它先检查同设备上是否已有完全相同的地址,避免重复添加;然后分配 inet6_ifaddr 对象,将地址、生命周期、标志位、scope 放进去;再根据地址是否属于链路本地/多播/组播做对应的 anycast 处理和加入 solicited-node 多播组;最后把地址按照 scope 排序插入到 idev->addr_list 链表中。
如果你手动添加的是 2512:ff01::1 这样的地址,内核还会在路由表里插入一条 local 类型的路由,让本机能够路由到这个地址。这一条路由的插入动作也是由 ipv6_add_addr 后续调用的 fib6 操作完成的,阅读时不要忽略。
一个容易忽略的细节:IPv6 地址可以带 64 位前缀以外的用法。比如ip -6 addr add 2001:db8::1/128 dev eth0,前缀长度 128,地址只属于本机,这样的地址在 addrconf.c 里同样会走完整的“添加、插路由、触发通知”流程,只不过 scope 和路由行为跟 64 位前缀不同。
4.3 网卡事件如何影响地址
内核状态变化经常不是 netlink 主动下发,而是设备事件触发。addrconf_notify 函数处理 NETDEV_UP、NETDEV_DOWN、NETDEV_CHANGE、NETDEV_CHANGEADDR 等事件。一个实际场景:拔掉网线再插回去,网卡驱动发送 NETDEV_UP 后,addrconf_notify 会重新评估设备上是否还缺链路本地地址,如果缺就重新生成;如果保留着,就重新触发前缀发现的定时器。这也解释了为什么有时候ip -6 addr show里地址明明还在,但 ping 不通,因为链路事件之后 DAD 可能被重新触发,地址进入了 TENTATIVE 状态。
4.4 删除地址也走netlink
删除路径是 inet6_rtm_deladdr -> ipv6_del_addr。它比添加简单,但要注意:删除一个带有 MANAGETEMPADDR 标志的公共地址,会连带把从它派生的临时地址一起处理。这个联动在代码里有明确体现,我第一次读时没注意到,后来排查“临时地址为什么跟着消失”时才明白过来。
5. 临时地址与隐私扩展:addrconf.c会“自动变脸”的机制
5.1 临时地址的价值在哪里
如果 IPv6 地址直接用 EUI-64 方式从 MAC 地址生成,设备走到哪里,地址后缀都是一样的。外部观察者只要看到这个地址出现,就能判断是同一台设备在移动。隐私扩展(RFC 4941,后来由 RFC 8981 更新)就是为了解决这个问题:在公共地址之外再生成一个随机接口标识符的临时地址,定时更换。addrconf.c 中的管理路径由 sysctl use_tempaddr 控制,默认值是 1,也就是启用;值为 0 表示关闭。
5.2 从公共地址到临时地址:manage_tempaddrs的触发
当一个公共地址被添加时,如果它带 IFA_F_MANAGETEMPADDR,内核会启动临时地址管理逻辑。这个逻辑会扫描当前地址链,根据 use_tempaddr、max_addresses 等配置决定要不要创建新的临时地址。创建时,临时地址的 preferred_lft 和 valid_lft 通常比父地址短,并且两个寿命是独立计算的。
核心函数是 manage_tempaddrs,它的逻辑可以概括为:
- 找到父地址(带 MANAGETEMPADDR 的公共地址)
- 检查现有临时地址数量是否达到 max_addresses 上限
- 如果没有达到,用当前随机种子生成临时接口标识符
- 对生成结果做去重检查,避免与已有地址冲突
- 创建临时地址并启动 DAD
注意,临时地址不是一次性只生成一个。内核会尽量保证网络上有“当前临时地址”和“下一个临时地址”同时存在,这样当前临时地址过期前,应用可以平滑切换到下一个。这个“双地址并存”策略在代码里也有体现。
5.3 临时地址的回收机制
临时地址到期后会进入 DEPRECATED 状态,继续被处理;在临时地址 deprecated 后,内核会优先在回收前生成新的临时地址。如果父地址被删除,其所有临时地址也会被连带删除。这正是很多用户反映“IPv6 地址每隔一段时间就自己变了”的原因。
如果你排查问题时发现临时地址数量异常,优先查看这三个 sysctl:use_tempaddr、max_addresses、regen_max_retry。regen_max_retry 控制生成临时地址时遇到重复或失败的情况下最多重试几次,默认值并不大,在某些 NAT64 或专网环境不够用,需要调大。
6. 阅读与调试经验:如何验证你对addrconf.c的理解
6.1 打开动态调试观察addrconf
addrconf.c 里很多关键路径都是 pr_debug 级别的日志,默认不输出。可以在 root 权限下打开:
echo "file net/ipv6/addrconf.c +p" > /sys/kernel/debug/dynamic_debug/control然后配合dmesg -w观察。不想改内核配置的话,bpftrace 直接挂 kprobe 更灵活,比如想看 DAD 失败时的调用参数:
bpftrace -e 'kprobe:addrconf_dad_failure { printf("%s\n", ksym(reg("ip"))); }'对于有图形化习惯的读者,我建议用 tracefs 的 function_graph 和 filter 带上 addrconf_* 前缀查看调用链,能很直观地看到 RTM_NEWADDR 消息进内核后依次调用了哪些函数。
6.2 我踩过的几个真实坑
第一个坑:多网卡设备上不同接口生成了重复接口标识符。两个网卡的 MAC 不同,但 EUI-64 生成的接口标识符在翻转 U/L 位之后可能撞在一起。此时 DAD 失败日志会出现,但单看网络拓扑又找不到冲突设备。解决办法是把其中一个接口的 stable-privacy secret 重置,或者改用 RFC 7217 生成稳定隐私地址。这类问题在容器和虚拟机多网口环境中尤其常见。
第二个坑:SLAAC 地址死活不出现,netlink monitor 也没有任何上报。排查后发现设备的 accept_ra 被全局关掉了,RA 报文被丢弃,地址自然无法自动生成。在 6.19 中 accept_ra 的默认行为也会根据 forwarding 开关联动:开启 IP 转发后内核会停止接受 RA,这是很多人没意识到的隐藏逻辑。
第三个坑:临时地址被删除,但公共地址还在。原因是我给公共地址设置了较短的 valid_lft,公共地址虽然还没到期,但寿命计算已经把“临时地址寿命不能超过父地址”的约束触发。后来我给公共地址设了更长的 valid_lft,临时地址就稳定了。
6.3 推荐的阅读顺序
如果从头开始读这个文件,我的建议是:先读 include/uapi/linux/if_addr.h 中的 IFA_F_* 标志,然后从 inet6_rtm_newaddr 入口进入,往下走到 ipv6_add_addr;把添加地址的路径理清后,再看 addrconf_dad_begin 和 dad_timer 这一组 DAD 函数;最后跟着 addrconf_verify_rtable 看地址过期回收。千万不要一上来就盯 addrconf_notify,因为它处理的事件类型太多,分支分散,容易把人绕晕。
读到这里的读者,可能已经有了自己动手翻一遍 addrconf.c 的冲动。我个人在实际分析中的体会是,这个文件虽然看起来庞杂,但真正的骨架只有三块:地址怎么加、DAD 怎么跑、寿命怎么管。把这三个主循环搞懂,再遇到 IPv6 地址层面的问题,基本都能快速定位到具体函数。如果后续大家感兴趣,我还可以继续写 ndisc.c 里 NS/NA 的处理细节,以及路由表如何与地址状态联动,这些都是接着 addrconf.c 往下走的话题。