☰
STM32H750嵌入式以太网调优与排障实战
2026/11/2 9:22:39 网站建设 项目流程

1. 先想清楚这套组合各自的定位

把 STM32H750、CubeIDE、FreeRTOS、LAN8720A、LwIP 这五个词摆在一起,本质上是搭一条完整的嵌入式联网链路:Cortex-M7 内核负责跑业务逻辑,CubeIDE 负责把底层寄存器配置代码生成出来,FreeRTOS 负责把网络线程和业务线程隔离开,LAN8720A 是挂在 RMII 接口上的物理层收发器,LwIP 是跑在 MCU 上的轻量 TCP/IP 协议栈。这五样东西单独拿出来都不算难,难的是它们之间的边界——谁管时钟、谁管内存、谁管中断上下文、谁管缓存一致性。我见过太多项目在单机上跑得挺顺,一插网线就 ping 不通,或者跑几个小时就死,问题九成出在边界上而不是协议栈本身。

这篇内容适合三类人:手里已经有一块带 LAN8720A 的 H7 板子、想从零把网络跑起来的人;网络能通但性能上不去、隔三差五丢包的人;以及打算把这套组合做成产品、需要先把坑都摸一遍的人。下面的内容全部基于我在实际项目里反复折腾过的配置和参数,能直接照抄的部分我会给完整代码,需要根据自己板子调整的地方我会说明判断依据。

先说结论性的判断:这套方案在百兆以内的工业数据采集、设备网关、远程监控场景里非常合适,H7 的 480MHz 主频足够跑 LwIP 加一堆业务逻辑,LAN8720A 便宜、外围简单、RMII 只需要 7 根线。但它有两个先天约束必须先认下来——H750 的片上 Flash 只有 128KB,LwIP 加 FreeRTOS 加业务代码很容易撑爆;以及 H7 带 D-Cache,网络 DMA 和 CPU 抢同一块内存时缓存一致性会咬人。这两条决定了后面所有的架构选型。

1.1 H750 的 128KB Flash 是绕不开的第一道坎

H750 这颗片子很有意思,480MHz 的 M7 内核、512KB 的 AXI SRAM、双精度浮点,但片上 Flash 只给了 128KB。官方的定位就是"性价比路线",指望你把程序放到外部 QSPI Flash 里去 XIP 执行。

一个空工程加 FreeRTOS 加 LwIP 加 HAL 库,编译出来通常在 90KB 到 120KB 之间,取决于你开了多少外设、LwIP 开了哪些功能。这时候你只剩十几 KB 给业务代码,稍微写两个状态机就红了。

我试过三条路,实测下来建议这样排优先级:

方案做法优点代价
裁剪关掉用不到的外设 HAL、LwIP 关 DHCP/DNS/HTTPD、编译开-Os零硬件成本功能受限,后期扩展难受
外挂 QSPI Flash XIP程序放外部 Flash,配置内存映射模式容量解耦,通常是 8MB 甚至 16MBQSPI 时序要调,XIP 有等待周期,代码执行效率下降
外挂 Flash + 关键代码放 ITCM热路径函数用__attribute__((section(".itcm")))搬到 ITCM兼顾容量和速度需要改链接脚本,函数放置要人工规划

我现在的常规做法是第二条打底加第三条补速度。链接脚本里把向量表、启动代码、中断服务函数留在片上 128KB,把 LwIP 协议栈、FreeRTOS 内核、业务主体全部丢到 QSPI 映射区。中断处理函数一定要留在片内,因为 XIP 的取指延迟在中断上下文里会被放大,出现过中断响应时间抖动导致串口偶发丢字节的情况。

注意:QSPI 的 XIP 模式对 Flash 的 dummy cycle 配置非常敏感,不同厂家的片子要求不一样。数据手册里的 tCLQV 参数决定 dummy cycles,配少了读出来全是 0xFF,配多了浪费带宽。我一般先用单线模式降频读到 ID,确认通信正常后再切四线全速。

1.2 各层职责怎么分,决定了后面出问题好不好查

