STM32+LAN8720A+LwIP无操作系统以太网ping不通排查指南

发布时间:2026/10/26 13:28:21
STM32+LAN8720A+LwIP无操作系统以太网ping不通排查指南

简介:面向STM32以太网开发初学者与嵌入式工程师的LAN8720A联网实践工程,基于STM32F429IGT6、HAL库V1.27.1与LWIP 2.1.2,在无操作系统环境下完成RMII数据通信和SMI寄存器读写的PING测试示例。压缩包共1300个文件、约31.16MB,以678个C源文件和450个头文件为主体,配合78个汇编文件、46个链接脚本及少量库文件、工程配置与说明文档,覆盖底层驱动、协议栈移植和Keil工程组织。已有1989人学习,适合用来对照CubeMX 6.6.1配置,理解PHY初始化、以太网收发流程与LWIP裸机集成,并借助现成工程快速复现ping连通、排查链路与协议栈问题。

1. 一块「插上网线却 ping 不通」的板子:STM32+LAN8720A+LwIP 无操作系统这套组合到底卡在哪

一块带 LAN8720A 的 STM32F407 板子摆在桌上,CubeMX 里 ETH 选 RMII、LwIP 选无操作系统,Generate Code,编译零警告,烧进去,网线插上,RJ45 座里的灯微微一闪,电脑那头ping 192.168.1.10就是一片「请求超时」。这种场景我见过太多次——HAL 库确实把 ETH 外设的寄存器全包了,但 LAN8720A 的 50MHz 参考时钟从哪来、PHY 地址是 0 还是 1、lwipopts.h和ethernetif.c里那几项到底怎么填,仍然得人一根线一根线去对。对上了 ping 通,对不上串口打印一切正常、网络就是死的,而且这种「死」没有任何黑匣子提示,抓包也抓不到,只能从底层往上一点点反推。这套环境适合已经能点亮 LED、会用 CubeMX 建工程、C 语言能看懂指针和结构体的开发者,也是很多人第一次把 MCU 真正接到以太网上的那一关。

2. 装环境之前的版本对齐:CubeMX、HAL 包和 LwIP 2.1.2 怎么选

2.1 工具链和固件包先固定下来

这套环境最怕的不是装错,是「上次能跑、这次换个 CubeMX 就编不过」。我一般的做法是先把下面这张表抄下来,装完对一遍再动手。

组件我用的版本选它的原因
STM32CubeMX6.11.x6.x 后期版本的 ETH + LwIP 生成器已经比较稳定,早期 6.0~6.3 生成的ethernetif.c里接收路径有已知的对齐问题
STM32Cube FW_F4V1.28.0内含 HAL 驱动和 LwIP 2.1.2,HAL_ETH_Init的自协商超时处理比 1.25 之前清楚
LwIP2.1.2CubeMX 自带,跟随固件包走,不要自己去官网下新版替换
工具链STM32CubeIDE 1.16 / Keil MDK 5.38 + ARMCC 6两者都行,但 ARMCC 5 对__attribute__((aligned(4)))的写法要换成__align(4)
调试器ST-Link V2/V3只要能读 SWD、能打断点就够
串口助手任意用来打印 PHY ID、链路状态和 LwIP 的netif状态

LwIP 的版本不要自己换。CubeMX 生成的ethernetif.c、lwipopts.h和 HAL 的 ETH 驱动是配套的,你从官网拉一个 LwIP 2.2.0 塞进去,编译可能过,运行大概率在pbuf分配或者校验和那一步翻车——这种翻车最难查,因为栈里全是 LwIP 自己的函数,看不出是你换版本导致的。

安装 CubeMX 本身没什么坑,唯一要提醒的是:CubeMX 依赖 Java 运行时,自带的那套一般够用,不要为了别的工具去动系统里的 Java 环境变量。装完 CubeMX 之后打开 Help → Manage embedded software packages,把 FW_F4 V1.28.0 下载下来,这一步要联网,装完本地就有缓存,之后离线也能建工程。

