STA 连接(六)从 L2 连接到 “能上网”

四次握手完成、密钥安装到驱动——L2 加密通道已经建立。但此时 STA 还 "不能上网":没有 IP 地址,没有路由,系统不知道这个网络的存在。从 L2_CONNECTED 到用户看到 "已连接,可上网",中间还有一段精密的 L3 配置链路。本篇追踪从 L3ProvisioningStateConnectivityService 注册网络的完整过程。

上一篇追踪了 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 配置全局分层图

图注:本篇 L3 配置链路的全局分层——五层自顶向下:ClientModeImplIpClientNetworkAgentConnectivityServicenetd → 内核。虚线连出的文字标注每层间的跨进程/跨态边界(AIDL・IIpClient / oneway Binder / AIDL・INetd / netlink),蓝箭头是配置数据流的下发方向。§4.3 会沿这条链逐层展开代码。

1.1 触发时机:从 L2ConnectedState 到 L3ProvisioningState

在上一篇文章中,我们追踪了四次握手的完整过程——最终 PTK/GTK 安装到驱动,L2 加密通道建立。在 ClientModeImpl 状态机中,这对应 L2ConnectedState。进入该状态后,状态机自动转移到 L3ProvisioningState

1
2
3
L2ConnectedState(密钥安装完成)
→ 自动转移
L3ProvisioningState(开始 L3 配置)

L2ConnectedState.enterImpl() 中(ClientModeImpl.java:6500),除了发送 CONNECTING 广播和清除 Target BSSID 外,还做了一件对后续整个链路至关重要的事——创建 WifiNetworkAgent

1
2
3
// L2ConnectedState.enterImpl() 内部(ClientModeImpl.java:6500)
mNetworkAgent = mWifiInjector.makeWifiNetworkAgent(nc, mLinkProperties, naConfig,
mNetworkFactory.getProvider(), new WifiNetworkAgentCallback());

WifiNetworkAgent 构造函数立即调用 register(),向 ConnectivityService 注册网络——此时 mLinkProperties 还空着,没有 IP 地址。这意味着 ConnectivityService 在 L3 配置启动前就已经知道了这个 WiFi 网络的存在,只是它「身份待完善」(见 §5.1)。L3ProvisioningState.enterImpl() 的源码已经在上一篇中展示过,这里聚焦它的核心动作——调用 startL3Provisioning()

1
2
3
4
5
6
7
8
9
10
11
12
13
// ClientModeImpl.java - L3ProvisioningState
class L3ProvisioningState extends RunnerState {
@Override
public void enterImpl() {
startL3Provisioning();
if (mContext.getResources().getBoolean(
R.bool.config_wifiRemainConnectedAfterIpProvisionTimeout)) {
sendMessageDelayed(obtainMessage(CMD_IP_PROVISIONING_TIMEOUT),
WAIT_FOR_L3_PROVISIONING_TIMEOUT_MS);
}
}
// ...
}

这段代码做了三件事:

  • enterImpl() 是 L3 配置的起点——进入状态即启动 IP 配置
  • 如果配置了 config_wifiRemainConnectedAfterIpProvisionTimeout,同时启动 provisioning 超时定时器——超时后网络仍保持 "已连接" 但标记为 local-only
  • 超时处理在 processMessageImpl() 中:收到 CMD_IP_PROVISIONING_TIMEOUT 后调用 onL3ProvisioningTimeout(),将 mIpProvisioningTimedOut 设为 true

startL3Provisioning() 区分 FILS 预连接和正常连接两条路径:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// ClientModeImpl.java
private void startL3Provisioning() {
WifiConfiguration currentConfig = getConnectedWifiConfigurationInternal();
if (mIpClientWithPreConnection && mIpClient != null) {
// FILS 预连接路径:通知 IpClient 预连接完成
mIpClient.notifyPreconnectionComplete(mSentHLPs);
mIpClientWithPreConnection = false;
mSentHLPs = false;
} else {
// 正常路径:启动 IpClient
startIpClient(currentConfig, false);
}
getWifiLinkLayerStats();
}

它实现了三个功能:

  • FILS 预连接模式(802.11ai):L2 连接前已预先发送 DHCP 报文(HLP),此时只需通知 IpClient 预连接完成
  • 正常模式:调用 startIpClient() 从头启动 IP 配置
  • 同时获取链路层统计信息并重置报文发送计数器,为后续连接状态跟踪建立基准

1.2 IpClient 架构:IP 配置总管

在深入 startIpClient() 之前,先理解 IpClient 的整体架构。IpClient 是 Android 中 IP 配置的总管状态机,管理 DHCPv4、DHCPv6、ARP 监测等子组件的生命周期:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ClientModeImpl
│ startIpClient()

═ IpClient ════════════════════════════════════════
(IP 配置总管状态机)
║ ║
║ ├── DhcpClient (DHCPv4 协议实现)
║ ├── Dhcp6Client (DHCPv6 协议实现)
║ ├── IpReachabilityMonitor (网关可达性监测)
║ └── IpClientLinkObserver (netlink 事件监听)
╚═══════════════════════════════════════════════════╝
│ AIDL (IIpClient)

ConnectivityService

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 转移到 ClearingIpAddressesStateIpClient.java:3147,清除旧 IP 地址等残留状态),清理完成后进入 StartedStateIpClient.java:3246)的子状态 RunningStateIpClient.java:3395),开始 IP 配置流程。RunningState 内部并行管理 DhcpClient(DHCPv4)、Dhcp6Client(DHCPv6)和 IpReachabilityMonitor(基于 NUD——Neighbor Unreachability Detection——的网关探测)三个子组件。

IpClient 状态机全景

1.3 startIpClient ():静态 vs 动态的分岔路口

