STM32以太网开机UDP丢包:TX Buffer假满与PHY链路等待方案 搞嵌入式网络开发最烦的事情之一就是“开机那几秒”——程序跑得飞快网线那头还没反应过来。之前在STM32H563上做UDP通信就栽在开机瞬间发UDP包这件事上TX Buffer莫名其妙被占满头几十个包全部丢掉等链路稳定之后一切又恢复正常。这个坑非常典型很多做Ethernet通信的工程师都遇到过但网上很少有文章把机制讲透。这篇文章从现象、根因、复现到三种解决方案完整梳理一遍希望能帮你少走弯路。1. 开机UDP丢包现象背后的TX Buffer假满机制1.1 一个典型的启动时序先描述一下我当时遇到的场景。设备上电后主程序初始化HAL、配置时钟、初始化Ethernet MAC和PHY紧接着就通过lwIP向主机发送UDP状态包。理论上这个流程天经地义——初始化都做完了发数据有什么问题实测结果很打脸设备刚上电的头500ms内发出的UDP包主机端一个都收不到。等系统稳定下来再发完全正常。更诡异的是有时候首次发送后连后面的数据也发不出去要等一两秒才恢复。用调试器看内部状态就看到了本文标题说的现象TX Buffer被填满了。这不是个别现象。做工业设备、物联网网关、数据采集节点的朋友只要涉及“开机上报”这个需求大概率都会踩到。尤其是那些依赖UDP广播做设备发现的场景——设备希望能被发现但开机瞬间的广播包恰恰最容易丢。1.2 TX描述符环是怎么被占满的要理解这个问题得先搞清楚STM32H563的以太网DMA发送机制。它用的是描述符环Descriptor Ring发送方向通常配置4个TX描述符ETH_TX_DESC_CNT每一次调用HAL_ETH_Transmit会抢占一个空闲描述符DMA负责把描述符指向的内存数据搬运到MAC的TX FIFO帧发送完成后再释放描述符。关键点来了如果MAC的TX FIFO塞满了帧DMA会暂停搬运所有TX描述符都会处于“被占用”状态。这时候应用层继续调用HAL_ETH_Transmit底层拿不到空闲描述符只能返回HAL_BUSY。你从应用层看到的“TX Buffer满了”本质上是描述符环被占满而不是内存不够。把这个机制代入启动场景问题就串起来了PHY还没有完成自协商物理链路未建立应用层已经开始调用HAL_ETH_TransmitDMA把帧搬进了MAC TX FIFO帧在PHY侧送不出去FIFO永远排不空DMA被卡住4个TX描述符全部被占满后续UDP包全部发送失败应用层要么丢弃要么重试也无效。换句话说丢包不是随机的而是“启动竞态”导致的必然结果。只要发送动作发生在链路建立之前TX缓冲区的假满就不可避免。2. 根因定位PHY链路状态与DMA发送的竞态2.1 PHY自协商到底要多久很多人对PHY的启动时间没有概念。实际上一颗PHY从复位到链路建立需要经历三个阶段上电稳定PHY的电源、时钟、复位释放后内部PLL和模拟前端需要时间稳定典型值10ms以上复位完成部分PHY比如板载的LAN8742A需要读取寄存器确认复位完成或者等待一个固定延时自协商PHY与对端交换机协商速率、双工模式这一步最耗时通常需要300ms到2秒取决于对端设备。也就是说从MCU的角度看Ethernet初始化函数执行完只代表MAC和DMA配置好了物理链路很可能还没就绪。如果应用层不检查链路状态就直接发送等于在高速公路上还没通车的时候就往上冲。RMII模式下还得注意一点Nucleo-H563ZI这类板子REF_CLK是由PHY输出的50MHz时钟。PHY没有起来之前这个时钟可能缺失或者不稳定MAC侧的数据收发完全不能工作。这也是为什么初始化之后再等一等比初始化时反复配置更有效。2.2 复现与实证用数据确认根因我当时做了一个很简单的测试脚本开机后立即向主机固定IP和端口发送50个UDP包每包间隔1msWireshark在主机端抓包。结果非常直观前30多个包丢失从某个包开始突然恢复之后一直正常。为了确认是PHY链路问题而不是lwIP问题我做了两步验证第一步在发送循环前加一个轮询PHY链路状态的逻辑等到Link Up之后再发。结果50个包全部到达一个不丢。第二步在发送过程中实时读取PHY的基本状态寄存器确认发送失败期间链路状态位确实为0。两个证据一拼根因锁定发送动作先于链路建立。所以结论很明确不要在PHY链路建立之前调用HAL_ETH_Transmit。这是问题的核心所有解决方案都是围绕这个原则展开的。3. 必杀技启动阶段的发送保护策略3.1 方案一启动时等待Link Up最直接、最稳妥的方案就是在初始化完MAC和PHY之后加一个“等待链路建立”的轮询循环。虽然简单但效果立竿见影。核心代码如下#define PHY_BSR_REG 0x01U #define PHY_LINK_STATUS 0x04U /* 等待PHY链路建立带超时保护 */ uint8_t ETH_WaitLinkUp(uint32_t timeout_ms) { uint32_t tickstart HAL_GetTick(); uint32_t phy_status 0; do { if (HAL_ETH_ReadPHYRegister(heth, PHY_BSR_REG, phy_status) ! HAL_OK) { return 0; } if (phy_status PHY_LINK_STATUS) { return 1; } HAL_Delay(10); } while ((HAL_GetTick() - tickstart) timeout_ms); return 0; }调用方式MX_ETH_Init(); if (ETH_WaitLinkUp(3000)) { /* 链路已就绪可以开始发送 */ UDPSendStartupMessage(); } else { /* 超时可能是网线没插或者对端设备没启动 */ Error_Handler(); }这里有个设计细节值得讨论轮询周期选多少我当时用10ms。太短会增加MDIO总线压力太长会拖慢启动。10ms对绝大多数PHY来说足够温和同时也不会让上层等待太久。另外一定要加超时否则网线没插的时候设备会卡在启动阶段这在产品化阶段是不可接受的。如果你做的是对启动时间敏感的产品不想让主流程干等可以改用PHY的中断引脚。很多PHY有nINT引脚链路状态变化时会触发中断。把这个引脚接到MCU的EXTI在中断回调里设置一个“链路已建立”标志位。这样主流程可以继续做其他初始化发送任务看到标志位再开始发包兼顾了响应速度和启动效率。3.2 方案二用TX描述符余量做流控有些场景下产品启动时需要立即发出数据哪怕链路还没建立也要先把数据准备好。这时可以在每次发送前检查TX描述符的剩余数量做到“有余量就发没余量就缓存”而不是盲目调用HAL_ETH_Transmit。uint32_t tx_free 0; HAL_StatusTypeDef status; /* 查询当前空闲的TX描述符数量 */ if (HAL_ETH_GetTxDataFreeEntry(heth, tx_free) ! HAL_OK) { return ETH_BUSY; } if (tx_free 0) { status HAL_ETH_Transmit(heth, tx_buffer, 1, 100); if (status ! HAL_OK) { /* 发送队列满或超时记录丢包 */ tx_drop_cnt; } } else { /* 全部描述符都在忙缓存到应用层队列 */ EnqueueTxPacket(boot_queue, udp_packet, len); }这个方案的核心价值是把“发送失败”从底层处理提升到了应用层可感知的层面。HAL_ETH_Transmit返回HAL_BUSY时你至少知道此刻硬件缓冲处于饱和状态而不是稀里糊涂地丢包。如果你用的是lwIP也有对应做法在调用udp_sendto之前先通过netif_is_link_up(netif)检查链路状态。lwIP的网卡结构体里维护了链路标志只是很多裸机移植里这个标志没有被及时刷新。我的习惯是写一个任务每100ms读一次PHY寄存器更新netif-flags的NETIF_FLAG_LINK_UP位。这样应用层拿到的链路状态永远是实时的。3.3 方案三应用层补发与握手机制前两个方案解决的是“链路未建立导致发不出去”的问题但工业生产里还有一类更隐蔽的场景链路已经建立了可对端应用程序还没准备好或者对端在重启。这种情况下MAC层发送成功协议栈返回成功但包到了对端却被丢弃。这类问题只能靠应用层协议来兜底。我常用的做法是“启动握手 补发”机制设备开机后周期性发送UDP启动通告包间隔从100ms递增到1s最多发送10次主机收到通告包后回复一个ACK设备收到ACK后停止通告进入正常工作模式如果设备发完10次都没收到ACK进入降级模式——继续发但降低频率同时错误计数上报。这段逻辑不复杂但对工程实用性提升巨大。尤其在设备发现、固件升级、配置下发这类场景中握手机制能消除“开机竞态”造成的所有不确定性。你要知道链路层解决了不代表应用层就绪了分布式系统里永远要对“对方不在”做好准备。一个细节补发时的内容最好带序号和时间戳。接收端可以根据序号判断是否有丢包根据时间戳判断数据是否过期。比如设备刚开机时上报的是“系统事件”等主机恢复后收到一堆过期数据反而会干扰逻辑判断。4. 踩坑实录H563在Ethernet上的特殊注意事项4.1 PHY寄存器读取中的坑读PHY寄存器是排查链路问题的基本手段但这里有几个坑值得单独说。第一部分PHY的状态寄存器BSR链路状态位是锁存型的。也就是说链路曾经断开过即使现在已经恢复状态位仍然保持断开的状态必须连续读两次才能拿到实时值。LAN8742A的行为不太一样但如果你换了瑞昱、裕太微等其他厂家的PHY务必确认这一行为。稳妥做法是连续读两次以第二次为准。uint32_t reg1 0, reg2 0; HAL_ETH_ReadPHYRegister(heth, PHY_BSR_REG, reg1); HAL_ETH_ReadPHYRegister(heth, PHY_BSR_REG, reg2); if (reg2 PHY_LINK_STATUS) { /* 链路已建立 */ }第二MDIO的读写速度。HAL_ETH_ReadPHYRegister执行一次大约需要几十微秒到上百微秒取决于MDC时钟频率。如果你的代码在中断里调用它要注意影响中断响应时间。另外MDIO事务期间不要关闭Ethernet时钟否则直接卡死。第三很多PHY有“掉电模式”。如果某些板子在进入低功耗时把PHY的电源关了重新上电后需要重新初始化。别以为初始化代码跑了一遍就万事大吉低功耗唤醒后的PHY状态检查必不可少。4.2 时钟配置与缓存一致性STM32H563的Ethernet时钟是从PLL出来的具体RCC配置各板子略有不同。Nucleo-H563ZI上ETH用的是RMII模式需要确保RMII的50MHz参考时钟正确。这个时钟如果不对MAC根本跑不起来丢包现象会和本文讨论的完全不一样——不是“启动丢包”而是“永远发不出去”。排查时先确认时钟源再查PHY。另外H563是Cortex-M33内核带D-Cache。如果你把UDP发送缓冲区放在可缓存的RAM区域比如AXI SRAMDMA读数据时可能读到Cache里的旧数据导致发出的包内容错误。发送前务必做一次Cache CleanSCB_CleanDCache_by_Addr((uint32_t *)((uint32_t)tx_buffer_addr ~0x1F), ((len 31) ~31));接收方向同理读完数据后做Cache Invalidate。很多工程师排查半天找不到问题其实只是忘了Cache操作。这块在H7系列上非常常见H5系列一样不能忽略。4.3 调试工具与抓包姿势定位这类问题时Wireshark是不可替代的工具。但抓包也有技巧主机网卡和开发板直连时抓包过滤条件写成udp避免无关广播包干扰对比“开机后立即抓”和“稳定后抓”两份记录确认丢包只发生在启动窗口期如果想验证吞吐量用iperf3配合UDP模式打流可以精准定位是发送端瓶颈还是接收端瓶颈。命令很简单iperf3 -u -s跑服务端iperf3 -u -c 目标IP -b 10M -t 10跑客户端开发板的PHY寄存器状态用调试器在MDIO读函数上打条件断点观察链路状态变化时刻如果怀疑是DMA描述符问题直接查看描述符结构体字段重点看TDES0的OWN位。OWN1表示描述符还属于DMAOWN0表示软件可以操作。这个位的分布情况可以直观反映TX环的占用率。4.4 千万别忽略的PHY复位时序还有一个细节特别容易翻车PHY的复位时序。很多板子的PHY复位引脚由MCU的GPIO控制正确的操作顺序是复位引脚拉低保持至少10ms拉高复位引脚等待至少10ms让PHY完成内部上电初始化再执行MAC初始化。如果你把PHY复位和MAC初始化做成了并行操作或者复位释放后立即操作MDIOPHY可能还没准备好读回来的寄存器全是0xFFFF链路状态自然是错误的。我当时就遇到过这个情况调试时读PHY ID寄存器发现读出来是0xFFFFFFFF查了半天才意识到是复位后等待时间不够。这个等待有个更严谨的做法读取PHY ID寄存器寄存器2和3确认返回值不是0xFFFF也不是全0再继续后续流程。这样比固定延时更可靠PHY没就绪不会进入后续逻辑。4.5 常见问题速查表现象可能原因排查方法启动时TX Buffer满之后恢复PHY链路未建立DMA被FIFO堵死等待Link Up后再发送TX描述符一直占用不释放DMA未完成或Cache未Clean导致数据异常检查OWN位补Cache操作PHY寄存器读回0xFFFFPHY未复位完成或MDIO时序不对拉长复位等待检查MDC时钟链路已建立但包总是丢对端应用未就绪或发送过快加应用层握手和重传一线直连正常过交换机丢包交换机端口协商速度慢检查交换机端口配置或固定速率实操总结与个人体会把这个问题解决完之后我最大的感触是嵌入式网络调试百分之六十的精力都花在“时序”上。硬件上电时序、PHY复位时序、链路协商时序、应用层发送时序任何一个环节错位表现都是丢包或缓冲溢出。做这类功能时我的习惯是先把“链路建立”作为发送的硬前置条件再叠加一个计数器和超时机制双保险。最后再分享一个小技巧在启动上报代码里加一个调试变量记录每一次HAL_ETH_Transmit的返回值和当时的TX描述符空闲数。这个变量平时不影响运行出问题时用调试器看一眼几秒钟就能定位是底层缓冲满还是应用层逻辑问题。这个习惯救了我好几次比反复加日志效率高得多。