SAP(五)收房费给钥匙 — 从客户端关联到能上网
你打开手机热点,朋友连上来,屏幕几乎同时亮起 "已连接",然后他的手机上弹出一条通知:网络已连接,可以上网了。从按下开关到朋友能刷视频,中间只隔了几百毫秒——但就在这瞬间,Android 在手机内部走完了一条从
Tethering到IpServer再到DhcpServer的完整分房流程:热点接口被创建、一个私有网段被划出来、DHCP 服务在接口上监听、朋友的客户端拿到地址和网关,然后数据才被允许转发到上游。
¶ 本章导读
上回讲到《SAP(四)》:SAP 作为 "老板" 要处理共存(DBDC/MCC/SCC)、要管理驱动层状态机、还要在客户端离开时打扫房间。但有个环节被跳过了——客户端关联成功后,它连的只是一个加密的无线链路,它还没有 IP 地址,也就谈不上上网。民宿挂牌了(《SAP(一)》)、装修好了(《SAP(二)》)、客人入住办完手续(《SAP(三)》)、老板在管理前台和客房部(《SAP(四)》)——可客人到现在还没拿到房间钥匙。
本篇就是那把钥匙。民宿的钥匙交给谁、怎么发、发多久、房间不够了怎么办,以及民宿的水电(上游网络)从一路切到另一路时由谁调度。沿用 "开民宿" 的比喻:Tethering 是总管家,IpServer 是前台主管,DhcpServer 是具体发钥匙的接待员。
读完本篇,你就能回答这些问题:
- 热点开起来之后,
Tethering是通过哪条回调链得知 "可以开始分 IP 了" 的? IpServer的状态机只有 5 个顶层状态,但它如何决定给热点接口配哪个网段、谁来启动 DHCP?- 发钥匙的接待员
DhcpServer住在另一个进程(NetworkStack)里,IpServer怎么跨进程把它叫醒? - DHCP 四步曲(DISCOVER/OFFER/REQUEST/ACK)在服务端是怎么实现的——
DhcpLeaseRepository的地址池如何保证不冲突、租约过期怎么清理? - 手机开热点时自己的 WiFi 客户端必然下线,上游切到蜂窝——
Tethering怎么选上游、怎么把 DNS 和转发规则下发给 netd?
源码基准:AOSP main(packages/modules/Connectivity 的 Tethering 模块 + packages/modules/NetworkStack 模块)
¶1 热点起来了,谁负责给客户端发 IP?
先看全景。数据包路径上,SAP 接口的 IP 服务是一条自上而下的链条:Tethering(总管家)→ IpServer(前台主管,每接口一个)→ DhcpServer(发钥匙的接待员,在 NetworkStack 进程)。启动方向如下:
客户端关联上来后,它会主动广播 DHCPDISCOVER;AP 侧的 DhcpServer 响应。注意这里的方向:不是 AP 主动塞 IP 给客户端,而是客户端连上无线后自己发起 DHCP,AP 侧接待员应答。这条链路的触发源头,是 "接口 ready" 这个事实——在 Android 里由 Tethering 感知,而不是由 hostapd 直接驱动。
¶1.1 上篇终点:SoftAP 已启动,接口已 up
《SAP(一)》追踪到 SoftApManager 调用了 startSoftAp(),《SAP(二)》追踪到 hostapd 把 Beacon 推上空口。这些完成后,WifiManager 会向注册的 SoftApCallback 回调 WIFI_AP_STATE_ENABLED,并带上实际使用的接口名(如 wlan1)。Tethering 在启动时就注册了自己的回调,专门接收这个事件:
1 | // Tethering.java:1576 |
- 收到
WIFI_AP_STATE_ENABLED表示 hostapd 已经把接口切成 AP 模式、Beacon 在发射——此时才适合在这个接口上起 IP 服务。回调里拿到的iface(接口名)和request(当时启动热点的请求)是后续一切动作的输入 - 关键动作是
enableIpServing(request, iface):进入 Tethering 自己的 IP 服务启动流程 - 若 SAP 启动失败(
WIFI_AP_STATE_FAILED),则把错误码回传给调用者;若 SAP 中途被关(WIFI_AP_STATE_DISABLED),则disableWifiIpServing()收尾
挂牌开张(Beacon 发射)后,总管家收到 "分店已开业" 的通知,安排前台主管布置房间号(IP 网段)、让接待员上岗(启动 DHCP)。
¶1.2 enableIpServing:创建 IpServer + 激活服务
enableIpServing() 干两件事:先确保有一个 IpServer 存在,再向它发 "开始服务" 的请求:
1 | // Tethering.java:1690 |
第一行 ensureIpServerStartedForType() 是 "造前台":如果这个接口还没有对应的 IpServer,就 new 一个并启动它的状态机。第二行 tetherInternal() 是 "发工单":让这个前台主管进入服务状态。先看造前台:
1 | // Tethering.java:3098 |
mTetherStates是ArrayMap<iface, TetherState>,以接口名为 key 保证每个接口只有一个IpServer——重复回调会被忽略new IpServer(...)传入了一长串依赖:mNetd(netd 的 binder)、mBpfCoordinator(BPF 卸载转发)、mRoutingCoordinator(路由 / 前缀分配)、mConfig(Tethering 配置,含上游选择策略)ipServer.start()启动状态机,初始停在InitialState(对应 "前台主管就位但还没开始服务")
再发工单:
1 | // Tethering.java:1191 |
- 前置校验:接口必须是 "可用"(
STATE_AVAILABLE),否则拒绝——防止在接口还没 ready 时强行服务 promoteRequestToServing()把启动请求从 "待处理" 提升为 "正在服务",这是RequestTracker的记账逻辑- 最关键的一行:
ipServer.enable(request)——向IpServer状态机发送CMD_TETHER_REQUESTED消息
IpServer.enable() 本身只是投递消息,状态转移发生在其内部线程:
1 | // IpServer.java:473 |
到这里,链条从 Tethering(总管家)交接到了 IpServer(前台主管)。IpServer 收到 CMD_TETHER_REQUESTED 后如何决定进入哪个服务状态、如何把 IP 服务和 DHCP 拉起来,是下一节的内容。
¶2 IpServer 状态机是怎么把 DHCP 拉起来的?
IpServer 是 Android 中 tethering 每个下游接口的 "服务主管"。它管理一个接口的完整 IP 生命周期:IPv4 前缀选择、接口地址配置、DHCP 启动 / 停止、IPv6 RA、以及向 Tethering 上报状态。它本身是一个状态机,顶层有 5 个状态。
为什么这里要 "每接口一个状态机",而不是做一个单例统一管所有下游?看类注释就明白了——IpServer 的 javadoc 开宗明义写的是 "Provides the interface to IP-layer serving functionality for a given network interface"(IpServer.java:117-118),设计意图就是 "一个接口配一个管家"。背后是 Tethering 能同时服务多个异构下游:WiFi 热点、USB、蓝牙、P2P 可以并行开着,Tethering 里 mTetherStates 以接口名为 key(Tethering.java:253),每加一个接口就 put 一项。各接口的 up/down、scope、DHCP 服务完全独立——WiFi 接口被拔掉(CMD_INTERFACE_DOWN)不该拖垮正在给蓝牙设备分 IP 的那个前台;若用单例,所有下游的 IP 生命周期就耦合在一个对象里,一个接口的状态转移会牵连其他接口。
所以状态机按接口粒度拆,每个 IpServer 自带独立的 Handler 线程和 DHCP 服务,互不阻塞。Tethering 还额外维护了一个 mNotifyList(Tethering.java:1979),专门保存 "还挂着待清理状态" 的 IpServer,保证接口从 mTetherStates 移除后、状态机被回收前有地方记录它——这从反面印证了 "多个状态机并存" 是常态,单例设计根本不需要这张清单。
¶2.1 状态机全景
构造函数里 addAllStates 注册了 5 个顶层状态,并用 mServingMode 对外暴露当前服务模式:
1 | // IpServer.java:386 |
对应关系(getStateString(),IpServer.java:129):
| 服务模式常量 | 含义 | 触发 |
|---|---|---|
STATE_AVAILABLE | 待命,可接受服务请求 | 进入 InitialState |
STATE_TETHERED | 全功能共享(有上游,客户端可上网) | TetheringRequest 为全局 scope 时进入 TetheredState |
STATE_LOCAL_ONLY | 仅本机热点(无上游,客户端仅能访问热点自身) | scope 为 local 时进入 LocalHotspotState |
STATE_UNAVAILABLE | 接口已 down,不可用 | 收到 CMD_INTERFACE_DOWN |
注意 mWaitingForRestartState 的父状态是 mTetheredState——它是 Tethered 的一个子状态,用于 DHCP 前缀冲突后等待重启的场景。
状态转移的入口在 InitialState.processMessage()。收到 CMD_TETHER_REQUESTED 时,根据请求的 "连接范围" 分叉:
1 | // IpServer.java:1096 |
- 普通用户开热点时,
Tethering构造的请求是全局 scope(CONNECTIVITY_SCOPE_GLOBAL)——即客户端不仅能访问热点,还能通过热点上网。所以走transitionTo(mTetheredState) - 本地热点(Local Only Hotspot)不带上游,scope 是 local,走
LocalHotspotState——它和 Tethered 共享同一个BaseServingState父类,只是 scope 不同 maybeConfigureStaticIp()先处理静态 IP 配置(如果用户配置了静态网段),覆盖默认网段
总管家发来 "开始接待客人(全局模式)" 的工单,前台主管就从 "待命" 切换到 "营业中"。
¶2.2 BaseServingState.enter():营业中要做的事
TetheredState 和 LocalHotspotState 都继承自 BaseServingState,真正的重活都在这两个状态的公共父类 enter() 里:
1 | // IpServer.java:1130 |
startServingInterface() 是核心,它按顺序做四件事:
1 | // IpServer.java:1164 |
- 第 (2) 步
startIPv4()是我们这篇的主线——它内部会触发DhcpServer的创建(见 2.3) - 第 (3) 步
mNetd.tetherInterfaceAdd(mIfaceName)是给内核下命令:把这个接口标记为 tethering 下游,允许转发。这一步之后,接口才具备 "把客户端流量转给上游" 的资格 - 第 (4) 步
startIPv6()(IpServer.java:791)负责 IPv6 侧:先经mDeps.getInterfaceParams()拿到接口参数,再创建RouterAdvertisementDaemon并start(),周期性广播 RA(Router Advertisement)让客户端用 SLAAC 自动生成地址;Android S 起还会创建DadProxy(mDeps.getDadProxy()),替下游客户端转发邻居发现报文(NS/NA),且必须等 IPv6 上游就绪、拿到确定的上游接口后才开始转发——因为它的作用是把下游客户端做重复地址检测的 NS 探测转发到上游、再把上游的 NA 应答带回,这样客户端用 SLAAC 生成地址时能 "看到" 上游已占用的地址,避免选到与上游撞车的地址;上游还没就绪就转发,这套跨链路探测就没了着落。任一步失败就stopIPv6()回滚。SAP 的 IPv4 走 DHCP、IPv6 走 RA,双栈并行
¶2.3 startIPv4:从选网段到启动 DHCP
startIPv4() 一行调 configureIPv4(true, scope)。configureIPv4 是 IPv4 配置的总入口:
1 | // IpServer.java:719 |
流程分三步:选地址 → 配接口 → 起 DHCP。
第一步:选地址。 requestIpv4Address(scope, true) 决定 SAP 用哪个网段:
1 | // IpServer.java:776 |
- 优先级:用户静态配置 > 蓝牙特例 > P2P 专用网段 > 动态分配
- 动态分配走
mRoutingCoordinator.requestStickyDownstreamAddress()——"sticky"(粘性)是关键:它尽量复用上次用过的网段,避免每次开热点都换一个前缀。想想看,如果每次开热点客户端都要重新 DHCP,体验会很差;粘性地址让 "同一台手机开热点" 总是给出一致的网段,客户端再次连接时更可能命中缓存
那为什么不用纯随机,而是 "粘性优先"?看 PrivateAddressCoordinator 的实现就清楚了,它的类注释第一句就是 "This class coordinate IP addresses conflict problem"(PrivateAddressCoordinator.java:59)——这个协调器存在的目的不是 "随机分配",而是避免下游网段和上游网络撞车。sticky 的 "复用" 是有条件的:它按 (interfaceType, scope) 做成 AddressKey 缓存(PrivateAddressCoordinator.java:221),每次请求先查缓存;只有 !isConflictWithUpstream(cachedAddress)——即这个上次用过的网段跟当前上游(比如蜂窝数据分配到的网段)不冲突——才会真的复用(PrivateAddressCoordinator.java:226)。一旦发现缓存的网段已经和上游重叠,就立刻放弃粘性、改走随机。
随机也不是拍脑袋:getRandomPrefixIndex(PrivateAddressCoordinator.java:267)在 10.0.0.0/8、172.16.0.0/12、192.168.0.0/16 三个候选块里按权重挑,/8 占了约 93.7% 的概率,因为现实中大多数私有网段都在 10.x 大块里;chooseDownstreamAddress(PrivateAddressCoordinator.java:305)再避开 .0/.1/.255 和常见的 192.168.0/24、192.168.1/24 等网段,防止跟用户家里路由器的默认网段撞上。所以 "粘性" 真正的意思是:能复用就复用(客户端体验),一旦与上游冲突就退回随机(正确性兜底),两种策略都不是为了 "显得随机",而是服务 "别撞车" 这个总目标。
- 默认 WiFi SAP 用的是一个私有 /24 网段(
PrivateAddressCoordinator.PREFIX_LENGTH = 24,PrivateAddressCoordinator.java:70)——前缀长度是 24,网段本身在requestStickyDownstreamAddress里动态选,不是TetheringConfiguration里写死的一个固定地址;TetheringConfiguration里的LEGACY_DHCP_DEFAULT_RANGE(TetheringConfiguration.java:76)是旧版 dnsmasq 的遗产,新路径不走它
第二步:配接口。 mInterfaceCtrl.setInterfaceConfiguration(mIpv4Address, null) 通过 netd 把地址写到热点接口上。对 WiFi 接口,up/down 状态由 WiFi 栈自己管(setIfaceUp = null),这里只设地址。
第三步:起 DHCP。 地址配好后,configureDhcp(true, mIpv4Address, mStaticIpv4ClientAddr) 走到 startDhcp():
1 | // IpServer.java:654 |
makeServingParams()把 "谁来当网关、谁当 DNS、租约多久" 打包成DhcpServingParamsParcel。注意网关和 DNS 都填的是热点接口自己的地址addr——对客户端来说,默认网关就是热点手机,DNS 转发也由热点手机代理DHCP_LEASE_TIME_SECS = 3600(IpServer.java:147),租约 1 小时mDhcpServerStartIndex是一个单调递增的序号,用于识别 "过期的启动请求"(见 3.2 中onDhcpServerCreated里的比较)mDeps.makeDhcpServer(ifName, params, cb)是依赖注入点——真正的实现在TetheringService里,这一步开始跨进程(下一节)
前台主管敲定楼层布局(网段)、钉好门牌(接口地址)后,通知接待员 "上岗"——接待员在另一栋楼里上班(NetworkStack 进程),中间靠一条电话线(AIDL)联系。
¶3 DhcpServer 住在另一个进程,怎么叫醒它?
mDeps.makeDhcpServer() 看起来是本地方法调用,实际它跨了一个进程。IpServer.Dependencies.makeDhcpServer 的实现在 TetheringService 里(Tethering 进程),而 DhcpServer 本体在 NetworkStack 进程。
¶3.1 调用链
1 | IpServer.startDhcp() |
TetheringService 里的实现——它并不真创建 DHCP 服务器,只是拿到 AIDL 连接器后把请求转发给 NetworkStack 进程:
1 | // TetheringService.java:469 |
getNetworkStackConnector()会阻塞轮询等待NetworkStack.getService()返回 binder——最多等 60 秒。NetworkStack 是独立于 Tethering 的一个进程,专门承载 DHCP / 网络监测等 "协议实现",通过 AIDL 接口INetworkStackConnector暴露能力
为什么 DHCP 协议实现要跨进程放进 NetworkStack,而不是直接写进 Tethering 进程?三个理由。其一,mainline 模块边界:NetworkStack 是独立的 mainline 应用模块(APK,Android.bp 里 android_app { name: "NetworkStack" },updatable: true),进程名 com.android.networkstack.process(AndroidManifest.xml:55)——它作为 APK 可以脱离系统版本单独通过 Play 更新,DHCP 的 bug 修复不用等整机系统升级。
其二,与 STA 侧共享同一套协议代码:DHCP 客户端 DhcpClient(STA 用)和服务端 DhcpServer(AP 用)都在这个进程里,两者 import 的是同一个 DhcpPacket 解析器(DhcpClient.java:19-23)——同一台手机开热点时,一侧是 "问问题的客人"、一侧是 "查房本的接待员",它们读同一份协议字典,解析行为天然一致,不会出现客户端和服务端对 RFC 2131 字段理解分歧。
其三,进程隔离:DhcpServer 要开 raw UDP socket(端口 67)接收任意来源的包,这是攻击面最大的地方(任何人都能往 67 端口发垃圾包,见 §4 开头的健壮性处理)。把它放进独立进程,崩溃或安全问题就被限制在 networkstack 进程里,不会拖垮负责转发规则下发的 Tethering 主进程。跨进程的代价就是这套 AIDL + 60 秒阻塞轮询 + binder 回调,但换来的是模块可独立升级、协议实现单一来源、风险面隔离。
service.makeDhcpServer(ifName, params, cb)是跨进程调用:把 "接口名 + 服务参数 + 回调" 传给 NetworkStack 进程- 回调
cb(DhcpServerCallbacks)也是 binder 对象——NetworkStack 进程创建好DhcpServer后,通过它把IDhcpServer(管理接口)传回 Tethering 进程
NetworkStackService 一侧的响应:
1 | // NetworkStackService.java:340 |
server.makeConnector()返回一个IDhcpServer.Stub(DhcpServerConnector),作为 binder 对象传给回调onDhcpServerCreated(STATUS_SUCCESS, connector)跨进程回到 Tethering 进程的DhcpServerCallbacksImpl
¶3.2 IpServer 收到 "接待员已上岗" 后的处理
回到 Tethering 进程,DhcpServerCallbacksImpl.onDhcpServerCreated() 处理新拿到的 IDhcpServer:
1 | // IpServer.java:531 |
getHandler().post(...):跨进程回调到达时不一定在 IpServer 自己的线程上,所以先投回 handler 线程再处理——避免竞态- 序号防抖:
mStartIndex != mDhcpServerStartIndex判断这次创建是否已被更新的请求取代。比如用户快速开关热点,前一次makeDhcpServer的回调晚到,此时序号已经变了,就停掉这个过时 server。这是处理异步竞态的一个典型手法 - 序号匹配后调用
mDhcpServer.startWithCallbacks(statusCallback, dhcpEventCallback)——正式让 DHCP 接待员开工,并注册租约变化回调DhcpEventCallback DhcpEventCallback.onLeasesChanged()把 NetworkStack 报上来的租约列表转成TetheredClient列表存到mDhcpLeases- 之后
mCallback.dhcpLeasesChanged()把租约变化推给 Tethering——通知栏 "已连接设备" 的客户端列表就是从这里来的
¶3.3 DhcpServer 状态机:Stopped → Running
DhcpServer 本身也是一个状态机,2 个顶层状态 + 2 个子状态:
1 | StoppedState(根) |
startWithCallbacks 最终发 CMD_START_DHCP_SERVER。StoppedState 处理它:
1 | // DhcpServer.java:410 |
transitionTo(mRunningState) 会先进入 RunningState 的父状态 StartedState。StartedState.enter() 干重活:
1 | // DhcpServer.java:442 |
mPacketListener.start()创建 UDP socket 并开始监听——这是 "接待员坐在前台开始等客人" 的时刻- 启动失败会
deferMessage(CMD_STOP_DHCP_SERVER)回滚到 StoppedState - 把
mEventCallbacks注册到mLeaseRepo(DhcpLeaseRepository.addLeaseCallbacks),这样每次租约变化都能通过 binder 推给 IpServer
socket 怎么建?看 PacketListener.createFd():
1 | // DhcpServer.java:866 |
- UDP socket,
bind到DHCP_SERVER端口(67)——DHCP 服务端标准端口 bindSocketToInterface(mSocket, mIfName)把 socket 绑定到 SAP 接口,这样只收这个接口上的 DHCP 包,不会收到其他接口的广播SO_BROADCAST允许发广播响应(客户端还没 IP 时,OFFER/ACK 可能要以广播形式发出)- 收到包后
onReceive()检查源端口:只处理来自DHCP_CLIENT(68)的包,然后sendMessage(CMD_RECEIVE_PACKET, packet)交给状态机
接待员上岗后坐在前台(UDP:67),听到客人喊 "有空房吗" 就响应。客人喊话的窗口(端口 68)是固定的,接待员只听自己这栋楼(绑定接口)的喊声。
到这里,跨进程叫醒接待员的整条链路走完了:DhcpServer 在 NetworkStack 进程就位、监听打开、租约变化能推回 IpServer。接下来看客人真正上门时,接待员怎么查房态、发钥匙、处理房间不够和客人退房——DHCP 四步曲的服务端实现,是下一节的内容。
¶4 服务端怎么实现 DHCP 四步曲?
客户端连上无线后,会按 RFC 2131 的流程发起 DISCOVER → 收 OFFER → 发 REQUEST → 收 ACK。DhcpServer 的 RunningState 收到 CMD_RECEIVE_PACKET 后,先把包按类型分发:
1 | // DhcpServer.java:512 |
- 第一道过滤是 Server Identifier:如果包里的 server id(option 54)存在且不是自己,直接丢弃——这是 DHCP 的 "认领" 机制,防止客户端在多个 DHCP 服务器间跳转时收到错误响应
- 四种包对应四个处理器:
processDiscover/processRequest/processRelease/processDecline - 解析异常(
MalformedPacketException)只记日志不崩溃——攻击者可以向 UDP:67 发任意垃圾包,服务端必须健壮
如果只想弄清 "四步曲怎么走通",可以略过 4.1 的地址池实现直接看 4.2/4.3——地址池的分配细节不影响对 DISCOVER→OFFER→REQUEST→ACK 流程的理解。
¶4.1 地址池与租约仓库:DhcpLeaseRepository
四步曲的核心数据都落在 DhcpLeaseRepository(租约仓库)。它维护两张表:mCommittedLeases(已确认租约,ArrayMap<IP, DhcpLease>)和 mDeclinedAddrs(被 DECLINE 过的地址,带过期时间)。地址池的大小由前缀长度决定:
1 | // DhcpLeaseRepository.java:148 |
mNumAddresses是可分配地址总数:/24 网段就是 256 个。若配了singleClientAddr(固定客户端地址),则只分配 1 个- 分配算法从 MAC 地址哈希出起点(
getFirstClientAddress,模仿 dnsmasq 行为),然后线性探测下一个可用地址——这样同一台客户端多次 DISCOVER 大概率拿到同一地址,利于缓存命中 getValidAddress会跳过.0、.255和保留地址:addrIndex == 0 || addrIndex == mNumAddresses - 1时强制跳到 1——一些老旧系统的网络栈处理不了广播地址和网络地址,DHCP 服务端主动避开
租约还有生命周期管理:removeExpiredLeases() 在每个分配操作前先清掉过期租约,把过期地址释放回池子。过期时间用 mNextExpirationCheck 做 "最早到期时间" 缓存,避免每次都要全表扫描。
¶4.2 DISCOVER → OFFER
客户端发 DHCPDISCOVER,processDiscover 决定给哪个地址:
1 | // DhcpServer.java:544 |
getOffer 的分配逻辑有三条优先级:
1 | // DhcpLeaseRepository.java:192 |
- 三条路:老客户续租 > 客户指定地址(且合法且空闲)> 池子里挑新地址
- 注意 OFFER 阶段不提交租约(注释明确说 "Do not update lease time in the map")——接待员只是口头应下 "这间房先给你留着",但还没在房态表上落笔,地址仍可能被别人订走。真正的确认发生在 REQUEST 阶段
makeNewOffer内部从 MAC 哈希起点开始,逐个探测isAvailable(addr) && !mDeclinedAddrs.containsKey(addr),找到就返回;地址耗尽则抛出OutOfAddressesException,processDiscover捕获后回 NAK
¶4.3 REQUEST → ACK
客户端选定地址后发 DHCPREQUEST,processRequest 正式确认租约:
1 | // DhcpServer.java:565 |
requestLease 处理客户端所处的三种 DHCP 状态(SELECTING / INIT-REBOOT / RENEWING-REBINDING):
1 | // DhcpLeaseRepository.java:281 |
sidSet是区分状态的钥匙:REQUEST 里带 server id 说明客户端处于 SELECTING(它挑了我们),不带则是 RENEWING/REBINDING(它在续租)checkClientAndMakeLease最终把租约写入mCommittedLeases并触发notifyLeasesChanged()(推给 IpServer),这是唯一真正把地址 "定下来" 的地方:
1 | // DhcpLeaseRepository.java:353 |
确认后 transmitAck 构造 DHCPACK 报文发回客户端。响应发到哪,有一套规则(getAckOrOfferDst):中继(giaddr)优先 > 广播标志 > 客户端已有地址(ciaddr)单播 > 新租约地址单播。
因为客户端此刻可能还没有 IP,OFFER/ACK 常常必须用广播或发到待分配的地址,所以发送前 addArpEntry 会往 ARP 表塞一条 "客户端 MAC ↔ 新 IP" 的记录,确保内核能把发往新地址的 UDP 包正确地封装成以太网帧送到客户端——否则内核根本不知道这个 IP 该从哪个接口发、发给谁。
¶4.4 异常路径:RELEASE、DECLINE、地址耗尽
正常四步之外,还有三条退路:
- RELEASE:客户端主动释放地址(关机、切网),
processRelease→mLeaseRepo.releaseLease()把地址归还池子。注意 RELEASE 无响应(DHCP 协议里 RELEASE 没有 ACK/NAK) - DECLINE:客户端发现地址冲突(比如 ARP 探测到已被占用)会发
DHCPDECLINE,processDecline→markAndReleaseDeclinedLease()把地址从租约表移除并加入 "拒绝名单"(带过期时间),短时间内不再分配它 - 地址耗尽:
makeNewOffer探测一圈找不到空闲地址时,会尝试回收过期 / 被 DECLINE 的地址,实在没有就抛OutOfAddressesException,回 NAK。注意 DHCP 的 NAK 只在 REQUEST 阶段有实际意义(告诉客户端 "别用这个地址"),OFFER 阶段的 "NAK" 只是记日志
对比《连接(六)》里 STA 侧 DhcpClient 的 12 状态,先看客户端视角:客户端是 "发起方",每一步都靠重传超时 + 指数退避保证请求不丢——发 DISCOVER、等 OFFER、发 REQUEST、等 ACK,拿到地址后还要在租约到期前按 T1/T2 续期。那 12 个状态,就是这出 "要钥匙" 戏码的每一步:开口问、等回音、确认要哪间、拿到钥匙、到期续租。
再看服务端视角:DhcpServer 是 "响应方",它不主动发起任何事,只是坐在 UDP:67 端口等客人敲门,用地址池 + 租约表保证分配不冲突——来一个 DISCOVER 就查房态给一个 OFFER,来一个 REQUEST 就在房态表上落笔(commitLease)、正式交出钥匙。客户端看到的是 12 个状态,服务端看到的是 4 种包(DISCOVER/REQUEST/RELEASE/DECLINE)的应答逻辑;两者在一台手机开热点时同时存在,只是角色相反。同一个进程里,问问题的客人和翻查房本的接待员住在同一个屋檐下——一个递问题,一个对房态。现在客人已经拿到钥匙、住进房间(客户端拿到了 IP 配置),但钥匙只保证进得了民宿的门,要真正上得了网,还得看民宿的水电——上游网络——从哪一路接进来,这正是下一节要讲的市政管网切换。
¶5 上游切换时,谁在替客户端重新指路?
DHCP 分完地址,客户端能连上热点,但要 "上网" 还需要转发:客户端发出的包要经热点转发到某个上游网络。普通用户开热点时,手机自己的 WiFi 客户端模式必然被关掉(同一块射频要么 STA 要么 SAP,见《SAP(四)》的共存章节),此时手机的互联网连接只剩下蜂窝数据。谁来选上游?Tethering 的 TetherMainSM。
¶5.1 选上游:chooseUpstreamType
TetherMainSM 里维护上游状态,核心是 chooseUpstreamType:
1 | // Tethering.java:2103 |
- 上游选择由
UpstreamNetworkMonitor提供:getCurrentPreferredUpstream()返回当前最优上游。它监听 ConnectivityService 的所有网络,按 "当前默认网络优先、WiFi 优先于蜂窝" 的规则排序 - 当手机开热点导致 WiFi 客户端掉线时,WiFi 网络从
UpstreamNetworkMonitor的视野里消失,剩下的最优选择就是蜂窝数据。热点服务本身(SAP)不会出现在上游候选里——UpstreamNetworkMonitor会排除 tethering 自己的下游接口 setTryCell(true)是 "试探蜂窝":有时蜂窝数据还没就绪(比如刚切卡),Tethering 会催一下再重试ns == null(没有上游)时,若不能试蜂窝,就发延迟消息CMD_RETRY_UPSTREAM稍后重试——上游出现是异步的,Tethering 要轮询等待- 上游变化后
reportUpstreamChanged通知系统(比如状态栏热点图标),onUpstreamCapabilitiesChanged更新通知
¶5.2 应用上游:DNS 转发 + 通知下游
选好上游后,setUpstreamNetwork 把决定落实到 netd 和各个下游 IpServer:
1 | // Tethering.java:2139 |
getTetheringInterfaces(ns)从上游的LinkProperties里找出带默认路由的接口(可能嵌套在底层接口上,比如rmnet_data0的pdp0)setDnsForwarders把上游 DNS 地址下发给 netd:
1 | // Tethering.java:2163 |
- 热点客户端发的 DNS 查询会被热点截获,由
tetherDnsSet配置的 DNS 服务器代为解析。上游有 DNS 就用自己的,否则退回默认 DNS(DEFAULT_IPV4_DNS) - 这里只配置 "谁来做 DNS 转发",实际的转发动作(iptables/BPF 规则)由 netd 在
tetherInterfaceAdd时建立的转发链完成——本文不展开内核细节
notifyDownstreamsOfNewUpstreamIface 把新的上游接口集广播给所有活跃的下游 IpServer:
1 | // Tethering.java:2191 |
CMD_TETHER_CONNECTION_CHANGED是IpServer收到的 "上游变了" 通知——总管家换了一路市政管网,广播给各楼前台。下游IpServer拿到新的上游接口集后,会更新 IPv6 转发(mLastIPv6UpstreamIfindex/mLastIPv6UpstreamPrefixes)并调整路由——但 IPv4 侧对客户端基本透明,因为客户端把网关指向热点本身,转发规则在 netd 里- 这解释了为什么用户无感:WiFi 客户端下线、蜂窝顶上,热点客户端已经拿到的 IPv4 配置(网关 = 热点、DNS = 热点)完全不用变,只有热点的上游路径变了
楼里的水电气(上游)从一路切到另一路,接待员不用重新给客人发钥匙——客人手里的钥匙(IP 配置)没变,变的只是楼底下的总管道(netd 转发规则)接到了新的市政管网(蜂窝数据)。
¶6 总结
回到最开始的问题:客户端关联后怎么拿到 IP?
- 感知:hostapd 把 SAP 拉起来,
WifiManager回调WIFI_AP_STATE_ENABLED,Tethering.StartTetheringSoftApCallback.onStateChanged接到通知 - 造前台:
enableIpServing→ensureIpServerStartedForType创建并启动IpServer(每接口一个状态机,5 个顶层状态),tetherInternal发CMD_TETHER_REQUESTED让它进入TetheredState - 定网段:
BaseServingState.enter→startIPv4→requestIpv4Address用 "粘性" 策略选私有网段,configureIPv4把地址写到接口 - 叫接待员:
configureDhcp→startDhcp→mDeps.makeDhcpServer跨进程(Tethering → NetworkStack)创建DhcpServer,回调拿到IDhcpServer后startWithCallbacks - 上岗收包:
DhcpServer状态机 Stopped → Running,PacketListener在 UDP:67 上监听,客户端广播 DISCOVER 后按 "已有租约 > 指定地址 > 池子探测" 三条路给 OFFER,REQUEST 后经DhcpLeaseRepository.commitLease定下租约并回 ACK - 接市政:
TetherMainSM.chooseUpstreamType从UpstreamNetworkMonitor选出蜂窝数据作为上游,setDnsForwarders下发 DNS,notifyDownstreamsOfNewUpstreamIface通知各IpServer上游路径已切换
关键技术结论:
| 环节 | 关键点 |
|---|---|
| 接口唯一性 | mTetherStates 以接口名为 key,每接口一个 IpServer |
| 前缀粘性 | requestStickyDownstreamAddress 复用上次网段,客户端再连更可能命中缓存 |
| 租约 1 小时 | DHCP_LEASE_TIME_SECS = 3600,到期前由客户端 T1/T2 续期 |
| OFFER 不提交 | 地址在 requestLease/commitLease 时才真正锁定,避免 "报而不占" |
| 跨进程 | DhcpServer 在 NetworkStack 进程,通过 INetworkStackConnector AIDL + 序号防抖回调 |
| 上游切换 | UpstreamNetworkMonitor 感知 WiFi 客户端下线,蜂窝顶上,客户端 DHCP 配置不变 |
有开张就有打烊。用户关掉热点,WifiManager 回调 WIFI_AP_STATE_DISABLED,StartTetheringSoftApCallback 走 disableWifiIpServing(Tethering.java:1729)收尾——ipServer.unwanted() 投递 CMD_TETHER_UNREQUESTED,TetheredState 退出时逆序拆除:stopDhcp() 让接待员下班、清空租约(IpServer.java:671),tetherInterfaceRemove 摘下接口的转发资格,mBpfCoordinator.removeIpServer 清掉 BPF 规则,mTetheringAgent.unregister() 注销网络代理,ConnectivityService 把 "热点网络" 移出列表。客人离开、房态清零、管网断开——民宿打烊,等下次 WIFI_AP_STATE_ENABLED 再开张。
现在,民宿的钥匙系统完整了:挂牌(《SAP(一)》)、装修(《SAP(二)》)、办入住(《SAP(三)》)、当老板管共存(《SAP(四)》)、分房发钥匙(本篇)。但还有一个问题悬着——如果同一台手机既想开热点,又想让另一台手机通过 Wi-Fi Direct 直连,P2P 的 Group Owner 也会给自己和成员分配 IP。SAP 的钥匙和 P2P 的钥匙是同一套系统在发吗?还是各发各的?这个问题的答案藏在一个独立的世界里——下一章,我们进入 Wi-Fi Direct,从 P2P 的初始化开始,看看另一种 "民宿" 怎么开张。
相关协议和代码仓库
- Android Framework(Tethering):AOSP
packages/modules/Connectivity,核心文件Tethering/src/android/net/ip/IpServer.java、Tethering/src/com/android/networkstack/tethering/Tethering.java、Tethering/src/com/android/networkstack/tethering/TetheringConfiguration.java - Android 网络地址协调(前缀分配):AOSP
packages/modules/Connectivity的 staticlibs,device/com/android/net/module/util/PrivateAddressCoordinator.java - Android NetworkStack(DHCP 服务端):AOSP
packages/modules/NetworkStack,核心文件src/android/net/dhcp/DhcpServer.java、src/android/net/dhcp/DhcpLeaseRepository.java - 协议参考:RFC 2131(DHCP)、RFC 4039(Rapid Commit)