ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32+LAN8720A+LwIP PING不通排查实战

STM32+LAN8720A+LwIP PING不通排查实战

简介:面向STM32以太网外设开发的实战工程包,以STM32F429IGT6为主控、LAN8720A为PHY芯片,基于LWIP 2.1.2协议栈在无操作系统环境下完成网络通信与PING测试,采用RMII接口与ETH外设对接,并通过SMI接口读写PHY寄存器,适合已具备一定HAL库基础、希望打通裸机网络链路的嵌入式开发者参考。工程基于MDK-ARM 5.27、STM32CubeMX 6.6.1与HAL V1.27.1搭建,版本信息明确,便于复现开发环境。压缩包共约1300个文件、31.16MB,其中678个.c与450个.h构成协议栈与网卡驱动主体,78个.s启动汇编、46个.icf链接脚本以及若干库文件和工程配置,共同保证工程可直接编译。已有1989人学习下载。读者可从中获得CubeMX下ETH与LAN8720A的完整配置路径、LWIP裸机移植与网卡驱动适配方法、PING不通时的分层排查思路,以及一份可编译运行的参考模板。

1. 板子上电灯亮,PING 却不回

网口灯常亮,netif也set_up了,电脑上ping过去就是请求超时——这是 STM32 HAL 库搭配 LAN8720A、跑无操作系统 LwIP 时最常见的开场。灯亮只能说明 PHY 供电正常、链路状态有回应,不代表描述符、PHY 地址、参考时钟、MAC 地址任何一项是对的。这套环境几乎每块带网口的 STM32 板子都会走一遍,代码全用 C 语言写,配置项散在 CubeMX 的 ETH、LwIP、时钟树三处,错任何一处表现出来的现象都一样:PING 不通。下面按我实际装板的顺序写,从版本对齐一路写到能稳定 ping 通,中间翻车的地方也照实记。

2. 装前把环境列清:CubeMX、固件包与 LwIP 的版本搭配

这套环境翻车,十次里有三次不是配置错,而是版本没对齐。CubeMX 生成的ethernetif.c、lan8720.c、lwipopts.h之间是有耦合的,换一个固件包版本,函数签名、结构体成员、甚至 PHY 驱动的调用方式都会变。所以先把版本锁死,再动配置。

2.1 三类版本必须对齐

我把参与这套环境的组件拆成三层:工具层是 STM32CubeMX 和编译工具链,中间层是对应芯片系列的 STM32Cube 固件包(里面含 HAL 驱动),协议层是 CubeMX 自带的 LwIP。这三层里最容易忽略的是中间层,很多人换电脑重装 CubeMX,顺手把固件包更到最新,结果老工程一编译,HAL_ETH_TransmitFrame找不到或者参数对不上。

组件建议版本区间关键差异点
STM32CubeMX6.4 及以上6.4 之前的 LwIP 配置页没有 LAN8720 的 PHY 下拉框,只能自己写 PHY 读写
STM32Cube 固件包 F41.27 前后HAL ETH 驱动在 1.7.x 之后收发要传ETH_TxPacketConfig结构体
STM32Cube 固件包 F7 / H71.17 / 1.11 前后H7 必须处理 D-Cache,F7 至少要做 Clean/Invalidate
LwIP2.1.2(随固件包)不建议手工替换成 2.2,CubeMX 生成的lwipopts.h是按 2.1.x 的宏写的
工具链Keil MDK 5.x 或 STM32CubeIDE 1.xC 语言标准按 C99,别开 C89,LwIP 里有用到//注释

说清楚为什么不能乱升:HAL 的 ETH 驱动在版本之间改过一轮接口。早期版本发一帧是HAL_ETH_TransmitFrame(&heth, len),新版本要先填ETH_TxPacketConfig里的Length、TxBuffer、Attributes,再调HAL_ETH_TransmitFrame(&heth, &TxConfig)。如果你从网上抄了一段low_level_output,编译报参数数量不匹配,基本就是这一条。我的习惯是工程建好之后把固件包版本号写进readme,以后回来看一眼就知道当时用的是哪套。

另一个容易漏的是 Java 环境。CubeMX 6.x 需要本机有 Java 运行时,装完打不开、闪一下就退,多半是 Java 版本太新或没配JAVA_HOME。这个属于工具层的问题,跟网络代码一点关系没有,但能把人卡半天。