很多教程上来就让 CubeMX 一路 Next,生成完直接跑,能 ping 通就收工。这种工程一旦出问题就是黑盒,因为代码是生成的,你不知道哪一层在干什么。我倾向于在生成之后先把三条主线理清楚,后面所有异常都能定位到具体一层。

第一条是时钟线:HSE 25MHz 进来,PLL1 分出 480MHz 给 CPU、240MHz 给 AXI/AHB、120MHz 给 APB;LAN8720A 自己需要一颗 25MHz 晶振,然后由它输出 50MHz 的 REF_CLK 给 MAC 的 RMII 接口。这条线上的任何一环错了,表现都是"网口灯不亮"或者"读不到 PHY ID"。

第二条是内存线:以太网 DMA 描述符和收发缓冲必须放在 DMA 能访问的 SRAM 区域,也就是 AXI SRAM 或 SRAM1/2/3,绝对不能放 DTCM——DMA 根本访问不到 DTCM,放进去的结果是描述符永远读不到,DMA 卡死。再加一层 D-Cache 的影响,就得决定哪些区域走 Non-cacheable。

第三条是任务线:LwIP 自己有tcpip_thread,网络中断里不能直接跑协议栈,必须把数据包投递给这个线程;应用层的 socket 或 raw API 调用又必须在合适的上下文里。FreeRTOS 的任务优先级和中断优先级配置错了,表现是"跑一会儿就 HardFault"。

这三条线里,时钟和内存是"配错就完全不工作",任务线是"配错就偶发崩溃"。排查顺序我一般是先看时钟(示波器量 REF_CLK 有没有 50MHz),再看内存(描述符地址是不是落在 0x30000000 或 0x24000000 段),最后看任务(是不是在中断里调了阻塞 API)。

2. 硬件与底层配置,这几处最容易翻车

硬件这一层看着简单,实际上是我踩坑最密集的地方。LAN8720A 的外围电路就那么几个元件,但每个都有讲究,接错了软件怎么调都没用。

2.1 REF_CLK 从哪来,25MHz 晶振到底谁提供

RMII 接口需要一个 50MHz 的参考时钟。LAN8720A 有两条路给这个时钟:一是不用外部晶振,让 MCU 输出 50MHz 到 PHY 的 XTAL1/CLKIN 引脚;二是 PHY 自己接 25MHz 晶振,内部倍频到 50MHz 后从 REF_CLK 引脚输出给 MCU。这两条路只能选一条,硬件上就固定了。

第二种更常见,也更省心。判断方法看nINT/REFCLKO引脚(第 5 脚)的方向:如果这个脚接的是 MCU 的 PA1(ETH_RMII_REF_CLK),那就是 PHY 输出时钟给 MCU。这时候 CubeMX 里 PA1 要配成ETH_RMII_REF_CLK,且必须是输入模式;PHY 内部寄存器 0x17 的 bit 0 要配置成 REF_CLK 输出使能。

这里有个特别隐蔽的坑:LAN8720A 上电时如果 REF_CLK 输出没使能,MCU 侧就收不到时钟,MAC 的 RMII 状态机不推进,表现为 HAL_ETH_Init 卡住或者超时。而 REF_CLK 使能是靠硬件 strapping 引脚和软件寄存器两者配合的,有些板子设计时把nINT/REFCLKO当成了中断脚用,结果时钟出不来。我遇到过一次,示波器一量 PA1 是干净的直流电平,翻了半天原理图才发现这个引脚被下拉了。

25MHz 晶振本身的负载电容也要对。LAN8720A 的数据手册给的典型负载电容是 20pF(含 PCB 走线),实际布板时用 18pF 到 22pF 都行,但如果接到 15pF 以下,起振裕量不足,低温下容易起不来。这种问题在实验室常温测不出来,量产了才爆发。

2.2 SYSCFG 的 EPIS 位,H7 特有的一个坑

F4 和 F7 上以太网配好引脚就能用,H7 多了一步:必须通过 SYSCFG 的 PMCR 寄存器里的 EPIS 位,告诉芯片 RMII 接口走哪组引脚映射。CubeMX 生成的HAL_ETH_MspInit里通常会带上这一句,但如果你是自己改工程或者从 F4 移植过来,很容易漏掉。