提示:CubeMX 的固件包缓存在用户目录下,换机器的时候可以直接把整个STM32Cube/Repository目录拷过去,省一次下载。

2.2 硬件侧先确认三件事,别等写完代码再回头查

在打开 CubeMX 之前,先把板子上的三件事确认清楚,这三件事直接决定后面参数怎么填:

第一件,LAN8720A 旁边有没有 25MHz 晶振。这是整套环境里最容易翻车的地方。LAN8720A 内部需要一个 25MHz 参考时钟,常见做法是 PHY 旁边挂一颗 25MHz 无源晶振,芯片内部 PLL 倍频后从nINT/REFCLKO引脚输出 50MHz 给 STM32 的 PA1(ETH_RMII_REF_CLK)。如果板上没有这颗晶振,而是想靠 STM32 的 MCO 输出时钟给 PHY,那就得反过来确认 PHY 的时钟输入脚接的是哪一路,两套方案引脚完全不一样。

第二件,PHY 地址是 0 还是 1。LAN8720A 的 PHYAD0 引脚和 RXER 复用,上电时被采样决定 SMI 地址:下拉为 0,上拉为 1。很多板子会在这个引脚上放一个电阻或者跳线帽,你拿万用表量一下 RXER 那条线对上拉电阻通不通就知道了。地址填错的表现是HAL_ETH_ReadPHYRegister一直返回HAL_ERROR,但 ETH 初始化不会报错,只会在自协商那里静默超时。

第三件,PHY 有没有独立的复位脚。有些板子把 LAN8720A 的 nRST 直接拉高常置,有些接了一个 GPIO。如果有,记住这个脚是低电平有效,而且拉低至少要维持 100μs 以上——我见过有人拉低 1ms 就放,PHY 根本没起来,读 ID 读到 0x0000。

2.3 工程骨架:新建,不要在老工程上改

这一点属于血泪经验。ETH 和 LwIP 在.ioc里的配置项和引脚是强耦合的,你在一个已经配了 USB、SDIO、FSMC 的老工程上直接勾 ETH,CubeMX 有很大概率把某个复用的引脚重新分配走,然后你在编译期看不到任何问题,运行期 USB 或网口其中一个不工作。

所以我的习惯是:新建一个干净的工程,只勾 RCC(HSE 选 Crystal/Ceramic Resonator)、SYS(Debug 选 Serial Wire)、ETH、LwIP 这四项,先把 ping 打通,再往这个骨架上叠别的外设。多花十分钟建工程,省下来的是后面几天查「为什么网口和 SD 卡不能同时用」的时间。

新建工程时,Project Manager 里有两个设置要改:

# Project Manager → Code Generator 里勾上这两项 # 1. Generate peripheral initialization as a pair of '.c/.h' files per peripheral # 2. Keep User Code when re-generating

第一条让 ETH 的初始化代码单独落在eth.c/eth.h里,不和main.c混在一起,后面手改好定位;第二条保证你手写的代码在重新生成时不会被冲掉。第二条尤其重要——ethernetif.c里凡是 CubeMX 自动生成的段落都有USER CODE BEGIN/END包裹,你自己加的东西必须写在这两个标记之间,否则点一次 Generate Code 就没了。

3. CubeMX 里配以太网:RMII 引脚、50MHz REF_CLK 和 LAN8720A 地址

3.1 时钟树里没有 ETH 时钟,但有一条隐含依赖

打开 Clock Configuration,你会发现 CubeMX 根本没有给 ETH 留一个时钟输入项。原因是以太网的 RMII 参考时钟由外部 PHY 提供,ETH 外设本身的寄存器访问时钟来自 HCLK。所以时钟树这边只要保证 HSE 频率填对(大部分板子是 8MHz 晶振)、HCLK 跑到你熟悉的频率就行,F407 一般是 168MHz。

