设备上电后,上位机在第一时间发来的UDP报文经常石沉大海,这个典型问题在我调试一块基于 STM32H563 的以太网控制板时被完整复现过。开始时怀疑 PHY 复位时序,怀疑 lwIP 配置,排查到后面才发现真正原因是 HAL 库的 TX Buffer(DMA 发送描述符池)在启动瞬间被占满,HAL_ETH_TransmitFrame返回HAL_BUSY,应用层没处理返回值,回包就被静默丢弃了。本文把整套定位过程、根因机制和修复方案拆开讲清楚。无论你用的是 HAL 裸机收发,还是 lwIP 协议栈,只要设备在启动阶段存在"外部快速发包"或"自身连续上报多帧"的场景,这个案例都能直接参考。
1. 复现现场:启动后的前几百毫秒,UDP 回包概率性失踪
1.1 我的测试环境和异常表现
硬件平台是一块自研的 STM32H563 控制板,以太网接口用 RMII 连接外置 PHY 芯片 LAN8742A,MCU 通过 MCO 引脚输出 50MHz 参考时钟给 PHY。软件基于 STM32CubeMX 生成工程,使用 HAL 库 + lwIP 协议栈,实现一个简单的 UDP 服务器:PC 作为上位机通过网线直连板子,设备收到 UDP 指令后立即回发一帧状态报文。
异常现象非常规律:PC 端在上电瞬间通过网络调试助手发送 3 个 UDP 报文,设备要么三帧全不回,要么只回最后一帧。更麻烦的是,这个现象只在设备冷启动后的前几百毫秒内出现,一旦设备运行几秒后再发包,一切正常。反复按复位键测试,丢帧规律不完全一致,带有明显的概率性,所以一开始很容易误判为硬件问题。
我用 Wireshark 在 PC 侧抓包,确认 PC 发出的 3 个 UDP 报文全部到了线上,而设备确实没有产生任何回包。也就是说问题不在物理链路,也不在 PC 侧,而是设备收到报文之后,在回包这个环节上出了问题。
1.2 用 GPIO 翻转把问题锁定到 TX 方向
纯软件调试看不到 HAL 层内部的真实情况,我用的办法是在关键代码位置插入 GPIO 翻转,用逻辑分析仪观察时序。板子上留了 3 个空闲 GPIO,我在三个位置各放一个翻转标记:
- 位置 A:lwIP 的 UDP 接收回调函数入口,进入即翻转
- 位置 B:
HAL_ETH_TransmitFrame调用之前,翻转一次 - 位置 C:
HAL_ETH_TransmitFrame返回之后,再翻转一次
实测结果很有意思:位置 A 的 GPIO 每次都有翻转,说明 UDP 报文确实到了应用层;位置 B 的翻转说明应用层也确实尝试过调用发送函数;但位置 C 的翻转间隔极短,而且逻辑分析仪抓到的脉冲宽度明显不如正常发送时。后来我在代码里把HAL_ETH_TransmitFrame的返回值打印到串口,真相立刻浮出水面:返回的一直是HAL_BUSY。
也就是说,收到的数据都进了接收回调,但发送函数因为描述符池被占满而拒绝接收新数据。丢包不在接收侧,就在 TX 路径上。
2. 从 HAL_BUSY 反推发送链路:DMA 描述符池是怎么被瞬间塞满的
2.1 STM32H563 以太网 DMA 描述符的工作机制
要理解为什么会出现HAL_BUSY,得先弄明白 STM32 以太网 MAC 的 DMA 描述符机制。很多人第一次看 HAL 库的以太网驱动时会觉得绕,其实原理并不复杂。
以太网 DMA 不会直接访问你定义的应用层缓冲区,它维护着一张描述符表。每个描述符(ETH_DMADescTypeDef)包含状态字(TDES0)、控制字(TDES1)、缓冲区地址(TDES2/TDES3)等字段。发送路径上,应用层调用HAL_ETH_TransmitFrame时,HAL 库会做三件事:把数据拷贝到当前 TX 描述符指向的缓冲区、设置描述符中的数据长度、然后置位描述符的OWN位。OWN位置 1 后,DMA 控制器才拥有这个缓冲区,才会把数据从内存搬到 MAC 的 FIFO 并通过 PHY 发出去。DMA 发送完毕后会清掉OWN位,描述符重新归软件所有。
这里的关键点在于:OWN位为 1 期间,描述符属于 DMA,软件不能再次使用。假如所有 TX 描述符的OWN位都是 1,说明整个发送队列已经塞满,再来任何数据都无处安放。HAL 库在HAL_ETH_TransmitFrame的开头就会检查当前描述符的OWN位,非零则直接返回HAL_BUSY,并不会帮你排队或者重试。
HAL_StatusTypeDef HAL_ETH_TransmitFrame(ETH_HandleTypeDef *heth, uint32_t FrameLength) { ETH_DMADescTypeDef *dmatxdesc; if (heth->gState == HAL_ETH_STATE_READY) { dmatxdesc = heth->TxDescList[heth->TxDescIndex].Desc; /* Check if the descriptor is owned by the DMA */ if ((dmatxdesc->TDES0 & ETH_DMATXDESC_OWN) != (uint32_t)RESET) { return HAL_BUSY; } /* ... 配置描述符、准备数据、置 OWN 位 ... */ } return HAL_ERROR; }所以 TX 路径上的"水位线"是由描述符数量决定的。CubeMX 生成的工程中,ETH_TX_DESC_CNT默认是 8,也就是说同一时刻最多只能有 8 个数据包在发送队列中等待 DMA 搬运。如果应用层在一瞬间塞进来 10 个包,最后 2 个就直接被丢掉了。
2.2 启动阶段的发包洪峰为什么容易耗尽 TX 队列
如果只看到"8 个描述符不够",那其实还没触及问题的核心。正常运行状态下,8 个描述符足够用了,因为 DMA 发送速度远快于应用层产生数据的速度。问题出在启动阶段形成的瞬时洪峰,几路数据叠在一起,8 个描述符瞬间被榨干。
我梳理了启动阶段的数据叠加路径,一共有四路:
第一路是上位机的主动探测。很多 PC 端工具在连接建立后会立刻发送广播包或发现报文,比如设备发现协议、组播探测、状态查询。这些报文到达设备后,lwIP 的 UDP 回调会触发回包逻辑,如果每收到一个包都立即回,回包数量几乎和输入数量成正比。
第二路是协议栈自身的 ARP 处理。冷启动时 lwIP 的 ARP 缓存是空的。设备收到 PC 的单播 UDP 报文后,如果要回包,需要先知道 PC 的 MAC 地址。如果 ARP 表里没有对应条目,协议栈会先发送 ARP 请求并等待应答,在这期间到达的多个 UDP 回包会被缓存在协议栈内部。一旦 ARP 应答返回,这些缓存的包会被一次性提交给底层发送函数,形成突发流量。
第三路是 DHCP 或其它初始化流程。如果设备使能了 DHCP,启动时会有一轮完整的 DHCP Discover/Offer/Request/Ack 交互,这套流程本身就产生多个 UDP 包,和用户应用的回包混在一起。
第四路最隐蔽,是应用自身在初始化期间积压的数据。比如设备启动后要上报状态、发送日志缓冲区的历史记录,这部分数据可能同时产生几十个包。
这四路流量在很短的窗口内叠加,8 个 TX 描述符的缓冲能力根本不够。用生活里的话说,描述符池就是一个储水池,8 根管子还不够粗,启动时四面八方同时往池子里灌水,池子瞬间被灌满,后续的水只能溢出。
3. 根因锁定:PHY Link Up 时序和 ARP 首通延迟才是真正的"启动窗口"
3.1 PHY 自动协商完成前,MAC 的发送链路本来就是半残状态
加大 TX 描述符数量之前,需要先搞明白为什么问题集中在启动阶段。这里牵扯到两个容易被忽略的时序问题。
第一个是 PHY 的自动协商过程。以太网 PHY 上电后,不是立刻就能通信的。PHY 需要和链路对端通过自动协商(Auto-Negotiation)确定速率和双工模式,这个过程一般需要 1~3 秒。在 Link Up 之前,PHY 的收发链路并没有真正建立,此时 MAC 往 PHY 发数据,PHY 无法把数据送到线路上,实际上处于半死状态。
很多应用层的代码并没有为这个过程预留等待时间。初始化完成后立即启动 UDP 服务,如果上位机碰巧在这个窗口发来报文,设备收到的数据包在 ARP 和 DHCP 流程中进出协议栈,最终提交给 MAC 发送时,PHY 可能还在协商中。HAL 库的发送函数不会感知 PHY 的状态,它只管把数据交给 DMA,DMA 又会把数据写给 MAC 的 TX FIFO。TX FIFO 满后,DMA 发送就不完整,描述符的OWN位也不会按时被清除,久而久之描述符池被占满,后续的HAL_ETH_TransmitFrame全部返回HAL_BUSY。
第二个是 ARP 缓存缺失导致的延迟。冷启动时设备的 ARP 表是空的,第一次收到 PC 的单播 UDP 包后,回包需要解析 PC 的 MAC。lwIP 的 etharp 模块会把待发送的数据包挂到 ARP 队列里,发一个 ARP 请求,等对端应答。这个过程中如果有新的 UDP 包进来,它们也会被排到 ARP 队列后面。等到 ARP 应答到达,协议栈一次性把队列里的多个包全部提交给底层发送接口。这个"一次性提交"的动作,会瞬间产生远大于 8 个描述符容量的数据包,正是 TX Buffer 被塞满的直接原因。
用一句话概括根因:启动阶段存在一个特殊的"盲区",这个盲区由 PHY 协商时序、ARP 首通延迟和 DHPC 流量叠加构成,而 TX 描述符池恰好在这个盲区里承担了所有瞬时流量的缓冲角色,描述符一旦被占满,丢包就成了必然结果。
3.2 为什么运行稳定后再发包就一切正常
这个问题能帮助验证根因判断。设备运行几秒后,PHY 早已 Link Up,ARP 缓存也已包含 PC 的 MAC 条目,之前那些叠加流量全部消失。此时即使应用层再连续发送多帧数据,DMA 的发送速度也远高于应用层产生数据的速度,8 个描述符基本不会出现同时为忙的情况。
所以在稳定的稳态场景下,HAL_ETH_TransmitFrame几乎总能成功。这个现象反过来印证了问题不在发送函数本身,而在启动窗口的流量特征。
3.3 一个容易被忽略的变量:上位机 ARP 缓存失效
排查过程中我还注意到一个对称方向的现象,那就是 PC 侧 ARP 缓存过期后,PC 发送的 UDP 包也会触发一次 ARP 请求。设备复位后 MAC 地址没变,PC 的 ARP 缓存还在,所以 PC 往往不需要重新解析 MAC,它的 UDP 包可以很快到达设备。但如果设备在复位过程中更换了 MAC 地址(一些产品会用动态 MAC 区分批次),PC 的 ARP 缓存就会失效,PC 发往设备的第一个 UDP 包也要先经过一轮 ARP 交互才能被真正发送。这个首通延迟会拉长整个启动窗口,让设备端的 TX 描述符更容易被后续流量填满。
如果你测试时发现"更换设备后丢包概率上升"或"拔插网线后首包延迟变大",可以考虑从这个方向排查。
4. 修复方案:不只是调大描述符数量,还要修掉发送逻辑的盲区
4.1 调大 TX 描述符池:从 8 个到 16 个或 24 个
最直接的缓解手段是增加 TX 描述符数量。CubeMX 生成的以太网代码中,描述符数量由宏ETH_TX_DESC_CNT控制,默认值是 8。把它改成 16 或 24,相当于把发送队列的水池挖深了一倍多。
在 CubeMX 生成代码中,需要修改的位置通常在main.h或ethernet.h:
#define ETH_RX_DESC_CNT 8U #define ETH_TX_DESC_CNT 16U同时要注意缓冲区内存的分配。描述符数组DMATxDscrTab和发送缓冲区Tx_Buff需要按 32 字节对齐地址分配,CubeMX 生成的代码通常会使用__ALIGN_BEGIN宏处理:
__ALIGN_BEGIN ETH_DMADescTypeDef DMATxDscrTab[ETH_TX_DESC_CNT] __ALIGN_END; __ALIGN_BEGIN uint8_t Tx_Buff[ETH_TX_DESC_CNT][ETH_TX_BUFFER_SIZE] __ALIGN_END;改描述符数量后,需要估算一下内存占用。ETH_TX_BUFFER_SIZE默认是 1536 字节,如果ETH_TX_DESC_CNT设为 16,单 TX 方向的缓冲区占用是 16 * 1536 = 24KB。再加上接收方向同样规格的缓冲区,总占用在 48KB 左右。对于 STM32H563 这种内置大容量 SRAM 的芯片来说可以接受,但如果你的工程里还跑着大数组或文件系统缓冲,需要确认内存总量不会超。
还要注意一个隐藏问题:DMA 和 CPU 之间的缓存一致性。STM32H5 系列带 D-Cache,如果 DMA 描述符和缓冲区落在可缓存的普通 SRAM 区域,传输数据时容易出现数据不一致的诡异现象。稳妥的做法是把描述符和缓冲区放到 non-cacheable 区域,或通过 MPU 配置对应内存区域为 non-cacheable。CubeMX 在有些系列上会自动生成 MPU 初始化代码,但 H5 工程里可能不会默认配置,需要手动确认。
提示:调大描述符数量是"加深水池",能有效缓解启动瞬间的洪峰,但它不能根治"入水速度长期大于排水速度"的问题。如果应用层持续以远超 DMA 发送能力的速度塞包,再大的描述符池也会被填满。所以这一步要配合发送逻辑的修复一起做。
4.2 根治发送逻辑:检查返回值 + 等待发送完成
严格的嵌入式开发习惯是:任何硬件外设的调用都要观察返回值,不能假设一定成功。HAL_ETH_TransmitFrame返回HAL_BUSY时,数据实际上没有进入发送队列,如果应用层忽略它,丢包就是必然。
我在裸机收发场景下常用的修复方式是发送前检查描述符状态,如果忙就主动等待 DMA 释放描述符。简单写法是轮询发送完成标志:
void eth_send_packet(uint8_t *data, uint16_t len) { uint32_t tick_start = HAL_GetTick(); uint32_t timeout = 100U; /* 100ms 超时保护 */ while (HAL_ETH_TransmitFrame(&heth, len) == HAL_BUSY) { if (HAL_GetTick() - tick_start > timeout) { /* 超时,丢弃该帧并记录错误计数 */ eth_tx_timeout_cnt++; return; } } }这里我额外加了超时保护。如果不加,一旦描述符长时间不释放,这个函数会卡死在 while 循环里,影响整个主循环的实时性。超时时间建议取 100ms 左右,既不会等太久,也不会因为典型 PHY 协商时序而提前放弃。
更优雅的做法是用 DMA 的发送完成中断配合二值信号量。在中断回调里清除ETH_DMA_FLAG_TX_COMPLETE并释放信号量,发送函数等待信号量,这样能将 CPU 从轮询中解放出来。不过对于大多数嵌入式 UDP 应用,轮询方式已经足够,而且代码更简单,不容易出状态机问题。
如果是 lwIP 场景,底层发送函数在ethernetif.c的low_level_output中,返回ERR_OK或ERR_IF。遇到HAL_BUSY时可以返回ERR_IF,lwIP 会重试或丢弃该包,但重试机制依赖 ARP 队列,不算完全可靠。我的建议是在low_level_output内部加一个有限次数的重试循环,同时把全局NETIF_INTERFACE_UP与 PHY Link 状态绑定,确保链路未就绪时不上报NETIF_FLAG_LINK_UP,这样协议栈层面就不会着急发包。
4.3 启动就绪门控:先确认 Link Up,再开放 UDP 服务
调大描述符和检查返回值解决了"发送通道被占满"的直接问题,但要彻底避开启动窗口,还需要从时序上做文章。核心原则是:以太网协议栈的收发功能应该以 PHY Link Up 为前提,不应该在初始化完成后立即全速运行。
HAL 库中有现成的 PHY 状态查询接口,通过 MDIO 读取 PHY 寄存器可以判断链路状态。LAN8742A 的寄存器 1(BSR)的 bit2 是 Link Status 位:
uint32_t phy_value = 0; HAL_ETH_ReadPHYRegister(&heth, PHY_BSR, &phy_value); if ((phy_value & PHY_LINKED_STATUS) != 0) { /* PHY Link Up,可以开始收发 */ udp_server_start(); } else { /* Link Down,继续等待 */ }在 lwIP 场景下,link 状态变化要同步到 netif 层。可以在ethernetif.c中周期调用 PHY 状态读取函数,更新netif->flags中的NETIF_FLAG_LINK_UP。只有NETIF_FLAG_LINK_UP置位后,lwIP 才会上报网络可用的状态,应用层此时再启动 UDP 服务或开启主动上报,能最大程度避开启动盲区。
我还会在应用层加一个启动冷却期。设备上电后,即使 Link Up,也先等待 500ms 到 1s,让上位机可能存在的开机广播和 DHCP 流程先走完。在冷却期内收到的 UDP 报文只解析不回包,或者只做状态记录,冷却期结束后再正常响应。这种"摇头策略"在工业现场很实用,能显著减少启动阶段偶发丢包带来的误报。
4.4 针对连续上报场景:应用层发送限速
如果你的设备一上电就要向后台上报几十个状态量,光靠描述符池和等待 Link Up 还不够。我处理过另一个类似项目,设备启动后要发送 50 个左右的日志帧,即使描述符调到 32 个,因为应用层在主循环里一口气调用发送函数,还是偶尔出现丢帧。
这个场景的根本解法是应用层限速。在发送函数外部包装一层队列,每次从队列取出一帧发送,发送完成后间隔 5ms 或 10ms 再发下一帧。这样平均发送速率被限制在每秒 100~200 帧,DMA 完全可以消化,不会产生洪峰。
void app_tx_task(void) { while (1) { if (app_tx_queue_count > 0 && eth_send_packet(data, len) == HAL_OK) { app_tx_queue_count--; } osDelay(5); } }这个思路的本质是让"应用层产生数据的速率"和"底层 DMA 的发送速率"匹配,而不是让两者在缓冲区里硬碰硬。启动阶段的突发数据量是有限的,只要把突发铺开到几百毫秒的时间窗口,描述符池的压力就会小很多。
5. 实测结果与后续延展:启动丢包还有哪些隐性原因
5.1 修复前后的实测数据对比
完成上述修复后,我在同一套板卡上做了对比测试。测试方法:PC 端通过网络调试助手在设备上电后立即发送 10 个 UDP 报文,记录设备回包数量,连续测 50 轮。
| 测试条件 | 描述符数量 | 发送逻辑 | 平均回包数 | 丢包率(按50轮统计) |
|---|---|---|---|---|
| 修复前 | 8 | 忽略 HAL_BUSY | 6.2 | 38% |
| 只加大描述符 | 24 | 忽略 HAL_BUSY | 8.4 | 16% |
| 描述符 + 检查返回值 | 24 | 轮询等待 | 9.9 | 1% |
| 完整修复(含Link门控) | 24 | 轮询等待 + Link Up判断 | 10.0 | 0% |
数据说明,单纯加大描述符能改善但不彻底,结合发送逻辑检查和启动门控后,启动阶段丢包基本消失。我还用iperf3的 UDP 模式做了长时间稳定性验证。在设备完全启动后运行iperf3 -u -b 20M -t 30打流,观察丢包率稳定在非常低的水平。这里给个小提示:iperf3的 UDP 模式可以手动指定带宽,-b 20M表示 20Mbps,如果指定过大,丢包率会因为上层带宽超过 100Mbps 而飙升,这是正常的测试限流,不代表以太网有问题。
5.2 容易被误判为"TX Buffer 满"的其他启动丢包原因
在排查这个问题的过程中,我顺带整理了几类症状相似但根因完全不同的情况,列出来供参考:
- RX 描述符不足导致的收包侧丢包:现象表现为设备收不到上位机的 UDP 报文,而且多发生在连续大量广播包的场景。检查
ETH_RX_DESC_CNT数量,以及接收中断回调里是否及时处理了接收数据。如果接收回调处理得太慢,RX 描述符也会被占满,新的数据包在 DMA 入口就被丢弃。 - D-Cache 一致性问题:如果启用了 D-Cache 但没有配置 non-cacheable 区域的缓冲区,DMA 发送时可能读到 Cache 中的旧数据,回包内容错乱甚至被 PHY 丢弃。现象是回包概率性丢失,且加大描述符数量后没有改善。检查 MPU 配置,确保以太网 DMA 描述符和缓冲区全部在 non-cacheable 区域。
- PHY 复位时序不足:PHY 芯片的复位低电平时间需要满足数据手册要求,LAN8742A 要求至少 25 微秒。如果复位电路或代码把低电平时间压得太短,PHY 可能未完全初始化,Link Up 状态异常,发送链路自然不稳定。
- MCO 时钟未稳定:如果以太网参考时钟由 MCU 的 MCO 引脚提供,MCO 输出的时钟稳定性会直接影响 PHY 和 MAC 的工作。启动时如果 PHY 初始化立刻依赖时钟,可能出现低速率的包收发异常。
- 应用层回调处理过于耗时:如果 UDP 接收回调中执行了 Flash 写入、文件系统操作或大的打印输出,回调执行时间会超过上位机发送间隔,导致 lwIP 的
sys_check_timeouts无法及时运行,表现为启动阶段丢包。这种场景下把回调里的耗时操作移入后台任务,效果立竿见影。
5.3 我的排查顺序建议
如果你也碰到类似"启动阶段 UDP 丢包"的问题,我建议按下面的顺序排查,避免一上来就陷入协议栈源码:
- 用 GPIO 翻转或串口打印确认丢包发生在接收侧还是发送侧。这一步决定了后续排查方向。
- 检查
HAL_ETH_TransmitFrame的返回值。如果出现HAL_BUSY,说明 TX 描述符池被占满,进入第 3 步。 - 计算启动阶段有几个流量源:PC 广播包、DHCP、ARP 队列缓存、应用主动上报,全部加起来估算瞬时数据量。
- 加大
ETH_TX_DESC_CNT,同时检查缓冲区对齐和 Cache 配置。 - 在发送函数外部加发送完成等待和超时保护,确保
HAL_BUSY时不会静默丢包。 - 最后做 Link Up 门控,把 UDP 服务的启动时机拖到 PHY 协商完成之后。
其中第 1 步被很多人忽略,但它是方向性问题。方向错了,后面所有工作都可能白费。我刚开始在这个项目上就差点一头扎进 lwIP 的 ARP 队列源码里,后来靠 GPIO 翻转的数据才及时拉回到 HAL 层。
最后分享一个工具层面的小建议:Windows 环境下抓包用 Wireshark 加 Npcap 驱动就够,但抓包时要注意把网卡的"巨型帧"和"校验和卸载"功能关闭,否则某些网卡会修改数据包的校验和信息,造成抓包内容和实际线上内容不一致,干扰判断。排查这类问题需要把"线上实际情况"和"设备内部状态"两边的数据对照起来,缺任何一边都容易得出错误结论。