ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Android以太网eth0静态IP配置实战:权限、框架与版本适配

Android以太网eth0静态IP配置实战:权限、框架与版本适配

1. Android 以太网 eth0 静态 IP 到底难在哪

Android 以太网 eth0 静态 IP 配置这件事,第一次做的人基本都会被卡住:界面里翻不到入口、adb敲了一堆命令没反应、代码里setConfiguration直接抛SecurityException。它真正的难点在于 Android 把以太网当成一个“二等公民”来管——Wi-Fi 有完整的公共 API 和用户界面,以太网从框架层开始就一直挂在@SystemApi和@hide后面,普通应用连门都摸不到。

这篇内容面向三类人:做广告机、POS 终端、工控一体机、车机和电视盒子的嵌入式/应用开发者,需要在出厂或现场批量把eth0的 IP 固定下来;系统定制方向的同学,想搞清楚EthernetManager、EthernetTracker、ConnectivityService之间怎么配合;以及被“设了不生效”“重启就丢”“能 ping 通 IP 但打不开网页”折磨过的现场实施人员。

我会把三条路径全部摊开讲:系统签名应用直接调 SystemApi、root 环境下用命令行硬配、以及没有权限时怎么优雅地引导用户自己配。每条路径都给可复现的代码和命令,重点讲清楚为什么这么做、不这么做会踩什么坑。安卓版本横跨 7 到 14,差异不小,我会在每个关键点标注版本边界,你按自己手上的系统对号入座。

2. 先摸清底层框架:一条 IP 配置指令的完整链路

2.1 从调用到网卡:谁在真正改 IP

很多人以为EthernetManager是个只管以太网的小服务,其实它只是个薄壳。你调setConfiguration()之后,请求先到EthernetServiceImpl,它把配置交给EthernetConfigStore持久化,再通过EthernetTracker通知底层,最后由ConnectivityService拉起来一个EthernetNetworkAgent注册到网络栈里,真正下发到内核的ip addr和ip route是netd干的。

这条链路上任何一环断了,表现都是“命令没报错,但 IP 没变”。我见过最典型的案例:EthernetTracker根本没接管这块网卡,配置存进去了也永远不生效。因为 AOSP 的EthernetTracker只会管理在白名单里声明过的接口,这个白名单叫config_ethernet_interfaces,在frameworks/base/core/res/res/values/config.xml里,老版本用的是config_ethernet_iface_regex正则(默认匹配eth\d)。

所以你拿到一台双网口设备,eth1怎么配都没反应,先别怀疑代码,去确认 overlay 里有没有把eth1加进去。这个点 AOSP 文档基本不提,但它是现场排障的第一顺位。

2.2 权限门槛:为什么你的代码总是抛 SecurityException

以太网配置需要的权限是android.permission.MANAGE_ETHERNET_NETWORKS,这个权限从 Android 10(API 29)开始正式对外暴露,保护级别是signature——意味着只有用平台签名签过的应用,或者放在/system/priv-app下并被privapp-permissions白名单放行的应用,才拿得到。

我在项目里反复强调一句话:反射能绕过 API 可见性,绕不过权限检查。你能Class.forName("android.net.EthernetManager")拿到方法并成功invoke,但调用进到服务端,EthernetServiceImpl里的enforceConnectivityInternalPermission或者MANAGE_ETHERNET_NETWORKS检查一样会把你挡回来,抛SecurityException: uid xxx does not have android.permission.MANAGE_ETHERNET_NETWORKS。

Android 9 之后还有一层 non-SDK interface 限制,android.net.IpConfiguration、IpConfiguration$IpAssignment这些 hide 类在灰名单或黑名单里,反射时可能直接抛NoSuchMethodException或收到警告。调试阶段可以临时用adb shell settings put global hidden_api_policy 1放开,但这是开发调试用的开关,出厂设备上开着它本身就是个隐患,别当方案用。

2.3 版本差异对照:别拿 Android 8 的代码去跑 Android 13

以太网的 API 在这几年改动相当剧烈,我把关键节点整理成表,你按自己的 targetSdk 和实际系统版本对:

Android 版本API 形态关键变化
5.0 - 8.1EthernetManager.setConfiguration(String, IpConfiguration)权限走内部CONNECTIVITY_INTERNAL,普通应用完全无法触及
9同上,non-SDK 限制收紧反射开始受限,hidden_api_policy生效
10新增MANAGE_ETHERNET_NETWORKS权限模型定型,EthernetManager进 SystemApi
11EthernetManager迁入 Connectivity 模块模块化后dumpsys ethernet输出格式变化
12StaticIpConfiguration.Builder公开构造静态配置不再只能反射
13新增EthernetNetworkUpdateRequest与updateConfiguration一次调用可同时改 IP 和网络能力
14接口状态查询能力增强多网口场景调试更方便

这张表最实用的结论是:如果你的设备跑 Android 12 及以上,代码尽量往updateConfiguration上靠;如果是 Android 10/11,setConfiguration还是主力;更老的系统只能走定制或者 root。写代码时用Build.VERSION.SDK_INT分支,别偷懒只写一套。

3. 三条落地路径怎么选:界面、命令行、代码

3.1 路径一:系统设置里手动配,最稳但最慢

Android 原生从 7.0 开始在设置里有“以太网”入口(部分厂商 ROM 会隐藏或删掉),插上网线后能看到eth0,点进去可以选 DHCP 或静态。这条路径的优点是完全走官方逻辑,持久化、DNS、路由、代理全都自动处理好,不会出现“IP 对了但 DNS 没设”的半吊子状态。

缺点是效率。我做过一个 200 台广告机的项目,逐台点设置平均每台 3 分钟,还要防止实施人员把掩码填错。后来全部改成脚本批量下发。如果你只是几十台以内、一次性部署,手动配反而是最省心的选择,尤其在没有系统签名的情况下。

现场有个小技巧:adb shell am start -a android.settings.ETHERNET_SETTINGS可以直接跳到以太网设置页,省得在一堆菜单里翻。这个 action 不是所有 ROM 都实现了,包一层try-catch捕获ActivityNotFoundException,跳不过去就退回android.settings.WIRELESS_SETTINGS。

3.2 路径二:adb / 串口命令行,批量部署首选

adb shell cmd ethernet是 Android 11 之后比较靠谱的入口,它背后是EthernetShellCommand。不同版本支持的子命令差异挺大,第一件事永远是先执行adb shell cmd ethernet把 help 打出来,别背命令。

常见的调用形态大致是这样:

# 先看帮助,确认当前系统支持哪些子命令和参数顺序 adb shell cmd ethernet # 典型的静态 IP 设置形式(参数顺序以 help 输出为准) adb shell cmd ethernet set-ip eth0 192.168.10.50/24 192.168.10.1 # 单独设置 DNS adb shell cmd ethernet set-dns eth0 192.168.10.1 114.114.114.114

这里有个必须注意的细节:cmd ethernet走的是 shell uid,shell 在多数系统上被授予了MANAGE_ETHERNET_NETWORKS,所以能成功;但你换成普通应用调同样的逻辑就会被拒。这也是为什么很多人adb测试通过、写进 App 就崩——测试环境和运行环境的权限模型根本不是一回事。

命令行还有个好处是能嵌进批处理脚本,配合adb devices遍历序列号,200 台机器半小时能刷完。我通常会把设备 MAC 和分配的 IP 做成 CSV,脚本读一行配一台,配完立刻用ping验证,失败的重试一次再报错。

3.3 路径三:应用内代码配置,灵活但门槛最高

代码配置适合两类场景:一是设备出厂首次开机自动下发(预置应用 + 系统签名),二是运行过程中根据业务动态切换网络(比如产线切换不同网段)。它的价值在于可控、可日志化、可远程触发,但前提是你得拿到系统签名。

选型上我给个简单的判断逻辑:单机部署少于 50 台、没有系统签名、需求只是固定一个 IP,那就手动配或者adb批量配,别折腾代码;设备量大、需要远程运维、需要和业务联动切换网段,那就老老实实做系统签名应用,走EthernetManager。

路径权限要求适用规模持久化主要风险
系统设置手动无小批量自动人为填错、效率低
adb 命令行shell 权限中大批量多数版本会存子命令随版本变化
应用内代码系统签名或 root大批量、需远程需自行确认权限、反射、版本兼容

4. 代码实操:把静态 IP 真正写进 eth0