/* 在 HAL_ETH_MspInit 里,使能 SYSCFG 时钟之后 */ __HAL_RCC_SYSCFG_CLK_ENABLE(); HAL_SYSCFG_ETHInterfaceSelect(SYSCFG_ETH_RMII);

没有这一句,HAL_ETH_Init 会返回HAL_ETH_ERROR_PARAM或者干脆卡在 DMA 软复位那一行。我第一次遇到时以为是 PHY 坏了,换了三块板子才发现是这一行代码。这个坑的特点是"没有任何报错提示,就是不通",所以如果你从零开始,这一句要写在检查清单第一条。

2.3 PHY 地址不是 0,取决于板子上的下拉电阻

LAN8720A 的 SMI 地址由PHYAD0引脚(第 10 脚,复用 RXER)在上电时的电平决定:接下拉就是地址 0,接上拉就是地址 1。很多开发板为了兼容,做成了可跳线的形式,或者干脆硬接 0。

代码里LAN8720A_ADDRESS这个宏定义错了,表现是HAL_ETH_ReadPHYRegister一直返回HAL_ETH_ERROR_TIMEOUT,或者读出来的 ID 是 0xFFFF。排查方法很直接:用HAL_ETH_ReadPHYRegister(&heth, 0, PHY_REG_ID1, &id1)循环扫地址 0 到 31,哪个能读出非 0xFFFF 的 ID 就是对的。LAN8720A 的 ID1 应该是 0x0007,ID2 是 0xC0F1。

自协商这块我一般不用 CubeMX 生成的简化版,而是自己走一遍完整流程:先软件复位(写 0x1F 寄存器 bit15),等 100ms,然后读 BMSR 判断链路状态,接着写 BMCR 启动自协商,再轮询 BMSR 的 bit 5(AN complete)和 bit 2(Link status)。这样每一步都有明确的失败点可以打日志。

static uint8_t phy_wait_link(uint32_t timeout_ms) { uint32_t bmsr = 0; uint32_t tick = HAL_GetTick(); do { if (HAL_ETH_ReadPHYRegister(&heth, PHY_ADDR, PHY_BSR, &bmsr) != HAL_OK) return 0; if (bmsr & PHY_LINKED_STATUS) return 1; } while (HAL_GetTick() - tick < timeout_ms); return 0; }

提示:不要在外设初始化阶段死等链路。有些交换机端口开着但没接线,自协商会一直不完成,死等会让整个启动流程卡死。我一般给 2 到 3 秒超时,超时后照样启动 LwIP,让 netif 处于 down 状态,后续在任务里周期性检查。

3. CubeIDE 工程搭建:参数怎么算、内存怎么放

这一步是把前面的硬件假设变成代码。CubeIDE 的好处是时钟树和引脚分配可视化,坏处是自动生成的代码藏了很多默认值,你得知道它默认了什么,才能判断对不对。

3.1 时钟树的计算过程

HSE 用 25MHz 的话,PLL1 的配置我通常是:

  • M = 5,得到 25/5 = 5MHz 的 PLL 输入
  • N = 192,VCO = 5 × 192 = 960MHz
  • P = 2,得到 480MHz 的 CPU 时钟
  • Q = 2,得到 480MHz 给外设(部分外设用)
  • R = 2

VCO 必须落在 192MHz 到 960MHz 之间,960MHz 是上限,正好卡边。有些板子的 HSE 是 24MHz 或者 8MHz,那 M 和 N 的组合就要换,但 VCO 一定要落在区间内,且 CPU 时钟 480MHz 需要 Voltage Scale 0(VOS0),CubeMX 里这个选项在时钟树下方,选错了最高只能跑 400MHz。

AXI 和 AHB 分频系数取 2,得到 240MHz。以太网 MAC 的时钟来自 AHB1,240MHz 完全够。APB1/APB2 取 4,得到 120MHz,I2C、SPI 这些够用了。