但有个隐含依赖容易被忽略:MDC 时钟是由 HCLK 分频得到的,必须落在 1.0~2.5MHz 之间。HAL 的HAL_ETH_Init会根据 HCLK 自动选分频系数,一般不用管,但如果你把 HCLK 压到很低(比如为了省电跑到 24MHz),MDC 就可能掉到规格之外,表现是 PHY 寄存器时读得到时读不到。

PA1 这根 RMII_REF_CLK 在 CubeMX 里不需要你手动配,选了 ETH 的 RMII 模式后它会自动被设成 AF11 输入。你要确认的是:这根线在原理图上确实接到了 LAN8720A 的nINT/REFCLKO引脚。如果接到了别的脚,后面所有配置都是白搭。

3.2 ETH 外设参数,一个一个对

在 Connectivity → ETH 里,参数按下面填:

参数值说明
ModeRMIILAN8720A 只支持 RMII,不要选 MII
Auto NegotiationEnable让 PHY 自己协商 10/100、全双工/半双工
Speed(自协商使能后变灰)不用管
PHY Address0 或 1按 2.2 节量出来的结果填
Rx ModeInterrupt Mode 或 Polling Mode无系统 LwIP 两种都能跑,我一般先用 Polling,少一个中断源
Checksum offload先关第一次调通别开,开了对不上会非常难查,后面 5.2 会讲

Advanced Parameters 里的 MAC 地址保持默认就行,CubeMX 会给一个00:80:E1:00:00:00之类的固定值,自己改也行,只要不和你局域网里别的设备撞。

RX Buffers 和 RX Descriptor 这两个数第一次调的时候保持默认(CubeMX 一般给 4 和 4),等 ping 通了再考虑加。这两个数太小会导致接收描述符耗尽,表现是「ping 通了几十包之后突然断了」,5.3 节会细说。

3.3 GPIO:MDC/MDIO 千万别加内部上拉

配完 ETH,CubeMX 会自动把下面这批引脚点亮(以 F407ZG 为例):

/* 这些引脚的复用功能不要手改,CubeMX 生成的 eth.c 里已经配好 */ PA1 -> ETH_RMII_REF_CLK (AF11, 输入) PA2 -> ETH_MDIO (AF11, 双向) PC1 -> ETH_MDC (AF11, 推挽输出) PA7 -> ETH_RMII_CRS_DV (AF11, 输入) PC4 -> ETH_RMII_RXD0 (AF11, 输入) PC5 -> ETH_RMII_RXD1 (AF11, 输入) PB11 -> ETH_RMII_TX_EN (AF11, 推挽输出) PG13 -> ETH_RMII_TXD0 (AF11, 推挽输出) PG14 -> ETH_RMII_TXD1 (AF11, 推挽输出)

MDIO 这根线有个细节:LAN8720A 的 MDIO 已经带了内部上拉(规格书里写的是 1.5kΩ 左右),你在 CubeMX 的 GPIO 设置里不要再勾 Pull-up。两个上拉叠在一起,MDIO 的上升沿会变缓,SMI 时序在高速下会不稳,读到 PHY ID 是 0xFFFF 的概率大幅上升。这个坑我在两块不同的板子上都踩过,量波形才看出来。

如果你板上 PHY 有独立复位脚(假设接在 PB2),那就在 CubeMX 里把它配成 GPIO_Output,初始电平 High,然后在main.c的USER CODE BEGIN 2里加一段复位时序:

/* main.c, USER CODE BEGIN 2 之间 */ HAL_GPIO_WritePin(PHY_RST_GPIO_Port, PHY_RST_Pin, GPIO_PIN_RESET); HAL_Delay(50); /* 拉低至少 100us,我给到 50ms 富余 */ HAL_GPIO_WritePin(PHY_RST_GPIO_Port, PHY_RST_Pin, GPIO_PIN_SET); HAL_Delay(100); /* 等 PHY 内部 PLL 锁定再访问 SMI */