4.1 参数整理:掩码换算和网关校验先做对

在写代码之前,先把三个参数算清楚,这三个地方出错率最高。

第一是前缀长度。用户给你的是255.255.255.0这种掩码,Android 要的是/24。换算不难,但顺序不能乱:掩码必须是连续的 1 后跟连续的 0,255.255.255.1这种非法掩码要提前拦下来,否则底层会抛IllegalArgumentException。

掩码前缀可用主机数
255.255.255.0/24254
255.255.255.128/25126
255.255.255.192/2662
255.255.255.252/302
255.255.0.0/1665534

第二是网关必须和 IP 在同一个子网内。部分版本在构造StaticIpConfiguration时会做校验,网关跨网段直接抛异常;即使不抛,配出来的路由也是错的,表现就是“设置成功但上不了网”。

第三是 DNS 顺序。主 DNS 放第一个,备用放第二个,别只填一个还填成网关地址就以为万事大吉——很多现场环境网关并不做 DNS 转发,这就是“能 ping 通 IP、打不开网页”的根源。

4.2 Android 10/11:IpConfiguration 的老写法

这个版本段用的还是IpConfiguration,它本身是 hide 类,得靠反射。下面是 Kotlin 版本,核心思路是构造StaticIpConfiguration(内部类Builder在 Android 12 才有,老版本只能用无参构造 + 反射赋值或者用公开的LinkAddress/InetAddress):