时钟配置完,第一件事是写个验证:在main里读HAL_RCC_GetHCLKFreq()和HAL_RCC_GetPCLK1Freq(),通过串口打出来。CubeMX 有时候会给你算出非整数分频,实际频率和你以为的不一样,串口波特率偏差大就是这么来的。

参数数值依据
HSE25MHz板载晶振
PLL1 VCO960MHzVCO 上限,需 VOS0
SYSCLK480MHzCPU 最高频率
HCLK (AXI/AHB)240MHzSYSCLK/2
PCLK1/PCLK2120MHzHCLK/4
ETH REF_CLK50MHz由 LAN8720A 输出

3.2 FreeRTOS 与 LwIP 的优先级和栈怎么排

CubeMX 里把 FreeRTOS 和 LwIP 都勾上之后,它会自动生成tcpip_thread,默认优先级是osPriorityNormal(也就是 24 左右,看你的configMAX_PRIORITIES设置)。问题在于,如果业务任务也用 Normal 优先级,网络线程可能长时间抢不到 CPU,表现为"TCP 连接建立了但收数据特别慢"。

我的排法是这样:

  • configMAX_PRIORITIES设成 7 以上,留足余量
  • tcpip_thread优先级设成osPriorityAboveNormal(比业务高一级)
  • 业务任务用osPriorityNormal
  • 日志、LED 指示这类辅助任务用osPriorityBelowNormal

中断优先级更关键。FreeRTOS 里有个configMAX_SYSCALL_INTERRUPT_PRIORITY的概念,取值通常是 5(数值越小优先级越高),所有调用FromISR系列 API 的中断,优先级数值必须大于等于 5,也就是优先级要低于它。以太网中断里最终会调用sys_mbox_trypost_fromisr,所以 ETH 中断的抢占优先级必须 ≥ 5。

配错的后果很典型:程序在configASSERT里死循环,或者随机的 HardFault,而且只在网络有流量的时候出现。我建议在main的开头加一句断言检查:

/* ETH_IRQn 的优先级数值必须 >= configMAX_SYSCALL_INTERRUPT_PRIORITY */ NVIC_SetPriority(ETH_IRQn, 6);

3.3 内存分区和 MPU 配置,决定稳定性

H7 的内存分区得先理清楚(以 512KB AXI SRAM 的型号为例):

区域起始地址大小能否被以太网 DMA 访问用途建议
DTCM0x2000_0000128KB否栈、频繁访问的变量
AXI SRAM (D1)0x2400_0000512KB是主堆、LwIP 内存池
SRAM1/2/3 (D2)0x3000_0000384KB是DMA 描述符、收发缓冲
SRAM4 (D3)0x3800_000064KB是低功耗域,一般不用

我的习惯是把以太网 DMA 描述符和 RX/TX 缓冲全部塞进 SRAM1(0x30000000 开始),并且用 MPU 把这个 128KB 区域配成 Non-cacheable。LwIP 自己的MEM_SIZE堆放 AXI SRAM,可以开 Cache,因为那部分内存是 CPU 独占访问的,不涉及 DMA。

static void MPU_Config(void) { MPU_Region_InitTypeDef MPU_InitStruct = {0}; HAL_MPU_Disable(); /* SRAM1:给以太网 DMA 用,Non-cacheable */ MPU_InitStruct.Enable = MPU_REGION_ENABLE; MPU_InitStruct.Number = MPU_REGION_NUMBER0; MPU_InitStruct.BaseAddress = 0x30000000; MPU_InitStruct.Size = MPU_REGION_SIZE_128KB; MPU_InitStruct.SubRegionDisable = 0x00; MPU_InitStruct.TypeExtField = MPU_TEX_LEVEL1; MPU_InitStruct.AccessPermission = MPU_REGION_FULL_ACCESS; MPU_InitStruct.DisableExec = MPU_INSTRUCTION_ACCESS_DISABLE; MPU_InitStruct.IsShareable = MPU_ACCESS_SHAREABLE; MPU_InitStruct.IsCacheable = MPU_ACCESS_NOT_CACHEABLE; MPU_InitStruct.IsBufferable = MPU_ACCESS_NOT_BUFFERABLE; HAL_MPU_ConfigRegion(&MPU_InitStruct); HAL_MPU_Enable(MPU_PRIVILEGED_DEFAULT); }