这三行的逻辑是:先把 PHY 按进复位,让它内部所有寄存器回到默认值;等 50ms 保证复位充分;释放复位之后再等 100ms,因为 LAN8720A 从复位释放到 SMI 可以正常响应大约需要 25ms 以上,给 100ms 是留余量。如果你把这 100ms 省掉,MX_LWIP_Init()里第一次读 BSR 就会失败,链路状态直接是 0。

注意:这段复位代码必须在MX_LWIP_Init()之前执行。CubeMX 默认把MX_LWIP_Init()也生成在USER CODE BEGIN 2附近,你要确认自己的复位代码排在它前面。

3.4 生成代码之后,第一件事是读 PHY ID

点 Generate Code 之前,先把 Project Manager 里的 Toolchain/IDE 选好,然后生成。生成完先别急着编译烧录,回到eth.c看一眼MX_ETH_Init:

/* eth.c 里自动生成的这一段,phyaddr 应该和你界面上填的一致 */ heth.Instance = ETH; heth.Init.AutoNegotiation = ETH_AUTONEGOTIATION_ENABLE; heth.Init.PhyAddress = 0; /* 填 0 还是 1,按 2.2 节量出来的结果 */ heth.Init.MACAddr = (uint8_t *)MAC_ADDR0; heth.Init.RxMode = ETH_RXPOLLING_MODE;

SVN/Git 里第一次提交之前,我习惯在main.c里塞一段 PHY ID 自检,用来确认 SMI 通路真的通了。LAN8720A 的 PHY ID 是固定的:寄存器 2 读出来是0x0007,寄存器 3 读出来是0xC0F1。这段代码直接抄:

/* main.c, USER CODE BEGIN 2 内,放在 MX_LWIP_Init() 之前 */ uint32_t phy_id1 = 0, phy_id2 = 0; if (HAL_ETH_ReadPHYRegister(&heth, 2, &phy_id1) != HAL_OK) { printf("SMI read fail: reg2\r\n"); /* SMI 完全不通 */ } if (HAL_ETH_ReadPHYRegister(&heth, 3, &phy_id2) != HAL_OK) { printf("SMI read fail: reg3\r\n"); } printf("PHY ID = 0x%04lX 0x%04lX\r\n", phy_id1, phy_id2); /* 三种结果对应三种不同的问题,5.1 节展开讲 */

读出来的结果直接决定你下一步往哪查:

读到的值含义往哪查
0x0007 0xC0F1PHY 正常继续往下走
0xFFFF 0xFFFFSMI 完全没有应答MDC 时钟、MDIO 上拉、PHY 地址
0x0000 0x0000PHY 没上电或还在复位供电、复位脚、25MHz 晶振

这段自检只花两分钟,但能把「网口不工作」这个大问题一刀切成上下两半:PHY 通了之后就只剩 LwIP 的事,PHY 不通就先别碰 LwIP,否则你会在两个层面之间来回怀疑,浪费整整一天。

4. 无操作系统 LwIP 移植:lwipopts.h 与 ethernetif.c 的落地改动

4.1 lwipopts.h:改界面,不要改文件

lwipopts.h是 LwIP 的行为总开关,无操作系统模式和带系统模式在这里差别巨大。但不要直接编辑这个文件——CubeMX 每次 Generate Code 都会覆盖它。正确的做法是在 CubeMX 的 Middleware → LwIP 配置界面里改,改完重新生成,文件里的值自然跟着变。

第一次调 ping,我一般只动这几个:

参数值为什么
NO_SYS1无操作系统核心开关,改 0 就会去链接 sys_arch,裸机下链接不过
SYS_LIGHTWEIGHT_PROT0无系统下没有临界区保护的必要,开了反而引入sys_arch_protect未定义
LWIP_NETCONN0不跑 netconn API,省 flash
LWIP_SOCKET0不跑 socket API,省 flash
LWIP_DHCP0第一次调通必须用静态 IP,DHCP 会引入额外变量
LWIP_ICMP1ping 靠它,别手滑关掉
LWIP_ARP1没有 ARP 就学不到 MAC,ping 第一包必丢
ETH_PAD_SIZE0帧头前面不补字节,除非你的 DMA 描述符有特殊对齐要求
MEM_SIZE16384 以上默认 1600 太小,ping 几个包就分配失败
PBUF_POOL_SIZE16收包缓冲池,太小会丢包