startIpClient() 是整篇文章最重要的决策点——它根据 IpAssignment 枚举决定走 DHCP 路径还是静态 IP 路径:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// ClientModeImpl.java
private boolean startIpClient(WifiConfiguration config, boolean isFilsConnection) {
if (mIpClient == null || config == null) {
return false;
}

final boolean isUsingStaticIp =
(config.getIpAssignment() == IpConfiguration.IpAssignment.STATIC);
// ... 构建 ProvisioningConfiguration ...

final ProvisioningConfiguration.Builder prov =
new ProvisioningConfiguration.Builder()
.withDisplayName(config.SSID)
.withCreatorUid(config.creatorUid)
.withLayer2Information(layer2Info)
.withDhcpOptions(convertToInternalDhcpOptions(options));

if (!isUsingStaticIp) {
// ===== 动态 IP(DHCP)路径 =====
sendNetworkChangeBroadcast(DetailedState.OBTAINING_IPADDR);
clearTargetBssid("ObtainingIpAddress");
stopDhcpSetup();
setConfigurationsPriorToIpClientProvisioning(config);
prov.withNetwork(network)
.withScanResultInfo(scanResultInfo)
.withPreDhcpAction();
} else {
// ===== 静态 IP 路径 =====
StaticIpConfiguration staticIpConfig = config.getStaticIpConfiguration();
prov.withStaticConfiguration(staticIpConfig)
.withoutIpReachabilityMonitor();
}

mIpClient.startProvisioning(prov.build());
return true;
}

两条路径的差异:

  • 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 从初始化到租约到期的完整生命周期。

DHCP 四步流程时序图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
StoppedState(初始状态)

└─→ DhcpState(DHCP 运行时的父状态)

├── WaitBeforeObtainingConfigurationState
│ └── 查询 IpMemoryStore 中是否有历史租约

├── DhcpInitState ← 发送 DHCP Discover(广播)
│ └── 收到 OfferDhcpRequestingState

├── DhcpRequestingState ← 发送 DHCP Request(广播)
│ ├── 收到 ACK → ConfiguringInterfaceState
│ └── 收到 NAK/超时 → 回到 DhcpInitState

├── IpAddressConflictDetectingState ← ARP 探测(RFC 5227
│ ├── 无冲突 → ConfiguringInterfaceState
│ └── 冲突 → DhcpDecliningState

├── DhcpHaveLeaseState(父状态,已获得租约)
│ ├── ConfiguringInterfaceState ← 配置接口 IP/掩码/网关
│ │ └── EVENT_LINKADDRESS_CONFIGURED → DhcpBoundState
│ ├── DhcpBoundState ← 已绑定,等待 T1/T2 续期
│ ├── DhcpRenewingStateT1 触发:单播续期
│ ├── DhcpRebindingStateT2 触发:广播续期
│ ├── DhcpRefreshingAddressState ← 漫游后刷新地址
│ └── DhcpDecliningState ← IP 冲突后 Decline

└── DhcpInitRebootState ← 重启后请求之前的 IP

2.2 第一步:DhcpInitState 发送 Discover

DhcpInitState 继承自 PacketRetransmittingState(带指数退避重传的超时状态),进入时启动新事务并发送 DHCP Discover:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// DhcpClient.java
class DhcpInitState extends PacketRetransmittingState {
public DhcpInitState() {
super();
}

@Override
public void enter() {
super.enter();
startNewTransaction();
mLastInitEnterTime = SystemClock.elapsedRealtime();
}

protected boolean sendPacket() {
return sendDiscoverPacket();
}

protected void receivePacket(DhcpPacket packet) {
receiveOfferOrAckPacket(packet, isDhcpRapidCommitEnabled());
}
}

sendDiscoverPacket() 构建 DHCP Discover 报文并发送:

1
2
3
4
5
6
7
8
9
10
// DhcpClient.java
private boolean sendDiscoverPacket() {
final boolean requestRapidCommit = isDhcpRapidCommitEnabled() && (getSecs() <= 4);
final ByteBuffer packet = DhcpPacket.buildDiscoverPacket(
DhcpPacket.ENCAP_L2, mTransactionId, getSecs(), mHwAddr,
DO_UNICAST, getRequestedParams(), requestRapidCommit, maybeGetHostnameForSending(),
mConfiguration.options);
mMetrics.incrementCountForDiscover();
return transmitPacket(packet, "DHCPDISCOVER", DhcpPacket.ENCAP_L2, INADDR_BROADCAST);
}

从这段代码可以看到几个设计:

  • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// DhcpClient.java
class DhcpRequestingState extends PacketRetransmittingState {
public DhcpRequestingState() {
mTimeout = DHCP_TIMEOUT_MS / 2; // 18 秒超时
}

protected boolean sendPacket() {
return sendRequestPacket(
INADDR_ANY, // ciaddr(当前无 IP)
(Inet4Address) mOffer.ipAddress.getAddress(), // Option 50: 请求的 IP
(Inet4Address) mOffer.serverAddress, // Option 54: DHCP 服务器标识
INADDR_BROADCAST); // 广播发送
}

protected void receivePacket(DhcpPacket packet) {
if (!isValidPacket(packet)) return;
if ((packet instanceof DhcpAckPacket)) {
if (maybeTransitionToIpv6OnlyWaitState(packet)) return;
final DhcpResults results = packet.toDhcpResults();
if (results != null) {
confirmDhcpLease(packet, results);
transitionTo(isDhcpIpConflictDetectEnabled()
? mIpAddressConflictDetectingState : mConfiguringInterfaceState);
}
} else if (packet instanceof DhcpNakPacket) {
Log.d(TAG, "Received NAK, returning to INIT");
mOffer = null;
transitionTo(mDhcpInitState);
}
}

@Override
protected void timeout() {
transitionTo(mDhcpInitState); // 超时 18s 后回到 INIT 重新 Discover
}
}

这段 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:如果存在,提取服务器指定的等待时长,转入 Ipv6OnlyWaitStateDhcpClient.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 通过 IpConflictDetectorDhcpClient.java:1564)执行碰撞检测。IpConflictDetector 继承自 PacketReader,其 createFd()DhcpClient.java:1587)创建 AF_PACKET raw socket 直接收发 ARP 包,绕过内核协议栈以实现精确的 ARP 探测控制。探测和冲突响应的完整逻辑如下:

IpConflictDetector 的设计透露了几层考量:

  • 创建 AF_PACKET raw 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// DhcpClient.java
