前阵子调试一台Android 10设备的热点功能,客户反馈说手机开了热点,其他设备能搜到WiFi,但一直卡在“正在获取IP地址”,最后直接提示连接失败。我打开logcat,看到一连串DhcpClient在发DISCOVER的日志,却始终没有等到服务端的OFFER响应。换做以前Android 9的机器,第一反应肯定是去查dnsmasq进程,但Android 10网络栈模块化之后,DHCP服务端已经从dnsmasq换成了由Java实现的DhcpServer.java,整个调试入口也变成了dumpsys network_stack。这篇文章就把我这次排查中用到的工具链、源码层面的调试方法,以及几个高频故障的定位思路完整梳理一遍,希望对正在搞Android系统开发、网络协议栈移植或者做设备认证的同行有帮助。
1. 为什么Android 10要把DHCP服务搬进独立进程
很多从Android 9或更早版本转过来的同学,在Android 10上第一次遇到热点分不出IP时,都会先习惯性地执行ps -A | grep dnsmasq,结果发现进程根本不存在,然后就开始怀疑是不是系统裁剪把DHCP服务裁掉了。其实不是裁掉,而是整个网络栈模块做了架构级的重组。
1.1 从dnsmasq到DhcpServer.java的架构变迁
在Android 9及以前,热点的DHCP服务依赖的是一个叫dnsmasq的native程序,这个程序由netd进程拉起,负责给连接热点的设备分配IP地址,同时承担一部分DNS缓存与转发功能。dnsmasq本身C语言编写,跑在native层,优点是稳定、性能好、部署简单,缺点也很明显:它是独立的一份代码逻辑,和Android框架层之间只能通过socket或者命令行参数做交互,很难做到细粒度的状态同步。
Android 10开始,Google把网络相关的代码集中到一个独立的系统模块NetworkStack中,以com.android.networkstack.process这个独立进程运行,而DHCP服务端就是其中的核心组件之一,对应的源码是DhcpServer.java。这里要强调一个关键变化:dnsmasq承担的是“纯服务端”职责,而NetworkStack里的DhcpServer.java同样承担服务端职责,再加上IpClient模块内部用到的DhcpClient负责客户端职责,两者的报文解析和状态管理逻辑,在代码层面做到了统一。
1.2 networkstack进程里到底装了哪些网络服务
调试之前先搞清楚这个进程里都有什么,后面看dumpsys输出才不迷糊。从源码结构来看,Android 10的NetworkStack模块主要包含三个类别的服务:IpClient、DhcpServer、NetworkMonitor。
- IpClient:负责管理一个网络接口的IP配置,包括通过DHCP获取地址(此时内部会驱动DhcpClient状态机)、静态IP配置、以及监听底层链路状态变化。
- DhcpServer:负责作为服务端响应客户端的DHCP请求,也就是热点场景里给别人分IP的那个角色。
- NetworkMonitor:负责对当前网络的连通性进行探测和校验,比如判断某个WiFi是否真的能上外网,是否需要弹窗认证。
这三个服务相互独立的,但会通过回调Ng进行通信。举个例子:热点打开时,框架层会启动SoftAp模式,同时把某个网络接口(通常是ap0或者wlan0)交给DhcpServer去监听,DhcpServer开始工作后,客户端设备发来DISCOVER报文,它回复OFFER,再完成REQUEST和ACK,客户端拿到合法IP,一条完整的链路就走通了。
1.3 模块化改造后调试入口的迁移路线
架构变了,调试入口也要跟着变,这是新人最容易踩坑的地方。把几条常用调试路径的对比列出来,一眼就明白:
| 调试目标 | Android 9及以前 | Android 10及以后 |
|---|---|---|
| 查看DHCP服务端状态 | ps -A | grep dnsmasq、读dnsmasq日志 | dumpsys network_stack查看DhcpServer段 |
| 查看DHCP客户端状态 | 依赖框架层logcat和netd日志 | dumpsys network_stack查看IpClient段 |
| 修改DHCP参数 | 改dnsmasq.conf或启动参数 | 改DhcpServer.java编译NetworkStack模块 |
| 抓包确认报文交互 | tcpdump在热点接口上抓包 | tcpdump在热点接口上抓包,逻辑不变 |
一句话总结:过去你面对的是dnsmasq这个独立程序,现在你面对的是NetworkStack这个系统模块,调试工具的入口收敛到了dumpsys network_stack这一条命令上。理解了这个迁移逻辑,后面所有操作都顺理成章。
2. dumpsys network_stack:一条命令摸清DHCP服务全貌
dumpsys是Android系统调试的老牌命令,几乎人人都会adb shell dumpsys activity、dumpsys window、dumpsys battery,但dumpsys network_stack可能是很多人第一次接触。我建议任何碰Android网络栈的工程师都把这条命令当成首选工具。
2.1 确认服务存在与基本用法
在Android 10设备上,先确认network_stack这个service是否正常注册:
adb shell service list | grep network正常情况下输出里会有network_stack这样的条目,如果查不到,说明模块没有正确加载,这本身就是一个问题需要排查。确认服务存在后,直接执行:
adb shell dumpsys network_stack这条命令会把NetworkStack进程里所有可dump的内容全部打出来。输出的信息量比较大,一般我会先用grep过滤一下:
adb shell dumpsys network_stack | grep -i -A 30 "DhcpServer" adb shell dumpsys network_stack | grep -i -A 50 "IpClient"注意不同厂商的ROM可能对dumpsys内容做了定制和裁剪,但Android原生的输出结构基本一致。
2.2 输出结构逐段拆解
以Android 10原生代码为例,dumpsys network_stack的输出大致可以分成这样几个段:
- IpClient状态段:会列出当前注册了哪些IpClient实例,比如wlan0、eth0,每个实例里包含当前的状态机状态(INIT、BOUND、RELEASED等)、启用的配置方式(DHCP还是静态IP)、获取到的IP地址、网关、DNS服务器、lease时长和过期时间等。
- DhcpServer状态段:列出当前正在运行的所有DhcpServer实例,关键信息包括绑定的接口名、启动状态、已分配IP的lease列表。如果热点开着但这里显示没有运行实例,基本可以断定是服务启动失败没有处于监听状态。
- NetworkMonitor状态段:这部分主要涉及网络连通性评估,排查“连上WiFi但上不了网”这类问题时很有用,但对DHCP本身影响不大,可以先略过。
我调试时最关注的是lease列表部分。通过dump信息能看到当前连接热点的每台设备分配了哪个IP、MAC地址是多少、lease什么时候过期,一旦发现客户反应的“明明只连了一台设备但IP不对”或者“两台设备拿到了同一个IP”,直接在这里就能找到证据。
2.3 实战:从dump信息判断“热点不分配IP”的根因
回到文章开头的场景:手机开热点,客户设备一直获取不到IP。我执行dumpsys network_stack后发现了三种不同情况,对应三类根因:
- 情况一:DhcpServer段完全没有。这说明服务根本没启动,问题出在SoftAp启动流程或者DhcpServer初始化环节,和DHCP报文交互无关。
- 情况二:DhcpServer存在但lease列表是空的,同时IpClient段显示热点接口的链路已经起来了。这说明服务端在监听,但没有收到客户端的DISCOVER报文,问题出在无线层,比如隐藏SSID、客户端兼容性、AP隔离等。
- 情况三:DhcpServer存在且lease列表非空,但客户设备依然提示获取IP失败。这种情况最有迷惑性,说明服务端分配过IP,但报文交互可能在中途断了,需要用抓包才能进一步确认。
很多人在“同一份dumpsys输出”里忽略掉每个段落的时序信息,其实应该结合logcat时间戳和dumpsys里lease列表的生成时间来判断当前是“从未分过IP”还是“之前分过后来掉了”,这两个状态对应的排查路径完全不同。dumpsys network_stack的价值就在这里:它给了一张静态快照,配合动态日志就能缩小问题范围。
3. 源码层面定位DhcpServer.java的关键路径
静态工具能告诉我们服务“处于什么状态”,但要知道“为什么会这样”,还得下到源码层面。Android 10的DhcpServer.java不是一坨特别复杂的代码,但理解了它的核心路径之后,很多疑难杂症的定位效率会明显提升。
3.1 源码获取与代码结构
我调试用的源码分支是android-10.0.0_r39,对应的NetworkStack模块路径在:
packages/modules/NetworkStack/其中和DHCP相关的核心文件有:
- src/com/android/networkstack/dhcp/DhcpServer.java:服务端实现
- src/com/android/networkstack/dhcp/DhcpClient.java:客户端状态机
- src/com/android/networkstack/dhcp/DhcpPacket.java:DHCP报文封装与解析
- src/com/android/networkstack/dhcp/IDhcpEventCallbacks.aidl:事件回调接口
如果你只需要读代码,直接在AOSP源码里看这几个文件就够了;如果要改代码,则需要编译整个NetworkStack模块。
3.2 从DISCOVER到ACK:DhcpServer的核心处理流程
DhcpServer.java的主体逻辑并不复杂,核心是它启动后会在指定网络接口上创建UDP socket,接收端口67上的DHCP请求。整个服务端的处理流程大致分成四步:
第一步,接收并解析报文。调用receivePacket从socket中读取数据,然后用DhcpPacket的解析方法构建一个请求对象,服务端通过op字段识别这是BOOTREQUEST还是BOOTREPLY,通过DHCP Message Type选项判断是DISCOVER、REQUEST、RELEASE还是INFORM。
第二步,判断来自哪个客户端。这里会读取报文里的chAddr字段(客户端MAC地址)和xid字段。xid是客户端发起一次DHCP交互时随机生成的transaction ID,服务端回复OFFER或ACK时会把同一个xid带回给客户端,客户端靠它来匹配响应。
第三步,分配或续订IP地址。这是最核心的部分。如果是DISCOVER请求,服务端会在配置的IP池里找一个空闲地址生成OFFER;如果是REQUEST请求,则正式把这个地址绑定到客户端MAC上并生成ACK。Android 10默认的热点网段是192.168.43.0/24,IP池从192.168.43.2开始分配,服务端会维护一个已分配lease列表来避免重复分配。我在代码里看到这个列表是一个基于Map的数据结构,key是客户端MAC地址,value是lease信息,包括分配的IP和过期时间。
第四步,构建并发送响应。根据处理结果构建OFFER、ACK或者NAK报文,设置好Server Identifier(服务器自身IP)、Subnet Mask、Router、DNS Server等选项,然后通过socket发回客户端。
3.3 客户端视角:DhcpClient的状态机与超时逻辑
排查DHCP问题时不能只盯着服务端看,客户端的处理逻辑同样重要。DhcpClient.java在Android 10里实现了一套标准的状态机,几个关键状态包括:
- INIT:客户端刚刚启动,准备发起第一次DHCP发现,此时会发送DISCOVER报文。
- SELECTING:客户端收到一个或多个OFFER之后,会从中选一个,然后发送REQUEST报文。
- REQUESTING:等待服务端确认,收到ACK则进入BOUND状态,收到NAK则回退到INIT。
- BOUND:已经拿到合法IP,开始正常使用。此时会启动一个到期定时器,在lease时间过半时进入RENEWING。
- RENEWING:向原服务器单播发送REQUEST续租,如果成功回到BOUND;失败且lease快到期则进入REBINDING。
- REBINDING:改为广播发送REQUEST,任何能响应的DHCP服务器都可以回应,收到ACK则回到BOUND,彻底失败则回到INIT重新获取。
这个状态机是排查“连上WiFi但一直拿不到IP”这条链路的关键。在logcat里看DhcpClient的状态迁移轨迹,基本可以确定卡在哪一步:一直处于SELECTING说明收不到OFFER,一直REQUESTING收不到ACK说明服务端要么没响应要么被网络路径拦截。
4. 三板斧:日志、断点、抓包
理解了源码之后,接下来聊聊实战调试最常用的三种手段。我自己调试DHCP问题时,基本遵循“日志定位方向,抓包确认细节,断点验证逻辑”的套路,三种手段互相配合而不是相互替代。
4.1 logcat:哪些tag是重点
Android 10的NetworkStack模块有自己的日志Tag,线上调试时先把下面几个Tag的日志都打开:
adb logcat -s DhcpServer:* DhcpClient:* NetworkStack:* -v time这条命令会实时打印三个Tag的日志,每条带上时间戳,方便和dumpsys里的lease列表时间对应上。
但这里有个坑:某些版本的Android 10上,DhcpServer的详细日志默认是不打印的,开发者需要通过设置log level来打开。我看到很多厂商的调试ROM里会预留setprop方式打开调试开关,比如persist.networkstack.dhcp.debug,具体名称不同版本有差异。遇到“logcat里看不到任何DhcpServer相关输出”时,不要急着怀疑代码没执行,先确认是不是日志级别没打开。
4.2 改源码加日志后如何推到设备上验证
如果现有日志满足不了需求,最直接的办法是在DhcpServer.java里临时加日志,然后编译替换NetworkStack模块。具体步骤是:
source build/envsetup.sh lunch <你的产品名> make NetworkStack编译产物是一个APK,路径一般在out/target/product/<产品名>/system/priv-app/NetworkStack/NetworkStack.apk。把它推到设备上有两种方式:如果设备能remount,直接替换到/system/priv-app下;如果产品用了APEX机制,还需要处理APEX包的安装和激活,这一步不同Android版本差异较大,Android 10之前的设备通常直接替换APK就行。
替换后重启NetworkStack进程:
adb shell pkill -f com.android.networkstack.process进程会自动被重启,然后再复现一次DHCP获取流程,抓取日志。
整个流程说起来简单,实际操作中容易遇到的问题集中在签名上:NetworkStack是priv-app,系统对签名有要求,如果直接push一个用默认debug key编译的APK上去,启动时会报signature相关错误。所以我一般建议优先用系统自带的logcat和dumpsys解决,改源码是最后手段。
4.3 tcpdump抓包与Wireshark报文对照
日志能说明代码层面的流转,但报文在网络里的真实交互还是得靠抓包。在Android设备上抓DHCP报文已经是屡试不爽的经验了:
adb shell tcpdump -i ap0 -s 0 -w /sdcard/dhcp.pcap热点接口名因产品而异,早期高通的方案是wlan0,Android 10之后很多产品改成ap0,具体可以用ip link show或者dumpsys network_stack里的接口名确认。
抓到pcap文件后拉到电脑上用Wireshark打开。DHCP报文的过滤条件用的是bootp:
bootp正常的一次四步交互在抓包结果里是这样:
| 时间 | 源 | 目的 | 信息 |
|---|---|---|---|
| 0.000 | 客户端MAC | 255.255.255.255 | DHCP Discover |
| 0.035 | 服务端IP | 255.255.255.255 | DHCP Offer |
| 0.042 | 客户端MAC | 255.255.255.255 | DHCP Request |
| 0.075 | 服务端IP | 255.255.255.255 | DHCP Ack |
四步走完,客户端才能拿到合法IP。如果抓到只有Discover没有Offer,说明服务端压根没有回;有Offer但是客户端后续没有Request,说明客户端在收到Offer后校验失败,最常见的原因是报文里的参数不符合客户端预期。
5. 高频故障案例分析:从“连不上”到“续租失败”
理论和工具都聊完了,最后整理几个实际调试中高频遇到的故障场景。这些场景也是搜索热词里“一直获取不到IP”“续订请求超时”“DHCP中继”这类问题在Android设备侧的落地体现。
5.1 设备一直“正在获取IP地址”的完整排查链路
这是热点调试最典型的故障,我习惯按照下面这个顺序排查:
第一步,先确认服务端是否在运行。执行dumpsys network_stack,看DhcpServer段是否存在以及是否绑定到热点接口。如果不存在,直接定位到启动链路。
第二步,确认客户端是否在发报文。用logcat过滤DhcpClient相关日志,看有没有反复发送DISCOVER的记录。如果客户端根本没发,问题可能出在WiFi连接本身的认证环节,而非DHCP。
第三步,抓包确认报文是否到达服务端。在热点接口上抓包,看有没有来自客户端的DISCOVER报文。注意如果热点接口开了AP隔离,客户端之间的报文会互相隔离,但服务端和客户端之间的交互是正常的。
第四步,对比客户端MAC地址和lease列表。如果服务端发了OFFER但客户端没收到,检查无线驱动层是否存在组播/广播丢包问题。DHCP报文在很多场景下是用广播地址发送的,AP与STA之间如果广播报文处理异常,就会出现“服务端明明发了却没人收到”的情况。
5.2 续租失败的排查:“无法联系DHCP服务器”是怎么回事
Windows上经常会看到“续订接口以太网时出错,无法联系DHCP服务器,请求超时”的报错,Android设备上也存在类似的续租失败问题,只是表现在日志里是DhcpClient多次从RENEWING跳回BOUND失败,最后回到INIT重新获取。
续租失败常见的原因有三个。第一个是lease time配置太短,热点下挂的设备一多,客户端同时进入续租窗口,服务端处理不及时导致部分续租请求丢失,这个可以通过dumpsys里的lease时长参数确认。
第二个是单播请求被拦截。RENEWING阶段客户端是向原服务器单播发送REQUEST的,如果无线网络里某些节点只放行广播报文而拦截单播报文,续租自然失败。抓包时如果发现客户端一直发单播REQUEST而服务端毫无响应,就往这个方向查。
第三个是服务端在续租周期内重启过。DHCP服务重启后如果lease状态没有持久化,新的服务端实例不认旧客户端的续租请求,客户端就会被迫回到INIT重新走完整流程,此时能看到设备一瞬间断网重连的现象。
5.3 环境因素:DHCP Snooping、AP隔离和网关冲突
做Android设备网络调试时,还要考虑到设备所处的网络环境。比如企业局域网的交换机开了DHCP Snooping功能,它会将非信任端口收到的DHCP OFFER和ACK报文丢弃。如果Android设备的eth0口连接到这类交换机上,无论系统内怎么调DhcpServer,客户端都可能拿不到IP。这种场景下的特征非常明显:抓包能抓到DISCOVER,却抓不到任何回应,同时把设备换到普通家用路由器下又一切正常。
AP隔离是另一个常见因素。很多路由器或者无线控制器默认开启AP隔离,保证接入的终端之间只能和网关通信而无法互访。AP隔离一般不影响终端从AP获取IP,但在某些弱信号场景下广播报文转发异常,会导致DHCP交互卡在DISCOVER阶段。
再有就是网关冲突问题。Android设备开热点时,自身作为DHCP服务器使用的网段是192.168.43.x,而如果设备通过以太网上联的局域网恰好也是192.168.43.x网段,两个网段的IP就会互相冲突,表现出的现象是热点下的客户端时而能上网时而彻底不通。排查经验是:除了看DhcpServer的lease列表,还要顺手看一下上联网络接口的IP网段,两边一起对比才能快速定位。
5.4 关于DHCP中继的注意事项
搜索热词里频繁出现“DHCP中继配置”和“dhcp select global”,这是路由器侧经常用到的一个功能:局域网里不想每台设备都跑DHCP服务,而是把客户端的广播报文转成单播交给中心的DHCP服务器去分配。
Android设备侧很少直接涉及DHCP中继配置,但有两个相关场景值得提一下。一个是在做车机或者IoT设备认证时,被测设备可能接在一个启用了DHCP中继的交换机后面,如果设备跑的是DHCP客户端,中继会把报文转出去,只要链路正常就能拿到IP。另一个是在做热点共享时,如果希望把客户端的IP分配职责上交给上游网络,Android原生系统并不直接支持这种桥接模式下的DHCP中继,通常需要额外开发或者借助第三方工具。
调试中遇到“拿到IP但无法上网”的问题时,可以检查一下设备获取到的网关和DNS是否来自正确的DHCP服务器。很多企业网络用了多级DHCP中继,设备可能收到了来自上一级或下一级服务器的错误响应,特征是通过dumpsys看到的网关地址和设备实际所处的网段不一致。
说到dumpsys network_stack,最后再分享一个小技巧:调试热点分配异常时,我经常连续执行两次dumpsys network_stack,间隔几秒钟,对比两次lease列表的变化。如果第一次列表为空,第二次多了一个IP,说明服务端的分配逻辑是在正常运行的,问题大概率出在客户端收到OFFER后的校验阶段。如果两次都是空,再去看logcat和tcpdump。这个习惯帮我省了很多瞎翻日志的时间,每次都能快速把问题的上半场和下半场切开,值得一试。