这里有个细节值得说:为什么用MPU_TEX_LEVEL1配 Non-cacheable,而不是直接MPU_ACCESS_NOT_CACHEABLE加 bufferable?因为以太网的发送描述符需要"写完之后立即被 DMA 看到",如果配成 bufferable,写操作可能在写缓冲里待着,DMA 读到的还是旧值,表现为"第一个包能发出去,后面的包全丢"。具体取值建议按TEX=001, C=0, B=0, S=1来,这个组合是 Shareable + Non-cacheable,对 DMA 最友好。

MPU 配置必须在SCB_EnableDCache()之前调用,而且配置完之后要执行一次SCB_CleanInvalidateDCache()把已有的脏数据刷掉。

4. 关键代码实现:从描述符到能 ping 通

到这一步,硬件通了、时钟对了、内存放好了,可以开始写网络层。CubeMX 会自动生成ethernetif.c,但生成的代码有几个地方需要改,否则大概率跑不稳。

4.1 描述符数量与缓冲大小怎么定

描述符数量直接影响吞吐和内存占用。H7 HAL 里默认是 4 个 RX 加 4 个 TX,这个数量在百兆全速下勉强够用,但一旦 CPU 忙起来来不及取包,RX 描述符耗尽,后面的包直接被硬件丢弃,表现就是 ping 丢包率 5% 到 10%。

我的经验值:

参数建议值说明
ETH_RX_DESC_CNT8突发流量缓冲
ETH_TX_DESC_CNT8发送队列深度
ETH_MAX_PACKET_SIZE1536等于 1522 + 对齐余量
RX 缓冲总占用8 × 1536 ≈ 12KB放在 SRAM1
TX 缓冲总占用8 × 1536 ≈ 12KB放在 SRAM1

描述符必须是 32 字节对齐,且不能被编译器优化到别的位置去。写法:

#if defined ( __ICCARM__ ) #pragma data_alignment=32 ETH_DMADescTypeDef DMARxDscrTab[ETH_RX_DESC_CNT]; #pragma data_alignment=32 ETH_DMADescTypeDef DMATxDscrTab[ETH_TX_DESC_CNT]; #elif defined ( __CC_ARM ) __attribute__((at(0x30000000))) ETH_DMADescTypeDef DMARxDscrTab[ETH_RX_DESC_CNT];

在 GCC 下用__attribute__((aligned(32), section(".eth_dma"))),然后链接脚本里把.eth_dma段定位到 0x30000000。这一步不做,描述符会被放到默认的.bss,也就是 AXI SRAM,虽然 DMA 也能访问,但会和你 MPU 配置的 Non-cacheable 区域对不上,缓存一致性就出问题了。

ETH_MAX_PACKET_SIZE为什么是 1536 而不是 1518?因为 HAL 内部会在收包时做一次对齐处理,实际写入的长度可能略大于以太网帧长(1500 payload + 14 头 + 4CRC = 1518),留一点余量避免越界写。我试过设成 1520,跑大包的时候偶尔会踩到下一个描述符的缓冲。

4.2 收发路径上的缓存一致性怎么处理

开了 D-Cache 之后,收发两个方向都要维护一致性,但方式不一样:

发送方向是"CPU 写、DMA 读"。CPU 把数据写进 TX 缓冲后,数据可能还在 Cache 里没落到 SRAM,DMA 拿到的就是旧数据。所以发送前必须 clean:

SCB_CleanDCache_by_Addr((uint32_t *)buffer, length);

接收方向是"CPU 读、DMA 写"。DMA 把新数据写进 SRAM,但 Cache 里可能还留着旧的数据副本,CPU 读到的是垃圾。所以接收后必须 invalidate:

SCB_InvalidateDCache_by_Addr((uint32_t *)buffer, length);

这两个函数的名字容易记混,我的记法是:写之前 clean(把脏数据刷下去),读之前 invalidate(把旧副本作废)。