class ConfiguringInterfaceState extends LoggingState {
@Override
public void enter() {
super.enter();
// 必须在配置 IP 地址之前调用 notifySuccess()
// 否则 IpClient 看到 IP 地址出现后会立即进入 provisioned 状态而缺少 DHCP 配置
notifySuccess();
mController.sendMessage(CMD_CONFIGURE_LINKADDRESS,
mDhcpLease.leaseDuration, 0, mDhcpLease.ipAddress);
}

@Override
public boolean processMessage(Message message) {
super.processMessage(message);
switch (message.what) {
case EVENT_LINKADDRESS_CONFIGURED:
transitionTo(mDhcpBoundState);
return HANDLED;
default:
return NOT_HANDLED;
}
}
}

这里有三步操作:

  • 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
2
3
4
|←────────────── 租约时间(lease_time)──────────────→|
| T1 ≈ 50% T2 ≈ 87.5% 到期 |
| | | | |
| 单播续期(Request) 广播续期(Request) 租约到期 |

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,这才是真正的 "租约到期"。DhcpHaveLeaseStateDhcpClient.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_ACTIONDHCP_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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
StoppedState(初始状态)
│ CMD_START_DHCP6
└─→ StartedState(父状态)

├── SolicitState ← 发送 Solicit,定位服务器
│ ├── 收到 AdvertiseRequestState
│ └── 收到 ReplyRapid Commit)→ BoundState

├── RequestState ← 发送 Request,正式请求前缀
│ ├── 收到 ReplyBoundState
│ └── 超时/失败 → 回到 SolicitState

└── HaveLeaseState(父状态,已获得租约)
├── BoundState ← 已绑定,通知 IpClient 可用前缀
│ ├── T1 到期 → RenewState
│ └── T2 到期 → RebindState
├── RenewState ← 单播续期
└── RebindState ← 广播重绑定

SolicitState:定位 DHCPv6 服务器。与 DHCPv4 的 Discover 不同,Solicit 本身携带 IA_PD hint option(期望的前缀长度和空前缀),同时默认携带 Rapid Commit——如果服务器支持,可以跳过 Advertise/Request 两步,一步到位:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// Dhcp6Client.java - SolicitState
class SolicitState extends MessageExchangeState {
SolicitState() {
// 首个 Solicit 延迟 0~1s 随机,避免多设备同时启动的同步风暴
super((int) (new Random().nextDouble() * SECONDS) /* delay */,
SOL_TIMEOUT /* IRT */, 0 /* MRC */, () -> mSolMaxRtMs /* MRT */);
}

@Override
protected boolean sendPacket(int transId, long elapsedTimeMs) {
// 构建 IA_PD hint option:请求 /64 前缀
final IaPrefixOption hintOption = new IaPrefixOption(
(short) IaPrefixOption.LENGTH, 0, 0,
(byte) RFC7421_PREFIX_LENGTH, new byte[16] /* empty prefix */);
return sendSolicitPacket(transId, elapsedTimeMs,
new PrefixDelegation(IAID, 0, 0,
Collections.singletonList(hintOption)).build());
}

@Override
protected void receivePacket(Dhcp6Packet packet) {
final PrefixDelegation pd = packet.mPrefixDelegation;
// 忽略无可用前缀的响应,继续重传 Solicit 等待其他服务器
if (pd.statusCode == Dhcp6Packet.STATUS_NO_PREFIX_AVAIL) {
Log.w(TAG, "Server responded to Solicit without available prefix, ignoring");
return;
}
if (packet instanceof Dhcp6AdvertisePacket) {
mAdvertise = pd;
mServerDuid = packet.mServerDuid;
mSolMaxRtMs = packet.getSolMaxRtMs().orElse(mSolMaxRtMs);
transitionTo(mRequestState); // 正常流程:Solicit → Request
} else if (packet instanceof Dhcp6ReplyPacket && packet.mRapidCommit) {
mReply = pd;
mServerDuid = packet.mServerDuid;
mSolMaxRtMs = packet.getSolMaxRtMs().orElse(mSolMaxRtMs);
transitionTo(mBoundState); // Rapid Commit:跳过 Request
}
}
}

值得注意的几个设计细节:

  • 首个 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/ACKSOLICIT/ADVERTISE/REQUEST/REPLY
Rapid Commit可选优化(前 3 个 Discover)默认携带(一步到位获取前缀)
重传机制指数退避,MAX_TIMEOUT = 512sRFC 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// Dhcp6Client.java - RequestState
class RequestState extends MessageExchangeState {
RequestState() {
// IRT=REQ_TIMEOUT, MRC=REQ_MAX_RC, MRT=REQ_MAX_RT
super(0 /* delay */, REQ_TIMEOUT, REQ_MAX_RC, () -> REQ_MAX_RT);
}

@Override
protected boolean sendPacket(int transId, long elapsedTimeMs) {
return sendRequestPacket(transId, elapsedTimeMs, mAdvertise.build());
}

@Override
protected void receivePacket(Dhcp6Packet packet) {
if (!(packet instanceof Dhcp6ReplyPacket)) return;
if (packet.mPrefixDelegation.statusCode == Dhcp6Packet.STATUS_NO_PREFIX_AVAIL) {
transitionTo(mSolicitState); // 服务器反悔了,重新 Solicit
return;
}
mReply = packet.mPrefixDelegation;
transitionTo(mBoundState);
}

@Override
protected void onMessageExchangeFailed() {
transitionTo(mSolicitState); // 超过 MRC 或 MRD,回到 Solicit
}
}