2.2 LAN8720A 的地址和参考时钟先量一遍

在打开 CubeMX 之前,硬件上有两个参数必须先确认,因为它们直接决定后面填什么数字,改硬件比改代码贵得多。

第一个是 PHY 的 SMI 地址。LAN8720A 的地址由 RXER/PHYAD0 这个引脚在上电时的电平决定,拉低是 0x00,拉高是 0x01。很多模块把这个脚默认拉低,代码里却按#define LAN8720_ADDR 0x01写,现象就是 MDIO 读回来全是 0xFFFF,初始化直接卡在 PHY 自检。拿万用表量一下那个脚,或者干脆在代码里把 0x00 和 0x01 都试一遍读 PHY ID,能读到0x0007/0xC0F1的那个就是对的。

第二个是 50MHz 参考时钟从哪来。RMII 接口要求 MAC 侧有一个稳定的 50MHz 时钟送到 REF_CLK 引脚。两种常见接法:一种是用板上 25MHz 晶振给 LAN8720A,再由它的 nINT/REFCLKO 脚输出 50MHz 送给 MCU,此时 PHY 的 MODE 脚要配成 REF_CLK Out 模式;另一种是 MCU 用 MCO 输出 50MHz 给 PHY 的 XI 脚,PHY 不再接晶振。这两种接法在 CubeMX 的时钟树里配置完全不同,接错了不会报错,只会表现为收发全废。

F4 系列走 RMII 的典型引脚对应关系如下,接线时对着核一遍:

信号MCU 引脚(F407 为例)LAN8720A 引脚
RMII_REF_CLKPA1REFCLKO / nINT
MDIOPA2MDIO
MDCPC1MDC
RMII_CRS_DVPA7CRS_DV
RMII_RXD0PC4RXD0
RMII_RXD1PC5RXD1
RMII_TX_ENPB11TXEN
RMII_TXD0PB12TXD0
RMII_TXD1PB13TXD1
PHY 复位任意 GPIOnRST

MDIO 线上那颗上拉电阻别省,常见做法是 1.5k 到 2.2k 上拉到 3.3V,用 10k 也能读到 ID,但速率一上来就容易丢帧。复位脚建议单独接一个 GPIO,不要直接接复位芯片,因为 LAN8720A 有时需要在软件里单独软复位一次才能起来。

2.3 工程骨架生成后先点数

CubeMX 生成代码之后,别急着编译烧录,先确认文件齐不齐。少一个文件,后面补起来比重新生成还麻烦。

# 生成目录下核对关键文件,缺哪个后面都要手工补 find . -name "ethernetif.c" # 网卡与 LwIP 的胶水层,收发主逻辑都在这 find . -name "lan8720.c" # PHY 驱动,CubeMX 选了 LAN8720 才会生成 find . -name "lwip.c" # MX_LWIP_Init 和 MX_LWIP_Process 在这里 find . -name "lwipopts.h" # 所有协议栈开关,改配置主要改这个 find . -name "*_hal_eth.h" # HAL 的 ETH 驱动,确认版本

这几条命令不只是找文件,更重要的是确认 PHY 驱动到底有没有生成。如果你的 CubeMX 版本老,LwIP 页里选不到 LAN8720,那就不会有lan8720.c,ethernetif.c里的LAN8720_Init之类的调用会直接编译不过。这种情况有两条路:升级 CubeMX,或者自己写两个函数——HAL_ETH_ReadPHYRegister和HAL_ETH_WritePHYRegister的薄封装,二十行代码的事,后面第四章会给。我一般选后者,因为老工程升级 CubeMX 可能把别的配置也一起动了。

3. CubeMX 里配通 ETH 与 LwIP:参数一项项落地

配置这一章是整篇里最容易出错的地方,因为它不是填对一个参数就行,而是 ETH 外设、LwIP 协议栈、时钟树、中断四块必须同时自洽。任何一块不自洽,现象都是同一个:PING 不通。所以下面按 ETH → LwIP → 时钟 → 生成 的顺序写,每一步说清楚为什么这么填。

3.1 ETH 外设:RMII、自动协商、校验和卸载