如果不做这两步会怎样?我实测过一次,关掉维护操作的后果是:ping 能通,但 TCP 传输文件时数据随机错乱,一帧里可能前面几百字节是对的、后面几百字节是上一帧的内容。这种 bug 极难用调试器抓,因为加断点会改变时序,Cache 行为就变了。所以我的建议是:新工程第一阶段直接关 D-Cache(注释掉SCB_EnableDCache()),先保证功能通,等跑稳了再开 Cache 加维护操作,逐个方向验证。

DMA 发送函数的完整写法可以参考这个结构:

static err_t low_level_output(struct netif *netif, struct pbuf *p) { struct pbuf *q; uint8_t *buffer = (uint8_t *)ETH_TX_BUF_ADDR; uint32_t framelen = 0; for (q = p; q != NULL; q = q->next) { memcpy(buffer + framelen, q->payload, q->len); framelen += q->len; } if (framelen < 60) { /* 最小帧补齐 */ memset(buffer + framelen, 0, 60 - framelen); framelen = 60; } SCB_CleanDCache_by_Addr((uint32_t *)buffer, framelen); HAL_ETH_Transmit(&heth, framelen, 100); /* 超时给 100ms */ return ERR_OK; }

HAL_ETH_Transmit有 5 个参数,最后一个超时值一定要给,不能传HAL_MAX_DELAY。网络本身就是会拥塞的,TX 描述符满了是正常现象,死等会让整个 tcpip 线程卡住,进而引发一连串的连接超时。

4.3 中断上下文与 tcpip 线程的交接

HAL 的 RX 完成回调是在中断上下文里跑的,这里不能直接跑协议栈。CubeMX 生成的ethernetif.c里是在回调里调ethernetif_input(&gnetif),而这个函数内部调netif->input,最终进tcpip_input。

LwIP 2.x 的tcpip_input底层用的是sys_mbox_trypost_fromisr,理论上是支持中断上下文调用的,但前提是你的sys_arch移植层正确实现了fromisr版本。CubeMX 生成的sys_arch.c有些版本里这个函数是空的,直接返回ERR_OK但没实际投递,结果就是"中断进了,包没了"。

保险的做法是自己加一层信号量:

/* 中断里 */ void HAL_ETH_RxCpltCallback(ETH_HandleTypeDef *heth) { BaseType_t hpw = pdFALSE; xSemaphoreGiveFromISR(eth_rx_sem, &hpw); portYIELD_FROM_ISR(hpw); } /* 专用接收任务,优先级高于 tcpip_thread */ static void eth_rx_task(void *arg) { for (;;) { if (xSemaphoreTake(eth_rx_sem, portMAX_DELAY) == pdTRUE) { ethernetif_input(&gnetif); } } }

这个接收任务的优先级要和tcpip_thread平级或者略高,栈给 512 字节(tcpip_thread本身建议 1024 以上,因为它要处理 socket 回调)。

4.4 应用层验证:先 ping,再 TCP

netif 加进去之后,别急着写业务。判断标准就三条:

  1. netif_is_link_up(&gnetif)返回真
  2. 本机 ping 得通,且ping -t跑 1000 个包丢包率为 0
  3. tcpip_thread的栈高位水位(用uxTaskGetStackHighWaterMark)还剩 200 字节以上

静态 IP 的配置我通常这么写,方便出问题时快速切换:

IP4_ADDR(&ipaddr, 192, 168, 1, 50); IP4_ADDR(&netmask, 255, 255, 255, 0); IP4_ADDR(&gw, 192, 168, 1, 1); netif_add(&gnetif, &ipaddr, &netmask, &gw, NULL, &ethernetif_init, &tcpip_input); netif_set_default(&gnetif); netif_set_up(&gnetif);

注意tcpip_input这个参数,CubeMX 生成的代码里有时会写成ethernet_input,两者在 NO_SYS=0 的模式下必须用tcpip_input,否则包会被直接在中断上下文里解析,栈深度和锁都会出问题。

5. 实测性能与参数调优