BoundState:租约就绪,通知上层。进入 BoundState 后安排 T1/T2 续期定时器,并通知 IpClient 可用前缀:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Dhcp6Client.java - BoundState
class BoundState extends State {
@Override
public void enter() {
super.enter();
scheduleLeaseTimers(); // 安排 T1/T2/到期
notifyPrefixDelegation(DHCP6_PD_SUCCESS,
mReply.getValidIaPrefixes()); // 通知 IpClient
}

@Override
public boolean processMessage(Message message) {
super.processMessage(message);
switch (message.what) {
case CMD_DHCP6_PD_RENEW:
transitionTo(mRenewState); // T1 到期 → 单播续期
return HANDLED;
default:
return NOT_HANDLED; // CMD_DHCP6_PD_REBIND 由子状态 RenewState 处理
}
}
}

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
2
3
4
5
6
// IpClient.java - handleLinkPropertiesUpdate()
if (newLp.hasIpv6DefaultRoute() && mIpv6AutoconfTimeoutAlarm == null) {
mIpv6AutoconfTimeoutAlarm = new WakeupMessage(mContext, getHandler(),
mTag + ".EVENT_IPV6_AUTOCONF_TIMEOUT", EVENT_IPV6_AUTOCONF_TIMEOUT);
mIpv6AutoconfTimeoutAlarm.schedule(alarmTime);
}

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 vs DHCP 两条路径对比

维度动态 IP(DHCP)静态 IP
决策条件config.getIpAssignment() != STATICconfig.getIpAssignment() == STATIC
广播通知sendNetworkChangeBroadcast(OBTAINING_IPADDR)不发送(配置立即生效)
Target BSSIDclearTargetBssid() ——允许驱动漫游不清除
PreDhcpActionwithPreDhcpAction() ——支持 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(租约秒数)、mtucaptivePortalApiUrl(Option 114)等。

DhcpResultsLinkProperties 的转换分两步走:DhcpResults.toStaticIpConfiguration()DhcpResults.java:76)通过 Builder 将 yiaddr 映射为 ipAddress、Option 3 映射为 gateway、Option 6 映射为 dnsServers,生成 StaticIpConfiguration;第二步,StaticIpConfiguration.toLinkProperties() 在此基础上加上接口名和路由,构建最终的 LinkProperties

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// StaticIpConfiguration.java
public LinkProperties toLinkProperties(String iface) {
LinkProperties lp = new LinkProperties();
lp.setInterfaceName(iface); // 绑定到 wlan0
if (ipAddress != null) {
lp.addLinkAddress(ipAddress); // 接口 IP 地址
}
for (RouteInfo route : getRoutes(iface)) {
lp.addRoute(route); // 路由表项
}
for (InetAddress dns : dnsServers) {
lp.addDnsServer(dns); // DNS 服务器
}
lp.setDomains(domains); // DNS 搜索域
return lp;
}

getRoutes() 的路由生成逻辑——这是从 IP 配置到内核路由表的映射。StaticIpConfiguration.getRoutes(iface) 自动生成两类路由(加一个边界情况):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// StaticIpConfiguration.java - 路由生成
public List<RouteInfo> getRoutes(@Nullable String iface) {
List<RouteInfo> routes = new ArrayList<>(3);
if (ipAddress != null) {
// 1. 直连路由:发往同一子网的流量直接从接口出去(不需要网关)
RouteInfo connectedRoute = new RouteInfo(ipAddress, null, iface);
routes.add(connectedRoute);
// 2. 边界情况:如果网关不在直连子网内,额外添加一条到网关的主机路由
// (这种配置理论上无效,但 K 及更早版本支持,为兼容保留)
if (gateway != null && !connectedRoute.matches(gateway)) {
routes.add(RouteInfo.makeHostRoute(gateway, iface));
}
}
if (gateway != null) {
// 3. 默认路由(null IpPrefix = 0.0.0.0/0):所有其他流量通过网关转发
routes.add(new RouteInfo((IpPrefix) null, gateway, iface));
}
return routes;
}

两条路由的设计意图:

  • 直连路由(如 192.168.1.0/24 → wlan0):无网关,直接通过接口通信。用于与局域网内其他设备通信(如文件共享、投屏)
  • 默认路由(如 0.0.0.0/0 → 网关 192.168.1.1 → wlan0):所有不在直连子网的流量通过网关转发。这是能 "上网" 的关键

最终 LinkProperties 对象包含了五个维度的网络属性(以下为 DHCP 路径来源,静态 IP 路径的对应值来自用户手动配置):

维度字段来源说明
接口寻址mLinkAddressesDHCP yiaddr + 子网掩码接口 IP 地址和前缀长度
路由mRoutesDHCP Option 3(网关)+ 自动生成直连路由默认路由和直连路由
DNSmDnsesDHCP Option 6(DNS)DNS 服务器地址列表
搜索域mDomainsDHCP 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

StaticIpConfigurationtoLinkProperties()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 层调用链,跨越进程边界和用户态 / 内核态边界。

先看全景——每一层在哪个组件、做什么动作、跨越什么边界:

组件关键动作跨越的边界
1ClientModeImpl(WiFi 框架)检测 LinkProperties 变化 → 调用 sendLinkProperties()
2NetworkAgent + AIDL深拷贝 + oneway Binder IPC 发送进程边界:system_server → netd
3ConnectivityService接收消息 → 9 步管线处理(路由/dns/mtu/vpn)
4updateRoutes() 路由 diff新旧路由 diff → 分 added/removed/updated 下发
5netd → 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 步管线:

  1. clatd fixup — IPv6-to-IPv4 转换适配
  2. updateInterfaces() — 绑定接口到 netId
  3. VPN 过滤规则更新
  4. MTU 更新
  5. updateRoutes() — 路由 diff 与下发(展开见第 4 层)
  6. updateDnses() — DNS 配置下发
  7. 更新 proxy / Captive Portal 配置
  8. 写回 nai.linkProperties = newLp
  9. 通知 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// ConnectivityService.java - updateRoutes 核心逻辑(简化)
private boolean updateRoutes(@NonNull LinkProperties newLp,
@Nullable LinkProperties oldLp, int netId) {
final CompareOrUpdateResult<RouteInfo.RouteKey, RouteInfo> routeDiff =
new CompareOrUpdateResult<>(
oldLp != null ? oldLp.getAllRoutes() : null,
newLp.getAllRoutes(),
(r) -> r.getRouteKey());

// 先添加 non-gateway 路由(直连路由),再添加 gateway 路由
for (RouteInfo route : routeDiff.added) {
if (route.hasGateway()) continue;
mRoutingCoordinatorService.addRoute(netId, route);
}
for (RouteInfo route : routeDiff.added) {
if (!route.hasGateway()) continue;
mRoutingCoordinatorService.addRoute(netId, route);
}
for (RouteInfo route : routeDiff.removed) {
mRoutingCoordinatorService.removeRoute(netId, route);
}
for (RouteInfo route : routeDiff.updated) {
mRoutingCoordinatorService.updateRoute(netId, route);
}
return !routeDiff.added.isEmpty() || !routeDiff.removed.isEmpty()
|| !routeDiff.updated.isEmpty();
}

