简介:一套基于STM32F429IGT6与LAN8720A的裸机以太网通信工程参考,借助HAL库与LWIP2.1.2协议栈,在无操作系统环境下实现RMII接口数据交互与PING应答,面向需要快速调通以太网外设或移植LWIP的嵌入式开发者。包内共1300个文件,以C源文件与头文件为主体,另有汇编启动文件、链接脚本、MDK工程配置及DSP数学库等,整体约31.16 MB,压缩包结构完整,便于直接打开对照分析。工程清晰呈现SMI接口读写LAN8720A寄存器的流程、RMII链路配置与STM32CubeMX 6.6.1初始化框架,可当作从PHY驱动到无系统LWIP集成的参考模板,帮助减少底层调试时间,快速验证网络通信与系统收包通路。已有1988人下载学习,适合正在调试STM32以太网功能或准备集成LWIP的开发者取阅参考。
1. 裸机 STM32 跑以太网 PING 通:先搞清 PHY 和驱动的衔接
某开发者的项目要在没有 RTOS 的 STM32F4 控制板上加以太网状态上报,硬件上选了 LAN8720A 这颗 PHY 芯片,软件栈是 HAL 库加 LWIP,模式是无操作系统裸机轮询。当初觉得最难的一定是 LWIP,结果真正卡住的是更前面的 PHY 初始化、RMII 引脚复用和 DMA 描述符配置。这套资源打包的正是这条完整链路:HAL 库以太网外设配置、LAN8720A 驱动、LWIP 无 OS 移植、PING 通整条通路,用 C 语言直接操作寄存器级别的细节都已经摊开。适合手里有 STM32F4 或同系列芯片、想在裸机环境里加以太网功能的开发者,也适合被 CubeMX 生成代码坑过的人。先声明一个结论:无 OS 跑 LWIP,真正的工作量不在协议栈,而在 PHY 和 MAC 之间的连接。
2. LAN8720A 选型与硬件连接:RMII 七根线和一个 50MHz 时钟
2.1 为什么是 LAN8720A:一颗 PHY 芯片的取舍
STM32 片内自带的是以太网 MAC 控制器,MAC 负责数据链路层,而物理层信号编解码、载波监听这些工作要交给外部 PHY 芯片。常见的 PHY 有 DP83848 和 LAN8720A,前者接口信号多、功能全,后者面积小、功耗低、外围器件少,所以在小型工控板和核心板上用得非常多。LAN8720A 内置 1.2V 稳压器,单 3.3V 供电就能跑,外围只需要少量去耦电容和电阻,这对布局紧张的双层板很友好。
另一个关键点是接口模式。MII 接口需要 16 根数据线和一根独立时钟,RMII 接口只要 7 根信号线,数据线从 4 位变成 2 位,时钟固定 50MHz。对引脚紧张的 MCU 来说,RMII 几乎是唯一选择,F4 系列也专门为 RMII 预留了 AF11 复用。资源包里给的连接方式就是 RMII,接线时不要试图把 LAN8720A 配成 MII 去对接 F4 的 ETH 外设,引脚不够,而且 HAL 库初始化时 MediaInterface 选 RMII 才是项目本意。
选型时还要确认 PHY 地址。LAN8720A 的 PHYAD[1:0] 引脚决定了 MDIO 总线上的地址,常见板子把 PHYAD0 拉低或拉高,地址可能是 0x00 或 0x01。HAL 库读写 PHY 寄存器时用的是 ETH_MDIO_ENABLE 时的默认设备地址,如果地址不对,读出来的寄存器全是 0xFFFF 或者忙超时。后面讲初始化时会再强调这一点。
2.2 RMII 信号线:F4 引脚复用对照表
RMII 信号线不多,但每根都不能接错。以 F4 系列为例,ETH 的 RMII 复用映射是固定的,不能随意改到其他引脚,除非你用引脚重映射芯片。标准对照关系如下:
| STM32F4xx 引脚 | RMII 信号 | 方向 | 说明 |
|---|---|---|---|
| PA1 | REF_CLK | MAC 输入 | 50MHz 参考时钟 |
| PA2 | MDIO | 双向 | 管理接口数据 |
| PA7 | CRS_DV | PHY 到 MAC | 载波侦听/数据有效 |
| PB11 | TX_EN | MAC 到 PHY | 发送使能 |
| PB12 | TXD0 | MAC 到 PHY | 发送数据位 0 |
| PB13 | TXD1 | MAC 到 PHY | 发送数据位 1 |
| PC1 | MDC | MAC 到 PHY | 管理接口时钟 |
| PC4 | RXD0 | PHY 到 MAC | 接收数据位 0 |
| PC5 | RXD1 | PHY 到 MAC | 接收数据位 1 |
这组引脚在 CubeMX 里配置 ETH RMII 后会自动生成,但很多板子为了布线方便,会把 LAN8720A 的引脚和 MCU 引脚做成非标映射,那就只能用 GPIO 模拟 MDIO 或者改设计。我在实际项目中遇到过把 RXD0 接反的情况,现象是 PHY 能 link 上,但收不到任何数据。接好线之后先确认 LINK 灯亮,再谈协议栈。
2.3 50MHz 时钟来源:MCO 输出还是外部晶振
RMII 模式下所有信号都以 50MHz 参考时钟为基准,这个时钟可以来自 MCU,也可以来自 PHY 侧。资源包里用的是 MCU 的 MCO 输出方式,也就是 STM32 的 PA8 输出 50MHz 到 LAN8720A 的 REF_CLK 引脚。相比外接一颗 50MHz 有源晶振,MCO 方案省掉了一个有源器件,缺点是 PA8 被占用,而且要求系统时钟配置合理。
F4 上的常见做法是用 PLL 时钟作为 MCO1 源,再四分频得到 50MHz。比如系统 PLL 跑到 200MHz,200 除以 4 就是 50MHz。初始化代码大致是:
HAL_RCC_MCOConfig(RCC_MCO1, RCC_MCO1SOURCE_PLLCLK, RCC_MCODIV_4);这段代码的作用是把 PLL 时钟经过 MCO1 分频后从 PA8 输出。参数 RCC_MCO1SOURCE_PLLCLK 表示时钟源选 PLL,RCC_MCODIV_4 表示四分频。如果你的系统时钟不是 200MHz,分频系数要相应调整,目标是让 PA8 输出精确的 50MHz。用示波器或者频率计量一下最稳妥,没有仪器就确认板子能正常 link,因为 PHY 对参考时钟频率偏差很敏感,偏差超过 50ppm 就可能频繁掉线。
另一种方案是 LAN8720A 自带 25MHz 晶振起振,再通过内部 PLL 倍频到 50MHz,但此时 REF_CLK 方向要仔细看原理图。资源包沿用 MCO 方案,因为大多数核心板都是这么设计的,移植时少踩一个坑。
2.4 PHY 寄存器初读:初始化前先自检 Link 状态
LAN8720A 的寄存器通过 MDC/MDIO 接口访问,HAL 库里对应 HAL_ETH_ReadPHYRegister 和 HAL_ETH_WritePHYRegister。初始化之前先读一下基本状态寄存器,确认 MDIO 通信是通的。基本控制寄存器地址 0x00,基本状态寄存器地址 0x01,PHY 特定状态寄存器地址 0x1F。后者可以读到当前协商速率和双工模式,调试时非常有用。
uint32_t bsr = 0, physcsr = 0; HAL_ETH_ReadPHYRegister(&heth, PHY_BSR, &bsr); HAL_ETH_ReadPHYRegister(&heth, 0x1F, &physcsr); if (bsr & 0x0004) { // bit2 是 Link Status,置 1 表示链路已建立 printf("Link Up\r\n"); } else { printf("Link Down\r\n"); }这里的 PHY_BSR 宏在 HAL 库里通常定义为 0x01。读出来的 BSR 寄存器 bit2 是 Link Status,和 PHY 的 LINK 指示灯走的是同一个状态源。如果这里一直读不到 Link Up,就不要继续调 LWIP,先回头查硬件连接和时钟。很多开发者一上来就对着 LWIP 调,结果问题根本不在协议栈,这套流程我至少帮别人排掉过三个硬件问题。
3. HAL 库初始化以太网外设:CubeMX 只生成起点,关键参数要自己确认
3.1 CubeMX 生成工程的取舍:哪些能信,哪些要改
CubeMX 可以勾选 ETH 外设并选择 RMII 模式,然后生成初始化代码,这一步确实省事。但生成出来的 HAL_ETH_Init 只覆盖了外设基本配置,PHY 的地址匹配、内部寄存器配置、时钟输出这些并不一定完整。比如 F4 的 CubeMX 默认不生成 MCO 配置,需要你在时钟配置页面手动选择 MCO1 输出,或者自己加代码。
我一般用 CubeMX 生成工程骨架,然后手工核对三件事:ETH 的 RMII 引脚是否都配置成 AF 复用;系统时钟树里 MCO 输出是否设置正确;LWIP 中间件是否选择了无操作系统模式。前两件事错了,后面跑多少遍都是白费。生成后还要注意,不要在 CubeMX 自动生成的函数里乱加业务代码,否则下次重新生成会被覆盖,后面避坑章节会专门讲。
3.2 HAL_ETH_Init 参数:Autonegotiation、ChecksumMode 与 MediaInterface
HAL 库的以太网初始化核心函数是 HAL_ETH_Init,关键结构体字段如下。下面这段是典型的 F4 配置:
ETH_HandleTypeDef heth; void MX_ETH_Init(void) { heth.Instance = ETH; heth.Init.AutoNegotiation = ETH_AUTONEGOTIATION_ENABLE; heth.Init.Speed = ETH_SPEED_100M; heth.Init.DuplexMode = ETH_MODE_FULLDUPLEX; heth.Init.MACAddr = (uint8_t *)MAC_ADDR; heth.Init.RxMode = ETH_RXMODE_POLLING; heth.Init.ChecksumMode = ETH_CHECKSUM_BY_HARDWARE; heth.Init.MediaInterface = ETH_MEDIA_INTERFACE_RMII; if (HAL_ETH_Init(&heth) != HAL_OK) { Error_Handler(); } }逐项说明一下。AutoNegotiation 开启自协商,让 PHY 和交换机自动协商速率和双工模式,Speed 和 DuplexMode 在自协商开启时会被协商结果覆盖,这里填 100M 全双工只是兜底值。MACAddr 是 MAC 地址指针,注意这个数组必须保持有效,不能是临时变量,否则 DMA 可能读到随机值。RxMode 选 POLLING,因为我们在无操作系统下用轮询方式接收,如果是中断模式就要配套 ETH_IRQHandler。
ChecksumMode 选硬件校验,让 MAC 硬件自动填充和校验 TCP/UDP/IP 校验和,CPU 不用逐包计算,PING 延迟会低一些。MediaInterface 必须选 RMII,如果选错成 MII,PHY 根本不会工作。HAL_ETH_Init 执行时内部会做 MAC 和 DMA 的软复位,之后再配置 DMA 描述符,所以 PHY 的寄存器配置最好放这个函数之后做。
3.3 MspInit 与 GPIO 复用:别把参考时钟接错引脚
HAL 库的初始化流程里,HAL_ETH_Init 内部会调用 HAL_ETH_MspInit,这个函数负责 GPIO 时钟、引脚复用、DMA 时钟和中断配置。CubeMX 会生成这个函数,但值得手动核对每个引脚。F4 上 RMII 的 GPIO 复用要开启 GPIOA、GPIOB、GPIOC 时钟,引脚全部配成 AF_PP,复用号是 GPIO_AF11_ETH,速度选 VERY_HIGH。参考代码:
void HAL_ETH_MspInit(ETH_HandleTypeDef *heth) { GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_ETH_CLK_ENABLE(); __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_GPIOB_CLK_ENABLE(); __HAL_RCC_GPIOC_CLK_ENABLE(); GPIO_InitStruct.Pin = GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_7; GPIO_InitStruct.Mode = GPIO_MODE_AF_PP; GPIO_InitStruct.Pull = GPIO_NOPULL; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_VERY_HIGH; GPIO_InitStruct.Alternate = GPIO_AF11_ETH; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); GPIO_InitStruct.Pin = GPIO_PIN_11 | GPIO_PIN_12 | GPIO_PIN_13; HAL_GPIO_Init(GPIOB, &GPIO_InitStruct); GPIO_InitStruct.Pin = GPIO_PIN_1 | GPIO_PIN_4 | GPIO_PIN_5; HAL_GPIO_Init(GPIOC, &GPIO_InitStruct); }这段代码里,PA1 是 REF_CLK、PA2 是 MDIO、PA7 是 CRS_DV,PB11/PB12/PB13 是 TX_EN/TXD0/TXD1,PC1/PC4/PC5 是 MDC/RXD0/RXD1。GPIO_MODE_AF_PP 表示复用推挽输出,GPIO_NOPULL 表示不加上下拉,RMII 信号线通常不需要内部上下拉。速度必须选 VERY_HIGH,因为 50MHz 时钟边沿比较陡,低速配置可能导致信号质量差。
PA8 作为 MCO 输出也要单独配置成复用功能,但 CubeMX 一般会把它算进时钟配置里,不需要在 MspInit 里手动配置。如果板子设计是用 PA8 直连 LAN8720A 的 REF_CLK,确认 PA8 没有同时被其他外设占用。
3.4 DMA 描述符与缓冲区:轮询模式下也要关心内存对齐
F4 的以太网 DMA 使用描述符链表来管理收发缓冲区,HAL 库的底层已经帮你把描述符定义好了,但缓冲区地址对齐和大小仍然要人工确认。描述符和缓冲区数组要按 4 字节对齐,定义时用 __ALIGN_BEGIN 和 __ALIGN_END 宏:
__ALIGN_BEGIN ETH_DMADescTypeDef DMARxDscrTab[ETH_RXBUFNB] __ALIGN_END; __ALIGN_BEGIN uint8_t RxBuff[ETH_RXBUFNB][ETH_RX_BUF_SIZE] __ALIGN_END; __ALIGN_BEGIN ETH_DMADescTypeDef DMATxDscrTab[ETH_TXBUFNB] __ALIGN_END; __ALIGN_BEGIN uint8_t TxBuff[ETH_TXBUFNB][ETH_TX_BUF_SIZE] __ALIGN_END;ETH_RXBUFNB 和 ETH_TXBUFNB 默认是 4 个描述符,ETH_RX_BUF_SIZE 默认 1524 字节,这些宏在 eth.h 或者 CubeMX 生成的配置里可以改。缓冲区大小要覆盖最大以太网帧,1518 字节加上 VLAN 标签和 CRC,1524 是一个安全值。描述符数量决定 DMA 能缓存多少帧,无 OS 下 4 个接收描述符勉强够用,如果 ping 大包或突发流量多,建议加到 6 到 8 个,代价是内存占用增加。
对齐问题的典型症状是进 HardFault 或者 DMA 读到垃圾数据。Cortex-M4 对非对齐访问会触发异常,而 DMA 控制器对缓冲区起始地址也有对齐要求。用了 __ALIGN_BEGIN 还能翻车的话,检查 linker 脚本是否把数组放到了不能被 DMA 访问的内存区域,比如某些芯片的 DTCM RAM 不能给以太网 DMA 用,这是 H7 系列上常见的坑,F4 上相对少见。
3.5 初始化后的 PHY 配置:复位和自协商是两件事
HAL_ETH_Init 完成了 MAC 和 DMA 的初始化,但 PHY 芯片还要单独复位和配置。LAN8720A 的复位有两种方式:硬件复位引脚拉低再释放,或者写基本控制寄存器 bit15 软件复位。软件复位更简单,不用额外 GPIO。HAL 库接口可以这么用:
uint32_t bcr = 0; HAL_ETH_ReadPHYRegister(&heth, PHY_BCR, &bcr); bcr |= 0x8000; // bit15 软件复位 HAL_ETH_WritePHYRegister(&heth, PHY_BCR, bcr); HAL_Delay(10);软件复位后 PHY 内部寄存器回到默认值,自协商也会重新开始。PHY_BCR 宏在 HAL 库里是 0x00。复位之后轮询读取基本状态寄存器的自协商完成位,两个过程不要合并。自协商完成通常需要几百毫秒到一秒,有些 PHY 在复位后立刻读状态会显示未完成,加一个超时循环比较稳妥。资源包里初始化流程的顺序是:HAL_ETH_Init → PHY 软件复位 → 等待自协商完成 → 启动 DMA 接收。
4. 无操作系统移植 LWIP:NO_SYS 模式下的轮询与 PING 链路
4.1 NO_SYS 模式的本质:LWIP 裸奔的调度方式
LWIP 支持带 RTOS 和裸机两种运行模式,区别的开关是 lwipopts.h 里的 NO_SYS 宏。NO_SYS 设为 1 时,LWIP 不需要任务调度、信号量和消息邮箱,所有协议处理都在当前线程里完成。这就意味着你不能像 FreeRTOS 版那样创建 tcpip_thread 然后把数据丢给它,而是要在主循环里反复调用 ethernetif_input 和 sys_check_timeouts。
无 OS 模式下 LWIP 的内部结构依然完整,TCP、UDP、ICMP、ARP 协议栈都还在,只是没有独立的协议线程。数据包接收路径是:DMA 收到帧 → 主循环调用 ethernetif_input → 底层把数据拷贝到 pbuf → 调用 netif->input → 进入协议栈处理。发送路径反过来,上层调用 netif->output,底层把 pbuf 里的数据搬运到 DMA 缓冲区然后启动发送。整个链路是同步的,处理时间长短直接影响主循环响应,所以不要在中断里做大量协议处理,数据拷贝和协议栈都放在主循环是更稳的做法。
资源包选择无 OS 模式,很大原因是宿主工程本身是裸机轮询架构,为了加一个以太网功能引入 RTOS,代价是整个项目的任务划分都要重做。LWIP 在裸机下的性能损失对 PING 和轻量 TCP 通信影响不大,但对大吞吐量场景要谨慎评估。
4.2 lwipopts.h 内存微调:PBUF 池够不够
LWIP 内存配置是移植时最容易抄错的部分,不同版本宏名略有差异,但核心几个参数是一致的。资源包使用的配置大致如下:
| 宏 | 值 | 说明 |
|---|---|---|
| NO_SYS | 1 | 无操作系统模式 |
| MEM_ALIGNMENT | 4 | 内存对齐字节数 |
| MEM_SIZE | 16 * 1024 | 堆内存池大小,用于 PBUF_RAM |
| PBUF_POOL_SIZE | 16 | PBUF_POOL 池的 pbuf 数量 |
| PBUF_POOL_BUFSIZE | 1518 | 每个池 pbuf 的负载大小 |
| LWIP_SOCKET | 0 | 不用 socket API |
| LWIP_NETCONN | 0 | 不用 netconn API |
| LWIP_ARP | 1 | 开启 ARP,PING 必需 |
| LWIP_ICMP | 1 | 开启 ICMP,PING 必需 |
| LWIP_UDP | 1 | 开启 UDP |
| LWIP_TCP | 1 | 开启 TCP |
MEM_SIZE 是 LWIP 内部的堆大小,动态分配 pbuf、内存池不够时都会从这里申请。16KB 对裸机 PING 足够,如果要跑 TCP 服务器同时处理几个连接,建议加到 24KB 或 32KB。PBUF_POOL_SIZE 是接收路径使用的池数量,每个池 pbuf 大小固定为 1518,一份完整的大帧要占一个 pbuf,所以 16 意味着最多缓存 16 个收到但还没处理的帧。如果主循环处理较慢,池耗尽会导致丢包,加大池数量是直接解法。
PBUF_POOL_BUFSIZE 设为 1518 对应完整以太网帧的最大长度,如果网内存在巨型帧或 VLAN 标签,这个值要相应增大。LWIP_SOCKET 和 LWIP_NETCONN 在 NO_SYS 模式下默认就应该关掉,因为它们依赖操作系统模拟层,开了反而会引入额外内存开销和编译错误。
4.3 ethernetif 驱动:low_level_input 与 low_level_output 的实现边界
LWIP 的数据包收发依赖网卡驱动结构体 netif,其中最重要的两个函数是 low_level_output 和 low_level_input。前者负责把 pbuf 链上的数据发出去,后者负责从 DMA 接收缓冲区取数据并转成 pbuf 交给协议栈。发送函数的典型逻辑:
static err_t low_level_output(struct netif *netif, struct pbuf *p) { struct pbuf *q; uint8_t *buffer; uint32_t len = 0; buffer = (uint8_t *)(heth.TxDesc->BufferAddr); for (q = p; q != NULL; q = q->next) { memcpy(buffer + len, q->payload, q->len); len += q->len; } if (HAL_ETH_Transmit(&heth, (uint8_t *)(heth.TxDesc->BufferAddr), len) != HAL_OK) { return ERR_IF; } return ERR_OK; }这段代码先把 pbuf 链上的数据逐段拷贝到 DMA 发送缓冲区,因为 pbuf 可能是链表结构,而 DMA 缓冲区是一段连续内存。拷贝完调用 HAL_ETH_Transmit 启动发送。注意 heth.TxDesc 是当前使用的发送描述符,HAL 库内部会在发送完成后自动切换到下一个描述符。如果 HAL_ETH_Transmit 返回 HAL_BUSY,说明描述符还不空闲,上一帧还没发完,此时直接返回 ERR_IF 会导致上层重传,性能下降但不会丢数据。
接收函数更直接,轮询模式下每次调用检查当前接收描述符是否有数据:
static err_t low_level_input(struct netif *netif) { struct pbuf *p; uint32_t len; uint8_t *buffer; len = HAL_ETH_GetRxDataLength(&heth); if (len == 0) { return ERR_IF; } buffer = (uint8_t *)(heth.RxDesc->BufferAddr); p = pbuf_alloc(PBUF_RAW, len, PBUF_POOL); if (p != NULL) { pbuf_take(p, buffer, len); } heth.RxDesc->Status |= ETH_DMARXDESC_OWN; return (p != NULL) ? ERR_OK : ERR_MEM; }HAL_ETH_GetRxDataLength 返回当前描述符收到的帧长度,为 0 说明没有新帧。收到数据后用 pbuf_alloc 从池里分配一个 pbuf,pbuf_take 把 DMA 缓冲区的内容拷贝进去。分配失败返回 ERR_MEM,但描述符仍然要重新交还给 DMA,否则之后一直接不到数据。ETH_DMARXDESC_OWN 位表示描述符归属归还给 DMA 控制器,这条必须最后执行。
这里的细节是把接收描述符的 ownership 归还时机放在处理完之后,顺序反了的话,DMA 可能会覆盖还没拷贝完的数据。LWIP 自带的 ethernetif.c 模板逻辑也是这个顺序,但实际开发中很多人会提前把 OWN 位写了,导致高负载下出现随机丢包。
4.4 netif 注册与主循环:PING 的运行链路
驱动函数写好后,要把 netif 注册到 LWIP 协议栈。无 OS 模式下 netif_add 的最后一个参数是 ethernet_input,表示数据包进来后直接进入协议栈输入函数。代码如下:
struct netif gnetif; 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(&gnetif, &ipaddr, &netmask, &gw, NULL, ethernetif_init, ethernet_input); netif_set_default(&gnetif); netif_set_up(&gnetif);IP4_ADDR 宏把四个数字拼成 IP 地址,这里的网关、本机 IP、子网掩码要根据实际网络环境修改。netif_add 之后 netif_set_default 把该网卡设为默认出口,netif_set_up 让网卡进入可用状态。ethernetif_init 内部会调用 low_level_init 完成 MAC 地址设置、DMA 描述符关联等底层初始化。
主循环里的轮询是整条链路的核心,无 OS 下所有事件都靠不断检查驱动层来完成:
while (1) { ethernetif_input(&gnetif); sys_check_timeouts(); /* 其他业务代码 */ }ethernetif_input 函数内部会循环调用 low_level_input,把当前描述符里所有已经收到的帧都取出来交给协议栈。sys_check_timeouts 负责处理 LWIP 内部定时任务,比如 ARP 表项超时、TCP 重传计时,无 OS 模式下如果不周期调用它,TCP 连接会卡在重传等待里,但 PING 这类无连接协议不太受影响。
关于 PING 响应有个常见误解:LWIP 收到 ICMP Echo Request 后会自动回复 Echo Reply,不需要你自己实现 ICMP 处理函数。你只需要保证 ARP 能解析、ICMP 协议开启,协议栈内部会完成应答。PC 端 PING 的完整过程是:先发 ARP 询问目标 MAC,拿到 MAC 后发 ICMP Echo,LWIP 收到后交换 MAC 地址并应答 ICMP。所以如果 PING 不通,第一步看 ARP 是否能解析,第二步才看 ICMP。
5. 避坑与排查:PING 不通和收发不稳定的五个检查点
5.1 PING 完全不通,网卡 IP 配置没问题但始终超时
现象是 PC 上 ping 设备 IP 一直超时,PHY 的 LINK 灯亮着,但抓包看不到设备发出的 ARP 回应。原因大概率是 netif 注册时 MAC 地址没有正确写入硬件,或者 MAC_ADDR 数组定义在函数内部导致地址失效。LWIP 初始化链路里 low_level_init 会把 netif->hwaddr 写入 MAC 寄存器,如果 hwaddr 没有正确设置,DMA 发出去的帧源 MAC 就是乱的,交换机可能直接丢弃。
解决方法是把 MAC 地址定义成全局静态数组,并确保在 netif_add 之前赋值,类似:
static uint8_t MAC_ADDR[6] = {0x00, 0x80, 0xE1, 0x00, 0x11, 0x22};同时检查 netif_add 和 netif_set_up 的调用顺序,netif_set_up 之前要确保底层描述符已经关联,否则协议栈认为网卡是 down 状态,直接丢弃所有包。
5.2 LINK 灯亮但 ARP 不响应,MDIO 能读到 PHY 但收发无数据
现象是 PHY 已经协商成功,BSR 寄存器 Link 位为 1,但 PC 发 ARP 请求设备没反应。原因多数是 RMII 时钟方向或 CRS_DV 信号接错。如果 REF_CLK 用的是 MCU MCO 输出,检查 PA8 的复用配置是否正确,PHY 是否真的收到了 50MHz 时钟。CRS_DV 接反会导致 MAC 收不到载波信号,接收路径完全瘫痪。
解决时先用示波器量 PA8 波形,确认频率是 50MHz 且幅度满足要求。再量 PA7 是否有高电平,CRS_DV 在收到数据时会拉高。没有示波器的话,写一个 GPIO 翻转加延时的小程序,手动测量引脚连通性,快速排除断路和错位。
5.3 PING 小包正常,大包丢包或者延迟抖动明显
现象是 ping 默认大小 32 字节没问题,ping 1400 字节或更大时开始丢包。原因通常是 PBUF_POOL_SIZE 不够,接收大帧时需要一次性分配一个完整的 1518 字节 pbuf,如果池里可用的 pbuf 不够,数据会被丢弃。另一个原因是主循环轮询不及时,DMA 接收描述符全部被占用后,新帧无处存放。
解决方法是把 PBUF_POOL_SIZE 从默认值提高,同时检查主循环里是否有耗时长的大块操作。在资源包基础上,建议把 PBUF_POOL_BUFSIZE 保持 1518,池数量至少 16。如果工程主循环里有 Flash 擦写、长延时这类阻塞操作,接收路径会周期性断档,考虑把以太网数据处理放到定时器中断里,或者重新规划主循环任务。
5.4 偶尔进 HardFault,调试器定位在 DMA 描述符相关代码
现象是系统运行一段时间后进入 HardFault,调用栈指向 memcpy 或描述符地址访问。原因多半是 pbuf 缓冲区地址没有按 4 字节对齐,或者 DMA 描述符被意外覆盖。无 OS 模式下所有内存都是静态分配的,数组定义时缺少 __ALIGN_BEGIN 宏是最常见因素。
解决方法是检查所有 DMA 缓冲区数组定义,确保使用了对齐宏。另外确认 ETH_RX_BUF_SIZE 是 4 的倍数,有些库版本对非 4 倍数长度的帧会自动 padding,但描述符长度字段的赋值方式依赖对齐。如果 F4 工程里用了外部 SDRAM 存放缓冲区,还要确认 SDRAM 的 AHB 总线访问权限配置正确,否则 DMA 访问会触发总线错误。
5.5 CubeMX 重新生成代码后,以太网功能消失或者初始化失败
现象是修改了 CubeMX 配置重新生成代码,之前调通的以太网功能变回不工作。原因绝大多数是用户代码被覆盖。HAL_ETH_MspInit、MX_ETH_Init 这些函数是 CubeMX 自动生成的,重新生成时会恢复到默认模板,你在里面手工加的 PHY 复位、MCO 配置全部丢失。
解决方法是把需要保留的手工代码放到 CubeMX 的 USER CODE 段里,也就是生成代码中带有 USER CODE BEGIN 和 USER CODE END 注释的区域。PHY 软件复位、自定义寄存器写入、MCO 输出配置这些逻辑,尽量封装成独立函数放在 user code 区,不要直接修改自动生成函数的函数体。从那以后我每次用 CubeMX 都会先检查生成代码里是否保留了手工修改,这个习惯帮我避掉了至少三次重新生成后又要重新调驱动的重复劳动。
6. 验证整条链路:抓包确认 ARP 和 ICMP,再谈扩展
工程调通后不要只看 PING 通了就收工,一套完整的验证流程能帮你发现潜在问题。我会先用 Wireshark 在 PC 端抓包,ping 设备 IP 的同时观察三次交互:PC 发出 ARP 请求询问设备 MAC,设备回 ARP 应答,然后 PC 发出 ICMP Echo,设备回 Echo Reply。如果 ARP 都完成不了,说明问题在链路层;如果 ARP 通但 ICMP 没回,问题在 IP 或 ICMP 层。抓包还能看帧长度、校验和是否由硬件正确填充,校验和错误时 Wireshark 会有红色标注,多半是 ChecksumMode 配置和实际硬件行为不一致。
验证完基本 PING,可以做两个小测试。第一个是连续 PING 大包,比如 ping 1400 字节大小跑 1000 次,统计丢包率和最大延迟,裸机轮询模式下丢包率应该为 0,延迟抖动主要看主循环负载。第二个是拔掉网线再插回,观察 PHY 能否自动重新协商并恢复通信,LAN8720A 的自协商恢复时间通常在几百毫秒,如果插回后要手动复位才能恢复,说明 PHY 的 link 状态监测处理不到位。
资源包在 PING 基础上留了一个轻量 UDP 上报的例子,这是无 OS 场景下最常用的功能。初始化 netif 后直接调用 udp_new 和 udp_bind,收到命令帧后解析并回包,整个过程都是同步调用,不需要处理多线程竞争。相比 TCP,UDP 在裸机上省掉了重传和连接管理的内存开销,适合周期状态上报。TCP 也能跑,但需要确保 sys_check_timeouts 调用频率足够,否则长时间空闲后连接会被对端断开。
从那以后,我每次移植以太网都会强制执行一遍自己的验证顺序:先确认 PHY link,再确认 ARP 通,然后才是 PING,最后才谈应用层协议。这个习惯是从一次线上问题学到的,当时花了半天查 LWIP 配置,最后发现是 REF_CLK 频率偏了,PHY 协商偶尔失败。希望帮到你。
本文还有配套的精品资源,点击获取