在 Connectivity 里选 ETH,Mode 选 RMII,这两个是最基础的。往下几个参数值得单独说。

Auto Negotiation 建议打开。打开之后 MAC 会在初始化时和 PHY 协商速率和双工模式,LAN8720A 支持 10/100M 自适应。如果关掉,你就得手工把 Speed 定成 100M、Duplex 定成 Full,一旦对端是百兆以下的自适应交换机,很容易协商成半双工,现象是能 ping 通但丢包严重。我在测试阶段一律开自动协商,稳定之后再考虑锁死。

Checksum offload 建议全部打开,也就是 IPv4、TCP、UDP 的硬件校验和。打开之后 CubeMX 会自动在lwipopts.h里把CHECKSUM_BY_HARDWARE置 1,同时把CHECKSUM_GEN_IP、CHECKSUM_GEN_TCP这些软件校验宏清 0。这里有个坑:如果你后来手工把CHECKSUM_BY_HARDWARE改回 0,却没把那几个CHECKSUM_GEN_*改回 1,发出去的包校验和全是错的,对端直接丢,PING 一样不通。这两个地方必须成对改。

PHY 地址填 2.2 节量出来的那个值。RX 描述符数量我一般给 4,TX 给 4,够用了;缓冲区大小保持默认。这几个数不是越大越好,描述符占的是内部 SRAM,H7 上尤其要算着来。

3.2 LwIP 参数:NO_SYS、内存池和 ICMP

进入 Middleware 里的 LwIP,第一件事是把NO_SYS勾上,这就是「无操作系统」的意思。勾上之后 LwIP 不再创建任何线程,所有协议处理都在你主循环里调用的MX_LWIP_Process()中跑完,sys_arch.c那套信号量和邮箱也就不用实现了。代价是响应速度取决于主循环转得多快,所以主循环里千万别放长延时。

内存相关的几个宏在lwipopts.h里,值得手工确认一遍:

/* lwipopts.h 里几个我每次都要核的开关 */ #define MEM_SIZE (16 * 1024) /* 堆大小,只跑 ping 2K 够,留余量给后面 TCP */ #define PBUF_POOL_SIZE 16 /* pbuf 池数量,一个接收包占一个 */ #define PBUF_POOL_BUFSIZE 1524 /* 单缓冲区,覆盖一个以太网最大帧 */ #define LWIP_ARP 1 /* 必须开,PC 先发 ARP 问 MAC,不回就不发 ping */ #define LWIP_ICMP 1 /* 必须开,ping 的回显应答靠它 */ #define LWIP_DHCP 0 /* 先用静态 IP,DHCP 留到链路确认之后再加 */ #define LWIP_NETIF_LINK_CALLBACK 1 /* PHY 插拔时通知协议栈,调试时很有用 */

逐条说原因。MEM_SIZE给 16K 是因为只跑 ping 确实 2K 就够,但你不可能永远只跑 ping,等你要加个 TCP 服务端再回头改这个宏、重编、重新复现问题,成本更高。PBUF_POOL_SIZE给 16 是按「同时最多有十几个包在飞」估的,PC 端ping -l 1000连续打的时候,池子太小会看到pbuf_alloc失败,但 LwIP 默认不打印,你只会看到丢包。LWIP_DHCP关掉是故意的:DHCP 依赖广播收发链路完全正常,链路没验证就去配 DHCP,出问题你分不清是链路错还是 DHCP 超时。

LWIP_ICMP和LWIP_ARP这两个是 PING 的命门。ARP 不回,PC 的ping连第一个包都发不出去,显示的是「无法访问目标主机」而不是超时,很多人看到这个提示就去查防火墙了,其实是 ARP 那层就没通。

3.3 时钟树和中断优先级

时钟这块有个硬性条件:RMII 的 REF_CLK 必须是 50MHz。如果走的是 PHY 晶振 + REFCLKO 输出 50MHz 的方案,MCU 侧只要在 CubeMX 里把 PA1 配成ETH_RMII_REF_CLK就行,不用额外开输出。如果走 MCU MCO 供时钟的方案,就要在时钟树里算 MCO1 的分频,保证输出正好 50MHz,同时把 PHY 的 XI 接过来、晶振位空焊。