路由 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
2
3
4
5
// RoutingCoordinatorService.java:82-85 —— ConnectivityService 侧跨入 netd 的出口
public void addRoute(final int netId, final RouteInfo route)
throws ServiceSpecificException, RemoteException {
mNetd.networkAddRouteParcel(netId, toRouteInfoParcel(route));
}

netd 的 C++ 实现(system/netd 中的 RouteController::modifyRoute)不在本仓库内,但它的终点——内核侧——是可追溯的。RTM_NEWROUTE netlink 消息在内核由 inet_rtm_newroute() 处理(前文所说 rtm_newroute() 的正式符号,PF_INET 注册的处理器),最终落到 fib_table_insert() 写入 FIB:

1
2
3
4
5
6
7
8
9
10
11
12
13
// QCOM/kernel-msm/net/ipv4/fib_frontend.c:885 —— RTM_NEWROUTE 在内核的处理器
static int inet_rtm_newroute(struct sk_buff *skb, struct nlmsghdr *nlh,
struct netlink_ext_ack *extack) {
struct net *net = sock_net(skb->sk);
struct fib_config cfg;
struct fib_table *tb;
int err = rtm_to_fib_config(net, skb, nlh, &cfg, extack);
if (err < 0) goto errout;
tb = fib_new_table(net, cfg.fc_table);
...
err = fib_table_insert(net, tb, &cfg, extack); // 写入 FIB
...
}
1
2
3
4
5
6
7
RoutingCoordinatorService.addRoute()
→ INetd.networkAddRouteParcel() // AIDL IPC → netd 守护进程
→ networkAddRouteParcel() // netd C++ 端
→ RouteController::modifyRoute(RTM_NEWROUTE, ...)
→ sendmsg(AF_NETLINK, NETLINK_ROUTE) // netlink 消息
→ [内核] rtm_newroute()
→ fib_table_insert() // 写入 FIB

这是整个路由配置链路的终点——经过 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
2
3
4
5
6
7
8
9
10
11
12
// WifiNetworkAgent.java - 构造函数(省略了 Context/Looper 参数,完整参数见注释)
public WifiNetworkAgent(
@NonNull Context context, @NonNull Looper looper, // 基础设施参数,非本文重点
@NonNull NetworkCapabilities nc, @NonNull LinkProperties lp,
@NonNull NetworkAgentConfig config, @Nullable NetworkProvider provider,
@NonNull Callback wifiNetworkAgentCallback) {
super(context, looper, TAG, nc, lp,
ConnectedScore.WIFI_INITIAL_SCORE, config, provider);
mCurrentNetworkCapabilities = nc;
mCallback = wifiNetworkAgentCallback;
register(); // 构造时立即注册!
}

几个关键点:

  • ContextLooper 是 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// [概念逻辑] ConnectivityService.rematchAllNetworksAndRequests 核心循环
private void rematchAllNetworksAndRequests() {
for (NetworkRequestInfo nri : getNrisFromGlobalRequests()) {
// 筛选满足 nri.request 的候选网络
List<NetworkAgentInfo> candidates = mNetworkAgentInfos.stream()
.filter(nai -> nai.satisfies(nri.request) && nai.isValidated())
.collect(Collectors.toList());
// 委托 NetworkRanker 按策略选出最佳网络
NetworkAgentInfo bestNetwork = mNetworkRanker.getBestNetwork(
nri.request, candidates, nri.satisfyingNetwork);
if (bestNetwork != nri.satisfyingNetwork) {
rematchNetwork(bestNetwork, nri);
}
}
}

NetworkRanker.getBestNetworkByPolicy() 并非简单的 "legacy int 比大小",而是按策略优先级对候选网络执行多轮筛选(NetworkRanker.javapackages/modules/Connectivity)。每一轮筛选用 partitionInto(candidates, predicate) 将候选网络分成 accepted/rejected 两组——如果 accepted 中只剩一个,它就是胜出者;否则保留 accepted 组继续下一轮:

轮次策略条件作用
1POLICY_IS_INVINCIBLE无敌网络(如始终在线的蜂窝),直接胜出
2POLICY_IS_VPN已连接的 VPN,优先于普通网络
3POLICY_EVER_USER_SELECTED + POLICY_ACCEPT_UNVALIDATED用户明确选择 + 接受未校验(如手动连 WiFi)
4POLICY_IS_VALIDATED + POLICY_ACCEPT_UNVALIDATED已通过校验的网络(或明确接受未校验);随后 applyYieldToBadWifiPolicy() 保留 "偏好差 WiFi" 候选
5!POLICY_EXITING排除正在退出的网络
6POLICY_TRANSPORT_PRIMARY每种 transport 只保留 primary 网络
7PREFERRED_TRANSPORTS_ORDER按 transport 优先级过滤(WiFi > Cellular > ...)
8!POLICY_IS_DESTROYED排除已标记销毁的网络
9currentSatisfier != 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_WIFINET_CAPABILITY_INTERNET 等,供后续 satisfies() 匹配)→ processLinkPropertiesFromAgent() 处理初始链路属性 → networkMonitor.start() 启动校验 → updateNetworkInfo() 触发 rematchAllNetworksAndRequests() 全局重匹配(WiFi 评分高于当前蜂窝则成为默认网络)。两阶段设计保证了 WiFi 侧拿到 netId 后可以立即开始发送数据,而注册和重匹配在 ConnectivityService 主线程异步完成。