功能通了之后,接下来是让它在真实负载下站得住。这一节的数据来自我在一块 100M 交换机直连的板子上反复测出来的结果,环境是 iperf 打流加长时间 ping 混合。

5.1 吞吐量的天花板在哪

百兆物理层理论上限 100Mbps,扣掉以太网帧头、IP 头、TCP 头,有效载荷大概 94Mbps。但 MCU 上跑 LwIP,实际能到多少取决于几个瓶颈:

配置项保守配置优化后影响
D-Cache关闭开启 + 维护提升约 30%
MEM_SIZE8KB32KB大包拼接不阻塞
PBUF_POOL_SIZE824减少 pbuf 耗尽丢包
TCP_MSS5361460减少分片开销
TCP_WND21448760提升滑动窗口效率
校验和卸载关闭硬件卸载降低 CPU 占用约 25%

实测数据(iperf TCP 客户端模式,接收方向):保守配置大概 22Mbps,开了 Cache 和维护之后到 38Mbps,再把TCP_WND和PBUF_POOL调上去能到 55Mbps 左右。UDP 方向能到 75Mbps。这个数字对绝大多数工业场景足够了。

硬件校验和卸载在 CubeMX 里配CHECKSUM_BY_HARDWARE,需要同时打开 MAC 的ChecksumOffload和 LwIP 的CHECKSUM_GEN_IP等宏的对应关系,配错了会出现"发送正常但接收端的校验和错误计数暴涨"。

5.2 长时间运行的稳定性靠什么保证

跑 24 小时不出问题,比跑出高吞吐更难。我遇到过的稳定性问题有两类:

第一类是内存泄漏。LwIP 的 pbuf 如果在上层应用里没有正确释放,池子会一点点耗尽,表现是"跑 6 小时后 ping 开始丢,重启就好"。排查靠memp_get_stats和pbuf_get_stats周期性打印池子使用率,正常应该在一个固定区间内波动,如果单调上升就说明漏了。

第二类是PHY 链路抖动。网线接触不良或者对端设备休眠,link 会 down 再 up。如果代码里没有处理 link 状态变化,netif 会一直以为链路是通的,TCP 连接挂在半开状态。正确做法是周期性读 PHY 的 BMSR 寄存器,检测到 link down 就netif_set_link_down,up 的时候重新走 DHCP 或者恢复静态配置。

static void link_monitor_task(void *arg) { uint32_t bmsr; static uint8_t last_link = 0; for (;;) { if (HAL_ETH_ReadPHYRegister(&heth, PHY_ADDR, PHY_BSR, &bmsr) == HAL_OK) { uint8_t link = (bmsr & PHY_LINKED_STATUS) ? 1 : 0; if (link != last_link) { if (link) { netif_set_link_up(&gnetif); } else { netif_set_link_down(&gnetif); } last_link = link; } } vTaskDelay(pdMS_TO_TICKS(1000)); } }

提示:HAL_ETH_ReadPHYRegister内部有 MDIO 的读写超时,如果 PHY 掉电或者时钟丢了,这个函数会阻塞 1 到 2 秒。放在高优先级任务里会拖慢整体调度,所以我把它放在独立的低优先级任务里,1 秒轮询一次足够。

6. 常见问题速查与排查实录

这一节是我这几年积攒下来的对照表,遇到问题先按症状查,能省下大量时间。

6.1 症状与原因对照