MAC 自己的工作时钟通过 AHB 总线来,CubeMX 会自动使能,不用管。但整个系统时钟(HCLK)建议先按默认的 168MHz 跑,别一上来就超频到 216MHz 之类。以太网对时序敏感,超频之后出现的偶发丢包极难定位。

中断方面,ETH 的中断在 NVIC 里使能,优先级给到中等偏上就行。注意 CubeMX 生成的stm32f4xx_it.c里要能看到HAL_ETH_IRQHandler(&heth),没有这一句,中断进不来,接收描述符永远不被回收,跑一会儿就彻底收不到包了。这个现象很有欺骗性:刚上电 ping 得通,几十秒后突然全丢,重启又正常——本质是接收环用完没归还。

3.4 生成代码后必须核对的三处

点生成之前,确认工具链和工程名,勾上「生成外设初始化代码」,不要勾「只生成 .c/.h 不生成工程」。

生成完之后,有三处必须打开看,不能直接编译:

第一处,lwip.c里的MX_LWIP_Init(),看netif_add传的 IP、掩码、网关是不是你要的。默认是192.168.0.10之类,如果你的电脑在192.168.1.x网段,两边根本不在一个网段,ping 不通是应该的。

第二处,ethernetif.c里的 MAC 地址。CubeMX 默认填的可能是00:00:00:00:00:00或者全 F,全 0 有些交换机会直接丢弃,全 F 是广播地址,协议栈行为不可预期。改成02:00:00:00:00:01这类本地管理地址,第一个字节的最低位必须是 0(单播),不能是奇数。

第三处,lwipopts.h里LWIP_NETIF_STATUS_CALLBACK和LWIP_NETIF_LINK_CALLBACK有没有按 3.2 节开。这两处是后面排查链路问题的唯一抓手,不开的话你连 PHY 什么时候 link up 都不知道。

4. 手写初始化链路:把收发通路接到描述符上

CubeMX 生成的代码能跑通大部分情况,但真正卡住人的往往是ethernetif.c里那几百行。这一章把关键路径拆开看,知道每一段在干什么,出问题才知道往哪查。

4.1 补两个 PHY 读写函数

如果 CubeMX 没生成lan8720.c,或者生成的驱动和你的 HAL 版本对不上,最省事的做法是自己包一层。HAL 已经把 MDIO 时序封装好了,直接用就行:

/* PHY 地址按硬件 RXER 脚电平来,这里用宏,改一处就行 */ #define LAN8720_ADDR 0x00U #define PHY_BCR 0x00U /* 基本控制寄存器,软复位和自协商都在这 */ #define PHY_BSR 0x01U /* 基本状态寄存器,bit2 是 link 状态 */ #define PHY_ID1 0x02U /* 高 16 位,LAN8720A 读出来是 0x0007 */ #define PHY_ID2 0x03U /* 低 16 位,LAN8720A 读出来是 0xC0F1 */ static uint16_t phy_read(uint16_t reg) { uint32_t val = 0; /* 第三个参数是寄存器地址,第四个是输出缓冲,超时用 HAL 默认 1000ms */ if (HAL_ETH_ReadPHYRegister(&heth, LAN8720_ADDR, reg, &val) != HAL_OK) { return 0xFFFF; /* 读失败统一返回全 F,方便上层判断 */ } return (uint16_t)val; } static void phy_reset(void) { phy_write(PHY_BCR, 0x8000); /* bit15 置 1 触发软复位 */ HAL_Delay(50); /* LAN8720A 手册要求复位后等一会儿 */ while (phy_read(PHY_BCR) & 0x8000) { /* 等复位位自动清零,别用死循环不设上限 */ } }

phy_read返回0xFFFF这个约定很重要:MDIO 读失败和读到一个真实的全 F 值在业务上都要当成「没有 PHY」,用同一个值表示,上层写起来简单。phy_reset里那个HAL_Delay(50)不是保险起见,是手册要求;复位没完成就去读 BSR,读回来全是 0,你会误判成链路没起来。

上电后建议先读一次 PHY ID 验证通路:能读到0x0007和0xC0F1,说明 MDIO/MDC 时序和 PHY 地址都没问题,后面再排查 link 状态才有意义。这一步五分钟的事,能省掉后面两小时。

4.2 描述符和缓冲区的对齐与 Cache

