1. 问题现象:明明链路正常,可设备就是“失联”了
做嵌入式网络开发的朋友应该都遇到过这种让人抓狂的场景:STM32H750板子刚上电,ping 192.168.1.100 一切正常,ARP也能正确解析,数据收发顺畅得很。结果跑了十几分钟、几个小时之后,突然ping不通了,ARP请求也没响应,像是设备直接“死”在了网络上。可是串口打印还在跑,RTOS任务还在调度,LED还在闪,整块板子分明活得好好的——就是网络层面完全失联。
这种“半死不死”的状态最坑人,因为它不像硬件故障那么明显,也不像代码崩溃那么直接。你接上调试器一看,程序明明在跑,以太网外设的中断还在触发,可就是回不了包。拔网线重插,通了;按一下复位键,又通了;然后过一段时间,再次失联。
我最早碰到这个问题是在一个数据采集项目上,设备需要在现场连续运行几个月,网络不能断。当时排查了一个多星期,试过换PHY芯片、换变压器、换网线、换交换机端口,问题依旧。后来静下心把STM32H75x的参考手册翻了几遍,才真正搞明白这个芯片在以太网设计上的一些暗坑。
这篇文章就把我排查这类问题的完整思路、根因分析和解决方案整理出来。方向基于STM32H750自带MAC加外部PHY的典型方案,但很多排查思路在STM32F4、H7系列其他芯片上同样适用。如果你也遇到“网络跑着跑着就失联”的诡异问题,这篇文章应该能帮你省下至少一个星期的排查时间。
2. 先搞清楚硬件架构:为什么H750的以太网这么容易出问题
2.1 STM32H750的MAC控制器和外部PHY的分工
STM32H750内置了10/100M以太网MAC控制器,但物理层收发器PHY在芯片外面,绝大多数据项目用的都是RMII接口加一颗外部PHY芯片,像LAN8720A、DP83848、KSZ8081这类。
MAC和PHY之间走RMII接口,时钟50MHz,数据线2根,加上CRS_DV和TX_EN,总共也就7根线。因为引脚少、布线简单,RMII几乎是STM32以太网项目的默认选择。
但RMII模式有一个非常关键的细节:50MHz的REF_CLK时钟源有两种接法:
- 一种是外部PHY提供50MHz时钟给STM32的ETH_CLK引脚;
- 另一种是STM32的MCO引脚输出50MHz时钟给PHY。
两种方案都在用,但时钟源不同,对软件配置的影响也不同。如果你用的是MCO输出方式,还要额外注意MCO引脚本身能输出的最大频率——STM32H750的MCO引脚在输出50MHz时有没有问题,这本身就是一个隐藏的坑,后面我会专门讲。
从软件层面看,STM32的以太网MAC通过DMA和内核交互数据,描述符结构、DMA引擎、MAC配置、PHY管理这几个模块各司其职。硬件看起来不复杂,但正因为分工明确,任何一个环节的配置出了偏差,都可能表现为“工作一段时间后失联”。
2.2 一个反直觉的现象:运行越久,缓存越脏
绝大多数“运行一段时间后网络失联”的问题,根因都不在PHY上,而在STM32的DMA描述符、Cache缓存、以及MAC地址过滤这几块的配置上。
之前查资料的时候看到很多人一遇到ping不通就怀疑PHY芯片有问题,其实这种概率很低。PHY芯片工作状态一般很稳定,除非硬件设计有问题导致信号质量太差。真正容易出问题的是内核和MAC之间的数据通路——特别是你开了D-Cache之后,如果DMA描述符和收发缓冲区的内存属性没有配置对,大概率会复现“跑一会儿就失联”的经典故障。
原因也很直白:STM32H750的内核是Cortex-M7,带L1-Cache。DMA控制器在搬运数据的时候直接访问物理内存,不会经过Cache,这时候CPU和DMA看到的数据就可能不一致。如果描述符被Cache缓存了,DMA更新了描述符内容,CPU读到的却是Cache里的旧数据,就会误以为缓冲区还没有收到数据,对应的接收描述符永远不会被释放回DMA,时间一长,可用的接收描述符耗尽,网络就彻底挂掉了。
类似的,如果发送缓冲区的数据还停留在Cache里没写回内存,DMA搬运的其实是旧数据,或者干脆搬运了非法地址,也会导致发送失败,最终表现为ping不通。
2.3 排查这类问题的核心原则
在进入具体配置之前,先确立一个排查思路:网络失联类问题,先软件后硬件,先内存后PHY。
我的习惯是先看代码里对MPU、Cache、DMA描述符的配置有没有问题,因为这类问题有个共同特征是“运行一段时间后才复现”,非常符合Cache一致性被破坏的特征。硬件信号完整性问题一般从一上电就不稳定,或者是偶发性的重启恢复,而不太会像Cache问题这样规律性地“跑一段时间就挂”。
3. 排查全过程:从现象定位到根因
3.1 复现实验:稳定复现是排查的命根子
排查这种间歇性问题,最怕的是半天不复现。所以第一步就是要稳定复现这个故障。我的做法是写了一个自动化脚本来帮忙:
- 每500ms ping一次板子的IP,连续ping,记录丢包时间点;
- 板子端用串口持续打印接收中断次数、DMA描述符状态、PHY寄存器状态。
实测下来,大概跑15到30分钟就会复现一次。复现时串口打印显示:
- 接收中断还在触发,说明MAC收到了数据;
- 但应用层收不到任何包,DMA接收描述符的状态看起来也不对;
- PHY的Link状态寄存器显示链路是正常的,一直是100M全双工。
这就很有意思了。MAC收到了数据、链路正常、但数据就是上不来,基本锁定问题出在DMA描述符和内存数据一致性上。
3.2 检查DMA描述符和Buffer的内存属性
STM32H750的DMA描述符是放在内存里的,一般我们用一个全局数组来定义:
ETH_DMADescTypeDef DMARxDscrTab[ETH_RX_DESC_CNT] __attribute__((section(".RxDescripSection"))); ETH_DMADescTypeDef DMATxDscrTab[ETH_TX_DESC_CNT] __attribute__((section(".TxDescripSection"))); uint8_t Rx_Buff[ETH_RX_DESC_CNT][ETH_RX_BUF_SIZE] __attribute__((section(".RxArraySection"))); uint8_t Tx_Buff[ETH_TX_DESC_CNT][ETH_TX_BUF_SIZE] __attribute__((section(".TxArraySection")));关键在于这些内存区域的MPU属性配置。对于DMA要访问的内存,最稳妥的做法是配置成非Cache、可共享(Normal, Non-Cacheable)或者Device属性,确保CPU和DMA看到的数据完全一致。
如果你没有配置MPU,Cortex-M7默认情况下D-Cache是开启的,DMA描述符和缓冲区都被Cache缓存,那么恭喜你,你中奖了。这个配置错误几乎100%会导致网络跑一会儿就挂。
我当时用的配置是这样的,把描述符和缓冲区所在的内存段全部配置为Non-Cacheable:
static void MPU_Config(void) { MPU_Region_InitTypeDef MPU_InitStruct = {0}; /* Disable MPU before configuration */ HAL_MPU_Disable(); /* Configure the DMA descriptors and buffers as Non-Cacheable */ MPU_InitStruct.Enable = MPU_REGION_ENABLE; MPU_InitStruct.BaseAddress = 0x24000000; MPU_InitStruct.Size = MPU_REGION_SIZE_32KB; MPU_InitStruct.AccessPermission = MPU_REGION_FULL_ACCESS; MPU_InitStruct.IsBufferable = MPU_REGION_NOT_BUFFERABLE; MPU_InitStruct.IsCacheable = MPU_REGION_NOT_CACHEABLE; MPU_InitStruct.IsShareable = MPU_REGION_NOT_SHAREABLE; MPU_InitStruct.Number = MPU_REGION_NUMBER0; MPU_InitStruct.TypeExtField = MPU_TEX_LEVEL0; MPU_InitStruct.SubRegionDisable = 0x00; MPU_InitStruct.DisableExec = MPU_INSTRUCTION_ACCESS_ENABLE; HAL_MPU_ConfigRegion(&MPU_InitStruct); HAL_MPU_Enable(MPU_CONTROL_HRD_MEM_FORCE); }这里要注意,0x24000000是STM32H750的DTCM RAM吗?不是。H750的紧耦合内存TCM是0x00000000(ITCM)和0x20000000(DTCM),而0x24000000是AXI SRAM。DMA能不能访问TCM?这是一个关键点。
3.3 注意:DMA无法访问TCM
STM32H750的内存布局里,TCM是直接挂在CPU内核上的,DMA控制器访问不到。所以你的DMA描述符和以太网收发缓冲区绝对不能放在TCM区域。
我记得刚接触H7的时候,很多人图方便把变量直接扔到默认的RAM区域。在H7上,链接脚本里默认的RAM区域如果指向了DTCM,那么以太网DMA根本没法工作,表现也很奇葩——要么完全不通,要么偶尔通一下然后挂掉。
所以必须把以太网相关的所有内存显式放到DMA可访问的区域,一般是AXI SRAM(0x24000000)或者SRAM1/2/3(0x30000000/0x30020000/0x30040000)。在链接脚本里把这些区域单独分配出来,然后通过__attribute__((section))指定。
这里贴一段我实际使用的链接脚本片段(GCC工具链),把以太网描述符和缓冲区固定放到AXI SRAM:
/* Specify the memory areas */ MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 128K DTCMRAM (xrw) : ORIGIN = 0x20000000, LENGTH = 128K SRAM1 (xrw) : ORIGIN = 0x30000000, LENGTH = 128K AXI_SRAM (xrw) : ORIGIN = 0x24000000, LENGTH = 512K } /* Ethernet DMA descriptors and buffers in AXI SRAM */ .eth_dma (NOLOAD) : { . = ALIGN(4); *( .RxDescripSection ) *( .TxDescripSection ) *( .RxArraySection ) *( .TxArraySection ) } > AXI_SRAM注意这个flash大小,STM32H750的Flash是128KB,但实际片内Flash物理大小不止这个,不过这是另一个话题,不要在这里跑偏。
3.4 检查接收描述符的状态机
还有一种情况是,MPU配置看起来没问题,Cache属性也对了,但问题出在接收描述符的处理逻辑上。
STM32的以太网DMA接收描述符有一个OWN位(第31位),表示该描述符当前归DMA所有还是归CPU所有。初始化时,所有接收描述符的OWN位都是1,表示DMA可以写入数据。CPU在处理完一个描述符后,需要手动把OWN位置1,把描述符还给DMA。
这块的逻辑如果写错了,比如在清OWN位之前先读了描述符的其他字段,或者处理完忘了置OWN位,那么很快就会耗尽接收描述符,网络也是跑一段时间就挂。
我梳理一下正确的处理流程:
- 检查当前接收描述符的OWN位,如果是0说明CPU拥有该描述符,数据已就绪;
- 读取描述符中的数据长度和数据地址;
- 把数据拷贝到应用层缓冲区;
- 清空描述符状态,重新设置OWN位为1;
- 如果是Ring模式,递增描述符索引,回绕;
- 必要时触发一次接收DMA的轮询恢复。
这里有一个很容易踩的坑:接收描述符的缓冲区地址在初始化时已经写死了,CPU在处理数据时不要修改这个地址,除非你故意做动态缓冲。我见过一些代码在收包之后把缓冲区地址换掉了,然后忘了恢复,结果描述符指向了一个无效内存,DMA往错误地址写数据,直接hardfault或者内存踩踏。
3.5 一个重要的怀疑对象:MAC地址过滤
还有一个容易被忽略的坑是MAC地址过滤配置。
H750的以太网MAC支持多种地址过滤模式,包括单播地址过滤、组播地址过滤、广播地址过滤等。如果配置有误,设备可能会在某些情况下错过发给自己的ARP请求或ICMP包。
最典型的问题是:你初始化时设置了MAC地址过滤,但地址比较大端小端搞反了。MAC地址的字节序在寄存器里是从高字节到低字节存储的,如果软件配置时把顺序弄反,那么设备只能收到部分包,或者干脆收不到任何单播包。表现就是偶尔能通、偶尔不通,因为ARP请求是广播包,可能还能收到,但单播的ping包就全丢了。
我当时排查的时候,先用Wireshark抓包,发现板子能发出ARP应答,但收不到后续的ICMP请求——这种现象非常像MAC地址过滤出错。后来检查寄存器,果然发现MAC地址高32位和低16位的配置函数里,参数顺序写错了。
所以如果你遇到“设备能发不能收”的诡异情况,先把MAC地址寄存器的值读出来,和实际MAC地址对一下,保证字节序完全一致。
4. Cache一致性:H750以太网失联的头号元凶
4.1 从一次“死等”现象说起
继续回到Cache问题。我排除了MAC过滤的问题之后,故障依旧。所以重新聚焦到Cache一致性上。
在不开Cache的情况下,CPU和DMA都直接访问内存,数据是实时同步的,问题不会暴露。但H750的Cortex-M7默认开D-Cache,性能提升明显,代价就是你需要自己管理数据一致性。
以太网场景下,有两处关键的数据一致性必须处理:
接收路径:DMA把网线收到的数据写到内存缓冲区,然后置位描述符的OWN位为0。CPU在读取数据前,必须保证Cache中的数据是无效的。如果之前CPU访问过这段地址,Cache里可能还有旧数据,CPU读出来是老的,就会丢包。
发送路径:CPU把要发送的数据写入内存缓冲区,然后置位描述符OWN位为1。DMA在搬运前,必须保证内存里的数据是最新的。如果数据还留在Cache里没写回,DMA搬运的就是旧数据。
4.2 正确的Cache维护操作
在HAL库中,有对应的操作接口:
/* 接收路径:在CPU读取DMA写入的Buffer之前,使Cache无效 */ SCB_InvalidateDCache_by_Addr((uint32_t *)rx_buffer, length); /* 发送路径:在DMA搬运Buffer之前,将Cache写回内存 */ SCB_CleanDCache_by_Addr((uint32_t *)tx_buffer, length);关键点在于:
- 接收路径用Invalidate(使无效),因为数据在内存里,Cache里的副本可能是旧的;
- 发送路径用Clean(写回),因为数据在Cache里,需要先写回内存。
有些代码偷懒,收发都用Clean+Invalidate,虽然也能工作,但效率略低。更坑的是,如果你用了错误的组合——比如接收路径用Clean而不是Invalidate——就可能读到旧的脏数据,丢包会变得极不规律。
4.3 更稳妥的方案:所有以太网内存全部Non-Cacheable
对于嵌入式以太网这种高频数据收发的场景,我个人的习惯是:不跟Cache较劲,直接把以太网相关的DMA描述符和收发缓冲区全部配置为Non-Cacheable。
这样做的优点是:
- 彻底避免Cache一致性问题,无论是CPU还是DMA访问,数据都是实时同步的;
- 排查难度大大降低,不需要在每一个收包/发包路径上仔细检查Cache操作;
- 性能损失基本可以忽略,因为以太网本身的瓶颈在PHY和MAC,而不在CPU访问内存的速度。
STM32H750有512KB的AXI SRAM,以太网缓冲区通常只占几十KB,非Cache化这点区域完全不影响整体性能。
有同学可能会担心:非Cache区域访问速度会不会太慢?实测下来,以太网收发数据的瓶颈主要在DMA和MAC频率上,CPU访问非Cache内存的延迟虽然比Cache高,但对于100M以太网来说完全够用,不会成为瓶颈。
4.4 如果坚持用Cache,这些细节必须做对
如果你出于某种原因不想把整个区域设为Non-Cacheable,坚持用Cache模式,那么有几个细节必须做对:
第一,描述符本身建议放到Non-Cacheable区域。因为描述符的更新频率极高,每次收发都要读写,任何一点Cache不一致都会导致严重的状态错乱,而且很难排查。
第二,缓冲区可以在Cache区域,但每次收发必须做Clean/Invalidate操作。而且要注意,Clean/Invalidate的地址必须4字节对齐,长度也要对齐到32字节(Cache line大小),否则会破坏相邻内存的数据。
以ST的HAL库为例,SCB_CleanDCache_by_Addr要求传入的起始地址是4字节对齐的,但内部处理时会对整个Cache line做操作。如果你传入的地址不是32字节对齐的,可能会把同一Cache line里其他数据也Clean掉,反过来,Invalidate也会把同一Cache line里其他有效数据丢掉。
所以,如果把缓冲区定义在普通全局数组里,要确保每个缓冲区都32字节对齐:
uint8_t Rx_Buff[ETH_RX_DESC_CNT][ETH_RX_BUF_SIZE] __attribute__((aligned(32)));对齐这个东西,不出问题的时候没什么存在感,一出问题就是莫名其妙的数据错乱、运行一段时间挂掉。强烈建议从一开始就约定好:所有DMA缓冲区,64字节对齐起步。
5. 收发描述符数量配置:太小了也会“跑一会就挂”
5.1 描述符数量和网络负载的关系
另一个典型坑是收发描述符数量配置得太少。
HAL库的默认配置里,ETH_RX_DESC_CNT和ETH_TX_DESC_CNT是可以自己定义的。很多人用默认值4个接收描述符、4个发送描述符,在低负载情况下没问题,但网络负载一旦上来,或者应用层处理速度稍有延迟,接收描述符很快耗尽,DMA无法接收新数据,网络就“挂”了。
这种“挂”和Cache问题不同,它更像是设备暂时性失去接收能力,等描述符释放后又可能会恢复。但如果你应用层收包处理不及时,或者描述符释放逻辑有BUG,就可能一直挂着。
我遇到过一个项目,接收描述符数量配置为4,应用层每收到一个包要解析、存储、上报,处理时间大概几毫秒。在持续大流量ping包下,DMA接收速度远快于应用层处理速度,4个描述符根本不够用,运行几分钟后就出现丢包,再久一点就直接失联了。
解决办法很简单:把接收描述符配置到16个或者32个,发送描述符8到16个。
#define ETH_RX_DESC_CNT 16 #define ETH_TX_DESC_CNT 85.2 描述符耗尽时如何自救
除了增加描述符数量,还要在应用层做一层保护:当接收描述符即将耗尽时,主动丢弃一些非关键包,或者触发一次DMA的接收恢复操作。
H7的以太网DMA有一个机制,如果接收描述符耗尽,DMA会停止接收,并置位接收状态错误位。你可以通过中断或者轮询捕获到这个状态,然后重新启动接收DMA。
在HAL库中,对应的处理逻辑在HAL_ETH_ReadData函数内部,采用Ring模式时,如果返回值是HAL_ETH_ERROR_RX_NOT_READY,说明描述符还没有被DMA释放,你需要稍后重试或者调整处理流程。
我个人的建议是:网络应用都尽量采用中断+信号量的方式,让以太网接收线程优先处理收包,尽快释放描述符,避免应用层业务逻辑阻塞收包线程。
5.3 系统负载和DMA仲裁
H750的多个DMA控制器共享总线带宽,如果系统里同时跑着ADC采样、SPI通信、SD卡写入等高负载外设,以太网DMA的带宽可能被抢占,导致收发延迟增大、缓冲数据堆积,表现出来也是“网络卡顿、时延增大、最后失联”。
这种情况下,可以调整DMA仲裁优先级。在HAL库中,可以通过配置DMA控制器的主/从优先级设置,让以太网DMA的优先级高于其他外设。
不过要提醒一句:优先级调高后,要留意其他外设是否会出现数据丢失,尤其是对实时性要求高的采集类外设。
6. 中断处理:为什么“只有一个网络中断”反而更安全
6.1 中断优先级和抢占
STM32H750的以太网DMA中断是走NVIC的,中断处理里通常会做两件事:读取接收描述符数据、释放描述符。
如果你用的是FreeRTOS这种RTOS,中断优先级需要设置成不超过FreeRTOS允许的最高优先级(一般是5到15,具体看中断分组配置),否则会触发断言或者导致系统不稳定。
还有一个细节:以太网中断处理函数的执行时间不能太长。中断里尽量只做数据搬运和描述符释放,真正的协议解析和业务处理放到任务上下文里。否则中断占用时间过长,会导致其他高优先级事件被延迟,甚至触发看门狗复位。
6.2 校验接收数据长度的合法性
在中断处理或者收包处理里,一定要校验DMA描述符里的接收长度。如果长度值是0或者超过缓冲区最大长度,说明数据有问题,直接丢弃,不要继续处理。
我见过一个bug是,收包长度未校验,把一个超大的长度值传给后面处理函数,结果内存越界拷贝,把整个系统搞崩了。这种问题一旦出现,极难排查,因为它表现为随机崩溃,网络丢包只是表象。
uint32_t frame_length = ETH_GetRxFrameLength(); if ((frame_length == 0) || (frame_length > ETH_RX_BUF_SIZE)) { /* 丢弃异常帧,释放描述符 */ HAL_ETH_ReleaseRxBuffer(...); return; }6.3 Broadcast和ARP风暴的冲击
排查网络失联时,还要注意外界网络环境对设备的影响。如果设备所在的局域网内有广播风暴、ARP风暴,或者有人跑P2P下载器占满带宽,设备的网络栈都可能受影响。
最大流量的极端情况下,如果设备每秒钟收到几千个包,而你的接收描述符只有4个,那基本是秒挂。
这不是设备硬件问题,是你系统处理能力跟不上。对策有两个方向:一是增加描述符数量,提高缓冲能力;二是在MAC层做简单的帧过滤,只接收发给自己的单播帧和必要的ARP、ICMP帧,丢弃其他帧。
STM32以太网MAC自带帧过滤功能,可以配置只接收单播、组播或广播帧。在初始化时,可以设置HAL_ETH_InitStruct中的帧过滤字段:
heth.Init.RxMode = ETH_RXINTERRUPT_MODE; heth.Init.ChecksumMode = ETH_CHECKSUM_BY_HARDWARE;更极端的做法是,直接在硬件层面禁用不需要的帧类型。不过大部分项目用软件层的帧过滤就够了,在接收中断里判断以太网帧类型,非目标帧直接丢弃。
7. PHY端排查:寄存器状态是骗不了人的
7.1 用串口把PHY寄存器打出来
排除完软件问题后,如果故障还在,就要回到PHY环节做排查。虽然我前面说PHY出问题的概率不高,但也不能排除硬件问题的可能性。
排查PHY最简单的方法是:在设备失联时,通过串口命令读取PHY的关键寄存器。
以LAN8720A为例,几个关键寄存器:
| 寄存器地址 | 寄存器名称 | 关键内容 |
|---|---|---|
| 0x00 | Basic Control Register | 软复位、速度/双工设置,速度指示位 |
| 0x01 | Basic Status Register | Link状态、速度/双工检测结果 |
| 0x04 | Auto-Negotiation Advertisement | 协商能力广播 |
| 0x05 | Auto-Negotiation Link Partner Ability | 对端协商能力 |
| 0x1F | PHY Special Control/Status | 中断状态、能量检测等 |
我在排查的时候,通过串口命令实时读取0x01寄存器,发现Link状态一直正常,这说明PHY的物理链路没有问题。随后读0x04和0x05寄存器,确认协商结果是100M全双工,也没有问题。
7.2 PHY复位时序:很多“跑一会挂”其实是复位时序不对
还有一个容易被忽略的点是PHY的复位时序。
很多硬件设计里,PHY的复位引脚由STM32的GPIO控制,上电后需要延迟一段时间再拉高复位引脚,等PHY内部上电稳定后才能真正工作。如果复位时序太短,PHY可能没有完全初始化,表现为上电能通,跑一段时间后失联,复位后又能通。
LAN8720A的数据手册建议复位低电平持续时间至少1ms,上电到PHY就绪时间在10ms左右。如果片子外接的25MHz晶振起振时间偏长,这个时间还需要更充裕。
我项目里的处理方式是:
- 上电后先把PHY复位引脚拉低,延时至少10ms;
- 再拉高复位引脚,延时至少10ms;
- 再对PHY写寄存器进行初始化。
这组时序放在系统上电初始化里,不要放在以太网初始化之前太早。如果你在上电后立即初始化以太网,PHY可能还没就绪,后续配置就会丢失或者部分生效。
7.3 PHY的中断引脚和状态检测
有些方案的PHY芯片会接一个中断引脚到STM32,用来检测Link状态变化。如果这个中断引脚配置不当,或者PHY的中断被触发但软件没有及时处理,可能导致PHY进入异常状态。
所以如果项目里用了PHY中断,务必把中断处理函数写完整:读取中断状态寄存器,清除中断标志,根据Link状态变化做相应处理。
如果PHY中断处理不及时,在Link状态频繁抖动的时候,中断标志会堆积,PHY芯片的某些内部状态机可能卡住,最终表现为“网络失联”。
我当时排查时把PHY中断暂时关闭,直接用轮询方式读取Link状态,结果故障复现周期变慢了,虽然没完全解决,但也佐证了PHY状态管理对稳定性的影响。
8. 优化方案:一套能稳定跑几个月的配置思路
8.1 完整的初始化流程
综合前面的排查,我最终沉淀了一套稳定的初始化配置,供大家参考。这套配置在STM32H750 + LAN8720A + FreeRTOS + lwIP环境下,稳定运行了几个月没有出现网络失联。
完整流程如下:
- 硬件上电,等电源稳定;
- GPIO控制PHY复位引脚拉低,延时10ms;
- 拉高复位引脚,延时10ms,等PHY就绪;
- 配置STM32的RMII引脚复用功能;
- 如果使用MCO输出50MHz时钟给PHY,先配置MCO时钟到50MHz,并确认输出稳定;
- 初始化ETH外设的DMA描述符和缓冲区,放置在AXI SRAM区域;
- 配置MPU,将以太网描述符和缓冲区区域设为Non-Cacheable;
- 调用HAL_ETH_Init初始化MAC,设置MAC地址、帧过滤模式等;
- 调用PHY的初始化驱动,读取PHY ID,确认PHY通信正常;
- 配置PHY的自动协商(或强制100M全双工),等待Link up;
- 启动ETH的DMA传输,开启接收中断;
- 在RTOS中创建网络接收任务,通过信号量或消息队列与中断交互;
- 配置lwIP的netif接入,启动TCP/IP协议栈。
8.2 收发缓冲区的对齐建议
前面提到缓冲区要对齐到32字节,我建议直接对齐到64字节,应对各种Cache line大小和DMA对齐要求。定义方式:
#define ETH_RX_BUF_SIZE 1536 /* 标准以太网帧最大长度 + 头部 */ #define ETH_TX_BUF_SIZE 1536 uint8_t Rx_Buff[ETH_RX_DESC_CNT][ETH_RX_BUF_SIZE] __attribute__((aligned(64))); uint8_t Tx_Buff[ETH_TX_DESC_CNT][ETH_TX_BUF_SIZE] __attribute__((aligned(64)));缓冲大小1536字节刚好可以容纳一个VM maximum以太网帧——1500字节负载加14字节以太网头加CRC,考虑VLAN标签的话可以再放宽一点到1536以上。我见过一些项目把缓冲区设成2048,其实也没问题,只是浪费点内存。H750的RAM足够大,没必要太抠门。
8.3 轮询模式和中断模式的选择
H7以太网驱动支持轮询模式和中断模式。
- 轮询模式:主循环周期调用HAL_ETH_ReadData,适合协议栈有周期tick的场合,简单可靠,但实时性略差。
- 中断模式:接收中断触发后,在中断里或通过信号量通知任务处理,实时性好,但代码复杂度略高。
我在FreeRTOS + lwIP场景下推荐中断模式。以太网接收中断里调用HAL_ETH_ReadData拿到数据,放到队列或者直接交给netif->input。这么做的核心原因是避免在中断里做过多处理,保证中断快速退出,把业务逻辑放到任务里。
一个典型的配置是:
void ETH_IRQHandler(void) { HAL_ETH_IRQHandler(&heth); } void HAL_ETH_RxCpltCallback(ETH_HandleTypeDef *heth) { /* 在中断回调中释放信号量,通知网络任务处理数据 */ BaseType_t xHigherPriorityTaskWoken = pdFALSE; vTaskNotifyGiveFromISR(NetworkTaskHandle, &xHigherPriorityTaskWoken); portYIELD_FROM_ISR(xHigherPriorityTaskWoken); }8.4 lwIP层降低丢包风险
lwIP配置里的几个关键参数对稳定性影响很大:
- MEMP_NUM_PBUF:PBUF数量,如果太小,在突发流量下会丢包;
- MEMP_NUM_TCP_SEG:TCP分段数量,太小会影响TCP发送吞吐;
- PBUF_POOL_SIZE:PBUF池大小,建议增大到16到32;
- TCP_WND:TCP接收窗口,适当增大可以提高吞吐。
这几个参数在lwipopts.h里配置,项目里我习惯把PBUF_POOL_SIZE设到32,MEMP_NUM_PBUF设到32,TCP_WND设到16KB左右。这些参数占用的RAM在H750的512KB SRAM面前不算什么,但收益很明显。
8.5 用Watchdog做最后一道防线
嵌入式设备往往有看门狗。对于网络通信,我建议加一个“网络看门狗”逻辑:如果超过一定时间没有收到任何网络包,或者协议栈长时间无响应,软件主动软复位以太网外设,而不是整个系统复位。
比如在应用层维护一个计时变量,每次收到有效网络包时清零。如果超过10秒没收到任何包,则调用以太网外设的重新初始化流程,从第6步开始重新配置ETH。
这个软复位逻辑不会影响其他任务运行,比整个系统复位温和很多。对现场维护来说,“断网10秒后自动恢复”比“设备彻底失联”要容易接受得多。
实测下来,即便我后来的配置已经解决了根因,网络看门狗仍然是生产环境中的必备保险丝。网络环境千奇百怪,无法预测所有异常场景,有这一层保护心里会更踏实。
9. 实测数据:调整前后对比
为了让读者直观看到配置调整带来的变化,我把两次测试的数据列出来。
测试环境:
- 板卡:自制STM32H750板卡
- PHY:LAN8720A
- 软件:FreeRTOS + lwIP 2.1.2
- 上位机:PC持续ping,间隔1秒,ping 10000次
| 配置项 | 调整前 | 调整后 |
|---|---|---|
| 接收描述符数量 | 4 | 16 |
| 发送描述符数量 | 4 | 8 |
| MPU配置 | 未配置(全Cache) | 以太网内存Non-Cacheable |
| 缓冲区对齐 | 未对齐 | 64字节对齐 |
| PHY复位时序 | 1ms | 20ms |
| 网络看门狗 | 无 | 有 |
| ping丢包率 | 15分钟后开始丢包,30分钟后失联 | 10000次全部成功,0%丢包 |
| 长时间运行 | 约30分钟失联 | 连续运行数月无失联 |
这个表格很直观:前几项配置调整针对性解决了根因,网络看门狗相当于兜底。实际项目中,如果只改前几项,问题就能彻底解决;剩下的看门狗只是“保险丝”。
10. 这些问题背后的常见误区
10.1 “是不是我PHY芯片坏了”不是首选排查方向
遇到网络失联,很多人第一反应是查PHY芯片。从我排查过的项目来看,PHY芯片物理损坏的概率极低,绝大多数问题还是出在软件配置和内存管理上。
做嵌入式,怀疑硬件之前,先把软件的每个配置都过一遍,特别是MPU、Cache、描述符数量、缓冲区对齐、MAC地址字节序、中断处理这些点。
10.2 “为什么别人用默认配置就没问题”
有人可能会说:我照着例程跑,配置也是默认的,为什么别人没出问题?
这个问题得分两种情况:
一是例程可能只是在低负载、短时间的测试条件下跑通,没有做长时间稳定性验证。你把它改到实际项目里,数据量大了、运行时间长了,隐藏问题才暴露出来。
二是你的网络环境、以太网负载、系统中断负载和别人不一样。别人可能刚好没有触发问题,但你触发了。
所以做嵌入式网络,稳定性验证不能只跑几分钟,至少要连续跑几小时甚至一整天,然后分析丢包率、响应时间这些指标。
10.3 “不就是一个ping吗,哪里来这么多讲究”
这是很多初学者的想法。但其实ARP、ping这种最简单的协议,恰恰涉及网络协议栈最底层的机制:MAC寻址、DMA收发、描述符管理、中断处理、数据缓存。
这些问题一旦出现,往往意味着底层机制有Bug,而不是简单的命令配置问题。能把ARP和ping调通、调稳,才是嵌入式网络开发真正入门的标志。
11. 写在最后:一个排查经验清单
如果你正在被“STM32H750以太网跑一段时间就失联”折磨,给你一份可以直接照做的排查清单:
- 确认DMA描述符和缓冲区放在DMA可访问的AXI SRAM区域,不能在TCM里;
- 配置MPU,将以太网相关内存设为Non-Cacheable,或者确保每次收发做Cache Clean/Invalidate操作,缓冲区32或64字节对齐;
- 接收描述符数量不少于16个,发送描述符不少于8个;
- 检查MAC地址寄存器字节序,确认和实际MAC地址一致;
- 读取PHY寄存器,确认Link状态和协商结果正常;
- PHY复位时间至少10ms,上电初始化不要太急;
- 收包处理要快速释放描述符,不要在中断里做重活;
- 校验接收长度,异常帧直接丢弃;
- 网络负载大时,增加lwIP的PBUF池和描述符数量;
- 加一个网络看门狗,超过设定时间无包自动重新初始化以太网。
大概能解决90%以上的“网络跑一会就挂”问题。剩下的个别情况,可能集中在硬件信号完整性问题,比如RMII信号线过长、REF_CLK抖动过大、PHY供电波动等等,那就需要示波器上场了。
回头想想,这个Bug之所以难查,不是因为配置复杂,而是因为它只在运行一段时间后才暴露,很多人没耐心等到复现,也没想到把内存属性、描述符这些底层细节一一过筛。嵌入式开发里,很多诡异问题都是这样——答案其实不神秘,关键是把基础知识吃透,把排查步骤走全。