import android.net.EthernetManager import android.net.IpConfiguration import android.net.LinkAddress import android.net.ProxyInfo import android.net.StaticIpConfiguration import java.net.Inet4Address import java.net.InetAddress fun setStaticIpLegacy( ethManager: EthernetManager, iface: String, ip: String, // "192.168.10.50" prefixLen: Int, // 24 gateway: String, // "192.168.10.1" dns1: String, dns2: String? ) { // 1. 构造 LinkAddress,前缀长度在这里生效 val linkAddress = LinkAddress(InetAddress.getByName(ip), prefixLen) // 2. 构造 StaticIpConfiguration,走反射因为构造器不可见 val sicCls = Class.forName("android.net.StaticIpConfiguration") val sic = sicCls.getDeclaredConstructor().apply { isAccessible = true }.newInstance() val ipAddressField = sicCls.getField("ipAddress") ipAddressField.set(sic, linkAddress) val gatewayField = sicCls.getField("gateway") gatewayField.set(sic, InetAddress.getByName(gateway)) val dnsField = sicCls.getField("dnsServers") @Suppress("UNCHECKED_CAST") val dnsList = dnsField.get(sic) as ArrayList<InetAddress> dnsList.add(InetAddress.getByName(dns1)) dns2?.let { dnsList.add(InetAddress.getByName(it)) } // 3. 拿枚举常量。IpAssignment / ProxySettings 也是 hide,用 Enum.valueOf 绕 @Suppress("UNCHECKED_CAST") val ipAssignCls = Class.forName("android.net.IpConfiguration\$IpAssignment") as Class<out Enum<*>> val staticEnum = java.lang.Enum.valueOf(ipAssignCls as Class<Enum<*>>, "STATIC") @Suppress("UNCHECKED_CAST") val proxyCls = Class.forName("android.net.IpConfiguration\$ProxySettings") as Class<out Enum<*>> val noneEnum = java.lang.Enum.valueOf(proxyCls as Class<Enum<*>>, "NONE") // 4. 组装 IpConfiguration val ipConfigCls = Class.forName("android.net.IpConfiguration") val ipConfig = ipConfigCls .getConstructor( ipAssignCls, proxyCls, Class.forName("android.net.StaticIpConfiguration"), Class.forName("android.net.ProxyInfo") ) .newInstance(staticEnum, noneEnum, sic, null) // 5. 下发 val setConfig = EthernetManager::class.java.getMethod( "setConfiguration", String::class.java, ipConfigCls ) setConfig.invoke(ethManager, iface, ipConfig) }

这段代码里最容易翻车的是第 3 步的枚举。不同 ROM 可能对 hide 类做了裁剪,Class.forName直接抛ClassNotFoundException。所以我一般会在应用启动时做一次“能力探测”:试着加载android.net.EthernetManager,拿到实例,试调一个无害的 getter(比如isAvailable()),全部通过才认为这台设备支持代码配置,否则自动切换到“引导用户手动配”的降级路径。这套探测逻辑上线后,线上崩溃率从接近百分之几降到了个位数次。

4.3 Android 12/13+:StaticIpConfiguration.Builder 与 updateConfiguration

到了 Android 12,StaticIpConfiguration.Builder公开了,构造静态配置终于不用反射字段了。Android 13 又加了EthernetNetworkUpdateRequest,可以一次性把 IP 配置和网络能力(NetworkCapabilities)一起提上去:

import android.net.EthernetManager; import android.net.EthernetNetworkUpdateRequest; import android.net.LinkAddress; import android.net.NetworkCapabilities; import android.net.StaticIpConfiguration; import java.net.InetAddress; import java.util.Arrays; public void setStaticIpModern(EthernetManager ethManager, String iface, String ip, int prefixLen, String gateway, String dns1, String dns2) throws Exception { // 1. 静态 IP 配置 StaticIpConfiguration.Builder builder = new StaticIpConfiguration.Builder() .setIpAddress(new LinkAddress(InetAddress.getByName(ip), prefixLen)) .setGateway(InetAddress.getByName(gateway)); if (dns2 == null || dns2.isEmpty()) { builder.setDnsServers(Arrays.asList(InetAddress.getByName(dns1))); } else { builder.setDnsServers(Arrays.asList( InetAddress.getByName(dns1), InetAddress.getByName(dns2))); } StaticIpConfiguration staticConfig = builder.build(); // 2. 网络能力:必须带以太网传输类型,否则请求会被丢 NetworkCapabilities caps = new NetworkCapabilities(); caps.addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET); caps.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET); // 3. 组装更新请求,两个 setter 都必填,漏一个 build 直接抛 EthernetNetworkUpdateRequest request = new EthernetNetworkUpdateRequest.Builder() .setIpConfiguration(staticConfig) .setNetworkCapabilities(caps) .build(); // 4. 下发 ethManager.updateConfiguration(iface, request); }

EthernetNetworkUpdateRequest.Builder有个坑:setIpConfiguration和setNetworkCapabilities两个都必须调,只设 IP 不设能力,build()会抛IllegalArgumentException。我第一次踩的时候一脸问号,翻源码才发现build()里对这两个字段做了非空断言。而NetworkCapabilities里如果不加TRANSPORT_ETHERNET,即使配置成功,网络也不会被ConnectivityService当作以太网网络选中,默认网络可能还挂在 Wi-Fi 上。

另外这段代码需要@RequiresPermission(MANAGE_ETHERNET_NETWORKS),所以必须跑在系统签名应用里。如果你的应用拿不到,别浪费时间调,直接看下一节。

4.4 反射调用的稳定性技巧与混淆注意

系统签名应用里,我建议优先用@SystemApi加@SuppressLint("MissingPermission")直接调,别一上来就反射——直接调 IDE 能检查方法签名,代码可读性也好得多。只有确实被 hide 挡住、编译期拿不到符号时再上反射。

反射部分务必做到三点:一是所有Class.forName都包try-catch并把异常吞成能力探测的返回值,不能让反射异常传到业务层;二是方法名、字段名抽成常量,集中在一个类里,方便将来某个版本改名时一处修改;三是开 R8/ProGuard 时把涉及反射的类加keep规则,虽然系统类通常不会被混淆,但你自己写的封装类会被,反射getMethod找不到方法的情况我见过不止一次。

提示:反射系统隐藏 API 的行为在 Android 9 以后受 non-SDK 接口策略限制,灰名单会打日志警告,黑名单直接拒绝。上线前一定在真机上跑一遍,模拟器上不一定复现真实策略。

5. 拿不到系统签名时的替代方案与边界

5.1 root 环境下的 ip 命令组合与 DNS 补齐

设备已 root(或者你做的是有 su 能力的定制系统)时,可以不碰框架层,直接用命令改内核网络配置:

# 1. 确认网卡存在且链路是 up cat /sys/class/net/eth0/operstate ip link set eth0 up # 2. 清掉旧地址,设置新地址 ip addr flush dev eth0 ip addr add 192.168.10.50/24 dev eth0 # 3. 路由:默认网关和同网段直连路由 ip route add 192.168.10.0/24 dev eth0 ip route add default via 192.168.10.1 dev eth0 # 4. 关键一步:告诉 netd/解析器 这个网络的 DNS # <netid> 先用 ip rule 或者 dumpsys connectivity 查到 cmd netd resolver setnetdns <netid> "" 192.168.10.1

这里是整条路子最大的坑:只改ip addr不改 DNS,域名解析一定失败。因为 Android 的 DNS 走的是netd的解析器,不看/etc/resolv.conf(那个文件在 Android 上基本是摆设)。很多人 ping 得通8.8.8.8却打不开网页,就是卡在这一步。

还有个隐蔽问题:框架层的EthernetTracker会周期性检查接口状态,一旦发现与你手配的不一致,可能把配置覆盖回 DHCP 结果。所以 root 方案的定位应该是“临时救急”或者“配合系统改造彻底关闭框架接管”,不能当作长期的量产方案。

5.2 无权限时的正确姿势:引导而非硬刚

普通应用在没有系统权限的情况下,最稳妥的做法是引导用户去系统设置里配。别小看这种方式,做好体验也能很顺滑:

try { Intent intent = new Intent("android.settings.ETHERNET_SETTINGS"); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); context.startActivity(intent); } catch (ActivityNotFoundException e) { // 部分 ROM 没实现,退回网络设置总入口 context.startActivity(new Intent(android.provider.Settings.ACTION_SETTINGS) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); }

配套要做的是“配置检测”:应用启动时读一次ConnectivityManager里以太网网络的LinkProperties,把当前 IP、网关、DNS 展示出来,和业务期望的网段比对,不一致就弹引导。这套逻辑不依赖任何特殊权限,LinkProperties是公开 API。

我踩过的一个坑是:不要试图靠adb shell settings put去改以太网配置,Settings.Global里没有对应的静态 IP 键值,写了也不会生效,只会污染设置数据库。

5.3 明确边界:哪些事千万不要做

有三件事我明确建议别碰。

第一,别改build.prop或者塞自定义persist.*属性来“预设 IP”。框架层不认这些属性,除了让后人维护时困惑没别的作用。

第二,别直接手改/data/misc/ethernet/下的配置文件。这个文件确实存在,是EthernetConfigStore的持久化位置,但它的序列化格式随版本变化过,直接改坏了会导致以太网服务启动异常,严重的话开不了机。

第三,别在量产版本里长期开着hidden_api_policy。这等于把系统的 SDK 限制整个拆掉,稳定性和安全性都没保障。

6. 验证与排查:从 dumpsys 到 logcat 的一条龙

6.1 怎么确认配置真的生效了

配置“返回成功”不等于“生效”,我见过太多setConfiguration没报错但实际没变的情况。验证要分层做,一层一层往下推。

第一层,框架层认不认:

adb shell dumpsys ethernet

输出里重点看三处:接口是否被 tracker 跟踪、IpConfiguration的ipAssignment是不是STATIC、LinkProperties里的地址和网关对不对。

第二层,内核层有没有:

adb shell ip addr show eth0 adb shell ip route show table all | grep eth0

第三层,网络栈有没有注册成可用网络:

adb shell dumpsys connectivity | grep -i ethernet

重点看这个网络的NetworkAgentInfo状态是不是CONNECTED,以及它有没有被选为默认网络(Default network: ... eth0)。如果它是CONNECTED但不是默认网络,说明被 Wi-Fi 抢了,看下一节。

第四层,真正能不能通:

adb shell ping -c 3 192.168.10.1 adb shell ping -c 3 www.example.com

第一个 ping 验证链路和路由,第二个 ping 验证 DNS。两个都通才算真的配好了。

6.2 路由与 DNS 的隐蔽问题

Wi-Fi 和以太网同时在线时,ConnectivityService按NetworkScore选默认网络。以太网的默认分数通常高于 Wi-Fi,但厂商定制可能改过。表现是:以太网配了静态 IP 也能 ping 通自己的网关,但 App 的请求还是走了 Wi-Fi,导致访问内网服务器失败。

确认默认网络归属用adb shell dumpsys connectivity,看Default network那一行。如果确实被抢,可以在系统定制里调NetworkScore,或者在应用里用ConnectivityManager.bindProcessToNetwork()显式绑定到以太网网络——后者是应用层能做的,不需要系统权限。

DNS 的问题更隐蔽。dumpsys connectivity里每个网络都有DnsAddresses,去看以太网那个网络的 DNS 是不是你填的那两个。如果显示的是路由器下发的地址,说明静态配置里的 DNS 没被采纳,多半是构造StaticIpConfiguration时dnsServers列表没塞进去——反射场景下这个字段是ArrayList,你得往里add,而不是set整个列表(set会替换掉内部引用,某些版本会失效)。

6.3 常见故障速查表

现象大概率原因排查命令处理方式
设置不报错但 IP 没变接口不在config_ethernet_interfaces白名单adb shell dumpsys ethernet修改系统 overlay 并重新编译
抛SecurityException缺少MANAGE_ETHERNET_NETWORKSadb shell dumpsys package <包名>系统签名或改走引导路径
抛IllegalArgumentException前缀长度非法、网关跨网段打印构造参数前置校验掩码连续性和网段
能 ping 网关,不能解析域名DNS 未下发到 netddumpsys connectivity看DnsAddresses补 DNS,或检查列表是否真塞进去了
重启后配置丢失未持久化或被 DHCP 覆盖重启前后对比dumpsys ethernet确认走的是setConfiguration而非裸ip命令
配了静态 IP 仍走 Wi-Fi默认网络被 Wi-Fi 抢占dumpsys connectivity看默认网络调分数或用bindProcessToNetwork
网卡状态为 down网线未插、交换机端口未启用cat /sys/class/net/eth0/carrier先解决物理链路
cmd ethernet提示未知命令系统版本不支持该子命令adb shell cmd ethernet换用setConfiguration或升级系统

注意:carrier文件读出来是1才代表物理链路 up,operstate是up但carrier是0的情况很常见——网线插着但对端没通电,这时候配什么 IP 都不会通。

7. 踩坑记录与工程化建议

7.1 刷新固件后配置回退

做量产的时候遇到过一次集体故障:几百台设备升级固件后,静态 IP 全部变回 DHCP。原因是新固件的 overlay 里改了config_ethernet_interfaces的默认值,而EthernetConfigStore在读到配置与白名单不匹配时会重置。后来我们把静态配置的预设逻辑放进了首次开机的BootReceiver,每次开机比对一次业务网段,不一致就重新下发,等于加了一层自愈。

这个思路值得借鉴:别指望配置一次就永远正确,让它具备自我修复能力。代价是开机时多几百毫秒,换来的是现场零上门。

7.2 批量部署脚本的几个细节

批量刷机场景下,我把设备按 MAC 排序后分配 IP,脚本里加了三重保险:下发后立刻ping网关,失败重试一次;重试仍失败就记录序列号到失败清单;全部跑完再统一dumpsys ethernet抓一遍状态做交叉核对。这套流程把一次 200 台的部署从两天压缩到半天,且事后能拿出完整的配置证据链。

另外提醒一句,adb并发不要开太高。我试过 16 路并发,结果三分之一设备adbd响应超时,反而更慢。8 路左右是比较稳的甜点。

7.3 我个人在实际操作中的几点体会

第一,先确认物理链路,再谈配置。我统计过自己排障的记录,大概三成的“配置不生效”最后查出来是网线、交换机端口或者 VLAN 划分的问题,跟 Android 一点关系没有。

第二,版本分支要写死在代码里。别用try-catch兜底所有异常然后静默降级,那样出了问题日志里什么都没有。按SDK_INT明确分支,每个分支打不同的 tag,排障时一眼就能定位走的是哪条路。

第三,能进系统签名就别用反射。反射带来的维护成本远超它省下的那点定制工作量,尤其是要跟多个 Android 大版本的时候。

第四,给现场留一个“恢复 DHCP”的入口。静态 IP 配错了导致设备失联是最难处理的情况,有个物理按键或者开机时的一个时间窗口能切回 DHCP,能救命。我们后来的设备都保留了这个机制,实现方式很简单:开机后 5 秒内检测某个组合键,命中就调一次setConfiguration把IpAssignment设回DHCP。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进