现象最可能原因验证方法处理
网口灯不亮,PHY 完全无响应REF_CLK 没有输出 / 25MHz 晶振没起振示波器量 PA1 有无 50MHz检查 PHY 寄存器 0x17 bit0,检查晶振负载电容
HAL_ETH_Init返回 PARAM 错误SYSCFG EPIS 未配置读 SYSCFG->PMCR 的 bit23-21加HAL_SYSCFG_ETHInterfaceSelect
读 PHY ID 一直 0xFFFFPHY 地址错 / MDIO 引脚复用错扫 0 到 31 地址改LAN8720A_ADDRESS宏
ping 不通但链路是 upIP 冲突 / 网关配错 / netif 没 set_up抓包看 ARP 有没有响应逐项核对 IP 配置
ping 通但丢包 5% 到 10%RX 描述符不足 / 中断优先级配错看 DMA 的 overflow 计数器描述符加到 8,ETH 中断优先级调到 ≥5
TCP 传输数据错乱D-Cache 未维护关掉 D-Cache 对比收发两端加 clean/invalidate
跑几小时后 HardFault中断里调用阻塞 API / 栈溢出看 HardFault 现场 LR 寄存器改 FromISR 版本,加大栈
大包(1400 字节以上)卡死ETH_MAX_PACKET_SIZE太小打日志看 framelen改到 1536
发送第一个包成功,后续全丢描述符区被配成了 bufferable检查 MPU 的 TEX/C/B 位改 Non-cacheable + Shareable
长时间运行后内存耗尽pbuf 未释放 / socket 未关闭打印 pbuf 池使用率逐层排查释放路径

6.2 三个印象最深的坑

第一个坑:DMA 描述符放进了 DTCM。链接脚本默认把.bss放到 DTCM,而描述符数组是全局变量,自然就进去了。表现是HAL_ETH_Init里软复位永远等不到完成标志。我查了一整天才发现是地址问题,因为 DMA 访问 DTCM 在总线上是直接无响应的,没有报错。后来养成习惯:所有和 DMA 相关的变量,声明的时候就用__attribute__((section(".eth_dma")))显式指定段,再在链接脚本里定位。

第二个坑:tcpip_thread栈不够。默认生成的栈是 1024 字节(configMINIMAL_STACK_SIZE的两倍),跑简单的 raw API 没问题,但一旦用上 socket 接口加select,栈就爆了。爆栈的表现不是崩溃,而是某个局部变量被悄悄改掉,逻辑走错分支。我现在的做法是给 2048 字节,并且周期性打印uxTaskGetStackHighWaterMark,低于 256 字节就警告。

第三个坑:CubeIDE 生成的ethernetif.c里low_level_init没设置 MAC 地址。如果hnetif->hwaddr全是 0,某些交换机会直接把你当成非法帧丢掉。表现是"本机 arp -a 看得到,但对端设备 ping 不通"。修法很简单,在low_level_init里填一个本地管理的 MAC,第一个字节的 bit1 置 1:

gnetif->hwaddr[0] = 0x02; gnetif->hwaddr[1] = 0x00; gnetif->hwaddr[2] = 0x00; gnetif->hwaddr[3] = 0x12; gnetif->hwaddr[4] = 0x34; gnetif->hwaddr[5] = 0x56; gnetif->hwaddr_len = ETH_HWADDR_LEN;

量产的话这个地址要从片内 OTP 或者外部 EEPROM 读,不能用固定值,否则同一网段下两台设备地址冲突,表现是"单独测都好,一起上就互相踢"。

6.3 一份可以直接照抄的检查顺序

新板子第一次上电,我按这个顺序走,基本能在半小时内定位到问题在哪一层:

  1. 量 PHY 的 25MHz 晶振,示波器探头要短,波形幅度到 1V 以上算起振
  2. 量 PA1 有没有 50MHz 方波,占空比接近 50%
  3. 串口打印HAL_ETH_Init的返回值,必须是HAL_OK
  4. 读 PHY ID1/ID2,必须是 0x0007 / 0xC0F1
  5. 读 BMSR 的 bit2,插网线后必须变 1
  6. netif_is_link_up返回真
  7. PC 端arp -a能看到板子的 IP 和 MAC 对应关系
  8. ping通,再ping -t -l 1400打大包
  9. 用netstat或 Wireshark 抓包看有没有重传

哪一步断了就在那一层深挖,不要跳步。我见过有人第 4 步没过就直接去改 LwIP 配置,折腾两天最后发现是 PHY 地址焊错了。

最后分享一个我觉得挺有用的小做法:把上面这套检查流程做成一个自检任务,上电后自动跑一遍,结果通过串口打印成表。板子发到现场之后,现场人员只要把串口日志发回来,我就能判断是硬件问题还是配置问题,省了大量沟通成本。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询