简介:面向 STM32 嵌入式开发者的以太网通信示例工程,基于 STM32F429IGT6 与 LAN8720A 以太网 PHY 芯片,配合 HAL 库、STM32CubeMX 6.6.1 生成的工程骨架和 LwIP 2.1.2 协议栈,在无操作系统环境中实现 PING 通主机的网络联通验证。工程包含 1300 个文件,以 678 个 C 源码、450 个头文件为主,另有启动汇编、链接脚本、工程配置及 Hex 烧录文件等,压缩包约 31.16MB,可直接查看库函数调用、PHY 寄存器读写和网络数据包处理逻辑。已有 1988 人浏览学习,适合入门 STM32 以太网外设、RMII 接口时序或 LwIP 移植调试的开发者参考。通过工程源码可了解 LAN8720A 的 SMI 寄存器配置、RMII 数据收发路径以及无 RTOS 环境下 LwIP 的初始化流程,对搭建同类以太网应用或排查 Ping 不通问题有直接帮助。
1. 立项先别急:STM32 加 LAN8720A 这套以太网组合,装不好就是三天起步
先把话说在前面:在 STM32 的 HAL 库生态里,以太网 RMII 接口加一颗 LAN8720A PHY 芯片,再挂上无操作系统版 LWIP 协议栈,是我见过翻车率最高的外设组合之一。不是因为难,而是因为步骤太多,任何一个环节错了,现象都不是“报错”,而是“PING 不通”,你连从哪里下手都不知道。很多开发者拿着某开发板的例程抄,改个引脚就烧进去,结果网络完全沉默,查了一天发现是 PHY 地址没对上。这类血泪经验我在项目里攒了不少。
这篇文章面向的是手里已经有 STM32 最小系统板或者某款以太网扩展板、正准备自己接线和移植 LWIP 的 C 语言开发者。我会把 RMII 接线、CubeMX 初始化、LWIP 无操作系统集成、IP 和内存池配置、以及 PING 不通时的排查路径全部走一遍。如果你正好卡在“为什么我的板子 PING 不通”,这篇文章就是给你准备的。装好一套能 PING 通的环境,后面 TCP/UDP 通信才有地基。
2. 硬件连接与初始化:RMII 这 12 根线,一根都不能错
2.1 RMII 模式下 LAN8720A 与 STM32 的引脚对应关系
LAN8720A 是常见的 10/100M 以太网 PHY 芯片,支持 RMII 接口。RMII 相比 MII 线少,但只有两种选择:要么按标准接,要么全部重来。RMII 需要 6 根数据/控制线:TXD0、TXD1、TX_EN、RXD0、RXD1、CRS_DV,再加一根 REF_CLK 时钟,两根 MDIO 和 MDC 管理线,以及一个 nRST 复位引脚。有的板子把 nINT 中断引脚也连出来了,如果你不用中断,可以悬空或者接 GPIO 输入。
我一般先看 STM32 的数据手册里的 RMII 映射表,但更可靠的方法是用厂商提供的图形化配置工具打开以太网外设,它会自动把可用引脚标出来。以常见的某款 F407 芯片为例,RMII 的 REF_CLK 只能在 PA1 上,TXD0 是 PB12,TXD1 是 PB13,TX_EN 是 PB11,RXD0 是 PC4,RXD1 是 PC5,CRS_DV 是 PA7,MDC 是 PC1,MDIO 是 PA2。这些引脚在不同的板子上可能被占用,所以第一步就是对照原理图确认你的 PHY 芯片的每个信号接到了 STM32 的哪个引脚。
接线的坑主要在 REF_CLK。LAN8720A 的时钟有两种模式:一种是从 STM32 输出 50MHz REF_CLK 给 PHY,另一种是 PHY 自己用 25MHz 晶振生成 50MHz 时钟并输给 STM32。我踩过最大的坑是某些扩展板把 REF_CLK 接到了 STM32 的 MCO1 引脚上,但配置工具里默认却被我设成了 PA1 的以太网时钟输入,结果时钟冲突,PHY 完全没反应。正确做法是看原理图,确认你的 REF_CLK 是“STM32 输出给 PHY”还是“PHY 输出给 STM32”,然后在时钟配置里选对来源。
另外,LAN8720A 的 nINT 中断引脚如果没接,就别在代码里使能 PHY 中断,否则你会等处在一个永远无法触发的中断里。复位引脚也重要,有的板子把 nRST 接到了 STM32 的普通 GPIO,需要在初始化代码里拉低再拉高,如果悬空,PHY 状态可能不稳,偶尔 PING 不通就是它引起的。
2.2 用配置工具生成以太网外设初始化代码
打开你的 HAL 库工程,先在图形化配置工具里找到以太网外设(ETH),选择 RMII 接口模式。然后按上面确认的引脚关系,把 TX、RX、MDIO、MDC、REF_CLK 和 CRS_DV 都分配到对应引脚。分配完之后,核对一下 PINOUT 视图,看看有没有冲突的复用功能。
接下来是时钟配置。以太网外设需要 50MHz 的 REF_CLK,以及 25MHz 的 PHY 时钟(如果 PHY 使用外部晶振则不需要 STM32 提供)。常见的配置是把系统时钟设为 168MHz(F407 典型值),然后让 ETH 的时钟源来自 PLL 的 50MHz 输出。我在配置时会在“Clock Configuration”里把 ETH 的时钟源选为 PLLQ,并确认输出是 50MHz。如果这里没设对,生成代码后再怎么调 LWIP 都是白搭,因为 PHY 根本没有时钟工作。
生成代码后,你会得到一个eth.c文件,里面有MX_ETH_Init函数。这个函数会填充一个ETH_InitTypeDef结构体,包括 PHY 地址、自动协商、速度和双工模式等。我通常会把自动协商打开,这样插上网线时能自适应 10/100M。但注意,无操作系统版 LWIP 中,PHY 的初始化并不是只靠这个函数就完成的,HAL 库的以太网驱动里还需要你提供HAL_ETH_MspInit和HAL_ETH_MspDeInit,这两个回调函数引脚和时钟的初始化,如果配置工具没有帮你生成,需要自己补写。
void HAL_ETH_MspInit(ETH_HandleTypeDef *heth) { GPIO_InitTypeDef GPIO_InitStruct = {0}; if (heth->Instance == ETH) { __HAL_RCC_ETH_CLK_ENABLE(); __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_GPIOB_CLK_ENABLE(); __HAL_RCC_GPIOC_CLK_ENABLE(); /* 配置 REF_CLK、MDIO、CRS_DV 等引脚 */ GPIO_InitStruct.Pin = GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_7; GPIO_InitStruct.Mode = GPIO_MODE_AF_PP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_VERY_HIGH; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); /* 配置 TXD0、TXD1、TX_EN */ GPIO_InitStruct.Pin = GPIO_PIN_11 | GPIO_PIN_12 | GPIO_PIN_13; HAL_GPIO_Init(GPIOB, &GPIO_InitStruct); /* 配置 RXD0、RXD1 */ GPIO_InitStruct.Pin = GPIO_PIN_4 | GPIO_PIN_5; HAL_GPIO_Init(GPIOC, &GPIO_InitStruct); } }这个函数里的引脚分组必须和你的实际接线一一对应。很多人直接复制例程的MspInit,结果自己的板子引脚不一样,以太网自然是死的。看没看到__HAL_RCC_ETH_CLK_ENABLE(),这行是使能以太网外设的时钟,漏了它,后面的任何寄存器操作都会触发 hardfault。另外,引脚模式要设成GPIO_MODE_AF_PP和GPIO_SPEED_FREQ_VERY_HIGH,RMII 信号频率高,速度等级不够会波形畸变。
2.3 PHY 地址与复位时序的确定方法
LAN8720A 的 PHY 地址通常由芯片的 PHYAD0 和 PHYAD1 引脚电平决定。常见的模块上这两个引脚都接地,所以地址是 0x00;也有的是接上拉,地址变成 0x01。我不知道你的板子是什么样,所以我一般会先看原理图,找不到的话就去读芯片手册,手册里会有 PHYAD[1:0] 的真值表。
在MX_ETH_Init里,phyAddress字段就填这个地址。如果填错了,HAL 库在HAL_ETH_Init阶段去读 PHY 的 BSR 寄存器时,读回来的全是 0xFF 或者 0x00,HAL_ETH_ReadPHYRegister就会返回超时错误。我见过有人在这个字段填了 0x01,结果整整折腾了一个晚上,最后才发现模块上的 PHYAD0 和 PHYAD1 其实是悬空,内部下拉接地,地址是 0x00。
复位时序也是个容易被忽略的点。很多板子把 nRST 接在 STM32 的一个 GPIO 上,但这根线如果不做控制,PHY 上电后可能处于复位状态,也可能不在。稳妥的做法是在main函数里,在MX_ETH_Init之前手动拉低复位引脚,延时 10 毫秒,再拉高,然后再初始化 ETH。如果复位引脚没连接,那就要靠 PHY 自带的电源上电复位电路,一旦上电时序不满足,后续所有寄存器操作都会飘忽不定。
HAL_GPIO_WritePin(GPIOE, GPIO_PIN_1, GPIO_PIN_RESET); HAL_Delay(10); HAL_GPIO_WritePin(GPIOE, GPIO_PIN_1, GPIO_PIN_SET); HAL_Delay(50); MX_ETH_Init();这段代码的前面两行是强制让 PHY 重新复位,第三行等它稳定,后面才初始化以太网外设。注意复位引脚是哪个脚,不同板子差异极大。如果 HSE 起振慢,建议把复位后的延时加大到 100 毫秒。这种情况下,HAL_Delay 依赖 SysTick,如果你在初始化 LWIP 之前禁用了 SysTick 中断,延时函数会卡死,这是另一个我踩过的坑。
3. 移植 LWIP 无操作系统版:三大文件的取舍与改动
3.1 LWIP 源码的版本选择与目录结构
在无操作系统的裸机环境下,LWIP 以源码形式嵌入到工程中。我不建议你从零去网上下载最新版 LWIP,而是优先使用芯片厂商固件包里自带的 LWIP 版本。因为厂商已经把它与 HAL 库的以太网驱动做了适配,你只需要复制目录并加入工程即可。以某个 HAL 库开发包为例,它的Middlewares/Third_Party/LwIP目录下就有现成的src和system两个子目录。src是 LWIP 核心协议栈,system里是厂商提供的移植文件,包括ethernetif.c和lwipopts.h的默认配置。
版本太新反而可能带来问题。我试过把 LWIP 从厂商配套的 2.0.3 升级到 2.1.2,结果ethernetif.c里的 API 有变更,比如tcpip_thread_input变成了tcpip_input,裸机版没有 TCP/IP 线程,这些线程相关的接口就需要重新适配。所以在没有必须升级的理由时,用厂商固件包里的配套版本最省心。如果确实要升级,记得对比ethernetif.c中的low_level_input和low_level_output这两个函数,看它们调用的 LWIP 核心函数是否仍然存在。
目录放在工程里的方式,我惯例是建一个LWIP文件夹,把src和system都塞进去,然后在 IDE 里把src/core、src/netif、src/api(裸机其实用不到 api 层,但编译需要头文件)以及src/port的路径全部加进 include。如果你图省事只加了顶层src路径,编译时会报找不到lwip/opt.h这种头文件,就是因为路径层级不够。
# 伪代码,示意工程中的 include 路径 INCLUDES += \ -I./LWIP/src/include \ -I./LWIP/src/include/lwip \ -I./LWIP/src/include/netif \ -I./LWIP/system \参数-I./LWIP/system别省,ethernetif.c里会 includelwipopts.h和arch/cc.h,这些都在 system 目录下。有些开发者把lwipopts.h放在用户目录,然后手动在编译选项里加路径,这种方式可行,但容易和 system 目录下的默认配置冲突,编译时出现重定义。我的习惯是只保留 system 目录下的一份lwipopts.h,所有配置都改它,别到处复制。
3.2 lwipopts.h 中必须开和必须关的宏
lwipopts.h是 LWIP 的配置总闸,裸机环境下的配置比带系统时简单,但有几个宏直接决定你能不能 PING 通。首先是NO_SYS,裸机必须设为 1,这样 LWIP 不会尝试调用任何与操作系统相关的信号量或线程接口。如果不设,在无系统环境下编译会报一堆sys_sem_new之类的未定义错误。
其次是LWIP_DHCP。如果你只是想先 PING 通内网固定 IP,把 DHCP 关掉,手动设置静态 IP。因为 DHCP 流程在裸机上需要额外的定时器处理,如果定时器没配好,DHCP 会反复超时,PING 也进不了正常状态。等静态 IP 验证通过后,再回头开 DHCP 也不迟。
内存配置是另一个重点。MEM_SIZE决定 LWIP 内核堆大小,PBUF_POOL_SIZE决定 PBUF 池数量,PBUF_POOL_BUFSIZE决定每个 PBUF 的缓冲区大小。这些参数直接关系到大帧的收发。如果PBUF_POOL_BUFSIZE小于网卡最大帧长,比如默认 1518 字节,而你的 MTU 设成 1500,加上以太网头 14 字节和 FCS 4 字节,1518 是刚好的。如果PBUF_POOL_BUFSIZE只有 256,那大报文会被丢弃,PING 大包就会失败,但小包 PING 正常,这种玄学问题特别坑。
#define NO_SYS 1 #define LWIP_DHCP 0 #define LWIP_STATS 1 #define LWIP_ICMP 1 #define LWIP_RAW 1 #define MEM_SIZE 1024 * 20 #define PBUF_POOL_SIZE 16 #define PBUF_POOL_BUFSIZE 1518 #define LWIP_NETIF_STATUS_CALLBACK 1LWIP_NETIF_STATUS_CALLBACK设为 1 之后,你可以在网卡状态变化时得到回调,调试链路起来很有用。LWIP_RAW如果不开,某些调试工具无法使用原始套接字,但不影响 ICMP。LWIP_STATS建议打开,协议栈内部收发包统计会放在全局变量里,出问题时直接看lwip_stats就能判断是哪个环节丢包。
还有CHECKSUM_GEN_ICMP和CHECKSUM_CHECK_ICMP默认是 1,不用动。我见过有人为了“增加性能”把 IP 和 ICMP 的校验全关了,结果局域网内 PING 是通的,但数据内容完全损坏,跨网段必炸。除了优化场景,千万别关校验。
3.3 ethernetif.c 与 PHY 驱动:low_level_init 里的硬核改动
ethernetif.c是 LWIP 与 HAL 以太网驱动的桥梁,它的核心函数是low_level_init。这里要完成几件事,包括设置网卡 MAC、初始化描述符、设置 PHY 工作模式、注册 netif 的回调函数。我一般不会直接改整个文件,而是把它复制到用户代码目录,再修改里面的low_level_init。
low_level_init里首先要调用HAL_ETH_Init,这个函数会读 PHY 的 BSR 寄存器并配置自动协商。然后是手动设置描述符:LWIP 裸机版通常使用连续内存块作为 DMA 描述符和缓冲区,所以需要定义一个很大的数组,比如ETH_TxDesc[ETH_TX_DESC_CNT]和ETH_RxDesc[ETH_RX_DESC_CNT]。HAL_ETH_Init之后,你会调用HAL_ETH_ConfigDMA把这些描述符首地址配置给 DMA 控制器。
static void low_level_init(struct netif *netif) { #if defined ( __ICCARM__ ) #pragma data_alignment=4 #endif static uint8_t buffer[ETH_RX_DESC_CNT * ETH_RX_DESC_SIZE + 4]; ETH.TxDesc = (ETH_DMADescTypeDef *) (uint32_t) & buffer[0]; ETH.RxDesc = (ETH_DMADescTypeDef *) (uint32_t) & buffer[ETH_RX_DESC_SIZE]; HAL_ETH_Init(&heth); HAL_ETH_ConfigDMA(&heth); netif->hwaddr_len = ETH_MAC_ADDR_LEN; netif->hwaddr[0] = 0x00; netif->hwaddr[1] = 0x80; netif->hwaddr[2] = 0xE1; netif->hwaddr[3] = 0x00; netif->hwaddr[4] = 0x00; netif->hwaddr[5] = 0x01; netif->mtu = 1500; netif->flags = NETIF_FLAG_BROADCAST | NETIF_FLAG_ETHARP | NETIF_FLAG_LINK_UP; netif->output = etharp_output; netif->linkoutput = low_level_output; netif->tx = low_level_output; }这一段里最容易出问题的是描述符的对齐要求。DMA 描述符的内存地址必须 32 字节对齐,所以用#pragma data_alignment=4其实不太够,更严谨的做法是用__attribute__((aligned(32)))。我在 GCC 环境下不会用 pragma,而是直接用 C 编译器的对齐属性。数组布局也有讲究,ETH_TxDesc和ETH_RxDesc中间要留出描述符的空间,代码里&buffer[ETH_RX_DESC_SIZE]就是给 TxDesc 分配在 RxDesc 之后。
MAC 地址在netif->hwaddr数组里填。我填的00:80:E1:00:00:01是通用的本地测试地址,不要和局域网内其他设备冲突。有的路由器可能会过滤本地管理地址,如果 PING 不通,可以改成你真实网卡的 MAC 格式。netif->flags里必须有NETIF_FLAG_ETHARP,否则 ARP 请求发不出去,连自己都 PING 不通。
PHY 驱动的部分,HAL 库提供了HAL_ETH_ReadPHYRegister和HAL_ETH_WritePHYRegister。LAN8720A 的寄存器布局和通用 PHY 不完全一致。比如它的 BSR 寄存器地址是 0x01,但比特位定义里,Link Status 是 bit2,而不是通用 PHY 的 bit2 也是 Link 状态,这里倒是比较标准。但读取速度状态时,LAN8720A 的 BCR 寄存器(0x00)里的 SPEED 位是 bit13,和标准一致。我在写read_phy函数时,会先读 PHY 的 ID 寄存器(地址 0x02 和 0x03),确认读出的值等于 0x0007 和 0x0080,这是 LAN8720A 的标识。如果读出来完全不对,大概率是 MDIO 引脚接错,或者 PHY 地址错了。
4. 核心配置:IP、MAC 与内存池,决定 PING 通的质量
4.1 静态 IP 和 MAC 地址在代码里的填法
LWIP 的网卡配置主要在tcpip_init(如果是带系统的)或者裸机里的lwip_init之后进行。无操作系统版里,我们通常在main函数中调用netif_add来注册网卡,同时传入 IP 地址、子网掩码和网关。这三个地址是写在ip4addr结构体里的,别直接在netif_add里写字符串,那样很多人会弄错字节序。
struct netif lwip_netif; ip4_addr_t ipaddr, netmask, gw; IP4_ADDR(&gw, 192, 168, 1, 1); IP4_ADDR(&ipaddr, 192, 168, 1, 100); IP4_ADDR(&netmask, 255, 255, 255, 0); netif_add(&lwip_netif, &ipaddr, &netmask, &gw, NULL, ethernetif_init, ethernet_input); netif_set_default(&lwip_netif); netif_set_up(&lwip_netif);netif_add最后一个参数是ethernet_input,这是 LWIP 从网卡接收线程传包的入口。在无系统环境下,你需要周期性地调用这个函数,它会从 HAL 库的接收缓存中取出数据并送入 LWIP 栈。ethernetif_init则由 LWIP 在初始化时自动调用来执行low_level_init。注意顺序:先netif_set_default再netif_set_up,有些教程顺序反了,默认网卡没设置,路由时选择失败,PING 网关就会报错。
网关和 IP 要在一个子网内。如果你用的是电脑直连开发板而不是经过路由器,网关可以填开发板自己的 IP,也可以填电脑的 IP,但要保证两个设备能互相 ARP。我调试时喜欢用 192.168.1.100 之类的高地址,避免和电脑的 192.168.1.1 网关冲突。子网掩码别乱填 255.255.255.0,如果你的电脑用的是 10.0.0.x 网段,这里就得改。
MAC 地址的作用容易被低估。同一个局域网里如果有另一台设备的 MAC 和你填的一样,ARP 缓存会互相打架,表现为 PING 时而通时而断。所以low_level_init里的netif->hwaddr一定要填一个独一无二的地址。我习惯用芯片的 UID(唯一标识)生成低 24 位 MAC,高 24 位用一个固定的厂商前缀,这样每块板子的 MAC 都不同。
4.2 内存池参数对 PING 大包和并发的影响
LWIP 在裸机环境下完全依赖内存池。PBUF_POOL_SIZE通常设成不少于 16,太小的话,当 CPU 正在处理上一个接收包,而网卡又连续收到多个广播包时,没有空闲 PBUF,LWIP 会直接把新帧丢弃。因为丢包是静默的,你会看到 PING 丢失,但协议栈统计的lwip_stats.link.drop在增加。我调试时会临时把这个值加到 32,确认问题是否消失,然后再调回合理的数值。
MEM_SIZE则影响 ARP 表、路由表和 UDP/TCP 控制块等内核内存。无系统环境下,LWIP 使用一段静态数组作为内存堆,这个数组大小就是MEM_SIZE。如果太小,调用tcp_new()或udp_new()时会返回 NULL,但 PING 不需要 TCP/UDP,所以 PING 通并不代表MEM_SIZE够用。我建议在应用层开始加 TCP 服务器之前,把MEM_SIZE从默认的 20K 至少放大到 32K,否则后续跑连接会很痛苦。
还有一个隐形参数是TCP_MSS,它和 MTU 强相关。如果TCP_MSS设置超过PBUF_POOL_BUFSIZE - 40(IP头+TCP头),TCP 分段就会出错。虽然 PING 走的是 ICMP,不涉及 MSS,但既然你是为后续通信打底,此时应该把TCP_MSS设为 1460,同时确认PBUF_POOL_BUFSIZE不小于 1500。不然等你调 TCP 的时候又得回头改这个,不如现在一次到位。
#define TCP_MSS 1460 #define PBUF_POOL_BUFSIZE 1518这两个值组合在一起,表示一个 TCP 报文(1460 字节载荷 + 20 字节 IP 头 + 20 字节 TCP 头)能刚好放进一个 1518 字节的 PBUF。如果 PBUF 太小,LWIP 会自动做分片发送,但接收端重组会消耗额外的内存和时间。所以,确保 PBUF 尺寸能装下最大 TCP 分段,是减少丢包的关键。
4.3 中断与轮询模式的选择:裸机也可以不裸奔
无操作系统不代表一定要用轮询。HAL 库的以太网驱动支持两种接收模式:中断模式和轮询模式。在 LWIP 无系统版本中,最常见的做法是打开以太网接收中断,在中断回调里直接调用HAL_ETH_ReadData,然后把数据投递给netif->input。但由于 LWIP 协议栈内部不是线程安全的,在中断里直接调用ethernet_input很危险,因为主循环可能正在修改 PBUF 链表。
我推荐的做法是分两步。第一步,在HAL_ETH_RxCpltCallback中把收到的帧复制到一个环形缓冲区,然后置一个标志位。第二步,在主循环里检测到这个标志位后,调用ethernetif_input(实际就是ethernet_input)处理数据。这样既不受中断频繁触发的影响,又能保证协议栈的操作都在主线程上下文。
void HAL_ETH_RxCpltCallback(ETH_HandleTypeDef *heth) { if (heth->Instance == ETH) { g_eth_rx_flag = 1; HAL_ETH_ReadData(heth, &rx_buffer[0]); } } while (1) { if (g_eth_rx_flag) { g_eth_rx_flag = 0; ethernetif_input(&lwip_netif); } sys_check_timeouts(); }这里的HAL_ETH_ReadData会从 DMA 接收描述符中取出数据到rx_buffer。注意rx_buffer必须足够大,至少PBUF_POOL_BUFSIZE。sys_check_timeouts()是 LWIP 的定时器处理函数,你必须周期性地调用它,否则 ARP 表中的旧条目不会过期,DHCP 超时永远不会发生,重传定时器也会卡住。我在裸机主循环中会把对它的调用放在最显眼的位置,因为它和 PING 的延迟表现直接相关。
ethernetif_input在无系统版本里被实现为一个宏或者直接调用ethernet_input。主循环里的调用频率不能太低,否则接收帧在缓冲区里堆积,丢包率会上升。我一般把主循环周期控制在 10 毫秒以内,也就是每秒至少 100 次调用,足以应对大多数低速网络流量。
5. STM32 + LAN8720A 避坑指南:PING 不通先查这五处
5.1 现象:PING 完全不通,PHY 的 LINK LED 都不亮
原因基本上可以集中在三个方向:PHY 没有上电、复位没有拉起来、或者 RMII 的 REF_CLK 没给到。先量 PHY 的供电电压,LAN8720A 需要一路 3.3V 和一路 1.2V(内部 LDO 可从 3.3V 转出)。如果板子上有电源指示灯但 PHY 芯片上没有,去看数据手册确认 LED 引脚是否有输出。另外,用示波器量 REF_CLK 引脚,应该有 50MHz 方波。没有波形,就先检查接线的时钟来源。解决方法是:如果是 STM32 输出时钟,确认 ETH 的 PLL 配置为 50MHz;如果是 PHY 晶振,确认晶振是否起振,直接换一颗 25MHz 晶振试试。
我在调试这个现象时,发现过最离谱的原因:PHY 地址配置成了 0x00,但板子的 PHYAD0 实际接高,真实地址是 0x01。导致HAL_ETH_Init里读 PHY 寄存器失败,代码直接跳到Error_Handler。不过看起来像“没初始化成功”而不是“PING 不通”,但最终表现就是这个样子。解决方式很简单,把phyAddress改成 0x01,或者用 I2C 工具读一下 PHY 的寄存器地址。
另外,网络变压器也有影响。有的板子用的变压器是带中心抽头的自耦变压器,和 STM32 的共模电平不匹配,RMII 信号能发出但收发异常。这个不好排查,只能看原理图确认变压器的类型。我用的板子没这个问题,但如果你买的是杂牌扩展板,优先看它的原理图里网络变压器是否标准。
5.2 现象:能 PING 通开发板,但 PING 不通网关
PING 通开发板但网关不通,说明你的物理链路和 LWIP 转发没问题,问题在 IP 配置或 ARP 处理上。先检查开发板的网关 IP 是否和路由器同一网段。我的板子 IP 是 192.168.1.100,网关就应该是 192.168.1.1。如果网段不匹配,LWIP 会认为网关不可达,所有跨子网流量都会被丢弃。
还有一个常见问题是NETIF_FLAG_ETHARP没设置。没这个标志,LWIP 不会发送 ARP 请求,自然也无法解析网关 MAC。检查一下netif->flags的赋值。再看etharp的定时器是否在运行,ARP 老化靠sys_check_timeouts周期触发。如果主循环没调用sys_check_timeouts,ARP 缓存里初始的条目过期后,新的 ARP 请求永远发不出去。这种情况 PING 开发板没问题(因为同子网不需要 ARP?不对,同子网也需要,但你电脑的 ARP 缓存里已经有开发板了,所以通),但网关需要新的 ARP 请求,然后就卡住了。
解决:在lwipopts.h里确保LWIP_ARP为 1,并在主循环定时调用sys_check_timeouts()。把网关填对后,再用arp -d清空电脑的 ARP 缓存重新 PING。
5.3 现象:PING 偶尔通、偶尔超时,特别是传输大数据时
这个现象往往不是协议栈问题,而是底层驱动丢包。优先检查ethernetif.c中的low_level_output是不是用了阻塞等待 DMA 完成的方式。在无系统环境下,发送函数会在循环里等待HAL_ETH_GetTxDataFreeDescCount大于 0,但如果 RX 中断和主循环抢占不当,可能 TX 也没有及时释放描述符。
另外,接收描述符数量太少也会导致丢包。如果ETH_RX_DESC_CNT是 4,而你的网卡突发接收大量帧,DMA 描述符被占满,后来的帧就会丢弃。加大ETH_RX_DESC_CNT到 8 或 16 能明显改善。内存池PBUF_POOL_SIZE也要跟着加大,否则 DMA 扔进来的帧没有 PBUF 可用,一样丢。
还有一点,PING 发送间隔太短,有些低成本的 PHY 芯片在 100M 模式下处理 auto-negotiation 和 link change 会有短暂延迟。我遇到过在热插拔网线后立刻 PING,前几包必丢的情况。这属于 PHY 重新协商的物理行为,可以在low_level_init里加一个网线插入检测,HAL_ETH_ReadPHYRegister读 BSR 的 bit2,等它稳定后再把netif_set_link_up调用出去。如果你没实现NETIF_FLAG_LINK_UP状态机,那么链路刚插上时丢包就不可避免。
解决方式:在ethernetif.c中增加 PHY 链路状态检测任务,或者至少在low_level_init里循环读 BSR 寄存器,等到 bit2 置位后再返回。注意不要让这个等待时间太长,否则netif_add会阻塞主流程。
5.4 现象:烧录程序后第一次 PING 必失败,重启板子后才能通
这是典型的 PHY 复位与初始化时序问题。芯片上电后,内部 LDO 需要时间稳定,如果你在main里上电后立刻初始化 ETH,PHY 可能还没完成上电复位,导致HAL_ETH_Init里读 PHY 寄存器失败,或者读到零值。HAL 库的HAL_ETH_Init会等待超时,但超时后不会返回错误,因为很多驱动代码里忽略了返回值。这解释了我们第一次 PING 失败,第二次复位板子后 PHY 已经充分上电,所以能通。
解决办法是在main函数启动最前面加 200 毫秒延时,再进行MX_ETH_Init。如果你有复位引脚,复位信号的从拉低到拉高也要在延时之后。把复位拉到低,等 100 毫秒,拉高,再等 200 毫秒,然后再初始化。我自己在项目中遇到过无复位引脚控制的板子,只能用这种延时大法,效果稳定。
有人会在HardFault_Handler里看到错误,但那其实不是 hardfault,而是 HAL 库在 PHY 未就绪时反复尝试的循环超时。你在调试模式下可能会看到HAL_ETH_ReadPHYRegister的返回值是HAL_TIMEOUT,那就说明 PHY 没有在预期时间内响应。加大延时重新上电就能避开。
5.5 现象:开发板能 PING 通,但响应延迟高,甚至超过 10ms
PING 延迟高,先确认是不是你自己的主循环太慢。无系统 LWIP 的主循环要同时处理ethernetif_input和sys_check_timeouts。如果HAL_Delay或者某个传感器读取函数在主循环里占了 50 毫秒,PING 响应自然卡顿。解决是不要把 PING 的接收处理和慢速任务放在同一个循环里,或者把慢速任务挪到中断里。
检查LWIP_ICMP的响应是否走了高效的路径。LWIP 的 ICMP echo reply 是在协议栈里直接构造报文,不走 TCP/IP 的套接字层,所以延迟不会太高。如果延迟高,大概率是ethernetif_input没有被及时调用。把主循环里ethernetif_input的调用频率提升到至少 200Hz 以上,也就是 5 毫秒一次,PING 均值就能回到 1ms 内。
另一个隐藏原因是HAL_ETH_ReadData里使用了memcpy把 DMA buffer 拷贝到 PBUF,如果 DMA buffer 和 PBUF 都是 32 位对齐,可以改用直接修改 PBUF 指针的方式避免拷贝。但那样做会破坏 LWIP 的内存管理,代价太大。我觉得在裸机上 1ms 的拷贝延迟完全可接受,不要因为优化引入内存越界。
6. 装完怎么验证:从命令行 PING 到寄存器级确认
6.1 第一步:用直接连接的网线做最小环境验证
拿一根网线,一头插开发板,一头插电脑,然后把电脑的有线网卡 IP 设为 192.168.1.1,子网掩码 255.255.255.0。注意,不要插路由器,因为路由器的 DHCP 可能会给你分配一个不同网段的 IP,然后又多了一层网关的变量。直连环境下,你的 PING 就是对开发板的 ADDR 和 MAC 地址的直接验证。
在电脑的命令行里先清掉 ARP 缓存,然后把开发板烧录程序,按复位键,等待串口打印或者观察 PHY LED,看到 LINK LED 亮起后,开始 PING:
ping 192.168.1.100 -t-t是持续 PING,Windows 下的参数;Linux 下用ping 192.168.1.100加 Ctrl+C 结束。观察是否每个包都有回复。我在这个阶段会把电脑网卡的网线拔插一次,确认 PHY 能重新协商,并且在热插拔后 PING 依然能通,因为热插拔是生产环境里的必然事件。
PING 通只能代表 ICMP 链路通,不代表 LWIP 的 TCP 或者 UDP 正常。所以 PING 通过后,下一步我会用电脑上的简单工具去连接开发板的 7 号 UDP 端口(因为我在 LWIP 里开了 UDP echo),如果 UDP echo 回复,那就说明 DMA、PHY、LWIP 的完整收发链路都正常。如果没有 UDP echo,再回头看ethernetif_input的调用频率是否足够。
6.2 第二步:抓包看 ARP 和 ICMP 的请求/回复序列
在电脑上打开抓包工具(我用的是某款开源多平台抓包工具),选中网线所在的网卡,开始捕获。然后再次 PING 开发板,抓包里应该能看到这样的序列:电脑发出 ARP 广播“谁是 192.168.1.100?”,开发板回复 ARP 应答,然后电脑发 ICMP echo request,开发板发 ICMP echo reply。
如果只看到 ARP 请求不断重发,没有应答,说明开发板的 ARP 响应没有发出去,那问题在 LWIP 的 etharp 或者板子的发送路径。如果 ARP 正常但你只看到 request 没有 reply,问题在 ICMP 的处理。这样一层层剥,很快就能定位。
抓包时还要注意帧长度。标准 PING 默认包长 32 字节,但加上 IP 头 20 字节和以太网头 14 字节,总帧长为 66 字节。如果你用ping -l 1400发大包,长度应该接近 1472 字节。如果大包发出去后没有回复,说明 MTU 或 PBUF 尺寸有问题,回到第 4 章设置PBUF_POOL_BUFSIZE至少为 1518。
No. Source Destination Protocol Info 1 192.168.1.1 192.168.1.100 ARP Who has 192.168.1.100? Tell 192.168.1.1 2 192.168.1.100 192.168.1.1 ARP 192.168.1.100 is at 00:80:E1:00:00:01 3 192.168.1.1 192.168.1.100 ICMP Echo request 4 192.168.1.100 192.168.1.1 ICMP Echo reply上面的四条记录就是一次完整的 PING 成功序列。如果第 2 条不出现,查看etharp是否禁用了;如果第 4 条不出现,检查统计变量lwip_stats.icmp或者etharp的发送计数是否增长,这能告诉我是在哪个回调函数里丢的。
6.3 第三步:直接读写 PHY 注册表确认硬件状态
如果抓包都正常但 PING 依然有丢包,最后一步我会在调试器里直接读 PHY 的寄存器,确认硬件协商状态。LAN8720A 的基本寄存器可以通过 HAL 库函数读取,比如HAL_ETH_ReadPHYRegister,我在串口命令里加一个调试命令,输入phy就打印一次 0x01(BSR)和 0x00(BCR)的值。
BSR 寄存器(地址 0x01)的 bit2 表示 Link Status,为 1 表示链路已经建立。bit5 表示 Auto-Negotiation Complete,为 1 表示自协商完成。BCR 寄存器(地址 0x00)的 bit12 表示 Auto-Negotiation Enable,正常情况下应为 1。如果 BCR 的 bit12 是 0,而 BSR 的 bit5 又是 0,说明自动协商没有打开,PHY 可能工作在强制模式,这会干扰连接的建立。
uint32_t phy_bsr, phy_bcr; HAL_ETH_ReadPHYRegister(&heth, PHY_BSR_REG_ADDR, &phy_bsr); HAL_ETH_ReadPHYRegister(&heth, PHY_BCR_REG_ADDR, &phy_bcr); printf("BSR=0x%04X BCR=0x%04X\r\n", (unsigned int)phy_bsr, (unsigned int)phy_bcr);如果读到 BSR 的 Link Status 一直为 0,那说明物理层没到 link 状态,检查网线、对端设备是否在同一个速率模式,但通常直连电脑没问题。如果 BCR 的 Speed 位(bit13)和 Duplex 位(bit8)和你预期的网卡速率不匹配,那可能是自协商失败,可以尝试强制 100M 全双工。强制时,把MX_ETH_Init里的自动协商关闭,直接设置Init.MediumInterface = ETH_MEDIUM_INTERFACE_RMII、Init.FullDuplex = ETH_MODE_FULLDUPLEX、Init.Speed = ETH_SPEED_100M,这招在老旧交换机上很管用。
从那以后,我再也不在拿到板子的第一时间就烧程序开 PING。我会先跑一遍上面这三步:直连小环境、抓包看 ARP/ICMP 序列、读 PHY 寄存器确认协商状态,三板斧走完基本不会再有找不到头的玄学问题。尤其是 PHY 寄存器那个命令,虽然平时用不上,但一旦遇到疑难杂症,它比瞎改代码有效十倍。希望这篇文章能帮你少走一轮弯路,让那片以太网在 STM32 上稳定地响起来。
本文还有配套的精品资源,点击获取