MEM_SIZE这一项是新手最容易忽略的。CubeMX 默认给 1600 字节,看着不少,但 LwIP 的堆里要放 ARP 表、pbuf 结构体、TCP PCB 等等,一个 ping 请求走一遍就吃掉大头。表现就是:第一包能回,第二包开始丢,或者ping -l 1000直接全超时。我第一次调的时候在这上面卡了半天,最后是靠 LwIP 的mem_malloc返回 NULL 的断点才定位到。

DHCP 关掉之后,静态 IP 在 LwIP 配置界面的 General 页填:

/* CubeMX 会把这里的值生成进 lwipopts.h,不要手改文件 */ IP_ADDR0 = 192 IP_ADDR1 = 168 IP_ADDR2 = 1 IP_ADDR3 = 10 /* 板上 IP:192.168.1.10 */ NETMASK0 = 255 NETMASK1 = 255 NETMASK2 = 255 NETMASK3 = 0 /* 子网掩码:255.255.255.0 */ GW_ADDR0 = 192 GW_ADDR1 = 168 GW_ADDR2 = 1 GW_ADDR3 = 1 /* 网关,直连电脑时填电脑的 IP 或不填 */

IP 的选择有个小坑:不要用192.168.1.1,那是绝大多数家用路由器的默认地址,你一插上网线就和路由器撞了,表现是 ARP 学得到 MAC 但 ping 不通,因为在同一个网段里有两个 1.1。用.10之后的地址,直接连电脑测试的时候记得把电脑网卡也设成同网段的不同地址。

4.2 ethernetif.c:先要能跑,再谈零拷贝

ethernetif.c是 LwIP 和 HAL ETH 驱动之间的桥,核心就两个函数:low_level_output负责把 pbuf 发出去,low_level_input负责把收到的帧变成 pbuf。CubeMX 生成的版本用的是零拷贝——直接把 pbuf 的 payload 指针塞进 DMA 描述符。零拷贝性能好,但对 pbuf 的链式结构有要求,一旦 LwIP 分配出一个跨多个 pbuf 的帧,DMA 就只发出第一段,剩下的丢了。

第一次调通,我强烈建议先改成「拷贝到连续缓冲」的保守版本,跑通了再说优化:

/* ethernetif.c,写在 USER CODE BEGIN 4 区域内 */ #define ETH_TX_BUF_SIZE 1524 __attribute__((aligned(4))) static uint8_t s_tx_buf[ETH_TX_BUF_SIZE]; /* ARMCC 5 编译器把上一行换成:__align(4) static uint8_t s_tx_buf[ETH_TX_BUF_SIZE]; */ err_t low_level_output_conservative(struct netif *netif, struct pbuf *p) { struct pbuf *q; uint32_t len = 0; if (p->tot_len > ETH_TX_BUF_SIZE) { return ERR_MEM; /* 超长帧直接丢弃,比交给 DMA 更安全 */ } /* 沿着 pbuf 链把每一段的 payload 拷进连续缓冲 */ for (q = p; q != NULL; q = q->next) { memcpy(&s_tx_buf[len], q->payload, q->len); len += q->len; } /* 这里调你封装的底层发送,内部是 HAL_ETH_TransmitFrame */ return eth_send_frame(s_tx_buf, len); }