第一阶段的精简代码——三个关键动作分配 netId、创建 NAI、启动 NetworkMonitor 创建:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ConnectivityService.java - 第一阶段:分配 netId + 创建 NAI + 启动 NetworkMonitor
private NetworkAndAgentRegistryParcelable registerNetworkAgentInternal(
INetworkAgent na, ...) {
// 1. 分配 netId,创建 NetworkAgentInfo(封装所有网络元数据)
final NetworkAgentInfo nai = new NetworkAgentInfo(na,
new Network(mNetIdManager.reserveNetId()), niCopy, lpCopy, ncCopy,
localNetworkConfig, currentScore, mContext, mTrackerHandler,
new NetworkAgentConfig(networkAgentConfig), this, mNetd, mDnsResolver, ...);
// 2. 向 NetworkStack 请求创建 NetworkMonitor(网络校验器)
mDeps.getNetworkStack().makeNetworkMonitor(
nai.network, name, new NetworkMonitorCallbacks(nai));
// 3. 返回 netId(供 WiFi 侧发送数据)和 registry(供 WiFi 侧注册)
result.network = nai.network;
result.registry = nai.getRegistry();
return result;
}

连接后,WifiScoreReport 会持续根据信号质量、链路速度等指标更新评分:

  • 信号好 → 评分保持 60(最高)
  • 信号变差 → 评分降低,如果降到低于蜂窝 → 蜂窝恢复为默认网络
  • 评分通过 NetworkAgent.sendNetworkScore() 实时上报 ConnectivityService

registerNetworkAgent 就是前台把你登记进酒店管理系统——录入入住信息、分配客编号码、跟已有 VIP 客人比优先级、通知各部门 "客人已入住"。评分就像酒店的会员等级:WiFi 初始就是金卡(60 分),蜂窝是银卡(~50 分),所以一登记就自动被系统选为 "当前默认"。

5.4 回调链路

WifiNetworkAgent 定义了 Callback 接口(WifiNetworkAgent.java:38),包含 10 个回调方法,ClientModeImpl 通过内部类 WifiNetworkAgentCallbackClientModeImpl.java:5465)实现。每个回调首先通过 isThisCallbackActive() 守卫检查(确认自身仍是当前活跃的 NetworkAgent 的回调,避免旧 Agent 的残留回调干扰新流程),然后将 ConnectivityService 的通知转换为状态机消息。两个核心回调的完整处理链如下:

onNetworkUnwanted() —— 系统不再需要 WiFiClientModeImpl.java:5473)。ConnectivityService 在 WiFi 评分低于蜂窝且蜂窝已恢复为默认网络时调用此回调。WifiNetworkAgentCallback 收到后调用 unwantedNetwork(NETWORK_STATUS_UNWANTED_DISCONNECT),发送 CMD_UNWANTED_NETWORK 到状态机。在 L3ConnectedStateprocessMessageImpl()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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// IpReachabilityMonitor.java
mIpNeighborMonitor = dependencies.makeIpNeighborMonitor(h, mLog,
(NeighborEvent event) -> {
if (mInterfaceParams.index != event.ifindex) return;
if (!mNeighborWatchList.containsKey(event.ip)) return;

final NeighborEvent prev = mNeighborWatchList.put(event.ip, event);
if (event.nudState == StructNdMsg.NUD_FAILED) {
mLog.w("ALERT neighbor went from: " + prev + " to: " + event);
handleNeighborLost(prev, event);
} else if (event.nudState == StructNdMsg.NUD_REACHABLE) {
mEverReachableNeighbors.add(event.ip);
handleNeighborReachable(prev, event);
}
});

这段代码实现了四个能力:

  • mNeighborWatchList:监测列表——从 LinkProperties 中提取的网关地址和 DNS 服务器地址
  • NUD_FAILEDhandleNeighborLost():邻居不可达,检查是否导致 provisioning 丢失
  • NUD_REACHABLEhandleNeighborReachable():邻居可达,记录到 mEverReachableNeighbors,漫游后检测 MAC 地址是否变化
  • mEverReachableNeighbors:只对曾经可达的邻居触发告警——从未可达的邻居(如配置了不通的 DNS)不触发

IpReachabilityMonitor 对 NUD 中间状态(NUD_STALENUD_DELAYNUD_PROBE)不做任何告警处理——源码注释明确指出这些是 "perfectly normal; we merely record the new state"。这些状态构成了内核邻居可达性确认的完整链路:邻居从 NUD_REACHABLE 超时后进入 NUD_STALE(数据仍可发送但可达性待确认),有数据发送时转入 NUD_DELAY(等待上层确认可达),超时后进入 NUD_PROBE(主动发送 ARP/NS 探测)。只有连续 ucast_solicitNUD_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_solicit10(config.xml 默认,稳定态)/ 5(config.xml 默认,漫游后)单播探测最大次数。内核在 NUD_PROBE 状态下每隔 retrans_time_ms 发送一次 ARP/NS 探测,连续 ucast_solicit 次无应答后进入 NUD_FAILED
retrans_time_ms750ms(稳定态与漫游后)探测包重传间隔。默认 1s,Android 调低到 750ms 以加快不可达检测
mcast_resolicit3(仅当功能门控启用)单播失败后改用组播重探测的次数。在 WiFi 环境中组播更可靠(避免 ARP 表过期),所以保留 3 次额外尝试

源码中 MIN_NUD_SOLICIT_NUM = 5MAX_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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// IpReachabilityMonitor.java
private void handleNeighborLost(@Nullable final NeighborEvent prev,
@NonNull final NeighborEvent event) {
LinkProperties whatIfLp = new LinkProperties(mLinkProperties);

for (Map.Entry<InetAddress, NeighborEvent> entry : mNeighborWatchList.entrySet()) {
final NeighborEvent val = entry.getValue();
final InetAddress ip = entry.getKey();

if (val == null || val.nudState != StructNdMsg.NUD_FAILED) continue;
if (!mEverReachableNeighbors.contains(ip)) continue;

for (RouteInfo route : mLinkProperties.getRoutes()) {
if (ip.equals(route.getGateway())) {
whatIfLp.removeRoute(route);
}
}
// 移除不可达 DNS 服务器
if (avoidingBadLinks() || !(ip instanceof Inet6Address)) {
whatIfLp.removeDnsServer(ip);
}
}
}

