STM32H563以太网启动UDP丢包:从PHY时序到D-Cache的排查与解决

STM32H563以太网启动UDP丢包:从PHY时序到D-Cache的排查与解决 做网络通信的嵌入式开发最烦的事情之一就是启动阶段丢包。最近在STM32H563上调试Ethernet UDP传输时我遇到一个很典型的问题系统刚上电板子通过UDP向PC端发送数据PC端前几十个包基本收不到打开调试日志一看TX Buffer早就被占满了发送函数一直在报错。这个问题虽然看起来像DMA缓冲不足但顺着调用链查下去真正的原因其实藏在MAC和PHY的启动时间差里。如果你也在用STM32H5系列做Ethernet通信或者碰到“启动后立刻发UDP必丢前几包”的现象这篇文章应该能帮上忙。1. 问题还原启动即发包前几十包全丢1.1 我遇到的现场我用的是一块基于STM32H563的自制板PHY是RMII接口的LAN8720A软件上基于CubeMX生成工程跑FreeRTOS lwIP网络接口只做UDP收发。板子上电后主任务立刻初始化网络并向上位机发送一帧心跳数据发送周期是100ms。刚开始调试的时候上位机软件上只能看到大约上电3秒之后的数据前三十几包全部丢失。日志很快暴露了问题[NET] udp_sendto return err: -5 [NET] tx timeout, descriptor busy [NET] eth_tx_buffers_full, drop pkt这三条日志指向同一个方向底层驱动拿不到可用的TX描述符发送链路被堵死。我把UDP发送改成阻塞等待结果更明显——发送函数卡在状态检查里直到某个时刻网络恢复卡住的包又突然一口气全部发出去上位机收到一坨“僵尸数据”。如果你也见过类似现象我建议先不要怀疑ETH DMA本身大多数情况下问题出在“以太网链路还没准备好应用层就开始发包”。1.2 顺着调用链看数据卡在哪里从应用层往下捋整个链路是应用调用udp_sendto - lwIP协议栈封装UDP/IP头 - etharp处理 - netif-linkoutputlow_level_output- HAL_ETH_Transmit_IT - ETH DMA写描述符 - MII/RMII发到PHY - 物理链路传输。启动阶段最容易出问题的地方在最后两段。STM32H563的ETH DMA用的是描述符环形队列发送之前软件要拿一个“空闲描述符”把数据地址和长度写进去再置OWN位交给DMA。DMA发送完成后会清掉OWN位并触发中断。问题就出在这里当PHY还没有完成自协商、链路没有建立时帧从MAC发到MII后根本没有办法真正上线。驱动侧如果一直等不到对应的TX完成中断描述符就一直被占住。软件层层超时之后新来的数据包找不到空闲描述符只能报错丢弃。用一句话说就是“TX Buffer fills up and drops initial UDP packets on startup”。所以别急着去调大缓冲区。缓冲区只是症状链路状态才是根因。2. 根本原因不是UDP的错是MAC和PHY没同步2.1 PHY自协商的“冷启动时间差”以太网PHY上电后并不会立刻进入可用状态它要完成复位、PLL稳定、自协商等一系列操作。以LAN8720A为例上电后需要等待软复位完成然后开始和交换机或PC网卡协商速率和双工模式。这个过程通常需要1到3秒如果PC网卡响应慢可能更久。而MAC和DMA初始化是毫秒级完成的。CubeMX生成代码后初始化顺序通常是复位ETH MAC和DMA配置MII/RMII引脚复用初始化MAC地址和过滤器配置TX/RX描述符使能DMA。这套流程跑完还在毫秒级远小于PHY的自协商时间。应用层几乎在MAC初始化一结束就开始发包于是前1~3秒内所有数据都撞在“链路未建立”这个窗口上。这就是为什么问题只出现在启动阶段稳定运行后完全正常。这类问题的典型特征是延时1~3秒后再发包问题消失或者把PHY的自协商改成强制100M/全双工偶尔能缓解但不彻底。2.2 TX描述符耗尽后的连锁反应硬件本身有多个TX描述符。CubeMX默认配置一般是4个需要的话可以在stm32h5xx_hal_conf.h里面改成8个或16个。如果网络正常4个描述符完全够用。但在启动阶段所有提交给DMA的帧都无法完成发送驱动等了超时后又把数据丢掉但没有主动回收描述符或者回收逻辑只在中断里执行中断一直不触发于是队列越堵越死。更麻烦的是很多基于HAL库的工程把发送失败当作“内存不足”处理。lwIP拿到ERR_IF之后会释放PBUF但不会做丢包统计导致你只能从应用层返回值倒推原因。实际排查时建议先看自己的驱动里有没有“发送超时后强制回收描述符”的逻辑如果没有这本身就是一个隐患。2.3 容易被遗漏的D-Cache一致性问题STM32H563的CPU是Cortex-M33带D-Cache。这对Ethernet这种DMA驱动的外设来说是一个隐藏雷区。DMA读取的缓冲区如果之前被CPU写入过而D-Cache没有及时刷回内存那DMA很可能读到的是旧数据反过来DMA往内存写完数据如果CPU继续用Cache里的旧数据也会出问题。在TX路径上标准操作是发送前调用SCB_CleanDCache_by_Addr确保CPU写入的数据刷到SRAM再让DMA读取发送完成后如果还要复读缓冲区则需要Invalidate。CubeMX生成的lwIP驱动模板不一定把这一步加到low_level_output里特别是从老工程迁移过来时很容易漏掉。这个坑不一定只在启动阶段出现但会和“启动丢包”叠加在一起让人误判成单纯的发送失败。排查时可以临时关闭D-Cache如果问题减轻就基本可以确定是Cache一致性导致。3. 实操方案从业务层到底层把缺口补上3.1 第一步启动阶段先等等PHY最直接、也是最推荐的做法在初始化完MAC/DMA之后通过MDIO读取PHY状态寄存器等Link Status置1再返回。如果你用CubeMX生成的标准初始化流程函数一般是MX_ETH_Init。我在MX_ETH_Init之后加了一个简单的等待函数static uint8_t Eth_WaitLinkUp(uint32_t timeout_ms) { uint32_t tick HAL_GetTick(); uint32_t reg_val 0; while ((HAL_GetTick() - tick) timeout_ms) { if (HAL_ETH_ReadPHYRegister(heth, PHY_BSR, reg_val) HAL_OK) { if (reg_val PHY_LINKED_STATUS) { return 1; } } HAL_Delay(10); } return 0; }这里有两个细节要注意。一是PHY_BSR和PHY_LINKED_STATUS这两个宏不同PHY定义不同。LAN8720A的状态寄存器地址是0x1FLink Status在第2位DP83848的规范状态寄存器是0x11所以务必先确认你的PHY型号。二是有些PHY在上电后第一次读寄存器可能失败所以读取失败不要立刻放弃继续读几次。拿到返回值后如果超时至少给应用层一个明确的状态不要闷头发包。3.2 第二步发送前检查netif link状态等PHY Link起来之后再初始化lwIP能解决大部分启动丢包问题。但更稳妥的做法是在应用发送逻辑里加一道保险发送前先确认netif的link状态。struct netif *netif g_netif; if (!netif_is_link_up(netif)) { // 网络还没准备好先缓存或跳过本帧 return ERR_IF; }在lwIP中netif的link状态需要通过netif_set_link_up或netif_set_link_down来更新。建议在单独的PHY状态轮询任务里定期检查PHY寄存器发现Link状态变化时调用对应函数。这样应用层只需要读netif状态不用关心PHY的细节。顺带说一句千万别在主任务里阻塞等Link Up否则你连日志都看不到。独立弄一个“网络状态任务”或放到低优先级后台任务里是比较好的习惯。3.3 第三步给发送加超时和重试就算前面都做了实际系统中还是有各种偶发情况PHY链路刚起来但MAC底层状态机还没恢复或者某个瞬间PHY报了一次远端错误导致一个发送事件异常。这时候如果发送函数从头到尾只做一次尝试UDP包依旧会丢。我的做法是给发送包装一个带重试的接口int net_udp_send(uint8_t *data, uint16_t len) { uint32_t tick xTaskGetTickCount(); err_t err; do { err udp_sendto(pcb, pbuf, remote_ip, remote_port); if (err ERR_OK) { return 0; } vTaskDelay(pdMS_TO_TICKS(2)); } while ((xTaskGetTickCount() - tick) pdMS_TO_TICKS(50)); return -1; }UDP本身不保证可靠所以重试次数不用太多50ms超时已经足够了。核心目的是让应用层在“链路刚恢复”的瞬间有机会成功而不是撞上一次ERR_IF就放弃。3.4 第四步对症调优TX描述符与缓冲区如果你把PHY等待加上了、netif状态也检查了启动丢包依旧存在这时再考虑调整底层参数。TX描述符数量从默认的4改成8能明显降低瞬时突发时的占用率但要注意内存占用。在CubeMX生成的工程里ETH_TX_DESC_CNT定义在stm32h5xx_hal_conf.h中#define ETH_TX_DESC_CNT 8每个描述符对应一个发送缓冲区缓冲区大小由ETH_TX_BUFFER_SIZE定义。你可以把这两个参数和LWIP_MEM_SIZE搭配调整。但我不建议无脑调大因为描述符多了内存占用线性增长而启动阶段的问题本质是链路没有建立不是缓冲区不够调大只治标不治本。另外如果跑的是lwIP还可以把PBUF_POOL_SIZE适当调大让协议栈在突发情况下有更多PBUF可用但这个值改成多少取决于你的RAM余量。我一般先不动等确定问题方向再试。4. 排查实录与常见雷区4.1 现象速查表下面是我这次调试过程中总结的几个常见现象和对应排查方向现象可能原因优先排查开机后前1-3秒发包全丢PHY自协商未完成等待Link Up后再发包日志显示描述符busyTX描述符被未完成发送占满检查TX完成中断是否触发开启D-Cache后偶发错数据Cache与DMA不一致发送前Clean接收后InvalidateFreeRTOS下偶发卡死ETH中断优先级过高中断优先级设置为低于临界区PC能收到包但内容错乱Cache一致性或缓冲区复用检查缓冲区是否有重复使用这张表不一定覆盖所有场景但基本能帮你快速定位方向。4.2 用日志和PHY寄存器确认状态排查时不要光看应用层返回值直接读PHY寄存器最能说明问题。我的调试顺序是把PHY的Link状态寄存器打在日志里确认上电后多久Link置位计算应用层第一次发包的时间点对比两个时间点如果发包时间早于Link置位时间基本坐实“链路未就绪”关掉D-Cache或补上Cache维护后再跑一次排除Cache干扰最后调大TX描述符测试确认是否为瞬时突发问题。在HAL库环境下读PHY寄存器很简单uint32_t phy_reg 0; HAL_ETH_ReadPHYRegister(heth, 0x1F, phy_reg); printf(PHY status: 0x%08lx\r\n, phy_reg);如果你用RTOS打印日志时需要加互斥或放到专门日志任务避免阻塞网络任务。4.3 FreeRTOS环境下的优先级坑这个问题在启动阶段很容易被掩盖。FreeRTOS里ETH DMA中断的优先级需要设置得比“进入临界区时屏蔽的优先级上限”更低也就是数字更大。如果优先级过高中断可能在临界区内触发导致死锁或丢失中断。用CubeMX生成工程时HAL初始化ETH中断时默认优先级是0最高这并不总是安全。我建议把ETH中断优先级配置成5数值上FreeRTOS里数字越小优先级越高具体取决于configMAX_SYSCALL_INTERRUPT_PRIORITY。同时检查是否调用了HAL_ETH_IRQHandler如果没有DMA发送完成中断永远不会被处理TX描述符也就永远不会释放。4.4 一个被忽略的PHY复位时序问题再说一个我踩过的坑PHY的复位引脚如果由MCU的GPIO控制那么GPIO拉低/拉高的时间必须满足PHY手册要求。LAN8720A要求复位低电平时间大于1ms然后等待内部上电就绪。很多人用RC复位电路或直接把PHY的NRST接到MCU的复位看起来能跑但复位不完全会导致PHY初始化异常。排查方法很简单上电后先读PHY的ID寄存器确认读到的是0x0007LAN8720A的ID如果读到0xFFFF说明MDIO通信异常或者PHY没正常复位。这个检查应该放在初始化最前面。5. 补一个实用的小技巧最后分享一个我在这个项目里用得很顺手的小功能在应用层维护一个“网络就绪”标志位。PHY Link Up之后网络任务把标志位置1应用发包时直接检查这个标志位。不要通过延时去硬凑也不要通过udp_sendto返回值去猜网络就绪状态就应该有一个明确的状态量。实现上我用了一个全局atomic标志volatile uint8_t net_ready 0; // 在PHY状态轮询任务里 if (link_up) { netif_set_link_up(netif); net_ready 1; } else { netif_set_link_down(netif); net_ready 0; }应用层的发送循环里如果net_ready为0就直接跳过本周期或者缓存待发数据。这个方案在STM32H563上实测很稳既不会丢掉启动阶段的业务数据可以选择缓存稍后发送也不会让应用层在未就绪时白忙活。根据我这次的调试经验STM32H563的Ethernet启动丢包十有八九不是硬件选型的问题而是“启动时序没对齐”。把PHY Link状态纳入考虑范围把netif状态当作应用层发送的门控再加上D-Cache一致性处理基本不会再被“启动丢包”折磨。