STA 连接(六)从 L2 连接到 “能上网”
四次握手完成、密钥安装到驱动——L2 加密通道已经建立。但此时 STA 还 "不能上网":没有 IP 地址,没有路由,系统不知道这个网络的存在。从 L2_CONNECTED 到用户看到 "已连接,可上网",中间还有一段精密的 L3 配置链路。本篇追踪从
L3ProvisioningState到ConnectivityService注册网络的完整过程。
上一篇追踪了 wpa_supplicant 到 Kernel 的 Auth/Assoc/EAPOL/四次握手全链路,终点是密钥安装到驱动、L2 加密通道建立。本篇从 L2 完成后继续,接上 IP 配置、路由构建、网络注册。
¶ 本章导读
WiFi 连上了但上不了网,到底是 DHCP 没拿到 IP,还是网关不通,还是 Captive Portal 拦截了?沿着 L2 加密通道建立之后的完整链路,本篇逐条排查:
startIpClient()中静态 IP 和 DHCP 两条路径的分叉代码与设计权衡 —— 为什么同样填 IP,DHCP 有 12 个核心状态而静态 IP 一行代码就跳过?- DhcpClient 状态机的 12 个核心状态和 DHCP Discover/Offer/Request/ACK 四步源码 —— DHCP 服务器不响应时系统何时放弃?
- Dhcp6Client 的状态机与 v4 的关键差异(含状态转移代码) —— 为什么 DHCPv6 不求地址而求前缀?
- DhcpResults 如何转换为 LinkProperties,构建系统路由表 —— 直连路由和默认路由各自解决什么问题?
- WifiNetworkAgent 向 ConnectivityService 注册 WiFi 网络的评分竞争机制 —— WiFi 评分 60、蜂窝 ~50,但为什么校验没通过就抢不过蜂窝?
- IpReachabilityMonitor 如何通过内核 NUD 监测网关可达性 —— 网关丢了是立即断连还是再等等?
本篇是连接系列第 6 篇,接续上一篇的 L2 加密通道建立,追踪 L3 配置到 ConnectivityService 注册的完整过程。
¶1 L2 完成后,谁启动 IP 配置?
四次握手完成后,ClientModeImpl 收到 L2 连接事件,进入 L3ProvisioningState,调用 startL3Provisioning() 启动 IP 配置。startIpClient() 根据 IpAssignment 枚举(STATIC vs DHCP)走两条完全不同的代码路径。
图注:本篇 L3 配置链路的全局分层——五层自顶向下:ClientModeImpl → IpClient → NetworkAgent → ConnectivityService → netd → 内核。虚线连出的文字标注每层间的跨进程/跨态边界(AIDL・IIpClient / oneway Binder / AIDL・INetd / netlink),蓝箭头是配置数据流的下发方向。§4.3 会沿这条链逐层展开代码。
¶1.1 触发时机:从 L2ConnectedState 到 L3ProvisioningState
在上一篇文章中,我们追踪了四次握手的完整过程——最终 PTK/GTK 安装到驱动,L2 加密通道建立。在 ClientModeImpl 状态机中,这对应 L2ConnectedState。进入该状态后,状态机自动转移到 L3ProvisioningState:
1 | L2ConnectedState(密钥安装完成) |
在 L2ConnectedState.enterImpl() 中(ClientModeImpl.java:6500),除了发送 CONNECTING 广播和清除 Target BSSID 外,还做了一件对后续整个链路至关重要的事——创建 WifiNetworkAgent:
1 | // L2ConnectedState.enterImpl() 内部(ClientModeImpl.java:6500) |
WifiNetworkAgent 构造函数立即调用 register(),向 ConnectivityService 注册网络——此时 mLinkProperties 还空着,没有 IP 地址。这意味着 ConnectivityService 在 L3 配置启动前就已经知道了这个 WiFi 网络的存在,只是它「身份待完善」(见 §5.1)。L3ProvisioningState.enterImpl() 的源码已经在上一篇中展示过,这里聚焦它的核心动作——调用 startL3Provisioning():
1 | // ClientModeImpl.java - L3ProvisioningState |
这段代码做了三件事:
enterImpl()是 L3 配置的起点——进入状态即启动 IP 配置- 如果配置了
config_wifiRemainConnectedAfterIpProvisionTimeout,同时启动 provisioning 超时定时器——超时后网络仍保持 "已连接" 但标记为 local-only - 超时处理在
processMessageImpl()中:收到CMD_IP_PROVISIONING_TIMEOUT后调用onL3ProvisioningTimeout(),将mIpProvisioningTimedOut设为 true
startL3Provisioning() 区分 FILS 预连接和正常连接两条路径:
1 | // ClientModeImpl.java |
它实现了三个功能:
- FILS 预连接模式(802.11ai):L2 连接前已预先发送 DHCP 报文(HLP),此时只需通知 IpClient 预连接完成
- 正常模式:调用
startIpClient()从头启动 IP 配置 - 同时获取链路层统计信息并重置报文发送计数器,为后续连接状态跟踪建立基准
¶1.2 IpClient 架构:IP 配置总管
在深入 startIpClient() 之前,先理解 IpClient 的整体架构。IpClient 是 Android 中 IP 配置的总管状态机,管理 DHCPv4、DHCPv6、ARP 监测等子组件的生命周期:
1 | ClientModeImpl |
IpClient 通过 AIDL(Android Interface Definition Language)接口 IIpClient 与 ClientModeImpl 通信。ClientModeImpl 通过 IpClientManager(封装了 AIDL 调用)向 IpClient 发送命令。
ClientModeImpl 通过 ProvisioningConfiguration.Builder 构建配置,然后调用 mIpClient.startProvisioning(prov.build())(ClientModeImpl.java)启动整个流程。
ProvisioningConfiguration 是配置的载体,关键字段包括:
| 字段 | 说明 |
|---|---|
mStaticIpConfig | 静态 IP 配置(非 null 时走静态 IP 路径) |
mUsingIpReachabilityMonitor | 是否启用 IP 可达性监测 |
mEnablePreconnection | 是否启用 FILS 预连接 |
mNetwork | 已注册的 Network 对象(用于 DHCP 续期等) |
mDhcpOptions | 自定义 DHCP 选项 |
IpClient 收到 CMD_START 后,从 StoppedState 转移到 ClearingIpAddressesState(IpClient.java:3147,清除旧 IP 地址等残留状态),清理完成后进入 StartedState(IpClient.java:3246)的子状态 RunningState(IpClient.java:3395),开始 IP 配置流程。RunningState 内部并行管理 DhcpClient(DHCPv4)、Dhcp6Client(DHCPv6)和 IpReachabilityMonitor(基于 NUD——Neighbor Unreachability Detection——的网关探测)三个子组件。
¶1.3 startIpClient ():静态 vs 动态的分岔路口
startIpClient() 是整篇文章最重要的决策点——它根据 IpAssignment 枚举决定走 DHCP 路径还是静态 IP 路径:
1 | // ClientModeImpl.java |
两条路径的差异:
isUsingStaticIp通过config.getIpAssignment() == IpConfiguration.IpAssignment.STATIC判断——这是唯一的决策变量- 动态 IP 路径:发送
OBTAINING_IPADDR广播通知上层、清除 target BSSID 允许驱动漫游、添加 pre-DHCP action 支持 - 静态 IP 路径:用
withStaticConfiguration()传入用户配置的 IP/网关/DNS、用withoutIpReachabilityMonitor()禁用网关监测——因为静态 IP 的网关是用户手动指定的,即使 "不可达" 也只是配置错误,不应触发自动重连 - FILS 预连接模式下不支持静态 IP:
isFilsConnection && isUsingStaticIp时直接返回 false
打个比方:动态 IP 就像你跟前台说 "随便给我一个空房间"——前台查系统、分配、确认没人住、给你房卡。静态 IP 就像你说 "我就要 808 房"——前台不用查系统,直接给你 808 的房卡。startIpClient() 的这个 if/else 分叉,就是 Android 系统在 "替你分配" 和 "按你说的来" 之间的唯一决策点。
¶2 DHCP——动态获取 IP 地址
DhcpClient 通过 12 个核心状态的层级状态机实现完整的 DHCP 生命周期。从 DhcpInitState 发送 DHCP Discover(广播探测),到 DhcpRequestingState 发送 DHCP Request(正式申请),再到 ConfiguringInterfaceState 配置接口,最终进入 DhcpBoundState 等待 T1/T2 续期。
在 §1 结尾,startIpClient() 检测到 DHCP 模式后,构建了完整的 ProvisioningConfiguration 并通过 mIpClient.startProvisioning() 启动 IpClient 状态机。IpClient 随即从 StoppedState 清理旧 IP 残留后进入 RunningState,向 DhcpClient 发送 CMD_START_DHCP 启动 DHCP 状态机。
回到酒店比喻:前台(ClientModeImpl)已经确认你是 "自动分配" 入住方式,房务分配窗口(DhcpClient)的灯亮了起来——12 状态的正式分配流程开始了。
¶2.1 DhcpClient 状态机全景
DhcpClient 是 Android 中 DHCPv4 协议的完整实现。它的状态机覆盖了 DHCP 从初始化到租约到期的完整生命周期。
1 | StoppedState(初始状态) |
¶2.2 第一步:DhcpInitState 发送 Discover
DhcpInitState 继承自 PacketRetransmittingState(带指数退避重传的超时状态),进入时启动新事务并发送 DHCP Discover:
1 | // DhcpClient.java |
sendDiscoverPacket() 构建 DHCP Discover 报文并发送:
1 | // DhcpClient.java |
从这段代码可以看到几个设计:
buildDiscoverPacket()使用ENCAP_L2封装(而非ENCAP_BOOTP)——因为 Android 在 L2 层直接构造以太网帧操作,不需要内核 UDP 协议栈参与。换言之,Android DHCP 客户端完全绕过了传统 Linux DHCP 客户端(dhclient),实现了更细粒度的重传控制和报文定制getRequestedParams()返回 Parameter Request List(Option 55):包含子网掩码、网关、DNS、域名等标准参数,加上 Captive Portal(Option 114)、IPv6-only Preferred(Option 108)等 Android 特定参数- Rapid Commit 选项:前 3 个 Discover(
getSecs() <= 4)携带 Rapid Commit,允许服务器跳过 Request/ACK 直接分配 IP——减少一个往返。为什么只限前 3 个?因为 Rapid Commit 是服务器可选特性,如果前几次 Discover 都没得到包含 Rapid Commit 的 Reply,说明服务器不支持,后续 Discover 就不再携带 PacketRetransmittingState实现指数退避重传:首次 1 秒,之后逐次翻倍直到 512 秒上限——这是为了在网络拥塞时不加重广播风暴- 收到 Offer 后保存到
mOffer,转移到DhcpRequestingState,进入下一步 Request 流程
getRequestedParams() 通过 ByteArrayOutputStream 拼接三类请求参数——基础 DHCP 选项(DEFAULT_REQUESTED_PARAMS:子网掩码、路由器、DNS、域名)、Android 特有扩展(DHCP_CAPTIVE_PORTAL Option 114 用于强制门户检测,DHCP_IPV6_ONLY_PREFERRED Option 108 用于 IPv6-only 网络偏好),以及工作配置文件(Work Profile)专属的 DHCP_DOMAIN_SEARCHLIST(Option 119,域名搜索列表)。回到酒店比喻:DHCP Discover 就像你站在大堂喊一声 "有空房吗?"——广播传遍所有楼层,只有有空房的服务台才会回应(Offer)。Rapid Commit 则是额外补一句 "直接给我房卡就行",让服务台跳过确认环节一步到位。
¶2.3 第二步:DhcpRequestingState 发送 Request
收到 Offer 后,DhcpRequestingState 广播 DHCP Request 正式申请 IP:
1 | // DhcpClient.java |
这段 Request 代码的核心逻辑:
mTimeout = DHCP_TIMEOUT_MS / 2= 18 秒超时——在全局 36 秒限制内保证足够的重试窗口- 收到 ACK:调用
confirmDhcpLease()(DhcpClient.java:633)保存租约信息——其内部先setDhcpLeaseExpiry(packet)从 ACK 报文的 Option 51 提取租约时长并计算到期时间戳mDhcpLeaseExpiry(后续scheduleLeaseTimers()据此推算 T1/T2 定时器),再acceptDhcpResults(results, "Confirmed")将 IP/网关/DNS 写入mDhcpLease——然后根据配置决定是否进入 ARP 冲突检测 - 收到 NAK:服务器拒绝了请求(例如 IP 已被分配),清除 Offer,回到
DhcpInitState重新 Discover - 超时:同样回到
DhcpInitState,不放弃,从头再来
但 ACK 并不总是意味着 "拿到 IPv4 地址"。RFC 8925 定义了 IPv6-only Preferred 机制(Option 108)——如果 DHCP 服务器在 ACK 中携带该选项,表示 "这个网络支持纯 IPv6,你不需要 IPv4"。DhcpClient 在 DhcpRequestingState.receivePacket() 中收到 ACK 后,首先调用 maybeTransitionToIpv6OnlyWaitState()(DhcpClient.java:1339)检查 Option 108:如果存在,提取服务器指定的等待时长,转入 Ipv6OnlyWaitState(DhcpClient.java:2121),向 IpClient 发送 CMD_POST_DHCP_ACTION(status = DHCP_IPV6_ONLY)。IpClient 收到后不做任何 IPv4 配置(IpClient.java:3813,空操作),因为网络已经承诺提供 IPv6 连接——SLAAC 或 DHCPv6 会并行工作,为 STA 分配 IPv6 地址。如果等待期内 IPv6 始终没有就绪,Ipv6OnlyWaitState 超时后调用 startInitReboot() 回退到 DHCPv4 重试。这个机制在运营商 464XLAT 场景下很常见:网络同时提供 IPv4 和 IPv6,但优先引导设备使用 IPv6 以减少 IPv4 地址消耗。用酒店的话说:你向前台申请了一间标准间(IPv4),前台说 "我们有更宽敞的套房(IPv6),你先等一会儿,如果套房没空再给你标准间"——这就是 IPv6-only Preferred 的 "先试 IPv6、不行再回退" 策略。
如果 DHCP 服务器完全不响应呢? 这是读者最可能遇到的实际场景——WiFi 连上了但上不了网。DhcpClient 的处理策略是 "不放弃":
DhcpInitState继承自PacketRetransmittingState,不覆写timeout(),因此每次 Discover 超时后只是指数退避重试(1s->2s->4s->...->512s(MAX_TIMEOUT_MS)),永不主动停止。DhcpRequestingState的 18s 超时也只是回到DhcpInitState重新 Discover,形成无限循环。真正的 "放弃" 发生在更上层:L3ProvisioningState在进入时启动CMD_IP_PROVISIONING_TIMEOUT定时器(WAIT_FOR_L3_PROVISIONING_TIMEOUT_MS= 18000ms),超时后调用onL3ProvisioningTimeout()将mIpProvisioningTimedOut设为 true,网络标记为 local-only(可局域网通信但无互联网),WiFi 保持连接但不切换默认网络。
¶2.4 第三步:ARP 冲突检测(RFC 5227)
即使 DHCP 服务器分配了 IP,也需要确认这个 IP 没有被局域网内其他设备占用。Android 遵循 RFC 5227 使用 ARP Probe 和 Announce:
IpAddressConflictDetectingState 通过 IpConflictDetector(DhcpClient.java:1564)执行碰撞检测。IpConflictDetector 继承自 PacketReader,其 createFd()(DhcpClient.java:1587)创建 AF_PACKET raw socket 直接收发 ARP 包,绕过内核协议栈以实现精确的 ARP 探测控制。探测和冲突响应的完整逻辑如下:
IpConflictDetector 的设计透露了几层考量:
- 创建
AF_PACKETraw socket 直接收发 ARP 包——绕过内核协议栈,精确控制 ARP 探测 - 发送 ARP Probe(sender IP = 0.0.0.0,target IP = 要检测的 IP)若干次,间隔随机分布在 RFC 5227 规定的范围内
- 接收侧通过
handlePacket()(DhcpClient.java:1574)处理 ARP 响应:ArpPacket.parseArpPacket()解析原始字节,hasIpAddressConflict()判断是否有其他设备占用了目标 IP——有冲突则发送EVENT_IP_CONFLICT消息,触发IpAddressConflictDetectingState转移到DhcpDecliningState发送 DHCPDECLINE,回到 INIT - 无冲突 → 发送 ARP Announce(sender IP = target IP),向全网宣告 "这个 IP 我占了"
- 连续冲突超过
MAX_CONFLICTS_COUNT(2 次,DhcpClient.java:210)→DhcpDecliningState.enter()不再发送 DHCPDECLINE,直接转移到ConfiguringInterfaceState配置接口——这是 "不管了,先用着" 的容错策略,避免设备在 ARP 欺骗环境下永远无法获取 IP
¶2.5 第四步:ConfiguringInterfaceState 配置接口
ARP 检测通过后,ConfiguringInterfaceState 负责将 IP 地址实际配置到网络接口上:
1 | // DhcpClient.java |
这里有三步操作:
notifySuccess()先通知 IpClient 已获取 DHCP 配置(DNS、网关等)——这是刻意的顺序设计:DNS 等配置必须先于 IP 地址配置,否则 IpClient 看到 IP 出现后会立即认为 provisioning 完成,导致 DNS 配置缺失CMD_CONFIGURE_LINKADDRESS指示 IpClient 将 IP 地址和租约时长写入网络接口——IpClient 在RunningState中收到该命令后,根据mPopulateLinkAddressLifetime标志选择两条代码路径:新路径通过NetlinkUtils.sendRtmNewAddressRequest()直接发送 RTM_NEWADDR netlink 消息,将 DHCP 租约时长作为地址有效期写入内核(IpClient.java:3764);旧路径通过mInterfaceCtrl.setIPv4Address()配置接口地址(IpClient.java:3772)。成功后 IpClient 回发EVENT_LINKADDRESS_CONFIGURED给 DhcpClient;失败时 IpClient 通过dispatchCallback(PROV_CHANGE_LOST_PROVISIONING, mLinkProperties)(IpClient.java:3787)通知 ConnectivityService provisioning 丢失,然后transitionToStoppingState(DisconnectCode.DC_PROVISIONING_FAIL)(IpClient.java:3788)关闭 IpClient——上层收到 provisioning lost 回调后会触发网络重评估或断连- 收到
EVENT_LINKADDRESS_CONFIGURED确认后转移到DhcpBoundState
DhcpClient 已经走完了 DHCP 前半程——Discover 广播探测、收 Offer、Request 正式申请、ARP 冲突检测、配置接口 IP。用酒店的话说,就是前台确认有空房(Offer)、你正式申请(Request)、确认房间没被占(ARP 检测)、最后拿到房卡开通内线和 WiFi(配置接口)。接下来进入稳定态:租约续期和 DHCPv6 补充路径。
¶2.6 租约续期:T1 和 T2
DHCP 分配的 IP 有租约期限。STA 需要在到期前续期,否则 IP 会被回收。RFC 2131 定义了 T1(续期时间)和 T2(重绑定时间):
1 | |←────────────── 租约时间(lease_time)──────────────→| |
scheduleLeaseTimers() 用几行算术算出三个闹钟:mRenewAlarm 设在剩余时间的 50%(T1 单播续期),mRebindAlarm 设在 87.5%(T2 广播续期),mExpiryAlarm 设在到期时刻。如果 mDhcpLeaseExpiry == 0(无限租约),直接返回不安排任何定时器。其余规则:
- T1 = 租约剩余时间的 50%:触发单播续期(
DhcpRenewingState),直接向原 DHCP 服务器发送 Request - T2 = 87.5%:触发广播续期(
DhcpRebindingState),向任意 DHCP 服务器广播 Request - 续期成功 → 更新租约时间,重新安排 T1/T2 定时器
T1 到 T2 之间是单播续期窗口,T2 到到期之间是广播续期窗口——两个窗口都用尽、服务器始终不回应,mExpiryAlarm 到期触发 CMD_EXPIRE_DHCP,这才是真正的 "租约到期"。DhcpHaveLeaseState(DhcpClient.java:1510)在 processMessage() 中处理这条消息:调用 notifyFailure(DHCP_FAILURE)(DhcpClient.java:900),然后 transitionTo(mStoppedState)。notifyFailure() 做两件事——先调用 setLeaseExpiredToIpMemoryStore()(DhcpClient.java:864)将 IpMemoryStore 中该网络的 IPv4 地址标记为 EXPIRED_LEASE,确保下次重连时不会尝试复用已过期的地址;再通过 mController.sendMessage(CMD_POST_DHCP_ACTION, DHCP_FAILURE) 通知 IpClient。同时 DhcpHaveLeaseState.exit() 取消所有闹钟、清理 DHCP 状态,并发送 CMD_CLEAR_LINKADDRESS 指示 IpClient 清除接口上的 IPv4 地址。
IpClient 在 RunningState 中收到 CMD_POST_DHCP_ACTION(DHCP_FAILURE)后进入 handleIPv4Failure()(IpClient.java:2474):清除 IPv4 地址、置空 mDhcpResults、向 ClientModeImpl 回调 onNewDhcpResults(null),然后调用 handleProvisioningFailure(DisconnectCode.DC_PROVISIONING_FAIL)(IpClient.java:2491)。handleProvisioningFailure() 重新组装 LinkProperties(此时已无 IPv4 配置),检测到 PROV_CHANGE_LOST_PROVISIONING 后通过 dispatchCallback() 向 ConnectivityService 发送 onProvisioningFailure() 回调,最后 transitionToStoppingState() 关闭 IpClient。ConnectivityService 收到 provisioning 丢失通知后触发网络重评估——如果蜂窝可用则切换默认网络,否则进入无网络状态。这与 §2.3 提到的 "DHCP 服务器完全不响应" 是不同的场景:§2.3 是初始 Discover 阶段服务器无响应(DhcpClient 自己无限重试),这里是已有租约到期且续期失败(系统主动清理 IPv4 配置并上报 provisioning 丢失)。
换种说法:拿到房卡不是永久的——前台告诉你 "明天中午 12 点前退房"。想续住?退房时间过半时跟前台说一声就行。前台没回应?退房前 2 小时就得广播,找任何有空房的管理员。T1(50%)和 T2(87.5%)就是这两个续期时机,scheduleLeaseTimers() 只用几行算术就算出了三个闹钟。两个窗口都用尽、前台始终不接电话?闹钟一响(mExpiryAlarm),房卡自动失效。系统清除你的房间号(IPv4 地址)、注销入住信息(IpMemoryStore 标记过期)、通知酒店管理系统 "这位客人已退房"(ConnectivityService provisioning 丢失)。前台开始处理下一位客人的入住。
¶2.7 DHCPv6:前缀委派
Android 同时支持 DHCPv6(通过 Dhcp6Client),但关注点不同——DHCPv6 获取的是 IPv6 前缀(Prefix Delegation,IA_PD)而非单个地址。Dhcp6Client 的状态机比 DhcpClient 简单(6 个状态 vs 12 个核心状态),但重传机制更精细(遵循 RFC 8415 而非 RFC 2131)。
状态机结构:
1 | StoppedState(初始状态) |
SolicitState:定位 DHCPv6 服务器。与 DHCPv4 的 Discover 不同,Solicit 本身携带 IA_PD hint option(期望的前缀长度和空前缀),同时默认携带 Rapid Commit——如果服务器支持,可以跳过 Advertise/Request 两步,一步到位:
1 | // Dhcp6Client.java - SolicitState |
值得注意的几个设计细节:
- 首个 Solicit 随机延迟 0~1s——因为 DHCPv6 使用组播(All_DHCP_Relay_Agents_and_Servers),多设备同时上线可能形成同步风暴
mSolMaxRtMs可从服务器 Advertise 或 Reply 中的 SOL_MAX_RT option 动态更新——服务器可以告诉客户端 "别发太快"STATUS_NO_PREFIX_AVAIL:服务器明确拒绝(无可用前缀),SolicitState 会忽略此响应并继续重传,等待其他服务器响应
与 DHCPv4 的关键差异:
| 维度 | DHCPv4 (DhcpClient) | DHCPv6 (Dhcp6Client) |
|---|---|---|
| 获取内容 | 单个 IPv4 地址 | IPv6 前缀(/64),由内核据此生成全局单播地址 |
| UDP 端口 | 客户端 68,服务器 67 | 客户端 546,服务器 547 |
| 四步流程 | DISCOVER/OFFER/REQUEST/ACK | SOLICIT/ADVERTISE/REQUEST/REPLY |
| Rapid Commit | 可选优化(前 3 个 Discover) | 默认携带(一步到位获取前缀) |
| 重传机制 | 指数退避,MAX_TIMEOUT = 512s | RFC 8415 §15:RT 翻倍 + +/-10% 随机抖动 |
| T1/T2 计算 | 固定百分比(50%、87.5%) | 由服务器在 Reply 中指定 T1/T2,为 0 时回退到 50%/80% 最短首选生命周期 |
| 续期对象 | 单播到原服务器 | Renew 单播到原服务器(Rebind 时广播) |
| 状态数量 | 12 个核心状态(含重启、冲突检测、IP 冲突等) | 6 个(纯前缀委派,无地址冲突检测) |
RequestState:正式请求前缀。收到 Advertise 后,向选定的服务器(通过 mServerDuid 标识)发送 Request,携带服务器通告的前缀参数:
1 | // Dhcp6Client.java - RequestState |
BoundState:租约就绪,通知上层。进入 BoundState 后安排 T1/T2 续期定时器,并通知 IpClient 可用前缀:
1 | // Dhcp6Client.java - BoundState |
MessageExchangeState 是 SolicitState 和 RequestState 的共同父类,封装了 RFC 8415 §15 的重传算法:
- RT(Retransmission Timeout)从 IRT(Initial Retransmission Time)开始
- 每次翻倍并加上 +/-10% 随机抖动,防止多设备同步重传
- 超过 MRC(Maximum Retransmission Count)或 MRD(Maximum Retransmission Duration)后调用
onMessageExchangeFailed()
Dhcp6Client 的 6 状态机覆盖了 DHCPv6 前缀委派的完整生命周期。但 IPv6 地址配置不只这一条路——内核的 SLAAC(无状态自动配置)也在并行运行,两条路径互补而非互斥。换个说法:v4 是前台给你分了一间房(单个地址),v6 是酒店给你的整层楼分了一段门牌号段(/64 前缀)——两条路同时走,互不干扰。
¶2.8 IPv6 双路径:SLAAC 与 DHCPv6
Android 在 IPv6 网络中有两条并行的地址配置路径:
| 路径 | 触发者 | 获取内容 | 内核 / 用户空间 |
|---|---|---|---|
| SLAAC(无状态自动配置) | 路由器通告(RA) | 全局单播地址(GUA)+ 默认路由 + RDNSS | 内核自动处理 |
| DHCPv6-PD(有状态前缀委派) | Dhcp6Client 主动请求 | IPv6 前缀(IA_PD) | 用户空间(Dhcp6Client) |
SLAAC 路径:内核监听 RA 消息(受 accept_ra sysctl 控制),收到 RA 中的 Prefix Information Option(PIO)后自动生成全局单播地址。同时 RA 中的 RDNSS Option 提供 DNS 服务器地址。
IpClient 通过 IpClientLinkObserver 监听 netlink 事件感知 SLAAC 的进展:
processNduseroptMessage()—— 入口函数,位于IpClientLinkObserver.java,过滤ICMPV6_ROUTER_ADVERTISEMENT类型的 ND User Option 消息,提取 RDNSS(DNS 服务器)和 PREF64(NAT64 前缀)processRtNetlinkAddressMessage()—— 监听RTM_NEWADDR事件,感知内核配置的 SLAAC IPv6 地址变化processRtNetlinkRouteMessage()—— 监听RTM_NEWROUTE事件(协议字段为RTPROT_RA),感知 RA 下发的默认路由
两条路径的关系:SLAAC 和 DHCPv6-PD 不是互斥的,它们可以同时运行。Android 的启动策略是「SLAAC 优先,DHCPv6 兜底」:先通过 mIpv6AutoconfTimeoutAlarm 等待 SLAAC 完成,超时后仍未获得全局 IPv6 地址再启动 DHCPv6-PD 补充。这个判断逻辑在 handleLinkPropertiesUpdate() 中:
1 | // IpClient.java - handleLinkPropertiesUpdate() |
SLAAC 是 IPv6 的基本配置方式(RFC 4862),几乎所有 IPv6 路由器都支持。DHCPv6-PD 是增强功能,仅在需要前缀委派(如 Android 做热点时给下游设备分配前缀)或 SLAAC 超时后启动。这种 "SLAAC 优先,DHCPv6 兜底" 的策略,保证了在大多数家庭路由器下的快速连接体验。
DHCP 的状态机——12 个核心状态、T1/T2 续期、ARP 冲突检测——覆盖了完整的「自动分配」路径。但如果你用的是静态 IP,手动填写地址,这条路径完全不经过 DhcpClient,直接跳到接口配置。就好比你已经知道要住哪间房——不需要前台查系统、不需要房务分配窗口排队,直接拿钥匙上楼。
¶3 静态 IP——另一条完全不同的代码路径
静态 IP 完全跳过 DhcpClient 状态机,配置是用户预设的。本节主要展示与 DHCP 路径的代码级差异,完整流程见 §7.2 对比表。
静态 IP 路径完全跳过 DhcpClient 状态机,直接使用 StaticIpConfiguration 中用户手动填写的 IP/网关/DNS 配置接口,同时禁用 IP 可达性监测。
¶3.1 IpAssignment 枚举
Android 用 IpConfiguration 封装每个网络的 IP 配置。IpConfiguration.IpAssignment 枚举定义了三种 IP 分配方式:STATIC(静态配置,用户手动填写)、DHCP(动态获取)、UNASSIGNED(未分配,保留已有设置)。
每个 WifiConfiguration 持有一个 IpConfiguration 实例,通过 getIpAssignment() 返回当前配置类型。静态 IP 的具体数据由 StaticIpConfiguration 承载,关键字段包括 ipAddress(LinkAddress,含前缀长度)、gateway(默认网关)、dnsServers(DNS 服务器列表)和 domains(搜索域)。
¶3.2 两条路径的代码级差异
回顾 startIpClient() 中的分叉逻辑,两条路径在 ProvisioningConfiguration 层面的差异总结如下:
| 维度 | 动态 IP(DHCP) | 静态 IP |
|---|---|---|
| 决策条件 | config.getIpAssignment() != STATIC | config.getIpAssignment() == STATIC |
| 广播通知 | sendNetworkChangeBroadcast(OBTAINING_IPADDR) | 不发送(配置立即生效) |
| Target BSSID | clearTargetBssid() ——允许驱动漫游 | 不清除 |
| PreDhcpAction | withPreDhcpAction() ——支持 DHCP 前后执行操作 | 不支持 |
| Provisioning 配置 | withScanResultInfo() + withPreDhcpAction() | withStaticConfiguration() + withoutIpReachabilityMonitor() |
| DhcpClient 状态机 | 完整运行:Init → Requesting → Configuring → Bound | 完全跳过 |
| IP 可达性监测 | 启用 IpReachabilityMonitor | 禁用(网关地址是用户手动填写的,不应因 "不可达" 而触发重连) |
| FILS 支持 | 支持预连接 | 不支持(isFilsConnection && isUsingStaticIp 直接返回 false) |
IP 冲突怎么办? 内核在配置任何 IPv4 地址时都会执行 DAD(RFC 5227)。但 DHCP 路径在检测到冲突后会进入
DhcpDecliningState发送 DHCPDECLINE 并重新申请 IP;静态 IP 路径没有这个回退机制——冲突只会导致接口配置失败,你需要手动换一个 IP。
动态 IP 是前台自动分配房间号,静态 IP 是你指定房号——但如果你指定的 808 房已经有人住了(IP 冲突),系统不会像 DHCP 那样自动检测并重新分配,你只能自己换一间。这也是为什么静态 IP 被定义为 "高级用户" 选项:你得自己保证填的参数是对的。
¶4 路由配置——LinkProperties 的构建
DHCP 成功后,DhcpResults(yiaddr/网关/DNS)被转换成 LinkProperties(IP 地址、网关、DNS、路由表)。静态 IP 路径下,StaticIpConfiguration.toLinkProperties() 直接从用户配置构建 LinkProperties。这个 LinkProperties 最终通过 NetworkAgent.sendLinkProperties() 上报给 ConnectivityService。
DHCP 分完了房间号,接下来要填一张入住信息汇总表——房间号、内线号码、WiFi 密码、叫醒服务——这张表就是 LinkProperties。路由配置就是告诉酒店总机:本层以内直接打内线(直连路由),打外线统一拨 9(默认路由)。
¶4.1 DHCP 路径:从 DhcpResults 到 LinkProperties
DHCP ACK 报文包含 yiaddr(分配的 IP)、子网掩码、网关(Option 3)、DNS(Option 6)等信息。DhcpPacket.toDhcpResults() 将这些字段解析为 DhcpResults 对象,核心字段涵盖 ipAddress(yiaddr+ 子网掩码)、gateway(Option 3)、dnsServers(Option 6)、domains(Option 15)、serverAddress(DHCP 服务器地址)、leaseDuration(租约秒数)、mtu、captivePortalApiUrl(Option 114)等。
从 DhcpResults 到 LinkProperties 的转换分两步走:DhcpResults.toStaticIpConfiguration()(DhcpResults.java:76)通过 Builder 将 yiaddr 映射为 ipAddress、Option 3 映射为 gateway、Option 6 映射为 dnsServers,生成 StaticIpConfiguration;第二步,StaticIpConfiguration.toLinkProperties() 在此基础上加上接口名和路由,构建最终的 LinkProperties:
1 | // StaticIpConfiguration.java |
getRoutes() 的路由生成逻辑——这是从 IP 配置到内核路由表的映射。StaticIpConfiguration.getRoutes(iface) 自动生成两类路由(加一个边界情况):
1 | // StaticIpConfiguration.java - 路由生成 |
两条路由的设计意图:
- 直连路由(如
192.168.1.0/24 → wlan0):无网关,直接通过接口通信。用于与局域网内其他设备通信(如文件共享、投屏) - 默认路由(如
0.0.0.0/0 → 网关 192.168.1.1 → wlan0):所有不在直连子网的流量通过网关转发。这是能 "上网" 的关键
最终 LinkProperties 对象包含了五个维度的网络属性(以下为 DHCP 路径来源,静态 IP 路径的对应值来自用户手动配置):
| 维度 | 字段 | 来源 | 说明 |
|---|---|---|---|
| 接口寻址 | mLinkAddresses | DHCP yiaddr + 子网掩码 | 接口 IP 地址和前缀长度 |
| 路由 | mRoutes | DHCP Option 3(网关)+ 自动生成直连路由 | 默认路由和直连路由 |
| DNS | mDnses | DHCP Option 6(DNS) | DNS 服务器地址列表 |
| 搜索域 | mDomains | DHCP Option 15(域名) | DNS 搜索域 |
| 接口标识 | mInterfaceName | 当前接口名(如 wlan0) | 网络接口标识 |
在 IpClient 内部,assembleLinkProperties()(IpClient.java:2005)按三阶段叠加策略整合所有来源:先通过 mLinkObserver.getLinkProperties() 拉取 netlink 层的 IPv4/IPv6 地址、路由和 DNS 作为基座;再检查 mDhcpResults——若非空(无论是 DHCP 服务器返回的真实 DhcpResults,还是静态 IP 路径下 handleIPv4Success() 从 mConfiguration.mStaticIpConfig 构造的 DhcpResults),则调用 toStaticIpConfiguration().getRoutes(iface) 追加 DHCP / 静态 IP 侧的路由、DNS、域名和 MTU;最后遍历 mDelegatedPrefixes 为 DHCPv6 委派前缀添加直连路由。三阶段组装完毕后,与 mLinkProperties 做地址 diff(LinkPropertiesUtils.compareAddresses),检测新增 / 移除的地址变更,每次收到新的配置事件后重新组装并通知 ConnectivityService。
¶4.2 静态 IP 路径:从 StaticIpConfiguration 到 LinkProperties
StaticIpConfiguration 的 toLinkProperties()(StaticIpConfiguration.java:237,完整代码见 §4.1)同样用于静态 IP 路径,区别在于输入来源:DHCP 路径的 ipAddress/gateway/dnsServers 来自服务器 ACK 报文,静态路径的这些值来自用户手动配置。
设计意图:
- 直接将
ipAddress加入LinkProperties的地址列表——不需要 DHCP 协商 getRoutes()自动生成两条路由:直连路由(ipAddress对应的子网)和默认路由(通过gateway)- DNS 服务器和搜索域直接来自用户配置,不需要从 DHCP 报文中解析
- 与 DHCP 路径的关键差异:DHCP 路径的
ipAddress/gateway/dnsServers来自服务器的 ACK 报文(不可控),静态路径的这些值来自用户手动输入(可控但可能配置错误)
¶4.3 sendLinkProperties:从 WiFi 到内核 FIB(Forwarding Information Base)的完整调用链
LinkProperties 构建完成后,ClientModeImpl 通过 NetworkAgent.sendLinkProperties() 上报给 ConnectivityService。这不只是一次简单的函数调用——从 WiFi 侧的 updateLinkProperties() 到内核 fib_table_insert() 写入 FIB,中间经过了 5 层调用链,跨越进程边界和用户态 / 内核态边界。
先看全景——每一层在哪个组件、做什么动作、跨越什么边界:
| 层 | 组件 | 关键动作 | 跨越的边界 |
|---|---|---|---|
| 1 | ClientModeImpl(WiFi 框架) | 检测 LinkProperties 变化 → 调用 sendLinkProperties() | — |
| 2 | NetworkAgent + AIDL | 深拷贝 + oneway Binder IPC 发送 | 进程边界:system_server → netd |
| 3 | ConnectivityService | 接收消息 → 9 步管线处理(路由/dns/mtu/vpn) | — |
| 4 | updateRoutes() 路由 diff | 新旧路由 diff → 分 added/removed/updated 下发 | — |
| 5 | netd → netlink → 内核 | sendmsg(AF_NETLINK) → fib_table_insert() | 用户态 / 内核态边界 |
下面按层追踪每一步的代码路径。
第 1 层:WiFi 侧触发
ClientModeImpl.updateLinkProperties()(ClientModeImpl.java,行 2920)检测到 LinkProperties 变化后,调用 mNetworkAgent.sendLinkProperties(mLinkProperties)(行 2929)将新的 LinkProperties 发送给 ConnectivityService。
这是 WiFi 模块和系统网络栈之间的唯一数据出口——WiFi 侧只负责获取 IP 和构建 LinkProperties,之后的一切交给 ConnectivityService。
第 2 层:AIDL 单向 Binder IPC
NetworkAgent.sendLinkProperties()(framework/src/android/net/NetworkAgent.java,行 1071-1076):深拷贝 LinkProperties 后通过 INetworkAgentRegistry AIDL 接口发送。这是一个 oneway 单向 Binder 调用——fire-and-forget,不等待返回。
Service 端在 NetworkAgentInfo.java(行 975-980)中,NetworkAgentMessageHandler 收到调用后构造 EVENT_NETWORK_PROPERTIES_CHANGED 消息,投递到 ConnectivityService 主线程的消息队列。
oneway单向调用意味着 WiFi 侧不会被 ConnectivityService 的处理速度拖慢——发完就返回,路由配置异步进行。
第 3 层:ConnectivityService 核心处理
消息在 ConnectivityService 主线程被处理。入口函数 handleUpdateLinkProperties()(ConnectivityService.java:11040)首先确保在 ConnectivityService 主线程执行,然后从全局注册表检查网络仍存在(已断开的网络忽略更新),通过防御性拷贝取出旧 LinkProperties,委派 updateLinkProperties(nai, newLp, oldLp)(行 9737-9806)执行以下 9 步管线:
- clatd fixup — IPv6-to-IPv4 转换适配
updateInterfaces()— 绑定接口到 netId- VPN 过滤规则更新
- MTU 更新
updateRoutes()— 路由 diff 与下发(展开见第 4 层)updateDnses()— DNS 配置下发- 更新 proxy / Captive Portal 配置
- 写回
nai.linkProperties = newLp - 通知
NetworkMonitor和所有注册的NetworkCallbacks
这 9 步的顺序不是随意的——路由更新(第 5 步)先于 DNS 更新(第 6 步),源码注释明确说明「add routes before removing old in case it helps with continuous connectivity」,先加新路由再删旧路由,保证连通性不中断。路由更新是其中最复杂的环节,下面展开。
第 4 层:updateRoutes () —— 路由 diff 与下发
ConnectivityService.java 行 10150-10204:使用 CompareOrUpdateResult<RouteInfo.RouteKey, RouteInfo> 对新旧路由做 diff。RouteKey 由(目标网段 + 网关 + 接口名)三元组构成,比较结果分三类:
- added:新路由 — 添加顺序为先 non-gateway 路由(直连路由),后 gateway 路由(默认路由),确保直连路由先生效
- removed:旧路由 — 删除
- updated:已变更路由 — 逐一调用
updateRoute()更新
每一步通过 mRoutingCoordinatorService.addRoute(netId, route) / removeRoute(netId, route) / updateRoute(netId, route) 下发。
1 | // ConnectivityService.java - updateRoutes 核心逻辑(简化) |
路由 diff 而非全量替换——只更新变化的部分。先加 non-gateway 后加 gateway 的顺序保证了:即使默认路由还没写入,局域网内通信已经可用。实际实现中每个
addRoute/removeRoute/updateRoute调用都包裹了 try-catch——单条路由添加失败(如 netd 返回ServiceSpecificException)只记录日志不传播异常,确保其余路由仍能正常配置。
第 5 层:netd → netlink → 内核 FIB
RoutingCoordinatorService.addRoute(netId, route)(行 82-84)→ mNetd.networkAddRouteParcel(netId, ...) → netd C++ 守护进程中的 networkAddRouteParcel() → RouteController::modifyRoute(RTM_NEWROUTE, ...) → sendmsg(AF_NETLINK, NETLINK_ROUTE) 发送 netlink 消息到内核 → 内核 rtm_newroute() → fib_table_insert() 将路由写入 Forwarding Information Base(FIB)。
链路的两个端点都有真实代码可以钉住。Java 侧的出口 RoutingCoordinatorService.addRoute() 就是 ConnectivityService 跨入 netd 的那一步——它把 RouteInfo 打包成 RouteInfoParcel,通过 AIDL 发给 netd:
1 | // RoutingCoordinatorService.java:82-85 —— ConnectivityService 侧跨入 netd 的出口 |
netd 的 C++ 实现(system/netd 中的 RouteController::modifyRoute)不在本仓库内,但它的终点——内核侧——是可追溯的。RTM_NEWROUTE netlink 消息在内核由 inet_rtm_newroute() 处理(前文所说 rtm_newroute() 的正式符号,PF_INET 注册的处理器),最终落到 fib_table_insert() 写入 FIB:
1 | // QCOM/kernel-msm/net/ipv4/fib_frontend.c:885 —— RTM_NEWROUTE 在内核的处理器 |
1 | RoutingCoordinatorService.addRoute() |
这是整个路由配置链路的终点——经过 Java → AIDL Binder → Java → AIDL Binder → C++ → netlink 六段跨进程 / 跨态跳转,最终一条
0.0.0.0/0 → 网关的路由被写入内核转发表。之后所有发往互联网的 IP 包都会被内核按这条路由转发。
小结:WiFi 侧负责获取 IP 地址和构建 LinkProperties(§1~§4),ConnectivityService 负责将 LinkProperties 拆解为路由 / DNS 并通过 netd → netlink 写入内核——这是 WiFi 和系统网络栈之间的分工边界。从 ClientModeImpl.updateLinkProperties() 到内核 fib_table_insert(),一共 5 层调用,跨越了 Java → AIDL Binder → Java → AIDL Binder → C++ → netlink → 内核的完整技术栈。
¶5 NetworkAgent 注册——向系统 "报到"
DHCP 或静态 IP 配置完成后,ClientModeImpl 创建 WifiNetworkAgent 并注册到 ConnectivityService。ConnectivityService 通过评分比较(WiFi vs 蜂窝)决定默认网络。WiFi 初始评分为 WIFI_INITIAL_SCORE(60),通常高于蜂窝,因此连接成功后 WiFi 会立即成为默认网络。
¶5.1 注册时机
填完了入住信息汇总表(LinkProperties),接下来就是去前台报到了——把登记表交给酒店管理系统,让系统知道「我住进来了,我是什么级别的客人」。这正是 WifiNetworkAgent 的注册过程。
IP 配置完成后,IpClient 通过回调通知 ClientModeImpl。WifiNetworkAgent 本身在 L2ConnectedState 阶段就已创建并注册到 ConnectivityService(ClientModeImpl.java:6500,见 §1.1)——构造函数立即调用 register(),向系统报到的是一个空 LinkProperties 的「壳」网络。L3 配置的职责是「补齐身份」,通过 sendLinkProperties() 将 IP/路由/DNS 填入已注册的网络:
1 | // WifiNetworkAgent.java - 构造函数(省略了 Context/Looper 参数,完整参数见注释) |
几个关键点:
Context和Looper是 Android Framework 基础设施参数,用于 Handler 线程绑定和系统服务访问——对理解 WiFi 注册逻辑而言非重点- 构造函数接收
LinkProperties(刚才从 DHCP 或静态配置构建的)和NetworkCapabilities(描述网络能力:传输类型、带宽等) - 初始评分 =
ConnectedScore.WIFI_INITIAL_SCORE= 60(WiFi 的最高评分) - 构造时立即调用
register()——这是关键设计,不需要额外的 "注册" 步骤
ConnectedScore 中,WIFI_MAX_SCORE=60,WIFI_INITIAL_SCORE=60(初始评分,取 WIFI_MAX_SCORE),WIFI_TRANSITION_SCORE=50(转移阈值),WIFI_MIN_SCORE=0(最低评分)。
¶5.2 ConnectivityService 的评分比较
WiFi 注册后能否成为默认网络,取决于 ConnectivityService 的评分比较机制。这个机制比 "60 > 50" 要精细得多。
评分的多维度构成。ConnectivityService 使用 NetworkScore 对象而非简单的 int——它封装了旧式评分 mLegacyInt、策略位掩码 mPolicies(如 POLICY_YIELD_TO_BAD_WIFI)和保持连接原因 mKeepConnectedReason(如 KEEP_CONNECTED_FOR_HANDOVER,切换场景下保持原网络)三个维度。
WiFi 的初始 legacy int 是 60,但这只是维度之一。ConnectivityService 通过 NetworkRanker 综合考虑 legacy int + policies + validation status 来决定最终排名,NetworkScore 本身不实现 Comparable。
全局重匹配:rematchAllNetworksAndRequests()。评分更新后,核心循环如下:
以下为
rematchAllNetworksAndRequests的概念逻辑。实际比较由NetworkRanker.getBestNetworkByPolicy()执行,不是简单的数值比较。
1 | // [概念逻辑] ConnectivityService.rematchAllNetworksAndRequests 核心循环 |
NetworkRanker.getBestNetworkByPolicy() 并非简单的 "legacy int 比大小",而是按策略优先级对候选网络执行多轮筛选(NetworkRanker.java,packages/modules/Connectivity)。每一轮筛选用 partitionInto(candidates, predicate) 将候选网络分成 accepted/rejected 两组——如果 accepted 中只剩一个,它就是胜出者;否则保留 accepted 组继续下一轮:
| 轮次 | 策略条件 | 作用 |
|---|---|---|
| 1 | POLICY_IS_INVINCIBLE | 无敌网络(如始终在线的蜂窝),直接胜出 |
| 2 | POLICY_IS_VPN | 已连接的 VPN,优先于普通网络 |
| 3 | POLICY_EVER_USER_SELECTED + POLICY_ACCEPT_UNVALIDATED | 用户明确选择 + 接受未校验(如手动连 WiFi) |
| 4 | POLICY_IS_VALIDATED + POLICY_ACCEPT_UNVALIDATED | 已通过校验的网络(或明确接受未校验);随后 applyYieldToBadWifiPolicy() 保留 "偏好差 WiFi" 候选 |
| 5 | !POLICY_EXITING | 排除正在退出的网络 |
| 6 | POLICY_TRANSPORT_PRIMARY | 每种 transport 只保留 primary 网络 |
| 7 | PREFERRED_TRANSPORTS_ORDER | 按 transport 优先级过滤(WiFi > Cellular > ...) |
| 8 | !POLICY_IS_DESTROYED | 排除已标记销毁的网络 |
| 9 | currentSatisfier != null | 如果旧网络仍在候选,保留它(避免乒乓切换) |
这里有几个细节:
nai.satisfies(nri.request)— 不仅要评分高,网络能力必须匹配应用请求(如NET_CAPABILITY_INTERNET)nai.isValidated()— 只有通过网络校验(见 §6.5)的网络才参与竞争。WiFi 刚注册时默认NOT_VALID,如果校验一直没通过,评分再高也不会成为默认网络- 策略优于数值 — 即使 WiFi legacy int=60、蜂窝 = 50,WiFi 在校验通过前也不会成为默认网络——因为第 4 轮
POLICY_IS_VALIDATED直接淘汰所有未校验网络 - YIELD_TO_BAD_WIFI:校验失败的例外 — 第 4 轮之后立即执行
applyYieldToBadWifiPolicy()(NetworkRanker.java:197),处理一个反直觉的场景:蜂窝网络带POLICY_YIELD_TO_BAD_WIFI策略时,会主动让位给 "曾经能上网但现在校验失败" 的 WiFi。判定 "preferred bad WiFi" 的条件在isPreferredBadWiFi()(NetworkRanker.java:155)中:必须是TRANSPORT_WIFI+ 未校验 + 非用户回避 + 曾经校验过(POLICY_EVER_VALIDATED)。换言之,一个你昨天还正常使用的 WiFi 今天 Captive Portal 突然弹出来,系统不会立刻切到蜂窝——它会给 WiFi 一个 "优先保留" 的机会,等你完成 Portal 认证后恢复 VALID 状态 - 旧网络偏好 — 第 9 轮确保如果当前默认网络仍然在候选列表中,不会被贸然替换,避免 "新网络刚连上就被旧网络抢回去" 的乒乓切换
¶5.3 registerNetworkAgent () 内部:WiFi 如何被 ConnectivityService "接收"
WiFi 侧调用 WifiNetworkAgent.register() 后,进入 ConnectivityService 内部。让我们看它如何处理这个新来的 WiFi 网络:
registerNetworkAgentInternal()(ConnectivityService.java:9504)是两阶段异步注册的第一阶段:它通过 mNetIdManager.reserveNetId() 分配 netId,创建 NetworkAgentInfo(封装了网络的所有元数据),然后调用 NetworkStack.makeNetworkMonitor() 创建 NetworkMonitor 实例(用于后续网络校验),最后将 netId 和 AIDL registry 返回给 WiFi 侧。第二阶段在 handleRegisterNetworkAgent()(ConnectivityService.java:9545)中完成:将 NAI 加入全局注册表 mNetworkAgentInfos(WiFi、蜂窝、以太网、VPN 都在此表中)→ 调用 mixInCapabilities() 注入系统级能力(TRANSPORT_WIFI、NET_CAPABILITY_INTERNET 等,供后续 satisfies() 匹配)→ processLinkPropertiesFromAgent() 处理初始链路属性 → networkMonitor.start() 启动校验 → updateNetworkInfo() 触发 rematchAllNetworksAndRequests() 全局重匹配(WiFi 评分高于当前蜂窝则成为默认网络)。两阶段设计保证了 WiFi 侧拿到 netId 后可以立即开始发送数据,而注册和重匹配在 ConnectivityService 主线程异步完成。
第一阶段的精简代码——三个关键动作分配 netId、创建 NAI、启动 NetworkMonitor 创建:
1 | // ConnectivityService.java - 第一阶段:分配 netId + 创建 NAI + 启动 NetworkMonitor |
连接后,WifiScoreReport 会持续根据信号质量、链路速度等指标更新评分:
- 信号好 → 评分保持 60(最高)
- 信号变差 → 评分降低,如果降到低于蜂窝 → 蜂窝恢复为默认网络
- 评分通过
NetworkAgent.sendNetworkScore()实时上报 ConnectivityService
registerNetworkAgent 就是前台把你登记进酒店管理系统——录入入住信息、分配客编号码、跟已有 VIP 客人比优先级、通知各部门 "客人已入住"。评分就像酒店的会员等级:WiFi 初始就是金卡(60 分),蜂窝是银卡(~50 分),所以一登记就自动被系统选为 "当前默认"。
¶5.4 回调链路
WifiNetworkAgent 定义了 Callback 接口(WifiNetworkAgent.java:38),包含 10 个回调方法,ClientModeImpl 通过内部类 WifiNetworkAgentCallback(ClientModeImpl.java:5465)实现。每个回调首先通过 isThisCallbackActive() 守卫检查(确认自身仍是当前活跃的 NetworkAgent 的回调,避免旧 Agent 的残留回调干扰新流程),然后将 ConnectivityService 的通知转换为状态机消息。两个核心回调的完整处理链如下:
onNetworkUnwanted() —— 系统不再需要 WiFi(ClientModeImpl.java:5473)。ConnectivityService 在 WiFi 评分低于蜂窝且蜂窝已恢复为默认网络时调用此回调。WifiNetworkAgentCallback 收到后调用 unwantedNetwork(NETWORK_STATUS_UNWANTED_DISCONNECT),发送 CMD_UNWANTED_NETWORK 到状态机。在 L3ConnectedState 的 processMessageImpl()(ClientModeImpl.java:7416)中,DISCONNECT 路径执行两步:先检查 RSSI——如果信号弱(低于 getSufficientRssi())且非用户近期主动选择的网络,调用 WifiConfigManager.updateNetworkSelectionStatus() 将网络标记为 DISABLED_UNWANTED_LOW_RSSI(阻止自动重连到这个信号差的网络);然后设置断连原因码 DISCONNECT_UNWANTED_BY_CONNECTIVITY,发送 CMD_DISCONNECT 触发状态机退出 L2ConnectedState,断开连接。
onValidationStatus() —— 网络校验结果回调(ClientModeImpl.java:5483)。这个回调处理三条路径,每条路径触发不同的系统行为:
VALIDATION_STATUS_NOT_VALID 路径(ClientModeImpl.java:5487):调用 unwantedNetwork(NETWORK_STATUS_UNWANTED_VALIDATION_FAILED),在 L3ConnectedState 中(ClientModeImpl.java:7471)执行三步——先调用 mWifiDiagnostics.reportConnectionEvent(CONNECTION_EVENT_FAILED) 报告连接失败事件;再调用 mWifiConfigManager.incrementNetworkNoInternetAccessReports() 递增该网络的 "无互联网访问" 计数器;最后根据历史记录决定惩罚力度:如果该网络从未校验过(!hasEverValidatedInternetAccess())且不期望无互联网(!noInternetAccessExpected),直接标记 DISABLED_NO_INTERNET_PERMANENT(永久禁用自动重连);否则标记 DISABLED_NO_INTERNET_TEMPORARY(临时禁用,一段时间后恢复)。
VALIDATION_STATUS_VALID 路径(ClientModeImpl.java:5493):调用 doNetworkStatus(status) 发送 CMD_NETWORK_STATUS,在 L3ConnectedState 中(ClientModeImpl.java:7510)执行恢复操作——mWifiDiagnostics.reportConnectionEvent(CONNECTION_EVENT_SUCCEEDED) 报告成功、mWifiScoreCard.noteValidationSuccess() 记录校验历史、mWifiBlocklistMonitor.handleNetworkValidationSuccess() 清除黑名单惩罚、mWifiConfigManager.setNetworkValidatedInternetAccess(true) 标记网络有互联网访问能力、恢复 autojoin 让该网络重新参与自动选择。
Captive Portal 检测路径(ClientModeImpl.java:5501):如果 redirectUri 非空(ConnectivityService 将 HTTP 302 重定向的 URL 传回),调用 mWifiConfigManager.noteCaptivePortalDetected() 记录该网络为 Captive Portal,同时通知 mCmiMonitor.onCaptivePortalDetected() 让系统弹出 "登录 WiFi" 通知。这个检测与 VALID/NOT_VALID 是并行的——一个网络可以同时是 NOT_VALID 且是 Captive Portal。
其他回调。WifiNetworkAgent.Callback 还定义了 7 个辅助回调:onSaveAcceptUnvalidated()(用户在设置中选择 "接受未校验网络" 时触发,发送 CMD_ACCEPT_UNVALIDATED)、onStartSocketKeepalive()/onStopSocketKeepalive()(TCP keepalive 管理,通过 CMD_START_IP_PACKET_OFFLOAD/CMD_STOP_IP_PACKET_OFFLOAD 下发到驱动)、onAddKeepalivePacketFilter()/onRemoveKeepalivePacketFilter()(APF 包过滤规则管理)、onSignalStrengthThresholdsUpdated()(信号强度阈值更新)和 onAutomaticReconnectDisabled()(禁用自动重连)。这些回调共同构成了 ConnectivityService 对 WiFi 连接的完整控制面。
¶6 IP 可达性监测——网关还在不在?
IpReachabilityMonitor 通过内核 NUD(Neighbor Unreachability Detection)持续监测网关和 DNS 服务器的可达性。如果网关变成 NUD_FAILED,通知 ClientModeImpl 触发重连。
¶6.1 为什么需要它?
入住登记完成、金卡身份确认,不代表一切就万事大吉。DHCP 分配了 IP、路由配置好了——接下来呢?酒店工程部还得持续监听每间客房的线路状态:网关死机、AP 断电、信号被屏蔽。这正是 IpReachabilityMonitor 的职责——通过内核 NUD 持续监测网关可达性,一旦发现线路断了就立刻告警。
¶6.2 核心机制:内核 NUD
IpReachabilityMonitor 并不自己发送探测包——它依赖 Linux 内核的 NUD(Neighbor Unreachability Detection)。NUD 是内核邻居子系统的内置功能:当内核需要向某个 IP 发送数据但 ARP/ND 缓存中没有对应 MAC 地址时,内核自动发送探测请求。
IpReachabilityMonitor 通过 netlink socket 监听内核的邻居状态变化事件:
1 | // IpReachabilityMonitor.java |
这段代码实现了四个能力:
mNeighborWatchList:监测列表——从LinkProperties中提取的网关地址和 DNS 服务器地址NUD_FAILED→handleNeighborLost():邻居不可达,检查是否导致 provisioning 丢失NUD_REACHABLE→handleNeighborReachable():邻居可达,记录到mEverReachableNeighbors,漫游后检测 MAC 地址是否变化mEverReachableNeighbors:只对曾经可达的邻居触发告警——从未可达的邻居(如配置了不通的 DNS)不触发
IpReachabilityMonitor 对 NUD 中间状态(NUD_STALE、NUD_DELAY、NUD_PROBE)不做任何告警处理——源码注释明确指出这些是 "perfectly normal; we merely record the new state"。这些状态构成了内核邻居可达性确认的完整链路:邻居从 NUD_REACHABLE 超时后进入 NUD_STALE(数据仍可发送但可达性待确认),有数据发送时转入 NUD_DELAY(等待上层确认可达),超时后进入 NUD_PROBE(主动发送 ARP/NS 探测)。只有连续 ucast_solicit 次 NUD_PROBE 无应答才会跌入 NUD_FAILED。这个设计避免了中间态的假阳性告警——邻居可能只是暂时没有数据交互(如用户在看本地文件),并非真正不可达。
NUD 探测参数。IpReachabilityMonitor 本身不控制探测频率——那是内核 NUD 状态机的职责。但它通过 setNeighborParameters() 调整内核的邻居参数(写入 /proc/sys/net/ipv{4,6}/neigh/<ifname>/),影响内核 NUD 的行为:
这些参数的硬编码边界定义在 IpReachabilityMonitor.java(行 159-163),实际值从 config.xml 资源文件读取(见下表):
| 内核参数(/proc/sys/...) | Android 默认 | 含义 |
|---|---|---|
ucast_solicit | 10(config.xml 默认,稳定态)/ 5(config.xml 默认,漫游后) | 单播探测最大次数。内核在 NUD_PROBE 状态下每隔 retrans_time_ms 发送一次 ARP/NS 探测,连续 ucast_solicit 次无应答后进入 NUD_FAILED |
retrans_time_ms | 750ms(稳定态与漫游后) | 探测包重传间隔。默认 1s,Android 调低到 750ms 以加快不可达检测 |
mcast_resolicit | 3(仅当功能门控启用) | 单播失败后改用组播重探测的次数。在 WiFi 环境中组播更可靠(避免 ARP 表过期),所以保留 3 次额外尝试 |
源码中 MIN_NUD_SOLICIT_NUM = 5 和 MAX_NUD_SOLICIT_NUM = 15 是硬编码的边界,实际值从 config.xml 资源文件读取:
config_nud_steadystate_solicit_num = 10→ 稳定态用 10 次探测config_nud_postroaming_solicit_num = 5→ 漫游后用 5 次探测
这个设计的核心考量是:
- 稳定态 vs 漫游后:稳定态用 10 次探测 × 750ms = 7.5s 判定时间——对于 "连接早已建立" 的稳态网络,宁可多等几秒也不因 transient 丢包产生假阳性断开。漫游后(
probeAll(true))切换到 5 次探测 × 750ms = 3.75s——刚切到新 AP 后需要快速确认网关可达,因为如果新 AP 有问题,用户已经在上网了,必须尽快发现并重连 - 为什么稳定态反而探测更多? 这是个看似反直觉但正确的设计:漫游场景下时间是第一位——"新 AP 能不能用" 必须快速回答,3.75s 找不到答案就赶紧换。稳定态下容错是第一位——偶尔一个广播帧丢包不应该导致已稳定的 WiFi 连接被掐断,所以给内核多几次尝试机会(10 次)
- 内核自动探测:NUD 状态机在内核中运行,不需要用户空间干预。当内核需要向邻居发送数据但 ARP/ND 缓存中没有对应条目时,自动进入 NUD_INCOMPLETE → NUD_PROBE → NUD_REACHABLE/NUD_FAILED 流程。IpReachabilityMonitor 只负责监听 netlink 消息并在 NUD_FAILED 时告警
¶6.3 不可达处理
handleNeighborLost() 不是简单地 "一个邻居丢了就告警",而是构建一个假设性 LinkProperties(whatIfLp),检查是否导致整个 IPv4 或 IPv6 provisioning 丢失:
1 | // IpReachabilityMonitor.java |
遍历完所有不可达邻居后,handleNeighborLost 检查 whatIfLp 是否还满足 provisioning 条件——如果原本有 IPv4 或 IPv6 配置,去掉不可达邻居对应的路由和 DNS 后却不再满足 provisioning,就认为发生了 provisioning 丢失,触发告警:
1 | final boolean lostIpv4Provisioning = |
这段代码有三层判断:
- 不只看单个邻居——通过构建 whatIfLp(假设网关丢失后的 LinkProperties),判断是否导致 provisioning 实质性丢失
- 只有曾经可达(
mEverReachableNeighbors)的邻居不可达才触发告警——避免初始化的假阳性 - 有机 NUD 失败(如设备休眠导致的超时)可以通过
mIgnoreOrganicNudFailure开关忽略 - 漫游后如果网关 MAC 地址变化 → 也触发
notifyLost(防止 ARP 欺骗) notifyLost最终通知 ClientModeImpl 的IpClientCallbacksWrapper,触发网络重连
notifyLost() 的告警并没有直接触发断连——它经过 IpClient 的 IpClientCallbacksWrapper 转发到 ClientModeImpl 的 handleIpReachabilityFailure()(ClientModeImpl.java:4191),由后者根据丢失原因分派处理。这个分派逻辑体现了 Android 对 NUD 失败的精细化处理——不同原因触发不同的恢复策略:
handleIpReachabilityFailure()(ClientModeImpl.java:4191)按 ReachabilityLossReason 分三路处理:
- 漫游(ROAM):直接调用
processIpReachabilityFailure()进入重试路径。 - 确认(CONFIRM)/ 有机(ORGANIC):先经过两层过滤。第一层
shouldIgnoreNudDisconnectForWapiInCn()(ClientModeImpl.java:4178),中国区 WAPI 网络在 V 以下版本直接忽略 NUD 断连。第二层mDeviceConfigFacade.isHandleRssiOrganicKernelFailuresEnabled()开关,决定走processIpReachabilityFailure()(新路径,保持 L2 重建 L3)还是processLegacyIpReachabilityLost()(旧路径,直接断连)。
重试路径在 processIpReachabilityFailure()(ClientModeImpl.java:4108)中实现。它维护一个 mNudFailureCounter 计数器(时间窗口 + 累计次数),如果在 getRepeatedNudFailuresWindowMs() 时间窗口内 NUD 失败次数达到 getRepeatedNudFailuresThreshold() 阈值,就认为这个网络「反复不可用」——调用 WifiConfigManager.updateNetworkSelectionStatus() 将该网络标记为 DISABLED_REPEATED_NUD_FAILURES(禁止自动重连),然后走 handleIpReachabilityLost() 断开。
未达阈值时,processIpReachabilityFailure()(ClientModeImpl.java:4108)选择更温和的恢复方式:先注销旧 NetworkAgent——Android T 及以上版本调用 unregisterAfterReplacement()(行 4144)优雅替换,低版本直接 unregister()——并立即创建新的 WifiNetworkAgent(行 4149)刷新网络身份;再调用 maybeShutdownIpclient()(行 4160)关闭旧 IpClient 实例,防止旧回调干扰新流程(参见 b/286338765);最后 transitionTo(mWaitBeforeL3ProvisioningState)(行 4162)等待后重新走 startIpClient() 启动完整 L3 配置——整个过程中 L2 连接始终保持不断,相当于「重新拉一下网线」而非「换一间房」。
最终断连路径 handleIpReachabilityLost()(ClientModeImpl.java:4080)执行六步操作:
- 将 BSSID 加入黑名单(
WifiBlocklistMonitor.handleBssidConnectionFailure())——避免自动重连到同一个有问题的 AP - 记录评分卡(
WifiScoreCard.noteIpReachabilityLost())——为后续连接质量分析保留数据 - 清空
mWifiInfo的 InetAddress 和计费标记——重置网络身份信息 - 设置断连原因码(
mFrameworkDisconnectReasonOverride)——根据丢失原因 ROAM/CONFIRM/ORGANIC 写入不同的WifiStatsLog断连原因,用于后续问题排查 - 发送
CMD_DISCONNECT断开连接——触发状态机退出 L2ConnectedState - 调用
updateCurrentConnectionInfo()刷新框架层连接信息——确保上层 UI 和系统服务看到最新的连接状态
¶6.4 主动探测与参数调整
连接建立后或漫游后,probeAll() 主动触发邻居探测:
1 | // IpReachabilityMonitor.java |
核心逻辑:
- 漫游后使用更激进的 NUD 参数(
config_nud_postroaming_solicit_num)——因为需要快速确认新 AP 下的网关可达性 - 确认所有邻居可达后,参数恢复到稳定状态(
config_nud_steadystate_solicit_num) - 探测期间持有 WakeLock 防止 CPU 休眠
¶6.5 网络校验——IP 通了不代表能上网
网关可达(NUD 通过)只说明 "数据包能发到网关",但不代表网关后面有互联网。IP 地址正确、路由表正确、网关可达——这三个条件都满足,按理说上网不成问题。但用户依然可能打不开网页。这就是 Captive Portal(强制门户)带来的问题:很多公共 WiFi(酒店、机场、咖啡馆)要求用户先在登录页输入密码或同意条款,DHCP 正常分配了 IP,网关也 ping 得通,但所有 HTTP 流量都被重定向到登录页。
Android 通过网络校验(Network Validation)来判断是否真的能上网。
NetworkMonitor:校验的发起者。当 ConnectivityService 注册了新网络后,会启动 NetworkMonitor 来验证网络的互联网可达性:
NetworkMonitor 通过状态机驱动整个验证流程——内核很简单,就是三级跳:
状态转移路径:
1 | EvaluatingState(初始) |
这个状态机的实际代码比上述简化版更复杂——包含 DNS 健康检测、多次重试、带宽评估等子状态,但核心骨架就是 Evaluating → Probing → Validated/CaptivePortal 三级。需要指出的是,实际层级关系与上图的线性表示有所不同:ValidatedState 是 DefaultState 的直接子状态(与 MaybeNotifyState 同级),而 EvaluatingState、ProbingState、CaptivePortalState 都在 MaybeNotifyState 下——这意味着从 ValidatedState 回到 EvaluatingState 不是「子状态退出再进入」,而是跨层级的状态跳转,由 DefaultState 统一处理路由 / DNS 变化事件后分派。
两个关键状态的 enter() 最能说明这条链路的实质。ProbingState.enter()(NetworkMonitor.java:2103)真正 "发出探测"——它在后台线程里跑 isCaptivePortal(),而不是在主线程阻塞等待 HTTP 响应:
1 | // NetworkMonitor.java:2103 —— ProbingState.enter():发起校验探测 |
ValidatedState.enter()(NetworkMonitor.java:1263)则是 "校验通过" 的落点——它上报 VALID 结果,然后立刻从一次性探测切换到持续监测(§6.6 展开):
1 | // NetworkMonitor.java:1263 —— ValidatedState.enter():校验通过,进入持续监测 |
重试机制。校验失败后,NetworkMonitor 并不无限重试——它使用指数退避策略,且首次 5 次重评估会被忽略以避免网络刚上线时的频繁抖动。核心常量定义如下(NetworkMonitor.java:445-458):
几个关键阈值的设计逻辑:
INITIAL_REEVALUATE_DELAY_MS(1000ms)→MAX_REEVALUATE_DELAY_MS(600000ms):退避从 1 秒开始,每次翻倍,上限 10 分钟。因此失败后系统不会立即重试,第 1 次重试等 1s,第 2 次等 2s,第 3 次等 4s…… 最终稳定在每 10 分钟重试一次IGNORE_REEVALUATE_ATTEMPTS = 5:ConnectivityService 在网络注册后可能会连续发出多次CMD_REEVALUATE(每条路由 / DNS 变化都会触发),前 5 次被静默忽略——避免 "每加一条路由就做一次全量校验" 的浪费CAPTIVE_PORTAL_REEVALUATE_DELAY_MS = 600000ms:Captive Portal 网络每 10 分钟重新校验一次——用户在浏览器完成认证后,系统不会立即感知,需要等下一次重评估周期- Probe 超时(3s)远短于 socket 超时(10s),因为 probe 只是发一个 HTTP GET 到
generate_204,服务器响应极快(通常 < 100ms),3s 已经非常宽松;socket 超时 10s 是兜底保护
Probe URL 和 HTTP 204 验证。NetworkMonitor 向 Google 的连通性检查服务器发送 HTTP 和 HTTPS probe:
1 | HTTPS probe: https://www.google.com/generate_204 |
如果返回的是 HTTP 302 重定向(被重定向到 Captive Portal 登录页),或者连接超时,NetworkMonitor 判定网络校验失败。
校验状态枚举。NetworkAgent 只定义了两个公开校验状态(NetworkAgent.java):
| 状态 | 含义 | 触发条件 |
|---|---|---|
VALIDATION_STATUS_NOT_VALID | 未通过校验 | 网络刚注册,尚未发送 probe;或 Captive Portal 检测到(HTTP 302,此时附带 redirectUri) |
VALIDATION_STATUS_VALID | 校验通过 | HTTP 204 返回 |
Captive Portal 如何传递:NetworkMonitor 内部用
NETWORK_VALIDATION_RESULT_PARTIAL标记部分校验结果(来自INetworkMonitor.aidl)。ConnectivityService 收到后转换为VALIDATION_STATUS_NOT_VALID+redirectUri,通知 WifiNetworkAgent。用户看到的 "登录 WiFi" 通知来自redirectUri而非一个独立的校验状态。
校验结果如何影响默认网络。前面 §5.2 提到 rematchAllNetworksAndRequests() 中 nai.isValidated() 是关键条件——只有 VALID 的网络才能成为默认网络。这意味着:
- WiFi 刚注册 →
NOT_VALID→ 即使评分 60,也不会被选为默认网络 - NetworkMonitor 发送 HTTP probe → 收到 204 →
VALID→ WiFi 评分 60 碾压蜂窝 ~50 → WiFi 成为默认网络 - 如果 probe 失败(Captive Portal) →
NOT_VALID(带 redirectUri)→ 系统弹出 "登录 WiFi" 通知 → 用户打开浏览器完成 Portal 认证 → 重新校验 →VALID
校验回调链路:NetworkMonitor → ConnectivityService → NetworkAgent.onValidationStatus() → WifiNetworkAgent → ClientModeImpl。ClientModeImpl 根据校验结果决定是否触发重连或通知用户。
NUD(§6.2)只确认房间电话能打到前台——线路是通的,但说明不了什么。网络校验才是试试看能不能叫客房服务:拨通 Google 的 HTTP 204,如果对方正常接听(返回 204 No Content),说明酒店正常营业。如果电话被转接到前台要求你先签 "入住协议"(HTTP 302 重定向到 Captive Portal 登录页),那就是强制门户——你得先去浏览器里点 "同意" 才能真正上网。
¶6.6 持续监测——校验通过后
校验通过进入 ValidatedState 并不意味着 NetworkMonitor 就此 "退休"。恰恰相反,ValidatedState.enter() 才是持续监测的真正起点——它启动 TCP 套接字跟踪和定期 TCP 轮询,同时 DNS 停顿检测器在后台静默监听每一次 DNS 解析事件。一旦检测到数据停顿,系统会立即触发网络重评估。
NetworkMonitor 通过三条并行的路径监测网络健康,各自独立运行,任意一条触发都会启动网络重评估:
| 检测路径 | 触发条件 | 检测间隔 | 告警动作 |
|---|---|---|---|
| NUD 失败(§6.2-6.3) | 内核 NUD 状态机检测到网关 NUD_FAILED(ucast_solicit 次探测无应答) | 内核自动探测,IpReachabilityMonitor 通过 netlink 监听 | notifyLost() → ClientModeImpl 触发重连 |
| TCP 数据停顿 | TCP 丢包率 ≥ 80% 且发送包数 ≥ 10 | 20 秒(可配) | evaluateDataStall() → 转回 EvaluatingState 重校验 |
| DNS 连续超时 | 连续 5 次 DNS 超时,均在 30 分钟内 | 事件驱动(每次 DNS 解析) | evaluateDataStall() → 转回 EvaluatingState 重校验 |
三条路径的检测粒度互补:NUD 在 L3 邻居层工作(最快,但只能检测 "网关是否可达");TCP 轮询在传输层工作(需要数据流才能判定,对空闲连接无效);DNS 超时在应用层工作(最慢,但直接反映用户感知——打不开网页)。下面展开 TCP 和 DNS 两条路径的内部逻辑。
TCP 轮询——楼层管家每 20 秒巡一次房
NetworkMonitor 在校验通过后转入持续监测模式:ValidatedState.enter() 启动 TcpSocketTracker 并通过 sendTcpPollingEvent() 安排定期巡检。默认间隔来自 DataStallUtils.DEFAULT_TCP_POLLING_INTERVAL_MS = 20 秒,设备可通过 data_stall_tcp_polling_interval 覆盖。
每次巡检从内核获取各 TCP 连接的丢包、重传和已发送计数,计算丢包率 (丢失 + 重传) / 已发送。当丢包率 ≥ 80%(DEFAULT_TCP_PACKETS_FAIL_PERCENTAGE)且发送包数 ≥ 10(DEFAULT_DATA_STALL_MIN_PACKETS_THRESHOLD)时,就像电话里的声音八成都是杂音——基本没法正常通话了,判定为数据停顿。
这里的阈值设计体现了两层考量:80% 而非 100% 是因为 TCP 本身有重传机制,少量丢包是正常现象;10 个包的最小阈值则避免了「刚发一个包就丢了」的统计噪声。
DNS 超时——前台翻看叫醒服务记录
与 TCP 轮询并行,DnsStallDetector 通过环形缓冲区监听每一次 DNS 解析事件。它的判断标准不是单次失败,而是连续超时:连续 5 次(DEFAULT_CONSECUTIVE_DNS_TIMEOUT_THRESHOLD)且第一次超时发生在最近 30 分钟内(DEFAULT_DATA_STALL_VALID_DNS_TIME_THRESHOLD_MS)。就像你叫了 5 次客房服务都没人接——不是偶发的「刚好走开了」,而是系统性的「这部电话根本不通」。
30 分钟窗口的设计尤为精妙:让旧的 DNS 失败自然过期,避免「昨天断过一次网,今天还报停顿」的假阳性。
TCP vs DNS:两条路径的互补关系。TCP 轮询在传输层工作,需要数据流动才能判定,对空闲连接无效(看网页才有数据包,看本地文件时 TCP 轮询什么也查不到);DNS 超时在应用层工作,直接反映用户感知(打不开网页),且是事件驱动——有 DNS 解析才触发,没有解析时静默监听。
综合编排——「任一触发即告警,任一正常即放行」
TCP 和 DNS 两条检测路径的结论最终汇入 isDataStall()(NetworkMonitor.java:3924)统一裁决。编排顺序体现了「TCP 优先、DNS 兜底」的层级容错策略。先执行守卫检查:网络校验需已通过,且 Captive Portal 检测已启用。再保护按流量计费的网络:距上次探测不满 60 秒则跳过,避免消耗用户流量。
然后检查 TCP 指标。如果 TcpSocketTracker.getLatestReceivedCount() > 0,说明近期有数据正常流动,直接清除停顿标志。最后才是 DNS 检查——DnsStallDetector.isDataStallSuspected()。这个编排的代码入口在 ValidatedState.processMessage()(NetworkMonitor.java:1314)。收到 EVENT_POLL_TCPINFO 消息后,先调用 TcpSocketTracker.pollSocketsInfo()(TcpSocketTracker.java:253)从内核获取最新 TCP 连接统计,再调用 evaluateDataStall()(NetworkMonitor.java:1336)。后者内部委托 isDataStall() 执行上述 TCP/DNS 双路径裁决。判定为数据停顿,则 transitionTo(mEvaluatingState) 回到校验状态重新探测;否则调用 sendTcpPollingEvent()(NetworkMonitor.java:1352)安排 20 秒后的下一次巡检。
这条链路的关键洞察是:两条路径是「或」不是「且」。TCP 通了就放行,不管 DNS 有没有超时;DNS 正常也放行,不管 TCP 有没有丢包。只有两条路都走不通时,才构建 DataStallReportParcelable——含检测方法位掩码 DETECTION_METHOD_DNS_EVENTS | DETECTION_METHOD_TCP_METRICS、失败率、轮询周期、连续超时次数——通过 notifyDataStallSuspected() 回调 ConnectivityService 触发网络重评估。这遵循监测系统的经典原则:宁可漏报(放过偶发问题),不可误报(掐断正常网络)。
打个比方:校验通过就像确认了酒店营业、客房服务能接通。但住下来之后,物业不会就此消失——楼层管家每 20 秒巡一次楼看看你房间有没有异常(TCP 轮询),前台每隔一会儿查一次叫醒服务的记录看有没有连续失败(DNS 停顿检测),工程部持续监听线路信号(NUD)。三条巡检路径互不干扰,任何一条发现问题——不管是电话线路断了(NUD)、你叫了 5 次客房服务都没响应(DNS),还是电话里的声音断断续续丢包率超过 80%(TCP)——都会立即上报,触发 "这间房是不是有问题" 的重新评估。
¶7 完整流程回顾——从 L2 连接到默认网络
¶7.1 时间线:每一步的技术动作
从四次握手完成到用户能上网,完整的调用链如下:
1 | L2 连接完成(四次握手、密钥安装) |
¶7.2 静态 IP vs DHCP:完整代码级差异
| 维度 | DHCP(动态 IP) | 静态 IP |
|---|---|---|
| 决策入口 | ClientModeImpl.startIpClient() 中 !isUsingStaticIp 分支 | isUsingStaticIp 分支 |
| 配置来源 | DHCP 服务器分配 | StaticIpConfiguration(用户手动填写) |
| ProvisioningConfiguration | withNetwork() + withScanResultInfo() + withPreDhcpAction() | withStaticConfiguration() + withoutIpReachabilityMonitor() |
| DhcpClient 状态机 | 完整运行:Init → Requesting → ConflictDetecting → Configuring → Bound | 完全跳过 |
| 广播通知 | sendNetworkChangeBroadcast(OBTAINING_IPADDR) | 无(配置立即生效) |
| LinkProperties 构建 | 从 DhcpResults 解析(toDhcpResults()) | 从 StaticIpConfiguration.toLinkProperties() |
| IP 可达性监测 | 启用 IpReachabilityMonitor | 禁用 |
| 租约续期 | T1(50%)单播续期,T2(87.5%)广播续期 | 无(永久有效) |
| FILS 预连接 | 支持 | 不支持 |
| Target BSSID | 清除(允许驱动漫游) | 不清除 |
¶7.3 关键状态枚举速查
IpConfiguration.IpAssignment(IpConfiguration.java):
1 | STATIC // 静态配置 |
NetworkAgent.NetworkAgentState(NetworkAgent.java):
1 | STATE_CREATED // 已创建,未注册 |
DhcpClient 关键常量(DhcpClient.java):
1 | FIRST_TIMEOUT_MS = 1 秒 // Discover 首次重传间隔 |
ConnectedScore(ConnectedScore.java):
1 | WIFI_MAX_SCORE = 60 // WiFi 最高评分 |
¶7.4 关键函数速查
| 函数 | 文件 | 作用 |
|---|---|---|
ClientModeImpl.startIpClient() | ClientModeImpl.java | 静态 IP/DHCP 分支决策 |
ClientModeImpl.L3ProvisioningState.enterImpl() | ClientModeImpl.java | L3 配置入口 |
DhcpClient.DhcpInitState.sendPacket() | DhcpClient.java | 发送 DHCP Discover |
DhcpClient.sendDiscoverPacket() | DhcpClient.java | 构建 DHCP Discover 报文 |
DhcpClient.getRequestedParams() | DhcpClient.java | 构建 DHCP Parameter Request List |
DhcpClient.DhcpRequestingState.sendPacket() | DhcpClient.java | 发送 DHCP Request |
DhcpClient.scheduleLeaseTimers() | DhcpClient.java | 安排 T1/T2/到期定时器 |
DhcpClient.notifyFailure() | DhcpClient.java | 租约到期 / 续期失败通知 IpClient |
DhcpClient.setLeaseExpiredToIpMemoryStore() | DhcpClient.java | 标记 IpMemoryStore 中租约过期 |
DhcpClient.ConfiguringInterfaceState.enter() | DhcpClient.java | 配置接口 IP 地址 |
Dhcp6Client.makeDhcp6Client() | Dhcp6Client.java | 创建 Dhcp6Client 实例 |
Dhcp6Client.scheduleLeaseTimers() | Dhcp6Client.java | DHCPv6 续期定时器 |
StaticIpConfiguration.toLinkProperties() | StaticIpConfiguration.java | 静态配置 → LinkProperties |
IpConfiguration.IpAssignment | IpConfiguration.java | IP 分配方式枚举 |
WifiNetworkAgent 构造函数 | WifiNetworkAgent.java | 创建并注册 WiFi NetworkAgent |
IpClient.startProvisioning() | IpClient.java | 启动整个 IP 配置流程 |
IpClient.handleIPv4Failure() | IpClient.java | 处理 DHCPv4 失败,清理 IPv4 配置 |
IpClient.handleProvisioningFailure() | IpClient.java | provisioning 丢失处理,通知 ConnectivityService |
NetworkAgentInfo.sendLinkProperties() | NetworkAgentInfo.java | Service 端接收 LinkProperties(AIDL oneway) |
ConnectivityService.updateNetworkScore() | ConnectivityService.java | 更新评分并触发重匹配 |
ConnectivityService.rematchAllNetworksAndRequests() | ConnectivityService.java | 全局网络重匹配 |
ConnectedScore.WIFI_INITIAL_SCORE | ConnectedScore.java | WiFi 初始评分常量 |
IpReachabilityMonitor.handleNeighborLost() | IpReachabilityMonitor.java | 网关不可达处理 |
IpReachabilityMonitor.probeAll() | IpReachabilityMonitor.java | 主动探测所有监测邻居 |
NetworkRanker.applyYieldToBadWifiPolicy() | NetworkRanker.java | 蜂窝让位给 "曾校验过" 的 WiFi |
NetworkRanker.isPreferredBadWiFi() | NetworkRanker.java | 判定 "preferred bad WiFi" 条件 |
WifiNetworkAgentCallback.onNetworkUnwanted() | ClientModeImpl.java | 系统不再需要 WiFi → 断开 |
WifiNetworkAgentCallback.onValidationStatus() | ClientModeImpl.java | 校验结果回调 → 三路处理 |
NetworkMonitor.evaluateDataStall() | NetworkMonitor.java | TCP/DNS 双路径数据停顿裁决入口 |
NetworkMonitor.sendTcpPollingEvent() | NetworkMonitor.java | 安排下一次 TCP 轮询(20s 间隔) |
TcpSocketTracker.pollSocketsInfo() | TcpSocketTracker.java | 从内核获取最新 TCP 连接统计 |
¶8 总结:从 L2 连接到能上网
¶8.1 全链路回顾
从 L2 加密通道建立到用户真正能上网,Android WiFi 系统内部经历了一段精心编排的 L3 配置链路。好比从酒店拿到房卡到真正入住:先通水(DHCP 分配 IP),再通电(路由配置),前台登记(NetworkAgent 注册),VIP 优先(评分竞争),物业巡检(可达性监测),最后确认能叫到客房服务(网络校验)——每一步都有对应的代码路径和技术决策。
¶8.2 设计亮点
- 4 个关键决策点:
L3ProvisioningState启动配置 →startIpClient()的静态 / DHCP 分叉 → ConnectivityService 的评分比较(WiFi vs 蜂窝)→ IpReachabilityMonitor 的可达性判断 - 12 个 DHCP 核心状态:从
DhcpInitState发送 Discover 到DhcpBoundState安排续期,完整的 RFC 2131 + RFC 5227 实现 - 2 条路径:DHCP 路径(完整状态机 + ARP 冲突检测 + 可达性监测)vs 静态 IP 路径(跳过 DHCP + 禁用可达性监测)
- 1 个评分决策:
WIFI_INITIAL_SCORE(60)确保 WiFi 连接后立即成为默认网络,后续由WifiScoreReport根据信号质量动态调整
但 "能上网" 三个字,其实只是一张入场券。真正见功力的是下一刻——你端着手机在屋里走动,AP 的信号越来越弱,中间还隔着一堵承重墙和信号更强的邻居路由器。系统该在哪个瞬间松手、又该在哪个瞬间抓住下一个 AP?802.11r 能不能真的做到毫秒级无缝换乘?802.11k 的邻居报告准不准、BTM 会不会被设备悄悄忽略?这套刚刚跑通的 L2/L3 机器,第一次撞上 "移动" 这个变量,会不会当场散架?
本篇涉及以下规范:
- RFC 2131:DHCPv4(Dynamic Host Configuration Protocol)
- RFC 8415:DHCPv6(Dynamic Host Configuration Protocol for IPv6)
- RFC 5227:IPv4 Address Conflict Detection(IPv4 地址冲突检测)
- RFC 4861:Neighbor Discovery for IP version 6(NUD 基础)
- Android 源码:
packages/modules/NetworkStack(DhcpClient、Dhcp6Client、IpClient、IpReachabilityMonitor) - Android 源码:
packages/modules/Connectivity(NetworkAgent、ConnectivityService、IpConfiguration、StaticIpConfiguration) - Android 源码:
packages/modules/Wifi(ClientModeImpl、WifiNetworkAgent、ConnectedScore)