low_level_init里干了三件事:初始化 MAC、建描述符环、把 netif 的hwaddr_len和flags填上。前两件是 HAL 干的,你主要核对第三件。

缓冲区对齐是个容易忽略的细节。以太网 DMA 描述符要求 4 字节对齐,缓冲区最好也按 4 字节对齐,H7 上还要考虑 Cache 行大小。常见的写法是在定义缓冲区时加对齐属性:

/* 接收缓冲区,按 32 字节对齐,覆盖 H7 的 Cache 行 */ __attribute__((aligned(32))) static uint8_t rx_buf[ETH_RX_DESC_CNT][ETH_MAX_PACKET_SIZE]; __attribute__((aligned(32))) static uint8_t tx_buf[ETH_TX_DESC_CNT][ETH_MAX_PACKET_SIZE];

在 F4 这种没有 D-Cache 的片子上,这段对齐只是让 DMA 访问更规整,不加也能跑。但在 H7 上不加对齐 + 不处理 Cache,现象非常诡异:ping 小包正常,ping 大包必挂,或者反过来;有时候第一次 ping 通,第二次就超时。原因是 CPU 读到的缓冲区内容还是 Cache 里的旧值,DMA 写进去的新数据看不到。

H7 上有两种处理方式。一是把描述符和缓冲区所在的 SRAM 区域用 MPU 配成 non-cacheable,一劳永逸,代价是这块内存访问慢一点;二是每次收发前后手工做 Cache 操作,接收前 invalidate、发送前 clean。我倾向前者,配一次就不用管了,而且 CubeMX 在 H7 上生成的 MPU 配置模板里就有现成的 ETH 段可以抄。

F7 没有 MPU 那么方便,但也要在low_level_input里加SCB_InvalidateDCache_by_Addr,在low_level_output里加SCB_CleanDCache_by_Addr。漏掉这两个,F7 上的表现和 H7 一样。

4.3 主循环里怎么调 MX_LWIP_Process

无操作系统模式下,整个协议栈靠你主动去「喂」。主循环里必须有MX_LWIP_Process(),它内部会检查有没有收到新包、有没有超时需要处理、有没有 ARP 要回。

while (1) { MX_LWIP_Process(); /* 轮询收包 + 协议栈定时器,必须高频调用 */ /* 其他业务代码放这里,但不要放 HAL_Delay 之类的长阻塞 */ }

这个函数的调用频率直接决定网络响应速度。主循环里如果有个 500ms 的延时,那 ping 的 RTT 就会在这个量级上下抖动,看起来像丢包。我一般把业务逻辑做成状态机,每次只跑一小片,保证主循环一圈在毫秒级。

另一种做法是用中断模式:ETH 收到包进中断,在回调里置标志位,主循环看到标志再去处理。这样主循环可以慢一点,但中断处理里不能调 LwIP 的接口(无操作系统模式下 LwIP 不是可重入的)。CubeMX 生成的工程默认是轮询,先用轮询验证通路,通了再考虑改中断。

4.4 发第一帧之前先自查这五项

烧录之前,把这五项过一遍,能挡掉八成问题:PHY 地址对不对(读 ID 能读到就是对的);MAC 地址是不是单播(第一字节最低位为 0);IP 和电脑是不是同网段;MX_LWIP_Process有没有在主循环里;ETH 中断里有没有调HAL_ETH_IRQHandler。

这五项里最容易漏的是第二项。CubeMX 默认给的 MAC 有时候是00:00:00:00:00:00,有些老交换机会把源 MAC 全 0 的帧当异常帧丢掉,你的板子发得出去,PC 收不到,抓包看是空的。改成02:00:00:00:00:01立刻就好。

5. 避坑与排查:PING 不通的五种典型现场

这一章按「现象 → 原因 → 解决」写,都是我实际遇到过的。建议先按现象对号入座,别一上来就怀疑协议栈。

5.1 现象:MDIO 读回来全是 0xFFFF

原因:PHY 地址填错,或者 MDIO 上拉缺失,或者 PHY 没复位起来。LAN8720A 的地址由 RXER 脚电平决定,代码里写 0x01、硬件拉低成 0x00,读什么都是全 F。