几个参数解释一下。ETH_TX_BUF_SIZE取 1524 是因为以太网标准 MTU 是 1500,加上 14 字节帧头和一些余量,1524 够用;__attribute__((aligned(4)))是必须的,ETH DMA 描述符和缓冲区都要 4 字节对齐,不对齐的话有些 F4 批次会在 DMA 搬数时出总线错误,表现是随机 HardFault,非常难查;p->tot_len判超长的意义在于:与其让 DMA 发一个超过描述符缓冲的帧导致描述符状态机卡死,不如在软件层直接扔掉。

low_level_input那边对应的做法是:从 DMA 描述符读出帧长度,申请一个PBUF_POOL类型的 pbuf(不是PBUF_RAM,收包用池更快),把描述符里的数据拷进 pbuf 的 payload,然后调netif->input(p, netif)交给上层。这里有个细节:拷完之后必须把描述符的所有权还给 DMA,也就是重设Status字段的 OWN 位,否则下一次接收就没地方放了,表现是「收了一包之后再也不收了」。

提示:low_level_input里pbuf_alloc失败时不要慌,直接return NULL让这帧丢掉就行。这本身就是 LwIP 的设计预期,丢一帧不影响 TCP 重传,硬要在失败时死等反而会把整个轮询循环卡住。

4.3 主循环怎么喂 LwIP:MX_LWIP_Process 的调用节奏

无操作系统模式下,LwIP 不是自己跑起来的,它靠你在主循环里反复调用MX_LWIP_Process()来驱动。这个函数内部会轮询 ETH 描述符、处理超时定时器、把收到的包交给上层协议栈。所以主循环长这样:

/* main.c 的 while(1) */ while (1) { MX_LWIP_Process(); /* 必须高频调用,建议每轮循环都调 */ /* 你自己的业务逻辑放这里,但每段逻辑都不能阻塞超过几毫秒 */ }

MX_LWIP_Process()的调用频率直接决定网络响应速度。如果主循环里有个HAL_Delay(100),那你的 ping 延迟就是 100ms 起步,丢包率也会上去,因为接收描述符在等待期间会被 DMA 填满,填满之后新来的帧全部丢弃。我一般会把所有阻塞操作挪到定时器中断里做,主循环保持「喂 LwIP + 查标志位」的轻量结构。

如果你的业务确实有长耗时操作(比如写 Flash),那就得把 ETH 改成中断接收模式,在 ETH 中断里只做「把描述符数据拷进 pbuf」这一件事,然后设置一个标志位,主循环看到标志位再调ethernetif_input。注意中断里绝对不能直接调netif->input,那里面会调pbuf_free之类可能触发内存操作的函数,在中断上下文里跑会破坏 LwIP 的堆,表现是随机丢包或者跑几个小时之后 HardFault。

4.4 静态 IP 通了再考虑 DHCP

很多人一上来就把LWIP_DHCP打开,结果 ping 不通的时候完全不知道是 DHCP 没拿到地址、还是拿到了地址但 ARP 不对、还是底层收发包本身就有问题。三种可能搅在一起,排查成本翻好几倍。

我的顺序永远是:先静态 IP 打通 ping,确认底层收发链路 100% 正常;再打开 DHCP,看能不能拿到地址;最后才接路由器跑真实网络。静态 IP 阶段如果 ping 不通,问题一定在 PHY 或者ethernetif.c,范围缩小一大半。

DHCP 阶段如果拿不到地址,先在串口里打印netif->ip_addr,看是不是 0.0.0.0。如果是,就打开 LwIP 的LWIP_DEBUG,把dhcp_debug打开,看看到底是 DISCOVER 没发出去,还是 OFFER 没收到。这一步的坑我在 5.4 里单独讲。

5. LAN8720A 与裸机 LwIP 的排查:五条踩坑记录与现象定位

这五条按「从底层到上层」的顺序排,你按顺序往下查,基本能在半天内定位到问题。

5.1 现象:读 PHY 寄存器返回 0xFFFF 或 0x0000

现象:3.4 节那段自检代码打印出PHY ID = 0xFFFF 0xFFFF,或者0x0000 0x0000,HAL_ETH_ReadPHYRegister返回HAL_ERROR。

