
去年底我给自己定了一个任务把 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路由表、fib6IPv6 转发表、netlinkrtnetlink 处理有大量交叉调用。以网卡启动为例dev_open 触发 NETDEV_UP 事件后addrconf_notify 被调用它会为设备创建 inet6_dev尝试生成链路本地地址然后触发路由器和前缀发现。这个流程中addrconf.c 是核心调度者但具体发送 NS/NA 报文的是 ndisc.c插入路由的是 route.c向用户态发通知的是 rtnetlink 机制。为了清楚展示协作关系列一下常见的模块间调用当前文件调用对象典型场景addrconf_notifyndisc_send_ns / inet6_ifa_notify设备 ready 后触发链路本地地址 DADipv6_add_addrip6_ins_rt / addrconf_join_solict地址添加后插入 local 路由、加入多播组inet6_rtm_newaddripv6_add_addr / ipv6_del_addr用户态通过 netlink 增删地址ndisc_recv_naaddrconf_dad_failure收到 NA 后发现重复地址这个表格想表达的意思是读 addrconf.c 不能只盯这个文件至少要把 ndisc.c 的 NS/NA 处理部分和 netlink 的 rtnetlink_rcv_msg 流程一并打开。2. 地址状态机从TENTATIVE到PREFERRED再到DEPRECATED2.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_DADFAILEDDAD 失败手动添加地址保留时出现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 后没有收到 NADAD 成功标志位清除地址进入 PREFERRED 状态并向用户态发送 RTM_NEWADDR 通知收到 NADAD 失败地址被标记 IFA_F_DADFAILED自动配置的地址最终会被清理地址带有 NODAD 或设备不支持 ARPDAD 直接跳过视同成功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 什么时候跳过DADDAD 并不是在所有场景都会执行。addrconf 中会做几个判断回环地址不做、IFA_F_NODAD 显式跳过、设备标识 IFF_NOARP 时跳过。再往下SLAAC 地址和管理员手动添加的地址在重传次数上还有不同的参考配置。内核同时保留了两个 sysctldad_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 75276.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 eth0ip 命令构造一条 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 地址每隔一段时间就自己变了”的原因。如果你排查问题时发现临时地址数量异常优先查看这三个 sysctluse_tempaddr、max_addresses、regen_max_retry。regen_max_retry 控制生成临时地址时遇到重复或失败的情况下最多重试几次默认值并不大在某些 NAT64 或专网环境不够用需要调大。6. 阅读与调试经验如何验证你对addrconf.c的理解6.1 打开动态调试观察addrconfaddrconf.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 往下走的话题。