Android 上排查“Wi-Fi已经连上但就是进不了网页”“手机开热点,另一台设备却拿不到IP地址”这类问题,很多人第一反应是怀疑路由器或运营商网络,但实际在Android 10之后,一台手机里已经完整内置了DHCP客户端和服务端两套实现,很多故障的根源就藏在这套实现里。Android 10把DHCP相关代码从原来零散的netd、dnsmasq等模块统一收进了独立的NetworkStack模块,调试入口也从过去靠感觉翻日志,变成了从dumpsys network_stack出发,一路追踪到DhcpServer.java、DhcpClient.java源码的标准化流程。这篇文章我不讲理论书上的DHCP报文格式,而是按实际干活时的排查顺序,把从命令查询到源码阅读的完整链路拆开讲一遍,也把这两年踩过的坑一并交代清楚。适合正在做系统定制、网络方案集成或者运营商问题反馈的Android工程师,以及那些在路由器和终端之间来回跳转却始终定位不了问题的人。
1. 先搞懂Android 10的DHCP代码到底在哪
1.1 独立出来的NetworkStack模块
Android 10之前,DHCP功能散落在好几个地方:客户端相关逻辑在netd里,热点和服务端相关的DHCP分配往往交给dnsmasq进程,框架层再有各种状态机去调度。那时候调试一个问题经常要同时开三四个终端,adb shell ps -A里看到dnsmasq和netd都有嫌疑,日志一多根本分不清谁是谁。
Android 10把这个局面彻底改了。与DHCP客户端、DHCP服务器、IP地址分配、DHCP租约存储直接相关的一套代码,统一集中到NetworkStack模块里,编译出来是一个系统级APK,进程名通常是com.android.networkstack。在AOSP源码中的路径是packages/modules/NetworkStack/,核心文件就是DhcpClient.java和DhcpServer.java两个,再加上一批DhcpPacket.java、DhcpClientStateMachine.java之类的辅助类。这个模块虽然是APK形式,但它并不像普通App那样运行在应用沙箱里,而是作为系统服务进程存在,同时被Wi-Fi、以太网、热点Tethering等子系统调用。
这一改动最大的好处是,DHCP相关日志、状态、进程边界都收敛了。过去那种“在netd里看到了,但在dnsmasq里好像也有”的纠结基本消失,调起问题来思路清晰得多。
1.2 DHCP在Android 10里涉及哪些进程和端口
虽然代码统一了,但实际运行的时候依然存在多进程配合。STA模式下,手机作为DHCP客户端,由NetworkStack进程内的DhcpClient负责发Discover、收Offer、发Request、收ACK,整个过程走标准的UDP 67服务端、68客户端端口。热点/软AP模式下,NetworkStack进程内的DhcpServer监听热点网卡(通常叫ap0或wlan0)出接口,监听UDP 67端口,给连接热点的设备分配地址。如果手机走USB网络共享(USB Tethering),DHCP服务器同样会出现在rndis0这类的网卡接口上。
所以排查时要先明确一个前提:你的问题到底是客户端侧还是服务端侧。客户端拿不到IP、续租报错,大概率要盯DhcpClient那条链路;热点或者USB共享下的设备拿不到IP,要看DhcpServer是不是在dumpsys输出里正常注册了对应接口。很多人卡在这里,一是不知道这两个进程实际跑在同一个com.android.networkstack进程里,二是不知道这个进程里其实分了两套完全独立的状态机。
另外一个容易忽视的点是,Android 10之后,dnsmasq在默认通路里不再承担热点DHCP服务器的职责,但部分厂商定制ROM或者服务器固件场景下仍然会存在一个tethering相关进程去调用底层脚本。如果你拔掉热点后ps -A | grep dnsmasq还能看到进程,那说明这台机器可能走了兼容路径,这时候按纯Java实现去排查就会对不上号。遇到这种情况,我建议先确认系统用的到底是Java版DhcpServer还是dnsmasq版本,别拿到一台设备就开始套命令。
2. dumpsys network_stack实操现场:第一步就是它
2.1 命令怎么敲,输出长什么样
进入Android 10之后,NetworkStack注册了自己的系统服务,dumpsys名称就是network_stack。排查DHCP问题,命令行第一站就是:
adb shell dumpsys network_stack如果觉得输出太长,可以加时间限制:
adb shell dumpsys -t 5 network_stack还有一个前序动作,先确认服务是否真的在运行:
adb shell dumpsys -l | grep network能看到类似network_stack、wifi、ethernet、connectivity这样的服务名,说明网络栈服务已经正常注册。
不同Android版本、不同厂商ROM对dumpsys network_stack的输出格式有很大差异,有些展示的是整个NetworkStack的概况,有些分客户端和服务端两个大块。我手头一台相对接近原生Android 10的设备,核心输出长这样:
NetworkStack service Client interfaces: interface: wlan0 state: CONNECTED ip: 192.168.1.23/24 gateway: 192.168.1.1 lease duration: 86400 dns: [192.168.1.1, 114.114.114.114] dhcp state: BOUND Server interfaces: interface: ap0 subnet: 192.168.43.0/24 server ip: 192.168.43.1 range start: 192.168.43.10 range end: 192.168.43.250 lease time: 7200 active lease count: 3以上是简化的模拟输出,不代表所有ROM一致,但几个关键字段基本都能找到:每张网卡当前走的DHCP状态、服务器监听的接口、分配的地址池、租约时间、当前活跃租约数。在真实设备上,如果恰好是自定义ROM,可能还会出现DhcpStats、PacketStatistics、LastError之类的字段,这些字段往往是厂商为了方便售后排查加的,利用好了能省一半时间。
2.2 从输出里快速定位异常
拿到dumpsys network_stack的输出后,不要从头到尾逐行读,直接按下面四条去扫:
第一,看Client interfaces下有没有你正在使用的网卡。如果网卡本身没出现在这个节点里,说明DhcpClient根本没有为该网卡启动,或者该网卡走了静态IP配置,问题跟DHCP无关,而去Settings里查看IP分配方式是DHCP还是Static。
第二,看dhcp state字段。出现BOUND说明地址已获取,出现INIT、SELECTING、REQUESTING、RENEWING、REBINDING分别对应状态机的不同阶段。卡在REQUESTING和RENEWING最常见。REQUESTING一直不跳转到BOUND,多数是服务端没有正确回复ACK;RENEWING阶段反复失败,则要重点怀疑租约表以及网关ARP交互是否正常。
第三,看Server interfaces下的盘点有没有预期接口。开热点后,这里应该出现ap0或者wlan0对应的DHCP服务配置。如果没有任何服务端接口,那问题根本不在DHCP上,而在Tethering开启流程上,Tethering都没把接口交给DhcpServer。
第四,看active lease count与实际连接设备数是否一致。热点开了,dumpsys里却显示0个租约,要么设备没真正连上热点,要么连上了但DHCP请求就没到达这个进程,需要继续抓包看报文是否送达。
看完这四个点,问题差不多就能框到具体的一段路径上:要么客户端网卡没起来,要么状态机卡住,要么服务端没有响应请求。后面再开始翻日志排错,信息就有了方向。
3. 深入DhcpServer.java核心逻辑:别再只靠猜
3.1 源码位置与关键类地图
dumpsys输出能让你知道问题在哪一层,想继续往下追,就必须看源码。DhcpServer.java在AOSP中的路径是:
packages/modules/NetworkStack/src/com/android/networkstack/dhcp/DhcpServer.java同目录下还有几个兄弟文件,命名很直观:
DhcpClient.java:客户端状态机与报文收发。DhcpPacket.java:各类DHCP报文的解析与封装,在AOSP中会根据OpCode翻出DiscoverPacket、OfferPacket、RequestPacket、AckPacket、NakPacket等子类。DhcpClientStateMachine.java:客户端状态机,里面能看到StateMachine的各个状态定义。DhcpServerLeaseRepository.java:服务端租约存储,负责记录当前已分配的IP、过期时间、MAC地址绑定。DhcpServerHandler.java:老版本里服务端的消息处理器,负责将网络层收到的原始数据包交给DhcpPacket解析,再交给具体逻辑分发。
Android 10之后,这个目录下代码做了不少结构重组,DhcpServer本体的run()方法里不再直接写死所有逻辑,而是把事件通过Handler分发。读源码时要抓住两条主线:一条是报文接收解析链,一条是租约分配链路。
连上之后,查看onCreate()里的makeDhcpServer工厂方法、start()启动逻辑、handleDiscover/handleRequest处理逻辑,这几个点是理解服务端行为最快的抓手。
3.2 核心报文处理流程解析
DhcpServer.java的run()方法创建了一个DatagramSocket,绑定到接口地址的UDP 67端口,然后进入while循环,不断调用receive()接收来自客户端的DHCP数据包。收到数据后,代码会调用DhcpPacket.decodeFullPacket()把原始字节解析成结构化对象。
按照标准DHCP流程,服务端先处理DHCPDISCOVER。发现请求后,DhcpServer会从地址池里挑一个未被占用的IP,构建一个OFFER报文,填入客户端的MAC、事务ID(xid)、建议IP、子网掩码、网关、DNS、租约时间这些选项,然后从同一个socket回给客户端。这个阶段不真正占用地址,只是“预分配”,给谁、给多久,都是临时状态。
接着客户端发来DHCPREQUEST。服务端在handleRequest里要做几件事:解析客户端想要哪个IP,校验这个IP是否还在地址池范围内,校验是否已经分配给了别的MAC,全部通过后生成DHCPACK,同时把这条租约写进DhcpServerLeaseRepository。如果校验失败,比如客户端本来是通过中继代理来的,但中继地址跟服务端子网对不上,或者IP已经被其他设备占用,则回复DHCPNAK。
服务端还有一个容易被忽略的流程,处理DHCPRELEASE和DHCPDECLINE。客户端主动释放IP时发送DHCPRELEASE,服务端删除租约并回收地址;客户端检测到地址被占用时会发DHCPDECLINE,服务端要把这个IP从池中标记为“脏地址”,避免再次分配出去。实际调试中,如果发现地址总是重复冲突,可以重点看DECLINE报文的数量,如果这个数值很高,往往不是服务端的问题,而是客户端自身ARP检测机制过于敏感。
3.3 几个必须掌握的静态方法
读DhcpServer.java时,以下几类方法是你最常打日志和断点的地方:
makeDhcpServer(...):创建服务端实例,传入接口名、子网对象、地址池配置。run():主循环,这就是服务端线程的入口。handleDiscover/handleRequest:控制OFFER和ACK/NAK逻辑。buildAckPacket/buildOfferPacket:组装返回报文。leaseRepository.toString():把当前租约表输出成字符串,调试时可以直接打印到logcat里看。shutdown():关闭socket、清理租约。
另外,DhcpPacket里有一个decodeFullPacket静态方法,它在每个报文进来时都会被调用。如果你想看所有进来的报文长什么样,在decodeFullPacket返回值之后打一行Log.d("DhcpServer", packet.toString()),就能在logcat里看到完整字段。这个改动虽然要重新编译模块,但说实话,比抓包还直观。
4. 日志与抓包:把证据链抓到手
4.1 开启详细日志的正确姿势
源码级调试第一步是打开详细日志。NetworkStack内部大量使用Log.d级别的日志,默认情况下release包是关闭的。要打开,可以在root过的设备上执行:
adb shell setprop log.tag.DhcpServer VERBOSE adb shell setprop log.tag.DhcpClient VERBOSE adb shell setprop log.tag.NetworkStack VERBOSE adb shell setprop log.tag.DhcpPacket VERBOSE设置后,重新触发一次Wi-Fi重连或者热点重开,再抓logcat:
adb logcat -v threadtime | grep -E "DhcpServer|DhcpClient|DhcpPacket|NetworkStack"如果你不想抓全量,也可以先清空缓冲再复现:
adb logcat -c # 触发问题 adb logcat -d -v threadtime | grep -E "DhcpServer|DhcpClient"注意,log.tag的优先级是VERBOSE > DEBUG > INFO,只有一个VERBOSE能打开所有级别。部分定制ROM会屏蔽persist.log.tag的修改,这时就要用adb root后直接改/system/etc/prop.default或者在init.rc里加一行属性,再重启生效。
4.2 抓包配合Wireshark才是王道
日志只能告诉你进程内部发生了什么,报文在网络层到底有没有到达,还得靠抓包。Android 10上最简单的方式是用系统自带tcpdump(如果有)或安装一个可以用root权限抓包的工具:
adb shell "tcpdump -i any -n -s 0 -w /data/local/tmp/dhcp.pcap 'udp port 67 or udp port 68'"在另一个终端里触发重连或重启热点,复现问题后按Ctrl+C停止抓包,然后:
adb pull /data/local/tmp/dhcp.pcap ./dhcp.pcap打开Wireshark,过滤规则填:
bootp or dhcpWireshark会把DHCP报文解析得很漂亮。我最常用的一组过滤表达式:
- 只看Discover:
dhcp.msg.type == 1 - 只看Offer:
dhcp.msg.type == 2 - 只看Request:
dhcp.msg.type == 3 - 只看ACK/NAK:
dhcp.msg.type == 5 || dhcp.msg.type == 6
抓包有个技巧:不要只抓Android设备本机的包,如果有条件,最好在路由交换设备或AP上同时抓一份。因为有些问题不是Android不发送报文,而是报文到了无线侧就被丢弃了。比如某些企业级AP开了DHCP Snooping,但信任口配置错误,导致DHCP报文直接被交换机丢弃,这种问题你在手机侧怎么抓都看不到服务端回应,换到AP上抓才看到报文压根没过去。这也是为什么一些一体化的排查工具里,中继、Snooping配置会成为必查项。
5. 高频故障排查实录
5.1 场景一:Wi-Fi连上却拿不到IP地址
这是最常见的故障。dumpsys network_stack里Client接口在线,状态却长期卡在SELECTING或REQUESTING。
排查步骤我先固定下来:
dumpsys network_stack看客户端状态,确认是INIT还是SELECTING。- 拉起logcat,确认
DhcpClient有没有发出Discover报文。 - 抓包确认Discover是否到了网络侧。
- 看服务端有没有Offer回包。
如果logcat里完全没有任何DhcpClient的报文日志,大概率问题在Wi-Fi连接本身,或者客户端预期的目标网段和实际网络不一致。有一版国内定制ROM,在Wi-Fi连接成功后,系统会异步等待ConnectivityService的网络评分结果,导致DhcpClient延迟启动,表现就是连上Wi-Fi后半分钟才有IP。这个在纯Android代码里很难一眼看出来,得结合dumpsys connectivity里的NetworkAgentInfo一起看,确认网络是否被标记为VALIDATED。
如果Discover发出去了,但收不到Offer,就要区分两种情况。一种是无线路由器本身没有开DHCP Server,或者把DHCP服务绑定到了错误的VLAN。另一种是中间网络设备丢包,比如前面提到的DHCP Snooping过滤、端口隔离、IGMP Snooping里存在错误组播设置等。我遇到过一例,是路由器开启了“DHCP中继全局模式”,但中继地址指向了空地址,所有Offer都被转去了不存在的网络,终端自然等不到。
5.2 场景二:拿到IP后过几分钟就断网,或者续租失败
这种故障最典型的地方在RENEWING阶段。DHCP租约是有生存期的,默认路由器的租约可能是24小时,但Android热点默认短一些。当租约过半,客户端会进入RENEWING状态,向原服务端单播续租请求。
dumpsys network_stack里如果一直卡在RENEWING,并且logcat里反复打印Unable to renew lease之类的日志,建议先查时间戳。我排过一个大项目,就是终端系统时间与路由器相差了8小时,导致续租请求里的剩余租约期直接被服务端判定为异常,NAK回去重新走完整Discover流程。这种问题不看报文完全没法发现,抓包后看到Offer里带的时间戳和本地相差一大截,才能对症。
还有一类续租失败是地址池耗尽。地址池通常只是192.168.43.10到192.168.43.250,如果前一期设备没有正常释放租约,池子慢慢被占光,新设备再来就分不到地址。此时dumpsys network_stack里active lease count会接近池子上限,把租约时间改短、清理一次租约缓存即可恢复。在部分ROM里,清空热点缓存可以直接去设置-系统-重置-重置网络设置,但代价是Wi-Fi、蓝牙配置全被清掉,操作要谨慎。
5.3 场景三:热点开了,但设备连上没有IP
这是服务端问题的高发场景。首先做一个快速二分法:热点打开后,adb shell dumpsys network_stack里有没有出现Server interfaces?没有,说明Tethering没有成功把接口交给DhcpServer。有,但设备连上后没有租约,再看接口名是否与设备实际连接的热点接口一致。
有一次我排查一个“连热点显示已保存但无法访问互联网”的问题,dumpsys network_stack里服务端绑定的接口是wlan0,可实际热点创建的虚拟接口是ap0。原因是厂商在Wi-Fi驱动配置里把AP接口的主接口名写错了,导致DHCP Server监听在一个无流量网卡上。这个问题在代码里看不出来,必须对比dumpsys wifi里的热点接口名和dumpsys network_stack里的Server接口名。
另一次问题在域名,非IP地址分配本身。设备拿到了192.168.43.x的IP,能ping通但打不开网页。这个其实不是DHCP服务端的问题,而是Tethering进程把DNS请求代理到了系统里一个未启动的服务。但让我意外的是,DhcpServer里的DNS配置选项没有错,问题出在Android的netd里DNS转发规则。所以提醒一句:遇到“拿到地址但上不了网”,别只盯着DHCP,先确认IP、网关、DNS三层各自正常。
5.4 场景四:涉及DHCP中继、跨VLAN区域的坑
手机做热点大多直接接入局域网,但企业项目里经常会把Android设备当无线接入点,后面挂交换机和路由器。这时候DHCP报文要跨网段,必须要在中间路由器上配置DHCP中继(DHCP Relay)。中继配置常见的也是两种写法:
- 接口模式下指定Global DHCP服务器地址,类似
dhcp select global。 - 指定具体中继地址,把客户端的Discover广播报文转换成Unicast发到服务端,再把服务端应答带回客户端。
如果中继配置不对,症状和前面很像:客户端不断发Discover但收不到Offer。但注意,这里的问题既不在客户端也不在服务端,而在中间的交换路由设备。排查方法很简单,抓包看giaddr字段。Discover报文如果到了服务器,报文头里的giaddr应该被中继设备改写为中继接口IP。如果服务器回包时giaddr为空或者指向了错误网段,客户端肯定瞎掉。
这个场景还牵出一个经验:很多工程师在跨VLAN环境里,习惯性地给中继接口配上全局DHCP策略,一旦服务端地址写错,排查方向就全往Android侧跑。我建议每次遇到“多网段拿不到地址”时,先在服务器端抓包看有没有收到来自目标网段的Discover,有,就说明中继链路基本OK;没有,才回头查Android侧。
6. 实战经验与压箱底技巧
6.1 一套可复用的排查顺序
我把自己在项目中反复验证过的排查顺序整理成一张速查表,能覆盖70%以上Android 10 DHCP问题:
| 步骤 | 命令/操作 | 预期结果 | 异常时说明什么 |
|---|---|---|---|
| 1 | adb shell dumpsys -l | grep network | 能看到network_stack | 服务没起来,问题可能在系统启动阶段 |
| 2 | adb shell dumpsys network_stack | 能看到Client/Server接口和状态 | 看不到接口则系统没有尝试启动DHCP |
| 3 | adb shell dumpsys wifi | grep -i interface | 热点接口名与Server接口一致 | 接口不符,多发生在厂商驱动改动上 |
| 4 | 打开logcat详细日志 | 能看到Discover/Offer日志 | 日志为空,则报文未到达客户端代码 |
| 5 | tcpdump抓包 | 能看到双向DHCP报文 | 单侧看不到,问题在网络链路,不在设备 |
| 6 | Wireshark里面看giaddr和option字段 | 字段值符合子网规划 | 中继配置错误或服务端选项有误 |
这套顺序的核心思想是从服务到接口,从接口到日志,从日志到报文,一层一层把范围缩小,而不是上来就翻源码。多数问题在这一层就能定位,只有真正需要改代码的场景才需要进DhcpServer.java。
6.2 改了源码怎么快速替换验证
源码级的改动,需要重新编译NetworkStack模块。在AOSP环境下:
source build/envsetup.sh lunch <你的产品名>-userdebug make NetworkStack编译好的APK一般在out/target/product/<产品名>/system/priv-app/NetworkStack/NetworkStack.apk。替换时有两种方式。
一种是直接push进系统目录,适合userdebug或者eng版本:
adb root adb remount adb push NetworkStack.apk /system/priv-app/NetworkStack/NetworkStack.apk adb shell chmod 644 /system/priv-app/NetworkStack/NetworkStack.apk adb reboot另一种是用adb install -r直接覆盖安装,但系统APK通常有签名校验,普通安装方式可能不生效。要加快验证效率,我习惯在代码里埋一个临时的“开关属性”:在DhcpServer启动时读取一个系统属性,比如persist.vendor.dhcp.debug,如果值为1,就打开额外日志和租约表打印,这样替换一次APK就可以长时间在线分析,不必每次改代码都重编。
有一点要特别注意:NetworkStack模块作为系统核心网络组件,替换失败会导致Wi-Fi/移动网络/蓝牙全部异常,甚至开不了机。所以动手前一定要备份原APK,做OTA升级场景的还需要确认系统分区空间足够。
6.3 三个不容易想到但很管用的细节
最后分享三个源码调试的小细节,都是我实际摸黑踩出来的。
第一个,关于DhcpServer.java里的租约存储。Android 10的租约默认保持在/data/misc/目录,不同厂商可能落在不同的子目录里。需要快速查看租约时,别去猜路径,直接在logcat里打印leaseRepository对象的内容,或者在源码里加一行Log.d("DhcpServer", leaseRepository.toString()),一了百了。
第二个,关于系统属性开关。很多工程师会随手把log.tag.DhcpServer设为VERBOSE,但Android 10对log.tag.*有长度限制和缓存限制,部分字段设置后在热点重启后会被重置。我的做法是把属性写到/data/local.prop或者vendor分区里的prop文件中,确保重启后仍生效。
第三个,关于抓包工具与DHCP选项里的HostName。很多Android终端在DHCP请求里带了HostName字段,路由器会把主机名登记到设备列表。如果你在路由器后台看到的主机名全是Android,不要奇怪,这是正常的,也正因为这样,用dumpsys按MAC地址去匹配租约,比按主机名过滤可靠得多。改主机名只能在系统层做,应用层是改不到DhcpClient发出的HostName字段的。
另外,遇到疑似地址冲突时,别忽略ARP层。DHCP Server分出去的IP只是“建议分配”,客户端拿到IP后还可能会做冲突检测(ARP Probe)。如果同一网段里有另一台设备占了同一个IP,客户端会发DECLINE,服务端将这个IP标记为冲突。此时再从地址池里继续分配,可能连环触发冲突。最快的定位办法就是用抓包同时过滤arp和bootp,把两个协议的时间线对照着看。
最后再补一个建议。排查Android 10的DHCP问题时,建议在电脑上固定备好三样东西:dumpsys network_stack的输出模板、DhcpServer.java和DhcpClient.java的索引文件、一份Wireshark过滤语法速查表。这三大件能让你在接到用户反馈的第一时间就直接进入取证状态,而不是在面对一串串晦涩输出时愣在当场。DHCP问题看起来都是“拿不到地址”这一个症状,但根源可能分布在驱动、框架、报文、路由好几个层面,每个层面只要多花几分钟做一次标准动作,定位到根因的时间就能缩短一大半。