十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

STM32启动阶段UDP丢包根因:DMA描述符池与HAL_BUSY

STM32启动阶段UDP丢包根因:DMA描述符池与HAL_BUSY 设备上电后上位机在第一时间发来的UDP报文经常石沉大海这个典型问题在我调试一块基于 STM32H563 的以太网控制板时被完整复现过。开始时怀疑 PHY 复位时序怀疑 lwIP 配置排查到后面才发现真正原因是 HAL 库的 TX BufferDMA 发送描述符池在启动瞬间被占满HAL_ETH_TransmitFrame返回HAL_BUSY应用层没处理返回值回包就被静默丢弃了。本文把整套定位过程、根因机制和修复方案拆开讲清楚。无论你用的是 HAL 裸机收发还是 lwIP 协议栈只要设备在启动阶段存在外部快速发包或自身连续上报多帧的场景这个案例都能直接参考。1. 复现现场启动后的前几百毫秒UDP 回包概率性失踪1.1 我的测试环境和异常表现硬件平台是一块自研的 STM32H563 控制板以太网接口用 RMII 连接外置 PHY 芯片 LAN8742AMCU 通过 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我在三个位置各放一个翻转标记位置 AlwIP 的 UDP 接收回调函数入口进入即翻转位置 BHAL_ETH_TransmitFrame调用之前翻转一次位置 CHAL_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 的状态它只管把数据交给 DMADMA 又会把数据写给 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 UpARP 缓存也已包含 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_IFlwIP 会重试或丢弃该包但重试机制依赖 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 的寄存器 1BSR的 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_BUSY6.238%只加大描述符24忽略 HAL_BUSY8.416%描述符 检查返回值24轮询等待9.91%完整修复含Link门控24轮询等待 Link Up判断10.00%数据说明单纯加大描述符能改善但不彻底结合发送逻辑检查和启动门控后启动阶段丢包基本消失。我还用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 驱动就够但抓包时要注意把网卡的巨型帧和校验和卸载功能关闭否则某些网卡会修改数据包的校验和信息造成抓包内容和实际线上内容不一致干扰判断。排查这类问题需要把线上实际情况和设备内部状态两边的数据对照起来缺任何一边都容易得出错误结论。
返回列表