最近在调一块 STM32N655X0HxQ 的板子,主控是 STM32N6 系列,软件栈用的 ThreadX + NetX Duo,跑了 DHCP client。现象很典型:用 Wireshark 抓包能看到 DHCP Discover、Offer、Request 三步都很正常,服务器也回了 ACK,但板子上nx_ip_address_get查出来的 IP 始终是 0.0.0.0,应用层根本拿不到地址,程序逻辑直接卡死。这个问题折腾了我一整天,网上相关讨论也不多,所以把排查过程完整记录下来,给后面踩同样坑的人一些参考。
这篇内容不光是讲“怎么改”,更多是讲“怎么查”。我会先拆 DHCP 在 NetX Duo 里的状态流转,再按我实际调试的顺序,把可能的原因一个个过,最后附上几个修复案例和避坑清单。适合正在用 STM32N6 + NetX Duo 做联网功能的嵌入式工程师,尤其是第一次从裸机或 lwIP 切换到 ThreadX 生态的,建议从头到尾读一遍。
1. 问题现场复现:ACK 已经到了,IP 却依然是 0.0.0.0
1.1 先说结论:这不是 DHCP 协议本身的问题
你可能会觉得“收到 ACK 了,怎么还没拿到 IP”,这句话本身就有歧义。DHCP 的 ACK 是服务器对客户端 Request 的确认,它意味着服务器已经“承诺”在租约期内把这个 IP 分配给你。但协议栈内部要做的动作还很多:解析 ACK 里的 option、把 IP 绑定到网络接口、设置子网掩码和网关、启动地址冲突检测(如果有的话),最后才能把状态机推进到BOUND。
所以“收到 ACK 但没拿到 IP”这个现象,通常不是“网络不通”,而是协议栈内部的某个环节没走完。我在调试时最先做的,就是确认nx_dhcp_start之后,DHCP 的状态到底停在哪一步。
1.2 NetX Duo 的 DHCP 状态机与事件回调机制
NetX Duo 的 DHCP client 状态机设计得很直接,它用一个内部线程来处理收包-发包的循环。状态流转大致是:
NX_DHCP_STATE_INIT:初始化完成,准备发 DiscoverNX_DHCP_STATE_SELECTING:收到 Offer,准备发 RequestNX_DHCP_STATE_REQUESTING:已发 Request,等待 ACKNX_DHCP_STATE_BOUND:收到 ACK 并完成地址绑定,这是你真正能拿到 IP 的状态NX_DHCP_STATE_RENEWING/NX_DHCP_STATE_REBINDING:租约续期阶段
每发生一次状态变化,NetX Duo 会调用通过nx_dhcp_set_state_change_callback注册的回调函数。这个回调是观察 DHCP 进度最好用的工具,没有之一。
我当时在回调里加了一行打印:
UINT dhcp_state_change(NX_DHCP *dhcp_ptr, UCHAR new_state, UCHAR old_state) { printf("[DHCP] state %d -> %d\r\n", old_state, new_state); if (new_state == NX_DHCP_STATE_BOUND) { ULONG ip = 0; nx_ip_address_get(&ip_instance, &ip, NULL); printf("[DHCP] BOUND, ip = %lu.%lu.%lu.%lu\r\n", (ip >> 24) & 0xFF, (ip >> 16) & 0xFF, (ip >> 8) & 0xFF, ip & 0xFF); } return NX_SUCCESS; }结果很有意思:回调直接没进,或者进了但打出来的状态不是BOUND,而是REQUESTING。这说明 ACK 虽然是收到了,但协议栈没有把状态推进下去。问题就出在这个“为什么没推进”上。
2. 为什么 ACK 到了,IP 却没生效:五个高频原因
2.1 回调函数没触发或触发时机不对
这个是最容易忽略的。nx_dhcp_set_state_change_callback必须在nx_dhcp_start之前调用,否则压根不会注册上。我见过不少同事把回调注册放到了nx_dhcp_start后面,结果状态变化的时候回调函数指针是空的,直接就跳过了。
还有一种情况是回调里做了阻塞操作,比如在回调里直接调用nx_ip_address_get后的打印任务里,用了tx_thread_sleep或者等待信号量,而 NetX Duo 的回调是在 DHCP 内部线程上下文里执行的。一旦阻塞,整个 DHCP 状态机就卡住了,后面的包不会继续处理。
2.2 查询 API 用错了:多接口和默认接口的区别
STM32N6 + NetX Duo 的工程里,很多人会启用多接口支持。如果你初始化的时候用的是nx_ip_interface_create而不是默认的nx_ip_create后直接使用主接口,那么nx_ip_address_get只查主接口的 IP,而你 DHCP 跑在次接口上,自然查出来是 0.0.0.0。
我在调试时也犯过这个错,查 API 文档才反应过来。正确做法是用nx_ip_interface_address_get,并且把接口索引传对:
ULONG ip_address = 0; ULONG netmask = 0; UINT status = nx_ip_interface_address_get(&ip_instance, 0, &ip_address, &netmask);注意接口索引从 0 开始。如果你只创建了一个主接口,索引就是 0。
2.3 PHY 链路状态没有同步
这个是 NetX Duo 特有的坑。它在处理 DHCP 包的时候,会先检查链路状态。如果 PHY 的 link up/down 状态没有被正确上报给协议栈,就会认为链路不可用,即使收到了 ACK 也不会继续绑定地址。
具体来说,NetX Duo 的 DHCP 线程在处理完一个包之后,会再次检查链路,如果发现链路状态不是“up”,就会回到INIT状态重新开始。我在 H7 上遇到过一次,在 N6 上也遇到了,非常典型。链路状态是通过nx_ip_link_status_check获取的,底层靠 PHY 驱动和以太网驱动的回调上报。
2.4 cache 和 DMA 一致性在 N6 上特别容易踩坑
STM32N6 和 STM32H7 一样,CPU 访问内存有 D-Cache,以太网 DMA 也在访问同一块内存,这就涉及 cache 一致性问题。NetX Duo 的包缓冲如果落在 cacheable 区域,而驱动收发 DMA 的时候没有做 invalidate/clean 操作,就会出现“内存里的数据是旧的”但 DMA 已经写入新数据的情况。
具体表现就是:MAC 已经收到了完整的 ACK 帧,CRC 校验也通过了,但协议栈解析的时候读到的是 cache 里的旧数据,结果解析失败,状态机不往前走。这个问题非常隐蔽,因为抓包看是正常的,但板子内部逻辑就是不对。
2.5 内存池太小,DHCP 选项解析失败
NetX Duo 的 DHCP 客户端需要从 ACK 报文中解析出一串 option,包括子网掩码、网关、DNS、租约时间等。这些解析结果要存到NX_DHCP结构体里,如果nx_ip_create时指定的 packet pool 的 payload 太小,收到的 ACK 报文可能被截断,或者处理时报“packet too small”错误。
我当时查的时候,发现默认生成的工程里 packet payload 给了 1536 字节,这理论上够用,但如果开了大量 debug 打印,或者 DHCP 选项特别多(有些路由器会塞一堆 option),还是可能出问题。保险起见,我习惯把 payload 加到 1600 以上,pool 大小也要够。
3. 一步步排查:从现象定位到根因
3.1 第一步:确认 DHCP 状态机真的到了 BOUND
不管现象多诡异,第一步永远是把状态机打印打开。这里有两个手段:一个是我前面说的状态变化回调,另一个是 NetX Duo 自带的 debug 打印。
如果你启用了NX_ENABLE_DHCP_CLIENT和调试输出,可以在工程宏里加NX_DHCP_CLIENT_DEBUG_ENABLE(不同版本名字可能略有差异),这样 DHCP 内部每个关键步骤都会打日志。如果你不想重新编译整个协议栈,最简单的做法就是靠回调打印。
我当时的输出是:
state 1 -> 2 state 2 -> 3对应INIT → SELECTING → REQUESTING,然后就没有然后了。这说明 ACK 可能收到了,但REQUESTING之后的处理逻辑没有跑通。
3.2 第二步:验证 IP 是否已经绑定到接口
如果你发现状态已经到BOUND了,但应用层查不到 IP,那就是查询 API 用错了。我在 2.2 节已经讲了多接口的问题,这里再补充一个细节:nx_ip_address_get返回的是主接口地址,而如果你用的是nx_ip_interface_address_get,第二个参数传接口索引,注意索引别传错。
还有一种情况:状态到了BOUND,IP 也能查到了,但你把查询的逻辑放在了一个比 DHCP 线程优先级还高的线程里,这个线程在 DHCP 还没有完成初始化的时候就去查了,查出来自然是 0.0.0.0。这不是 DHCP 的问题,是同步问题。解决方法是在状态回调里设置一个标志位,然后再让业务线程等待这个标志位。
3.3 第三步:检查链路状态与 PHY 初始化
这一步需要你熟悉自己的 PHY 芯片。我用的 PHY 是瑞昱的 RTL8201F,RMII 接口,初始化完成后拉高 link 中断引脚。NetX Duo 的驱动会在 link 状态变化时调用nx_ip_link_status_check来获取最新状态。
你可以用下面这个函数手动轮询链路状态:
ULONG link_status = 0; UINT status = nx_ip_interface_status_check(&ip_instance, 0, NX_IP_LINK_ENABLE, &link_status, 500); if (status != NX_SUCCESS) { printf("[NET] link not up, status = 0x%04X\r\n", status); }如果这个函数一直返回失败,说明 PHY 的 link 状态没有正确上报给协议栈。这可能是因为 PHY 的中断引脚没有触发,或者以太网驱动的初始化顺序不对。很多芯片要求 PHY 复位后至少等待几十毫秒才能读到正确的状态寄存器,如果你在 PHY 还没 ready 的时候就去读,很容易读到 0。
另一个检查点是 RMII 接口的参考时钟。我用的是外部 50MHz 有源晶振,如果这个时钟没起来,PHY 不会报 link up,但 MAC 可能仍然能收到包(因为某些 PHY 在没有时钟时也能检测到信号),这就会造成“能收到 ACK 但链路状态为 down”的诡异现象。
3.4 第四步:检查 cache / MPU / 时钟这些“底层暗礁”
这一步是 STM32N6 上最容易出问题的地方。Cortex-M55 和 M7 一样,D-Cache 是需要主动维护一致性的。
如果你在初始化里调用了SCB_EnableDCache(),那么以太网 DMA 的收发缓冲区和描述符都要注意:
简单做法是把所有以太网相关的 buffer 都放到非 cacheable 的 MPU 区域。CubeMX 生成的代码里,通常会帮你配置一个 MPU region,但有些版本只给 SRAM 配了 region,没把以太网描述符 buffer 也覆盖进去。
我当时的做法,是在main.c里用MPU_Config专门划了一个 region:
MPU_Region_InitTypeDef MPU_InitStruct = {0}; MPU_InitStruct.Enable = MPU_REGION_ENABLE; MPU_InitStruct.Number = MPU_REGION_NUMBER0; MPU_InitStruct.BaseAddress = 0x30000000; // 根据实际 buffer 地址调整 MPU_InitStruct.Size = MPU_REGION_SIZE_256KB; MPU_InitStruct.SubRegionDisable = 0x00; MPU_InitStruct.TypeExtField = MPU_TEX_LEVEL0; MPU_InitStruct.AccessPermission = MPU_REGION_FULL_ACCESS; MPU_InitStruct.DisableExec = MPU_REGION_DISABLE_EXEC; MPU_InitStruct.IsShareable = MPU_REGION_NOT_SHAREABLE; MPU_InitStruct.IsCacheable = MPU_REGION_NOT_CACHEABLE; MPU_InitStruct.IsBufferable = MPU_REGION_BUFFERABLE; HAL_MPU_ConfigRegion(&MPU_InitStruct);需要注意的是,如果以太网 buffer 使用的是外部 PSRAM 或 SDRAM,那 bufferable 和 shareable 属性的配置也要跟着调。不同的内存区域,DMA 访问能力和 cache 策略都不一样,这块没有统一答案,只能逐个测试。
如果你不打算改 MPU 配置,还有一个备选方案:每次 DMA 收发前后手动做 cache 维护。发送前调用SCB_CleanDCache_by_Addr,接收后调用SCB_InvalidateDCache_by_Addr。这个方案能解决问题,但代码侵入性稍强,而且如果描述符本身也在 cacheable 区域,维护起来很麻烦。我建议优先用 MPU region 来规划。
3.5 第五步:翻协议栈版本和 DHCP option 参数
如果上面的步骤都排查完了还是不行,就要考虑是不是协议栈版本和环境导致的问题了。
我踩过一个很隐蔽的坑:DHCP 服务器(比如某个家用路由器)发出的 ACK 里,携带了 option 82(中继代理信息),而某些版本的 NetX Duo 对未知 option 的兼容性不好,解析到一半就跳出了,导致后续的关键 option(如 option 1 子网掩码)没有被读取。这种情况下状态机不会崩,但地址绑定不完整。
解决方法是抓包,看看 ACK 里到底带了哪些 option。用 Wireshark 抓包,然后看 DHCP option 列表。如果 option 82 是问题所在,你可以尝试在 DHCP 客户端里把NX_DHCP_CLIENT_SEND_HOSTNAME等扩展选项关掉,或者检查 NetX Duo 的新版本是否有相关的 bug fix。
另外,MAC 地址问题也值得注意。如果板子的 MAC 地址没有从 UUID 或 OTP 里读取,而是写死了一个全 0 或者未烧录的地址,某些 DHCP 服务器可能会认为这是一个非法客户端,在 ACK 时故意不携带部分关键 option。STM32 系列每个芯片在出厂时都有唯一的 UUID,可以拼出一个全球单播 MAC 地址,但如果你的工程用的是默认的0x00地址,很可能会触发这种情况。
4. 三种典型场景的完整解决过程
4.1 场景一:状态回调被阻塞,IP 其实已经分配
我在调这块板子时,第一个排查方向就是回调。
现象:抓包能看到 ACK,但状态回调里new_state一直没有打印出BOUND,而是一直停在REQUESTING。
排查过程:
- 首先,我在回调里加打印,确认回调有被调用。
- 然后,我发现回调里我调用了业务线程的
tx_event_flags_set,这个事件在没有其他线程接收时确实会一直等待,但tx_event_flags_set本身是异步的,不会阻塞。 - 接着,我在回调里加了一个
HAL_GPIO_Toggle,用示波器看波形。发现回调确实被触发了,但只触发到REQUESTING状态。 - 最后,我在
nx_dhcp_start前后加了一堆打印,发现我没有开启定时器或系统 tick 的持续运行?不,ThreadX 有默认 tick,但如果 HCLK 配错,tick 计时不准,DHCP 的等待超时时间就会异常。
最终修复:把 CubeMX 里的系统时钟从默认的 HSI 改为外部 PLL 倍频,确保 HCLK 正确,ThreadX 的 tick 和 NetX Duo 的超时计算就能正常工作。其实这一步跟 DHCP 没有直接关系,但 SysTick 不工作或频率错误,会导致 DHCP 中“等待 ACK 超时后重发 Request”的逻辑反复触发,产生“每次都是 REQUESTING”的假象。
这个案例给我们的教训是:不要只盯着 DHCP 本身,要先确认整个系统的时钟、tick、事件机制是正常的,不然很容易走到误区里。
4.2 场景二:D-Cache 导致脏读,ACK 收到但内容解析失败
另一个很典型的场景,就是我在 2.4 节提到的 cache 一致性问题。
现象:Wireshark 能看到 ACK,板子侧也能通过 DMA 中断知道“收到了一包数据”,但 NetX Duo 解析这包数据时,得到的结果完全不对,DHCP 状态卡在REQUESTING,等待超时后重发。
排查过程:
- 一上来,我用
HAL_ETH_ReadData之类(不同驱动的 API 可能不同)把 DMA 接收缓冲区里的原始数据导出来,核对以太网帧头、IP 头、UDP 头。发现数据是对的。 - 然后,我把这部分数据用串口发到 PC 上,用 Python 脚本解析 DHCP option,发现 option 也是对的。
- 那么问题就出在 NetX Duo 的
NX_PACKET拿到数据之后,到 DHCP 解析之间的某个环节。打开 D-Cache 时,如果接收 DMA 写入内存后,协议栈读取时没有 invalidate,那读到的就是 CPU cache 里的旧数据。
修复方法:把以太网 DMA 相关的 buffer 放到 non-cacheable 区域。
这里还有一个更隐蔽的细节:STM32N6 的以太网 DMA 描述符和收发 buffer 可能分散在不同的内存区,有些在 DTCM,有些在 SRAM,有些在 AXI SRAM。DTCM 本身不在 AXI 总线上,DMA 不能直接访问(除非经过总线桥),如果你把 buffer 放在 DTCM 里,DMA 写入的数据可能根本不进你期望的 buffer,这时候收到的 ACK 可能只是 DMA 中断碰巧触发了,数据却是空的。
我在 STM32H7 上踩过 DTCM 的坑,在 N6 上同样需要留意。保险做法是把以太网相关的所有内存分配放到 AXI SRAM(比如0x24000000段)里,并配上 non-cacheable 的 MPU region。
4.3 场景三:PHY 中断没接,NetX Duo 不推进状态机
这个场景和 2.3 节讲的链路状态问题匹配。我用的板子,PHY 的中断输出引脚没有连接到 MCU,而是悬空状态。虽然 MAC 初始化后 PHY 能正常协商出 link up,但因为 NetX Duo 的驱动依赖 PHY 中断来触发链路状态变化回调,一旦这个回调不触发,协议栈就认为链路层不可用。
现象:DHCP 能发出去,也能收到 ACK,但状态机始终在REQUESTING和INIT之间循环跳转。因为每次收完包之后,协议栈检查链路发现是“down”,就复位回INIT。
排查过程:
- 先看 PHY 芯片手册,确认它的 link 状态寄存器地址。
- 然后在以太网驱动里,把
phy_link_status相关的读取逻辑打开,确认读取到的状态确实是 UP。 - 再看驱动是如何上报链路状态的,如果它依赖 GPIO 中断,而 GPIO 没初始化和配置,那就必然会失败。
修复方法有两个:一个是把 PHY 的中断引脚正确接到 MCU,并在驱动里配置为下降沿或上升沿触发;另一个是在驱动初始化后,手动调用一次链路状态更新函数,把 PHY 寄存器里读到的 link up 状态直接上报给 NetX Duo。后者适合硬件上已经没法改的场景。
我当时选择后者,在以太网驱动初始化完成后,加了一段代码强制上报 link up:
status = nx_ip_interface_status_check(&ip_instance, 0, NX_IP_LINK_ENABLE, &link_status, 0); if (status != NX_SUCCESS) { // 手动触发一次 link up 通知 nx_ip_interface_link_up(&ip_instance, 0); }这里用nx_ip_interface_link_up直接通知协议栈链路已就绪。之后 DHCP 就正常从INIT跑到BOUND了。
5. 常见问题速查表与避坑清单
5.1 快速对照表
我把这次调试过程中遇到的各种情况、可能原因和解决思路整理成一个速查表,方便你对照排查。
| 现象 | 可能原因 | 检查方法 | 解决思路 |
|---|---|---|---|
| 抓包看到 ACK,状态卡在 REQUESTING | DHCP option 解析失败 / 链路状态不对 / cache 脏读 | 打开状态回调打印,检查链路状态、检查 ACK 原始数据 | 按 3.3 / 3.4 / 3.5 节排查 |
| 状态已到 BOUND,但查 IP 是 0.0.0.0 | 查询 API 用错接口 | 确认用的是nx_ip_interface_address_get还是nx_ip_address_get | 改 API,传对接口索引 |
| 回调从未被触发 | 回调注册晚于nx_dhcp_start | 检查初始化代码顺序 | 注册回调提到nx_dhcp_start之前 |
| 链路状态始终 down | PHY 中断未接 / 驱动器未上报 | nx_ip_interface_status_check轮询 | 强制上报 link up / 修硬件 |
| 能收到部分包但 DHCP 超时 | 内存池太小被截断 / DMA 描述符在 DTCM | 抓包确认包长,查看 pool 配置 | 增大 payload,把 buffer 放到 AXI SRAM |
| ACK 里 option 解析异常 | 路由器增加了不常见 option | Wireshark 抓包看 option 列表 | 关扩展 option / 升级 NetX Duo |
| 偶发性,时好时坏 | cache 维护时序不固定 | 打开 cache 打印,检查 MPU 配置 | 用统一 non-cacheable region |
5.2 独家避坑心得
最后分享几个我这次调试过程中总结出来的经验,这些在官方文档里很难一次找齐。
第一,不要轻易关闭 D-Cache 来绕过问题。有些人遇到 cache 一致性问题,第一反应是关掉 D-Cache,这样确实能“解决问题”,但代价是整体性能下降非常多。N6 系列的 CPU 主频很高,如果 D-Cache 全关,很多计算密集型任务的耗时可能会翻几倍。正确做法是把网络相关的 buffer 单独划成 non-cacheable region,其他部分继续开启 cache。
第二,打印一定要分行、分模块、带时间戳。我在调试时发现,如果打印信息写的太密,比如printf("[DHCP] %d -> %d, ip=%lu", a, b, ip),信息多了以后很容易看花眼,错过关键转折点。建议把网络、协议栈、应用层三类打印分别用不同的标签前缀,比如[NET]、[DHCP]、[APP],方便 grep。
第三,Wireshark 抓到 ACK 不代表 ACK 一定合法。有些路由器或者 DHCP 服务器在广播域里可能回复多个 ACK,或者 ACK 里携带的 IP 是你之前已经过期租约的 IP。客户端必须在收到 ACK 后检查yiaddr字段与之前 Request 里的 IP 是否一致。NetX Duo 内部会做这个校验,但如果它校验失败,就会丢弃这个 ACK。这时候你的状态会一直停在REQUESTING,超时后重新走 Discover。抓包看起来是“收到了 ACK”,但协议栈认为这不是它想要的 ACK。
第四,用二层交换机抓包时要注意 DHCP 的广播行为。如果你的板子单独插在一个普通交换机端口上,并且用的是三层路由器的 DHCP 服务,广播可能会被路由器隔离,导致你抓不到 ACK。这种情况建议直接在板子和路由器之间接一个带镜像功能的交换机,或者直接用一根网线连到带抓包功能的树莓派上做桥接。
第五,考虑租约时间。如果抓包看到的是 DHCP ACK,但 ACK 里的lease time非常短,如 10 秒,客户端可能在绑定后几秒内就进入续租流程,如果你的代码在查询 IP 时刚好赶上续租间隙,也可能看到 IP 被清零的瞬间。虽然这不是“没有获取到 IP”的原因,但会导致你误判。
第六,检查线程优先级和tx_thread_sleep的使用。NetX Duo 的 DHCP 线程优先级要低于以太网接收线程,否则在繁忙的网络上可能出现丢包。很多从 lwIP 转过来的人,习惯把所有网络相关线程都放同一个优先级,这在 ThreadX 里是可行的,但必须保证preemption threshold配置正确。
第七,如果以上都查完了还是不行,仔细看 CubeMX 生成的 NetX Duo 版本号。STM32CubeFW_N6 里的 NetX Duo 版本可能比 ThreadX 官方仓库里的旧,某些 DHCP 相关 bug 已经在后续版本修复。你可以对比一下nx_dhcp.h文件里的版本宏,看看是否有已知的 ACK 处理问题。
6. 调试工具组合建议
在整个排查过程中,我几乎每天都固定使用这些工具,逐一说明。
串口打印是最基本的手段。但要注意串口打印本身也会占用 CPU 时间和中断,如果你在 PHY 中断或 DMA 中断里加打印,很可能导致中断处理时间过长,反而影响以太网接收。所以我一般只在应用层或线程上下文里打印。
Wireshark 抓包调试是必不可少的。没有它,你无法确认“ACK 到底来了没有,长什么样”。调试时建议把 DHCP 过滤表达式设置为dhcp || dhcpv6,并打开 Wireshark 的 “Decode As” 功能确认是 klass 还是别的协议。
SVD 文件和寄存器查看也很有用。Keil / IAR 的 debugger 可以直接查看 MAC 寄存器,比如接收描述符里的status位,确认 DMA 接收了没有报错。如果 DMA 报错了,比如RX_OVERFLOW或DESCRIPTOR_UNAVAILABLE,那说明驱动或 buffer 配置有问题。
最后是逻辑分析仪或示波器,在排查 PHY 时钟和中断时序时非常好用。如果你手头没有这些设备,也可以用 GPIO 翻转加定时器的方式来自制一个简易时间轴。这个办法土,但很有效。
7. 我的最终感受
这个问题其实不算特别罕见的故障,但在 STM32N6 + NetX Duo 这个组合下,排查起来比传统 STM32H7 + lwIP 要复杂一些,原因主要有三个:Cortex-M55 的 cache 策略需要额外关注、NetX Duo 的状态机对链路状态非常敏感、以及 CubeMX 生成的代码虽然好用,但默认配置往往不是你实际硬件的配置。
调试到后期,我最大的体会是:不要一上来就怀疑 DHCP 协议栈本身。绝大多数“收到 ACK 但拿不到 IP”的问题,根源都在协议栈之下的那一层——要么是链路层状态没同步,要么是内存访问的一致性出了问题,要么就是调用方式不对。先把这些基础环境确认好,再回头审视 DHCP 逻辑,往往能省下很多时间。
如果你正在调类似的问题,并且手里有 Wireshark 抓包记录,可以先看 ACK 报文里的option 60(Vendor Class Identifier)和 option 61(Client Identifier)。如果这两个 option 的格式和你的配置不一致,某些服务器会忽略你的请求。把这两个 option 打印出来对照一下,也有帮助。
在结尾再分享一个小技巧:在工程里留一个长按按键进入手动 IP 配置的后门。在调试 DHCP 问题的时候,如果长时间卡住,可以切到手动 IP,让 TCP 客户端功能先跑起来,验证上层逻辑是否正常。这样一来,就能把问题范围精确地缩小在 DHCP 这个模块内,而不是被上层业务逻辑干扰。这个后门非常有用,建议所有以太网产品都留一个。