解决:先把地址改成 0x00 再读一次 PHY ID;还读不到就量 MDIO 上拉电阻和 PHY 供电;最后用示波器看 MDC 有没有波形,没波形说明 GPIO 复用没配对,回 CubeMX 看 PA2/PC1 是不是配成了 ETH 功能而不是普通推挽输出。

5.2 现象:PHY ID 读得到,但 BSR 的 link 位一直是 0

原因:网线没插、对端交换机没供电,或者自协商被关掉了。还有一种不太常见的:50MHz 参考时钟没到 PHY,PHY 内部逻辑没跑起来,但 MDIO 是独立时钟域,所以还能读到 ID。

解决:先换根网线换个口;再把 BCR 的 bit12(自协商使能)确认置 1;最后量 REFCLKO 有没有 50MHz 输出。顺序不能反,因为量时钟要拆板子。

5.3 现象:link 位是 1,PC 上ping显示「无法访问目标主机」而不是超时

原因:ARP 没通。LwIP 里LWIP_ARP被关了,或者netif的NETIF_FLAG_BROADCAST没置上,收到 ARP 请求不回。

解决:先在 PC 上arp -a看有没有板子的 IP 对应的 MAC。没有的话,在low_level_init里确认netif->flags |= NETIF_FLAG_BROADCAST | NETIF_FLAG_ETHARP,这两个标志漏一个 ARP 就哑了。

5.4 现象:能 ping 通,但几十秒后突然全丢,重启又正常

原因:接收描述符没归还。中断里的HAL_ETH_IRQHandler没被调用,或者ethernetif_input没把包取走,DMA 环用完之后就收不到新包了。

解决:在stm32f4xx_it.c里搜ETH_IRQHandler,确认里面调了HAL_ETH_IRQHandler(&heth);再确认MX_LWIP_Process的调用频率,主循环里有没有长延时卡住。

5.5 现象:小包 ping 通,大包(-l 1000以上)必丢

原因:H7/F7 上的 Cache 一致性没处理,接收缓冲区被 CPU 读到了旧值;或者PBUF_POOL_BUFSIZE小于实际帧长,大包直接分配失败。

解决:H7 上把 ETH 描述符和缓冲区所在 SRAM 用 MPU 配成 non-cacheable;F7 上加SCB_InvalidateDCache_by_Addr。同时把PBUF_POOL_BUFSIZE调到 1524,覆盖一个完整以太网帧。

6. 装完怎么验证:从 PHY 寄存器一路打到 PING

验证不要一上来就 ping,按层往上打,出问题才知道停在哪一层。第一层看 PHY:读 ID 得到0x0007/0xC0F1,读 BSR 的 bit2 为 1。第二层看 MAC:在low_level_init之后打个断点,看heth.Init里速率双工是不是 100M 全双工。第三层才是 PING。

电脑端先清一次 ARP 缓存再 ping,避免旧表项干扰:

# Windows:先清 ARP,再连打 10 个包,看丢包率和 RTT 抖动 arp -d * ping 192.168.1.50 -n 10 # Linux / macOS:-c 次数,-i 间隔,-s 包长,用大包顺带验缓存一致性 ping -c 10 -i 0.2 192.168.1.50 ping -c 5 -s 1400 192.168.1.50

看结果有讲究。RTT 稳定在 1ms 以内、0% 丢包,说明轮询频率够、描述符环够用。RTT 忽大忽小但不出超时,多半是主循环里有长延时。前几个通、后面全丢,回头查 5.4 那条。小包通大包不通,查 5.5。

还有一个隐藏项:Windows 防火墙默认可能拦 ICMP 回显,你自己 ping 板子没问题,但板子主动发的包被拦。测试阶段我一般把当前网卡的防火墙临时关掉,或者建一条只放行 ICMP 的入站规则,免得把网络问题误判成防火墙问题。

我现在的习惯是,每块新板子第一次点亮,先不写任何业务代码,就放一个空主循环加MX_LWIP_Process,让它对着电脑 ping 半小时。半小时不丢包,才算这套环境真的装好了。这个习惯是被坑出来的——有一次板子跑了一分钟就断,查了两天才发现是接收描述符数量给太少,业务一压就爆。从那以后我每次上电都强制走一遍半小时长 ping,再往下做。希望这套流程能帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进