遍历完所有不可达邻居后,handleNeighborLost 检查 whatIfLp 是否还满足 provisioning 条件——如果原本有 IPv4 或 IPv6 配置,去掉不可达邻居对应的路由和 DNS 后却不再满足 provisioning,就认为发生了 provisioning 丢失,触发告警:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
    final boolean lostIpv4Provisioning =
mLinkProperties.isIpv4Provisioned() && !whatIfLp.isIpv4Provisioned();
final boolean lostIpv6Provisioning =
mLinkProperties.isIpv6Provisioned() && !whatIfLp.isIpv6Provisioned();
final boolean lostProvisioning = lostIpv4Provisioning || lostIpv6Provisioning;
final NudEventType type = getNudFailureEventType(isFromProbe(),
isNudFailureDueToRoam(), lostProvisioning);

if (lostProvisioning) {
final boolean isOrganicNudFailureAndToBeIgnored =
((type == NudEventType.NUD_ORGANIC_FAILED_CRITICAL)
&& mIgnoreOrganicNudFailure);
final String logMsg = "FAILURE: LOST_PROVISIONING, " + event
+ ", NUD event type: " + type.name()
+ (isOrganicNudFailureAndToBeIgnored ? ", to be ignored" : "");
Log.w(TAG, logMsg);
if (!isOrganicNudFailureAndToBeIgnored) {
mCallback.notifyLost(logMsg, type);
}
}
logNudFailed(event, type);
}

这段代码有三层判断:

  • 不只看单个邻居——通过构建 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)执行六步操作:

  1. 将 BSSID 加入黑名单(WifiBlocklistMonitor.handleBssidConnectionFailure())——避免自动重连到同一个有问题的 AP
  2. 记录评分卡(WifiScoreCard.noteIpReachabilityLost())——为后续连接质量分析保留数据
  3. 清空 mWifiInfo 的 InetAddress 和计费标记——重置网络身份信息
  4. 设置断连原因码(mFrameworkDisconnectReasonOverride)——根据丢失原因 ROAM/CONFIRM/ORGANIC 写入不同的 WifiStatsLog 断连原因,用于后续问题排查
  5. 发送 CMD_DISCONNECT 断开连接——触发状态机退出 L2ConnectedState
  6. 调用 updateCurrentConnectionInfo() 刷新框架层连接信息——确保上层 UI 和系统服务看到最新的连接状态

6.4 主动探测与参数调整

连接建立后或漫游后,probeAll() 主动触发邻居探测:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// IpReachabilityMonitor.java
public void probeAll(boolean dueToRoam) {
setNeighbourParametersPostRoaming(); // 漫游后使用更激进的参数

final List<InetAddress> ipProbeList = new ArrayList<>(mNeighborWatchList.keySet());
if (!ipProbeList.isEmpty()) {
mDependencies.acquireWakeLock(getProbeWakeLockDuration());
}
for (InetAddress ip : ipProbeList) {
final int rval = IpNeighborMonitor.startKernelNeighborProbe(
mInterfaceParams.index, ip);
}
mLastProbeTimeMs = SystemClock.elapsedRealtime();
}

核心逻辑:

  • 漫游后使用更激进的 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
2
3
4
5
6
7
8
9
10
11
EvaluatingState(初始)
│ 发送 DNS probe(可选)

ProbingState(发送 HTTPS probe → connectivitycheck.gstatic.com)
│ 收到 HTTPS 204(或 HTTP 204) │ 收到 HTTP 302/超时
▼ ▼
ValidatedState CaptivePortalState
│ reportEvaluationResult │ reportEvaluationResult
│ (VALID, null) │ (PARTIAL, portalUrl)
▼ ▼
回调 ConnectivityService 系统弹出"登录 WiFi"通知

这个状态机的实际代码比上述简化版更复杂——包含 DNS 健康检测、多次重试、带宽评估等子状态,但核心骨架就是 Evaluating → Probing → Validated/CaptivePortal 三级。需要指出的是,实际层级关系与上图的线性表示有所不同:ValidatedStateDefaultState 的直接子状态(与 MaybeNotifyState 同级),而 EvaluatingStateProbingStateCaptivePortalState 都在 MaybeNotifyState 下——这意味着从 ValidatedState 回到 EvaluatingState 不是「子状态退出再进入」,而是跨层级的状态跳转,由 DefaultState 统一处理路由 / DNS 变化事件后分派。

两个关键状态的 enter() 最能说明这条链路的实质。ProbingState.enter()NetworkMonitor.java:2103)真正 "发出探测"——它在后台线程里跑 isCaptivePortal(),而不是在主线程阻塞等待 HTTP 响应:

1
2
3
4
5
6
7
8
9
10
// NetworkMonitor.java:2103 —— ProbingState.enter():发起校验探测
public void enter() {
maybeStopCollectionAndSendMetrics();
startMetricsCollection();
final int token = ++mProbeToken;
...
mThread = new Thread(() -> sendMessage(obtainMessage(CMD_PROBE_COMPLETE, token, 0,
isCaptivePortal(deps, httpsUrls, httpUrls, fallbackUrl))));
mThread.start(); // 后台线程跑 HTTPS/HTTP probe,不阻塞状态机
}

ValidatedState.enter()NetworkMonitor.java:1263)则是 "校验通过" 的落点——它上报 VALID 结果,然后立刻从一次性探测切换到持续监测(§6.6 展开):

1
2
3
4
5
6
7
8
9
10
11
12
// NetworkMonitor.java:1263 —— ValidatedState.enter():校验通过,进入持续监测
public void enter() {
int result = NETWORK_VALIDATION_RESULT_VALID;
if (!mUseHttps && mAcceptPartialConnectivity) {
result |= NETWORK_VALIDATION_RESULT_PARTIAL;
}
mEvaluationState.reportEvaluationResult(result, null /* redirectUrl */);
mValidations++;
initSocketTrackingIfRequired(); // 启动 TCP 套接字跟踪
sendTcpPollingEvent(); // 安排 20s 后的 TCP 轮询
maybeStopCollectionAndSendMetrics();
}