原因:0xFFFF说明 SMI 总线上完全没有人应答,常见有三种:PHY 地址填错了(板子上是 1,你填了 0);MDIO 的内部上拉和外部上拉叠在一起导致时序不对;MDC 时钟超规格。0x0000说明 PHY 应答了但寄存器里全是 0,几乎都是 PHY 没上电、或者复位脚还拉在低电平、或者 25MHz 晶振没起振。

解决:先用万用表量 PHYAD0 引脚的电平,确认地址;再把 CubeMX 里 MDIO 的 Pull-up 去掉;然后用示波器量 MDC 的频率,应该在 1~2.5MHz 之间,超出范围就调 HCLK 或者查 HAL 的分频计算。晶振的话,量nINT/REFCLKO引脚应该有 50MHz 输出,没有就是晶振没起振,查晶振的负载电容和焊接。

5.2 现象:链路灯亮、能学到 MAC、ping 就是超时

现象:RJ45 座上的 Link/Act 灯正常,串口打印netif状态是 link up,电脑的 ARP 表里也能看到板子的 MAC,但ping一直超时。

原因:九成是校验和的问题。CubeMX 里 ETH 的 Checksum offload 如果打开了,ethernetif.c里必须同步定义CHECKSUM_BY_HARDWARE,并且接收路径要检查描述符的校验和状态位。两边不一致的话,发出的帧校验和是对的(硬件算的),但收到的帧 LwIP 会认为校验和错误直接丢掉。表现就是「电脑能收到板子的 ARP 请求,板子收不到电脑的 ARP 应答」。

解决:第一次调通,把 CubeMX 里的 Checksum offload 全部关掉,让软件算校验和。跑通 ping 之后再回头开硬件校验和,开的时候确认lwipopts.h里CHECKSUM_BY_HARDWARE是定义的,并且ethernetif.c的接收路径里有对应的状态检查。这一步不要图省事跳过,我见过太多人在这里耗一整晚。

5.3 现象:能 ping 通,几十秒后突然掉线

现象:刚烧录进去能 ping 通,几包到几十包之后开始丢包,最后完全不响应,复位一下又能通几十秒。

原因:接收描述符耗尽。ETH 的 RX DMA 是一圈描述符,每个描述符指向一块缓冲,DMA 收满一圈就回头覆盖。如果主循环里MX_LWIP_Process()调用不够频繁,或者low_level_input里处理完一帧之后忘了把描述符的 OWN 位还给 DMA,那么 DMA 转一圈之后就没有可用描述符了,新帧直接丢。MEM_SIZE太小也会导致类似的症状——pbuf_alloc一直失败,收到的帧拷不进 pbuf。

解决:先把 CubeMX 里 ETH 的 RX Buffers 从 4 加到 8 或 16,给主循环多一点余量;再检查low_level_input里描述符归还的逻辑;最后把MEM_SIZE加到 32KB 试试。这三步做完还掉线,就打开 LwIP 的mem_debug和pbuf_debug,看是不是某个地方申请了内存没释放。

5.4 现象:DHCP 一直拿不到地址

现象:静态 IP 阶段一切正常,切到 DHCP 之后netif->ip_addr一直是 0.0.0.0,串口打印 DISCOVER 发出去了但没有 OFFER。

原因:无操作系统模式下,DHCP 需要靠MX_LWIP_Process()里的定时器驱动。如果主循环太慢,DHCP 的重传定时器就跑不准,DISCOVER 发一次之后要等好几秒才发第二次,路由器那边可能已经超时了。另一种可能是LWIP_DHCP打开了但LWIP_UDP没打开——DHCP 走 UDP,UDP 关了 DHCP 就发不出去,CubeMX 不会给你任何警告。

解决:先确认LWIP_UDP是 1,再确认主循环里MX_LWIP_Process()至少每 10ms 跑一次,然后看dhcp.c里的DHCP_DISCOVER状态有没有被正确设置。如果都正常还是拿不到,就在电脑上装个抓包工具,看板子到底有没有把 DISCOVER 广播出来。抓不到就说明是发送路径的问题,抓到了没回应就查路由器那边的 DHCP 池。