重试机制。校验失败后,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
2
3
4
5
HTTPS probe: https://www.google.com/generate_204
HTTP probe: http://connectivitycheck.gstatic.com/generate_204
预期响应: HTTP 204 (No Content)
实际含义: 服务器收到了请求,无内容返回 = 互联网可达

如果返回的是 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 的网络才能成为默认网络。这意味着:

  1. WiFi 刚注册 → NOT_VALID → 即使评分 60,也不会被选为默认网络
  2. NetworkMonitor 发送 HTTP probe → 收到 204 → VALID → WiFi 评分 60 碾压蜂窝 ~50 → WiFi 成为默认网络
  3. 如果 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% 且发送包数 ≥ 1020 秒(可配)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 时间线:每一步的技术动作

从四次握手完成到用户能上网,完整的调用链如下:

L2 完成到默认网络时间线

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
L2 连接完成(四次握手、密钥安装)

├─[1] ClientModeImpl: L2ConnectedStateL3ProvisioningState
startL3Provisioning() → startIpClient()
│ ├── 决策: getIpAssignment() == STATIC ?
│ │
│ ├── [DHCP 路径]
│ │ ├── IpClient.startProvisioning(prov)
│ │ ├── DhcpClient: DhcpInitState → 发送 Discover(广播)
│ │ ├── DhcpClient: 收到 OfferDhcpRequestingState → 发送 Request
│ │ ├── DhcpClient: 收到 ACK → IpAddressConflictDetectingState(ARP 探测)
│ │ ├── DhcpClient: 无冲突 → ConfiguringInterfaceState → 配置 IP
│ │ └── DhcpClient: → DhcpBoundState(安排 T1/T2 续期)
│ │
│ └── [静态 IP 路径]
│ ├── IpClient.startProvisioning(prov with StaticIpConfiguration)
│ ├── 跳过 DhcpClient 状态机
│ └── 直接 ConfiguringInterfaceState → 配置 IP

├─[2] LinkProperties 构建
│ ├── DHCP: DhcpResultsLinkProperties(IP + 网关 + DNS + 路由)
│ └── 静态: StaticIpConfiguration.toLinkProperties()

├─[3] WifiNetworkAgent 注册
│ ├── new WifiNetworkAgent(lp, nc, WIFI_INITIAL_SCORE)
│ ├── register() → ConnectivityService.registerNetworkAgent()
│ ├── ConnectivityService: 评分比较(WiFi 60 vs 蜂窝 ~50
│ └── rematchAllNetworksAndRequests() → WiFi 成为默认网络

├─[4] IP 可达性监测启动
│ ├── IpReachabilityMonitor.updateLinkProperties(lp)
│ ├── 将网关、DNS 加入 mNeighborWatchList
│ └── 监听内核 NUD 事件(NUD_FAILED / NUD_REACHABLE)

└─[5] 网络校验(ConnectivityService
├── ConnectivityService 发起 Captive Portal 检测
├── VALID → 网络可用
└── NOT_VALID → 可能要求用户登录 Portal

用户看到 "已连接,可上网"

7.2 静态 IP vs DHCP:完整代码级差异

维度DHCP(动态 IP)静态 IP
决策入口ClientModeImpl.startIpClient()!isUsingStaticIp 分支isUsingStaticIp 分支
配置来源DHCP 服务器分配StaticIpConfiguration(用户手动填写)
ProvisioningConfigurationwithNetwork() + 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.IpAssignmentIpConfiguration.java):

1
2
3
STATIC      // 静态配置
DHCP // DHCP 自动获取
UNASSIGNED // 未分配

NetworkAgent.NetworkAgentStateNetworkAgent.java):

1
2
3
STATE_CREATED     // 已创建,未注册
STATE_REGISTERED // 已注册到 ConnectivityService
STATE_UNREGISTERED // 已注销

DhcpClient 关键常量DhcpClient.java):

1
2
3
4
5
FIRST_TIMEOUT_MS            = 1 秒    // Discover 首次重传间隔
MAX_TIMEOUT_MS = 512 秒 // 指数退避上限
DHCP_TIMEOUT_MS = 36 秒 // RequestingState 全局超时(用一半=18s)
T1 = 租约 x 50%
T2 = 租约 x 87.5%

ConnectedScoreConnectedScore.java):

1
2
3
4
WIFI_MAX_SCORE        = 60   // WiFi 最高评分
WIFI_INITIAL_SCORE = 60 // 初始评分(注册时)
WIFI_TRANSITION_SCORE = 50 // 转移阈值(低于此值考虑切换蜂窝)
WIFI_MIN_SCORE = 0 // 最低评分(触发断连)

7.4 关键函数速查

函数文件作用
ClientModeImpl.startIpClient()ClientModeImpl.java静态 IP/DHCP 分支决策
ClientModeImpl.L3ProvisioningState.enterImpl()ClientModeImpl.javaL3 配置入口
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.javaDHCPv6 续期定时器
StaticIpConfiguration.toLinkProperties()StaticIpConfiguration.java静态配置 → LinkProperties
IpConfiguration.IpAssignmentIpConfiguration.javaIP 分配方式枚举
WifiNetworkAgent 构造函数WifiNetworkAgent.java创建并注册 WiFi NetworkAgent
IpClient.startProvisioning()IpClient.java启动整个 IP 配置流程
IpClient.handleIPv4Failure()IpClient.java处理 DHCPv4 失败,清理 IPv4 配置
IpClient.handleProvisioningFailure()IpClient.javaprovisioning 丢失处理,通知 ConnectivityService
NetworkAgentInfo.sendLinkProperties()NetworkAgentInfo.javaService 端接收 LinkProperties(AIDL oneway)
ConnectivityService.updateNetworkScore()ConnectivityService.java更新评分并触发重匹配
ConnectivityService.rematchAllNetworksAndRequests()ConnectivityService.java全局网络重匹配
ConnectedScore.WIFI_INITIAL_SCOREConnectedScore.javaWiFi 初始评分常量
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.javaTCP/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)