5.5 现象:ping 小包通,大包丢

现象:ping 192.168.1.10通,ping -l 1000 192.168.1.10就大量丢包或者全超时。

原因:PBUF_POOL_SIZE或者MEM_SIZE不够。一个 1000 字节的 ICMP 包,加上 IP 头、以太网头,需要分配好几个 pbuf 结构,池子小了就分不到。另一种可能是ETH_TX_BUF_SIZE设得太小(比如有人改成了 512),大包在low_level_output里就被ERR_MEM挡掉了。

解决:PBUF_POOL_SIZE加到 16 或 32,MEM_SIZE加到 32KB,ETH_TX_BUF_SIZE保持 1524。改完重新生成,问题基本就没了。剩下的边角情况是 MTU 设错了,但这个在 CubeMX 默认配置里一般不会动到。

6. 装完怎么验证:从 ping 首包到抓包确认链路真的通了

验证这件事,我从来不信「能 ping 通就行」这一句话。ping 通只说明某一个方向、某一种包长的链路是通的,换个包长、换个协议可能立刻挂掉。所以我一般走三步,一步一步收紧。

第一步,先看 PHY 状态寄存器。链路起来之后读 LAN8720A 的 BSR(寄存器 1),bit2 是 Link Status,bit5 是 Auto-Negotiation Complete,读出来应该是0x796D之类的值——不用记具体数,看 bit2 和 bit5 都是 1 就行。这一步能确认物理层真的协商成功了,而不是「灯亮了但其实没协商上」。

第二步,分三种包长 ping。小包、中等包、大包各来一轮:

# 小包,验证基本收发路径 ping 192.168.1.10 -n 20 # 中等包,验证 pbuf 分配和缓冲大小 ping 192.168.1.10 -l 500 -n 20 # 大包,验证 MTU 和 TX 缓冲 ping 192.168.1.10 -l 1400 -n 20

三轮都 0 丢包,才算底层真的通。任何一轮有丢包,回去查 5.3 或 5.5。Windows 上的-n,Linux 上是-c,这个别搞混。

第三步,用抓包工具确认帧结构。在电脑上抓板子发出的包,看两个地方:以太网帧头的源 MAC 是不是你在代码里设的那个,IP 头的校验和是不是 0(硬件校验和开启时是 0,软件算是真实值)。这两个对上了,说明ethernetif.c的发送路径没有篡改数据,LwIP 也没把别的东西塞进去。

抓包还能看到一个隐性指标:ARP 请求的间隔。正常情况下一台设备开机后只发一次 ARP 请求学网关 MAC,之后就缓存了。如果抓包看到板子每隔几秒就发一次 ARP 请求,说明 ARP 表在不停老化或者被清空,问题多半在MEM_SIZE或者ARP_TABLE_SIZE上,这是 5.3 的一个变种。

最后补一个我自己每次都会做的小检查:把网线拔掉再插上,看netif的 link 状态会不会跟着变。CubeMX 生成的ethernetif.c里low_level_init默认不处理 link 变化,拔插之后 PHY 重新协商,但 LwIP 那边还以为链路是 up,结果就是「插着能 ping,拔一下再插就死了,非得复位」。处理办法是打开LWIP_NETIF_LINK_CALLBACK,在netif_set_link_callback里重新触发一次自协商。这个坑不常见,但一旦碰上,排查起来非常费劲,因为它不像配置错误那样一烧录就暴露,而是过一段时间才出现。

从那以后我每次调新的以太网板子,都会在烧录之前先把ping三种包长跑一遍、再拔插一次网线确认 link 回调生效,才敢往下写业务代码。这套流程看着啰嗦,但比事后在客户现场查「为什么网络跑一天就断」省事得多。希望帮到你。

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