☰
STM32 HAL库+LAN8720A+LwIP裸机以太网Ping通实战
2026/10/26 5:06:29 网站建设 项目流程

简介:面向STM32以太网开发初学者与进阶工程师的实战工程,聚焦HAL库驱动LAN8720A、基于LWIP 2.1.2实现无操作系统下的PING通信。工程基于STM32F429IGT6,采用RMII接口打通MAC与PHY数据通路,并通过SMI接口读写PHY寄存器,配套CubeMX 6.6.1、HAL V1.27.1和MDK-ARM 5.27,便于编译验证与移植。压缩包约31.16MB,共1300个文件,以678个C源文件、450个头文件和78个汇编文件为主,另含链接脚本、库文件、工程配置及说明文档。已有1989人学习,可对照理解以太网外设初始化、PHY地址与自协商、LWIP网卡接入及ARP/ICMP收发流程,也可作为无OS网络通信模板与排错参考,对嵌入式网络通信的工程组织、调试思路与后续功能扩展亦有帮助。

1. 焊完网口插上线,灯不亮的那五分钟

板子焊完,网线插上,交换机那头灯闪了两下就灭了,电脑上ping一直「请求超时」——做过 STM32 HAL 库 + LAN8720A + LWIP 无操作系统这套组合的开发者,大概率都经历过这种说不清原因的开局。这套组合涉及的外设层级比串口、SPI 多得多:MAC 要配、DMA 描述符要挂、PHY 要靠 MDIO 读寄存器、LwIP 还要在裸机下自己完成时基维护和内存管理,任何一层错一点,表现都是同一个「ping 不通」。这份工程的价值就在这:它把「STM32 的 ETH 外设通过 RMII 接口接 LAN8720A,HAL 库完成 MAC/DMA 初始化,LwIP 以 NO_SYS 模式直接跑在 main 循环里,最后能从电脑 ping 通」这条链路整理成了一份可以对照的最小实现。适合刚上手裸机以太网、被 PHY 和 LwIP 双向卡住的开发者,也适合想把 LWIP 接进现有裸机工程、需要一份可参照骨架的人。

2. 技术选型拆解:MAC、PHY、LwIP 三层如何对接

这套方案不是「一个库搞定网络」那么简单,它由三层结合而成,且每层都有各自的边界。搞清楚哪一层负责什么,后面调试才知道该在哪一层排查。

2.1 硬件链路:MAC 在片内,PHY 在片外,RMII 的 50MHz 从哪来

STM32 片内集成的是以太网 MAC 控制器,负责帧的组装、CRC 校验、DMA 搬运、地址过滤,但它不处理物理层电气信号——那部分由外挂的 LAN8720A 完成。两者之间常见的接法是 RMII,相比 MII 把数据线从 4 位砍到 2 位,引脚数从十几个降到 9 个,代价是收发共用一路 50MHz 参考时钟。

这就是第一个最容易出错的地方。RMII 要求 MAC 和 PHY 共用同一个 50MHz REF_CLK,而 LAN8720A 自己是靠外部 25MHz 晶振工作的,它把那颗 25MHz 倍频成 50MHz,从 nINT/REFCLKO 引脚输出给 MCU。也就是说,时钟方向是 PHY → MCU,不是 MCU → PHY。很多人下意识地在 CubeMX 里给 PA1 配了 MCO 输出,结果 PHY 没时钟、MAC 也没时钟,网口灯干脆不亮。

以 STM32F4 系列为例,一套典型的 RMII 引脚映射如下:

信号MCU 引脚方向说明
ETH_REF_CLKPA1输入50MHz 参考时钟,来自 PHY 的 REFCLKO
ETH_MDIOPA2双向管理接口数据线,需 1.5k~2.2k 上拉
ETH_CRS_DVPA7输入载波侦听/数据有效
ETH_MDCPC1输出管理接口时钟,HAL 分频后约 1~2MHz
ETH_RXD0PC4输入接收数据位 0
ETH_RXD1PC5输入接收数据位 1
ETH_TX_ENPB11输出发送使能
ETH_TXD0PB12输出发送数据位 0
ETH_TXD1PB13输出发送数据位 1

另外还有两根不能忘的线:PHY 的复位脚(nRST)一般接一个普通 GPIO,上电后要主动拉低再释放;PHY 地址由 PHYAD0 引脚在上电时锁存,接下拉就是地址 0,接上拉就是 1。CubeMX 里PHY Address填的那个值必须和硬件实际状态一致,否则 MDIO 读回来全是 0xFFFF,链接永远起不来。

LAN8720A 的工作模式靠一组配置脚在上电瞬间锁存,其中MODE[2:0]决定自适应能力,REGOFF决定内部 LDO 是否启用。这几个脚如果悬空或者接错,PHY 可能连自协商都不做,表现出来就是「灯亮着但一直 link down」。硬件阶段最好拿万用表把这几个脚的电平量一遍,比后面在软件里猜半天省事得多。

2.2 软件分层:HAL 驱动、LwIP 内核、胶水层各管什么

软件侧同样是三层,分工很明确:

最下面是 HAL 的 ETH 驱动,文件通常是stm32f4xx_hal_eth.c。它做的事情是初始化 MAC 寄存器、分配收发描述符、把 DMA 环形缓冲挂好,对外提供HAL_ETH_Init、HAL_ETH_Start_IT、HAL_ETH_Transmit_IT、HAL_ETH_ReadData(老版本 HAL 是HAL_ETH_GetReceivedFrame_IT)这几个入口。它完全不认识 LwIP,只认ETH_BufferTypeDef这种自描述的结构。

中间是 LwIP 内核,纯 C 实现,不依赖任何 RTOS。它管 ARP 表、IP 分片重组、ICMP 应答、TCP 状态机、内存池和 pbuf 链表。裸机模式下它不做任何调度,超时、重传全靠外部周期性调用sys_check_timeouts()推动。

最上面、也是最容易出问题的是胶水层ethernetif.c。这个文件负责把 HAL 的帧跟 LwIP 的pbuf互相转换:收方向把 DMA 缓冲区里的字节包装成 pbuf 往上交,发方向把 pbuf 链摊平成ETH_BufferTypeDef数组往下送。ST 官方在 CubeMX 里生成的ethernetif.c就是这个角色,工程里凡是「能 ping 通但 TCP 握手失败」「发几个包之后卡死」这类问题,八成都在这个文件里。

NO_SYS=1意味着没有线程、没有信号量,sys_arch.c里唯一必须实现的关键函数就是sys_now(),返回值是毫秒级系统时间,LwIP 用它算 ARP 缓存老化、TCP 重传超时、IP 分片重组超时。这个函数如果返回一个不增长的值,或者增长得比实际时间快,网络就会表现出一堆难以解释的行为:ARP 表永不老化、重传节奏错乱、连上了又莫名断开。常见做法是直接return HAL_GetTick();,但前提是 SysTick 真的在跑、且没被别的中断长时间关掉。

2.3 目录结构与关键文件清单

CubeMX 生成的工程结构大致是这样,把每个文件的职责记住,改起来才不会到处乱找:

Project/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ ├── stm32f4xx_hal_conf.h # 使能 HAL_ETH_MODULE_ENABLED │ │ ├── stm32f4xx_it.h │ │ ├── lwip.h # MX_LWIP_Init 声明 │ │ └── ethernetif.h # ETH_RX_DESC_CNT / ETH_TX_DESC_CNT │ └── Src/ │ ├── main.c # 主循环里调 MX_LWIP_Process │ ├── stm32f4xx_hal_msp.c # ETH 引脚复用、时钟、中断优先级 │ ├── stm32f4xx_it.c # ETH_IRQHandler -> HAL_ETH_IRQHandler │ ├── lwip.c # netif 添加、IP 配置、MX_LWIP_Process │ └── ethernetif.c # 收发胶水层,核心中的核心 ├── Drivers/ │ ├── STM32F4xx_HAL_Driver/ # hal_eth.c / hal_eth.h │ └── CMSIS/ ├── Middlewares/ │ └── Third_Party/LwIP/ │ ├── src/ # LwIP 内核源码 │ └── system/ # 裸机适配,sys_now、sys_arch └── LWIP/ ├── Inc/lwipopts.h # 所有 LwIP 编译期开关 └── Target/ethernetif.c # 部分版本把胶水层放这里

几个文件的关键点值得单独标一下:stm32f4xx_hal_conf.h里HAL_ETH_MODULE_ENABLED必须打开,否则链接阶段会报HAL_ETH_Init未定义;stm32f4xx_hal_msp.c里的HAL_ETH_MspInit负责 GPIO 复用为 AF11、开 ETH 时钟、配 ETH 中断优先级——中断优先级配错会导致接收中断被别的外设长期阻塞,表现为丢包;ethernetif.h里那两个描述符数量宏决定了 DMA 环形缓冲的深度,太小在高流量下会丢包,太大浪费 RAM。

提示:lwipopts.h是整个 LwIP 行为的开关面板。改任何跟内存、协议开关、调试输出相关的配置,都先来这里找,不要改 LwIP 源码目录下的opt.h,那个是默认值模板,一升级就冲掉了。

3. 从 CubeMX 配置到第一次 PING 通

配置这一步的正确顺序是:先把 PHY 的物理层打通(MDIO 能读到正确的 ID),再让 MAC 起来,最后才轮到 LwIP。顺序错了,同一个「不通」的现象你没法判断是哪一层的问题。

3.1 ETH 外设参数:RMII、PHY 地址、描述符数量

CubeMX 里Connectivity → ETH需要打开,Mode选 RMII,PHY Address填硬件实际值。参数面板里几个关键项:

参数典型取值说明
Auto NegotiationEnable交给 PHY 自协商,99% 的场景都该开
Speed / Duplex由自协商决定关闭自协商时才手填
Rx ModeInterrupt Mode裸机必须用中断,轮询会阻塞主循环
Rx Buff Len1524 或 1536要能装下最大以太网帧
Rx/Tx Desc4DMA 环形描述符数量,够用且省 RAM
Checksum Offload视芯片而定F4 只覆盖 IP 头,H7 支持更全

生成代码后,MX_ETH_Init()里能看到这样的结构:

static void MX_ETH_Init(void) { heth.Instance = ETH; /* MAC 地址:必须在整个网段内唯一,量产时从外部存储读 */ heth.Init.MACAddr[0] = 0x00; heth.Init.MACAddr[1] = 0x80; heth.Init.MACAddr[2] = 0xE1; heth.Init.MACAddr[3] = 0x00; heth.Init.MACAddr[4] = 0x00; heth.Init.MACAddr[5] = 0x00; heth.Init.MediaInterface = HAL_ETH_RMII_MODE; /* 与硬件接法一致 */ heth.Init.RxDesc = ETH_RX_DESC_CNT; /* 4 */ heth.Init.TxDesc = ETH_TX_DESC_CNT; /* 4 */ heth.Init.RxBuffLen = 1524; /* 单帧最大负载 */ if (HAL_ETH_Init(&heth) != HAL_OK) { Error_Handler(); } }

MACAddr这里是个隐患点:很多人从工程模板里抄来一组全 0 或者全 0xFF 的地址,接到同一台交换机上跟别的设备冲突,表现是「能收到广播、单播全丢」。哪怕调试阶段,也随手改成一个不重样的值。RxBuffLen要能覆盖整个以太网帧(含 14 字节头 + 1500 负载 + 可能的 VLAN 标签),1524 是安全值,设成 1024 的话超过这个长度的帧会被 DMA 截断,上层协议偶发异常。RxDesc/TxDesc给 4 是够用的,但如果你后面要跑 TCP 大流量、或者主循环里有耗时操作,调到 8 能明显减少丢包。

stm32f4xx_hal_msp.c里 HAL 会自动生成HAL_ETH_MspInit(),把 PA1/PA2/PA7/PC1/PC4/PC5/PB11/PB12/PB13 都配成GPIO_AF11_ETH、GPIO_SPEED_FREQ_VERY_HIGH、GPIO_MODE_AF_PP。这里有个细节:RMII 的 REF_CLK 是输入,理论上配成GPIO_MODE_AF_PP也能工作(AF 模式下方向由外设决定),但如果你手动把它改成了普通的输入模式,MAC 就收不到时钟。另外 MCO 那一路千万别动,PHY 的时钟是它自己产生的。

3.2 LwIP 裸机参数:NO_SYS、内存池与 pbuf 大小

lwipopts.h里必须先确认这几项,它们直接决定 LwIP 能不能在裸机下跑起来:

/* lwipopts.h —— 裸机模式的最小配置 */ #define NO_SYS 1 /* 无操作系统,全部走主循环 */ #define SYS_LIGHTWEIGHT_PROT 0 /* 裸机下不需要临界区保护 */ #define LWIP_NETCONN 0 /* 关闭 netconn API */ #define LWIP_SOCKET 0 /* 关闭 socket API */ #define LWIP_NETIF_API 0 #define LWIP_DHCP 0 /* 先跑静态 IP,通了再开 DHCP */ #define MEM_ALIGNMENT 4 #define MEM_SIZE (16 * 1024) /* LwIP 堆,给 TCP 用要更大 */ #define PBUF_POOL_SIZE 16 #define PBUF_POOL_BUFSIZE 1524 /* 必须 >= ETH 最大帧长 */ #define ETH_PAD_SIZE 0 #define LWIP_ICMP 1 /* ping 依赖它 */ #define LWIP_BROADCAST_PING 1 #define LWIP_MULTICAST_PING 1 #define TCP_MSS 1460 #define TCP_SND_BUF (4 * TCP_MSS) #define TCP_WND (4 * TCP_MSS) #define LWIP_DEBUG 0

PBUF_POOL_BUFSIZE是整个配置里最不能省的。它定义每个 pbuf 池元素能装多少字节,如果小于最大帧长,ethernetif.c里分配接收缓冲时会返回 NULL,DMA 拿不到缓冲区,帧直接被丢弃,现象是「有时能 ping 通、大包必丢」。给 1524 或者 1536 都行,别为了省那点 RAM 设成 512。

MEM_SIZE在纯 ping 场景下 4KB 就够,但凡计划后面跑 TCP,直接给 16KB 起步。LwIP 用的是自己的内存池,跟 C 库的malloc完全隔离,MEM_SIZE不够时mem_malloc返回 NULL,上层只会看到「连接建立失败」,不会有任何报错提示,属于典型的难以排查的问题。

NO_SYS=1还有个连带影响:LwIP 内核里的sys_arch_protect之类的保护机制会被编译成空操作,所有 API 都必须在同一个上下文(也就是主循环)里调用。如果你在 ETH 接收中断里直接调netif->input(),就会和主循环抢 pbuf 链表,偶尔崩在某个链表指针上。正确做法是中断里只置标志或调用ethernetif_input的队列版本,实际处理留给主循环。

3.3 主循环骨架:MX_LWIP_Process 的调用节奏

lwip.c里负责把 netif 挂上去:

struct netif gnetif; ip4_addr_t ipaddr, netmask, gw; void MX_LWIP_Init(void) { lwip_init(); /* 初始化 LwIP 内核、内存池、超时链表 */ IP4_ADDR(&ipaddr, 192, 168, 1, 100); IP4_ADDR(&netmask, 255, 255, 255, 0); IP4_ADDR(&gw, 192, 168, 1, 1); /* 最后一个参数是 netif_input,以太网用 ethernet_input */ netif_add(&gnetif, &ipaddr, &netmask, &gw, NULL, &ethernetif_init, &ethernet_input); netif_set_default(&gnetif); if (netif_is_link_up(&gnetif)) { netif_set_up(&gnetif); /* 这一步才会让协议栈真正开始收发 */ } else { netif_set_down(&gnetif); } }

netif_add的第 6 个参数ethernetif_init是胶水层的入口,第 7 个参数决定上层收到帧之后走哪个函数。以太网必须传ethernet_input(处理 ARP、IP),不是ip_input(跳过 ARP 解析)。这个参数写错的话,帧收到了但 ARP 永远解析不出来,ping 自然不会有回应。

主循环这边只有一个要求:多调用,别阻塞。

while (1) { MX_LWIP_Process(); /* 收帧 + 推进协议栈超时 */ /* 其他业务代码,注意不要出现长延时 */ HAL_Delay(1); }

MX_LWIP_Process()内部其实是两句话:

void MX_LWIP_Process(void) { ethernetif_input(&gnetif); /* 从 DMA 取一帧交给 LwIP */ sys_check_timeouts(); /* 处理 ARP 老化、TCP 重传、IP 重组超时 */ }

就这两句,但调用频率直接决定网络性能。ethernetif_input每次只取一帧,如果你主循环里有个HAL_Delay(500)在那儿等传感器,一秒钟只能处理两帧,ping 会稳定丢包、TCP 吞吐低得离谱。常见做法是把耗时的业务拆成状态机,一段一段推进,或者用定时器中断置标志,主循环判断标志再执行,绝不在主循环里用一个较长的延时一直等待。

还有一点:MX_LWIP_Process()里调的是ethernetif_input,而ethernetif_input内部走的是low_level_input。如果你改成了中断里直接投递 pbuf 的模式,就要相应调整这里,别两边都调一遍导致同一帧被处理两次。

3.4 编译、下载与第一次 ping 验证

代码烧进去之后,验证要按物理层 → 链路层 → 网络层的顺序走,不要一上来就 ping。

第一步,看灯。网口连接指示灯(Link/Act)常亮说明 PHY 已经和交换机完成自协商,黄灯闪烁说明有数据流动。灯不亮就别急着改代码,先量 REF_CLK 有没有 50MHz、PHY 复位脚电平对不对、MDIO 有没有上拉。

第二步,在初始化后加一段临时打印,把 PHY 的 ID 寄存器读出来:

uint32_t phy_id1 = 0, phy_id2 = 0; HAL_ETH_ReadPHYRegister(&heth, 0x00, 0x02, &phy_id1); /* PHY ID1 */ HAL_ETH_ReadPHYRegister(&heth, 0x00, 0x03, &phy_id2); /* PHY ID2 */ printf("PHY ID: %08X %08X\r\n", phy_id1, phy_id2);

LAN8720A 的 ID1 是0x0007、ID2 是0xC0F1。读出来是这两个值,说明 MDIO 通道、PHY 地址、PHY 供电全都没问题,可以往下走;读出来0xFFFF就是 MDIO 没通,回去查地址和上拉。这一步能省掉后面一大半的猜测。

第三步,看 IP 配置和 PC 是否同网段。板子配 192.168.1.100/24,PC 就得是 192.168.1.x/24,中间不能隔路由器。很多人笔记本上同时开着 WiFi 和有线,路由表默认走 WiFi,ping 请求根本没从网口发出去。测试时把 WiFi 断开最省心。

第四步,正式 ping:

# Windows ping 192.168.1.100 -t # Linux / macOS ping 192.168.1.100 # 看 ARP 表里有没有板子的 MAC,判断是链路层还是网络层的问题 arp -a | grep 192.168.1.100

arp -a能看到板子的 MAC 说明 ARP 已经通了,问题在 IP 层往上;看不到条目说明帧根本没到 PC,或者板子没回 ARP 应答,回去查ethernetif_input有没有被调用到。

4. 核心模块拆解:一帧数据从 PHY 进入到 PING 回包的完整链路

ping 通了只是个结果,真正要改代码,得知道一个 ICMP 请求从网线进来、到回包出去,中间经过哪几次内存拷贝、哪几个回调。这一节按收、发、状态、时基四条线拆开。

4.1 接收路径:从 DMA 缓冲到 pbuf 链表

接收路径的核心是「零拷贝」三个字。ST 的 HAL 提供了一个机制,让 DMA 直接把数据写进 pbuf 的 payload 区,而不是先写进一块独立缓冲再 memcpy 一遍。这套机制靠两个回调实现:

/* DMA 需要一块缓冲区时被调用,这里直接把 pbuf 的 payload 借出去 */ void HAL_ETH_RxAllocateCallback(uint8_t **buff) { struct pbuf *p; /* 从 POOL 池里拿一个定长 pbuf,长度是 PBUF_POOL_BUFSIZE */ p = pbuf_alloc(PBUF_RAW, PBUF_POOL_BUFSIZE, PBUF_POOL); if (p != NULL) { /* payload 前面留出偏移,之后靠偏移反推 pbuf 指针 */ *buff = (uint8_t *)p->payload; } else { *buff = NULL; /* 池子空了,HAL 会丢弃这一帧 */ } } /* 一帧收完后被调用,把零散的 pbuf 串成链表 */ void HAL_ETH_RxLinkCallback(void **pStart, void **pEnd, uint8_t *buff, uint16_t Length) { struct pbuf **ppStart = (struct pbuf **)pStart; struct pbuf **ppEnd = (struct pbuf **)pEnd; struct pbuf *p; /* 由 payload 地址反推出 pbuf 结构体地址 */ p = (struct pbuf *)((uint8_t *)buff - offsetof(struct pbuf, payload)); p->len = Length; p->tot_len = Length; p->next = NULL; if (*ppStart == NULL) { *ppStart = p; /* 链头 */ } else { (*ppEnd)->next = p; /* 接在上一段后面 */ } *ppEnd = p; /* 更新链尾 */ }

HAL_ETH_RxAllocateCallback在 DMA 描述符需要重新装载缓冲区时触发,注意它是在中断上下文里被调用的,所以里面不能做耗时操作,也不能调用任何可能阻塞的 LwIP API。pbuf_alloc从池子里取元素是 O(1) 的链表操作,可以放心用,但它有失败的可能——池子被上层还没释放的 pbuf 占满时返回 NULL,HAL 收到 NULL 就直接丢帧。这也是为什么PBUF_POOL_SIZE不能给太小。

HAL_ETH_RxLinkCallback里那个「由 payload 反推 pbuf」的写法是整套机制的关键。它依赖payload在struct pbuf里的固定偏移,所以千万不要手工去改 LwIP 的 pbuf 结构体布局,也不要在pbuf_alloc之后把payload指针挪位置。我在一个工程里见过有人为了对齐在pbuf前面加了个字段,结果这里算出来的指针偏了 4 字节,整个网络栈崩得莫名其妙。

收到帧之后,主循环里的ethernetif_input把它交给协议栈:

err_t ethernetif_input(struct netif *netif) { struct pbuf *p = low_level_input(netif); /* 内部调 HAL_ETH_ReadData */ if (p == NULL) { return ERR_MEM; /* 没收到帧,正常返回 */ } /* netif->input 在 netif_add 时被指定为 ethernet_input */ if (netif->input(p, netif) != ERR_OK) { pbuf_free(p); /* 上层不要,必须自己释放,否则内存泄漏 */ p = NULL; return ERR_ARG; } return ERR_OK; }

pbuf_free那一行是必须的。netif->input返回非 ERR_OK 表示上层拒绝了这个包(比如 IP 头校验失败、目标地址不是自己且没开转发),此时 pbuf 的所有权还在调用方手上,不释放就漏了。跑一晚上 ping 之后内存池耗尽、网络彻底哑掉,多半就是这里漏了或者别处漏了。

4.2 发送路径:low_level_output 与 pbuf 链的摊平

发送方向反过来:LwIP 交下来的是一个 pbuf 链表(TCP 分段时可能是多段),HAL 那边要的是ETH_BufferTypeDef数组,两者之间要做一次映射。

static err_t low_level_output(struct netif *netif, struct pbuf *p) { struct pbuf *q; uint32_t i = 0; ETH_BufferTypeDef Txbuffer[ETH_TX_DESC_CNT]; memset(Txbuffer, 0, sizeof(Txbuffer)); /* 把 pbuf 链摊平成 Buffer 数组,零拷贝,只搬指针 */ for (q = p; q != NULL; q = q->next) { if (i >= ETH_TX_DESC_CNT) { return ERR_IF; /* 段数超过描述符数量,只能丢 */ } Txbuffer[i].buffer = q->payload; Txbuffer[i].len = q->len; if (i > 0) { Txbuffer[i - 1].next = &Txbuffer[i]; /* 串成链 */ } if (q->next == NULL) { Txbuffer[i].next = NULL; /* 尾节点 */ } i++; } /* 启动中断发送,函数返回时数据还没发出去 */ if (HAL_ETH_Transmit_IT(&heth, &Txbuffer[0]) != HAL_OK) { return ERR_IF; } return ERR_OK; }

这里有几个隐含约束。Txbuffer是栈上的局部数组,只在函数返回前有效——HAL_ETH_Transmit_IT是中断方式,它只是把缓冲地址写进描述符就返回了,真正的 DMA 读取发生在之后。如果 HAL 版本要求缓冲区在传输完成前保持有效,这个栈数组就有风险。ST 的 ETH HAL 内部有一份描述符管理,HAL_ETH_Transmit_IT会等前一个描述符空闲才装载,所以在描述符数量够的前提下是安全的,但不能把ETH_TX_DESC_CNT设得太小,太小了在 TCP 大流量下HAL_ETH_Transmit_IT会返回HAL_BUSY,这里的ERR_IF触发,上层表现就是发送卡顿、重传。

第二个约束是i >= ETH_TX_DESC_CNT这个判断。pbuf 链的最大段数并不是固定的,取决于上层怎么切包。ETH_TX_DESC_CNT给 4 的时候,如果某个 TCP 包被切成了 5 段,这里就直接返回失败丢包,而且没有任何日志。所以要么把描述符数量调大,要么在这里把多段 pbuf 合并成一段再发(多一次 memcpy,但稳)。

HAL_ETH_Transmit_IT返回HAL_OK并不代表发成功了,只代表描述符装载成功。真正发出去之后会触发发送完成中断,如果你的代码在中断里没做任何事,那也没关系,HAL 内部会处理描述符回收。但如果你开了HAL_ETH_TxFreeCallback这种自定义回收机制,记得在里面把对应资源释放掉。

4.3 网口状态轮询:LAN8720A 的 BSR 到底怎么读

LwIP 需要一个netif_is_link_up的准确状态,这个状态不是 MAC 自己知道的,得靠软件定期读 PHY 的状态寄存器。ST 的ethernetif.c里有个ethernet_link_check_state(),典型的实现是:

#define LAN8720A_BSR 0x01 /* Basic Status Register */ #define LAN8720A_BSR_LINK 0x0004 /* bit2: Link Status */ #define LAN8720A_BSR_ANEG 0x0020 /* bit5: Auto-Negotiation Complete */ static void ethernet_link_check_state(struct netif *netif) { uint32_t bsr = 0; ETH_HandleTypeDef *heth = &heth; /* 第一次读:清掉 bit2 的锁存值 */ HAL_ETH_ReadPHYRegister(heth, heth->Init.PhyAddress, LAN8720A_BSR, &bsr); /* 第二次读:拿到真实的当前链路状态 */ HAL_ETH_ReadPHYRegister(heth, heth->Init.PhyAddress, LAN8720A_BSR, &bsr); if ((bsr & LAN8720A_BSR_LINK) && !netif_is_link_up(netif)) { /* 链路刚起来,同时确认自协商已完成 */ if (bsr & LAN8720A_BSR_ANEG) { netif_set_link_up(netif); } } else if (!(bsr & LAN8720A_BSR_LINK) && netif_is_link_up(netif)) { netif_set_link_down(netif); } }

「读两次」不是多余的仪式。LAN8720A 的 BSR bit2 是锁存低电平的:链路断开过哪怕一瞬间,这个位就会锁存成 0,直到你读一次 BSR 才恢复成当前真实值。只读一次的话,你会读到上一次断开时锁存的状态,链路明明是通的,软件却以为断了,或者反过来。这个细节我在两个不同项目上遇到过,表现都是「拔插一次网线之后网络就再也不通了,重启才恢复」——重启之所以能恢复,就是因为上电后第一次读的值恰好是对的。

另一个容易忘的是:netif_set_link_up之后要调netif_set_up,否则协议栈不会真正开始收发。反过来netif_set_link_down之后要调netif_set_down,让 ARP 表和连接状态都清掉。只调 link 相关的函数,netif 的 flags 没变,LwIP 还是会尝试发送,发到一半发现链路没了,各种超时重传全乱。

ethernet_link_check_state的调用位置也有讲究。它要定期执行,一般是放在MX_LWIP_Process()里加一个软件计数器:

void MX_LWIP_Process(void) { static uint32_t tick = 0; ethernetif_input(&gnetif); /* 每 200ms 查一次链路状态,别查太勤,MDIO 读寄存器有开销 */ if (HAL_GetTick() - tick > 200) { tick = HAL_GetTick(); ethernet_link_check_state(&gnetif); } sys_check_timeouts(); }

200ms 是个经验值。查太频繁(比如每 1ms)会让 MDIO 总线占用过高,而且 PHY 寄存器的状态本来也不会变得那么快;查太慢(比如 2s 一次)就会出现网线拔了半秒软件还没反应、ping 一直超时但 netif 还认为自己 up 的情况。

4.4 时间基准:sys_now 与那些说不清的超时行为

sys_now()是裸机移植里唯一必须自己实现的东西,写在sys_arch.c里:

u32_t sys_now(void) { return (u32_t)HAL_GetTick(); /* 毫秒级,1ms 的 SysTick */ }

就这一行,但它的正确性依赖三个前提:SysTick 频率是 1kHz、SysTick 中断优先级高于以太网中断、并且没有任何代码长时间关中断。第三个最容易被忽略——有些驱动在写 Flash 或者操作某些外设时会关中断几十毫秒,这段时间里 SysTick 不计数,HAL_GetTick()就「慢」了,LwIP 的 TCP 重传定时器会算错,出现明明链路很好却不停重传的情况。

HAL_GetTick()返回的是uint32_t,溢出回绕本身没问题,LwIP 内部的超时比较用的是无符号差值,只要两次调用的间隔小于 2^31 毫秒就都对。真正会出问题的是那些自己写的状态机,如果用if (HAL_GetTick() > deadline)这种写法,回绕那天就会失效,得改成if ((HAL_GetTick() - start) > timeout)。

还有一个常见的问题:HAL_Delay()内部也是基于 SysTick 的忙等,在NO_SYS=1下调用它,整个协议栈就停在那儿。LwIP 的 TCP 重传超时默认最小 200ms 起,你HAL_Delay(1000)一下,重传定时器就齐刷刷全到期,一口气重传一堆包。看起来是「网络时通时不通」,实际是主循环被阻塞了。要么换成状态机,要么把延时拆成很多个短延时,在每个短延时里穿插调用MX_LWIP_Process()。

注意:调试阶段如果开了LWIP_DEBUG,LWIP_PLATFORM_DIAG一般会重定向到串口。串口打印是阻塞的,115200 波特率下一行日志就要几毫秒,打印量一大就会严重拖慢MX_LWIP_Process的调用频率。定位问题时开日志,跑性能时必须关掉。

5. 常见问题排查:网口灯、ARP、丢包与死机

这一节把上面零散提到的坑集中一下,按「现象 → 原因 → 解决」记录。这几条基本覆盖了裸机以太网 90% 的调试时间。

5.1 网口灯完全不亮,link 一直 down

现象:上电后 RJ45 的连接指示灯不亮,MDIO 读 PHY ID 返回0xFFFFFFFF或0x00000000,netif_is_link_up永远是 false。

原因:最常见的三个,一是 RMII 的 50MHz REF_CLK 没进来——PHY 的 REFCLKO 没配置成输出模式(REGOFF/SEL这些配置脚接错),或者 MCU 侧 PA1 没复用成 AF11;二是 PHY 复位脚一直被拉低,PHY 根本没启动;三是 MDIO 没上拉,读回来全是悬空电平。

解决:先用示波器量 PA1 上有没有 50MHz 方波,没有就回去查 PHY 的配置脚和晶振。有波形但 ID 还是读不到,量 MDIO 在空闲时是不是高电平(上拉电阻 1.5k~2.2k 到 3.3V)。ID 能读到但 link 不 up,就去看 BSR 的 bit5 自协商完成位,同时确认对端交换机端口没被禁用、网线是好的。这一步不要靠猜,万用表和示波器比改代码快得多。

5.2 link 是 up 的,但 PC 上 ping 不通

现象:网口灯常亮,PHY ID 读得到,BSR 的 link 位也是 1,但 PC 上ping全是超时,arp -a里看不到板子的条目。

原因:按概率排,第一是 IP 不在同一网段,或者 PC 上有多个网卡、默认路由走了 WiFi;第二是netif_add传的最后一个参数写成了ip_input而不是ethernet_input,ARP 请求收到了但不回;第三是 MAC 地址跟别的设备冲突,交换机把帧转发到了错误的端口;第四是 PC 侧防火墙把 ICMP 拦了(这个方向反过来,是板子 ping 不通 PC)。

解决:先ipconfig确认 PC 网口地址和板子同网段,然后临时禁用 WiFi 再测。用 Wireshark 抓包,如果能看到板子发出的 ARP 请求但收不到应答,问题在板子的接收路径;如果连 ARP 请求都没有,说明发送路径就没通,回去查low_level_output有没有被调用。MAC 地址冲突这个最隐蔽,两台设备 MAC 一样时交换机会不停翻转 MAC 表,表现是「时通时不通」,随手换一组 MAC 就能验证。

5.3 ping 能通,但丢包严重、延迟忽高忽低

现象:ping -t跑起来,大部分能回,但每隔几秒丢一两个包,延迟从 1ms 跳到几百毫秒。

原因:主循环被阻塞是头号嫌疑。MX_LWIP_Process()调用频率决定了收包速度,主循环里任何一个HAL_Delay(100)以上的等待都会造成这个现象。其次是PBUF_POOL_SIZE太小,池子被占满后新帧直接丢弃。第三是RxDesc数量太少,DMA 环形缓冲来不及被软件回收,硬件层面就丢了。第四是中断优先级配置不当,ETH 中断被别的外设长时间抢占。

解决:先做减法,把主循环里除MX_LWIP_Process()之外的所有代码注释掉,再 ping 一轮。丢包消失就说明是主循环阻塞,把耗时逻辑改状态机。如果还丢,把PBUF_POOL_SIZE从 16 调到 32、RxDesc从 4 调到 8 再试。中断优先级方面,ETH 中断建议配成比 SysTick 略低、比普通外设高的档位,具体数值看芯片的中断分组配置,原则是别让它被串口、定时器这类高频中断压住。

5.4 能收不能发,或者发几包之后彻底卡死

现象:Wireshark 能看到板子收到请求,但没有任何回包;或者刚开始能回几个包,之后就完全静默,复位才恢复。

原因:发几包后卡死,几乎可以断定是 pbuf 泄漏。ethernetif_input里netif->input返回非 ERR_OK 时忘了pbuf_free,或者自己写的业务代码里pbuf_alloc之后某条分支提前 return 没释放。能收不能发则是low_level_output的问题,常见的是Txbuffer段数超了ETH_TX_DESC_CNT直接返回ERR_IF,或者HAL_ETH_Transmit_IT返回HAL_BUSY被当成致命错误处理了,实际上重试一次就好。

解决:把PBUF_POOL_SIZE临时改大(比如 64),如果卡死时间明显推后,就坐实了泄漏。然后在pbuf_alloc和pbuf_free上加计数打印,跑一轮看两个数是否平衡。发送侧把HAL_BUSY单独处理,返回ERR_WOULDBLOCK让上层重试,别直接丢。另外检查low_level_output里Txbuffer的next链是否真的串起来了——只填buffer和len不串next,HAL 会以为只有一段,后面几段数据根本没发出去,表现出来就是「小包能回、大包不回」。

5.5 开了 D-Cache 之后数据错乱或者 HardFault

现象:在带 D-Cache 的型号上,代码跑到HAL_ETH_ReadData附近 HardFault,或者收到的数据看起来是上一帧的残留。

原因:以太网 DMA 直接操作内存,不经过 CPU 的 Cache。CPU 从 Cache 里读到的可能是旧数据,DMA 写进内存的新数据 CPU 看不见;发送方向反过来,CPU 写进 Cache 还没回写到内存,DMA 就把内存里的旧内容发出去了。

解决:三条路,一是把 ETH 描述符和缓冲区所在的 RAM 区域配成 non-cacheable(用 MPU 设MPU_ACCESS_NOT_CACHEABLE),这是最省心的做法;二是在接收前SCB_InvalidateDCache_by_Addr、发送前SCB_CleanDCache_by_Addr,但地址和长度都要按 cache line 对齐,容易漏;三是干脆全局关 D-Cache,性能损失但绝对稳。调试阶段建议先关掉 Cache 把功能跑通,再逐段开启并配合 MPU 配置,否则你会在一个跟网络逻辑完全无关的地方卡很久。

6. 二次开发改造:从 PING 通到能跑业务的服务端

ping 通只是个起点,真正要用起来,下一步是把 ICMP 换成 TCP,把「回声」换成业务逻辑。这里给一条我验证过的改造路径。

6.1 加一个 TCP Echo 服务端

前提是在lwipopts.h里把LWIP_TCP打开(默认就是 1),并保证MEM_SIZE足够。然后在MX_LWIP_Init之后、主循环之前注册一个 TCP 服务端:

#include "lwip/tcp.h" #define TCP_ECHO_PORT 5000 static err_t echo_recv(void *arg, struct tcp_pcb *tpcb, struct pbuf *p, err_t err) { if (p == NULL) { /* 对端关闭连接 */ tcp_close(tpcb); return ERR_OK; } /* 原样回发,tcp_write 只入队,tcp_output 才真正发出去 */ tcp_write(tpcb, p->payload, p->len, TCP_WRITE_FLAG_COPY); tcp_output(tpcb); tcp_recved(tpcb, p->len); /* 必须调,否则接收窗口不推进 */ pbuf_free(p); return ERR_OK; } static err_t echo_accept(void *arg, struct tcp_pcb *newpcb, err_t err) { tcp_recv(newpcb, echo_recv); /* 绑定数据回调 */ tcp_arg(newpcb, NULL); return ERR_OK; } void tcp_echo_init(void) { struct tcp_pcb *pcb = tcp_new(); if (pcb == NULL) return; tcp_bind(pcb, IP_ADDR_ANY, TCP_ECHO_PORT); pcb = tcp_listen(pcb); /* 监听后 pcb 指针会被替换 */ tcp_accept(pcb, echo_accept); }

三个容易出错的地方:tcp_recved不调,接收窗口会越来越小直到为 0,对端发不动数据,本地却没有任何报错;tcp_write的最后一个参数不加TCP_WRITE_FLAG_COPY,LwIP 只存指针不拷数据,你pbuf_free之后那块内存就被复用了,发出去的是乱码;tcp_listen会返回一个新的 pcb,原来的 pcb 已经失效,用旧指针tcp_accept就是野指针。

6.2 静态 IP 换 DHCP、参数调优与验证

业务跑通之后再做这些调整会舒服很多。换成 DHCP 只要把LWIP_DHCP设为 1,netif_add时 IP 传IP_ADDR_ANY,然后周期性调dhcp_fine_tmr()(短周期)和dhcp_coarse_tmr()(长周期),或者直接用dhcp_start(&gnetif)配合 LwIP 自带的定时调用。DHCP 失败时netif会停在无地址状态,建议加一个超时回退到静态 IP 的逻辑,避免现场路由器没开 DHCP 就彻底失联。

调优的参数就那么几个,按场景选:

参数小数据量控制场景大流量传输场景
PBUF_POOL_SIZE1632 ~ 64
PBUF_POOL_BUFSIZE15241524
ETH_RX_DESC_CNT48
ETH_TX_DESC_CNT48
MEM_SIZE8 ~ 16 KB32 KB 以上
TCP_SND_BUF2 * MSS8 * MSS
TCP_WND2 * MSS8 * MSS

验证方法我一般用两步:先用ping -t跑十分钟看有没有丢包,这是最直接的链路健康度指标;再用iperf或者自己写个循环发数据的脚本跑吞吐,看能稳定在多少。如果吞吐上不去,先看主循环的调用频率够不够,再看MEM_SIZE和TCP_SND_BUF是不是卡住了窗口。

改完这些,别忘了最初那个教训:每次动lwipopts.h之后,我都会重新跑一遍「拔插网线 + 长 ping」的组合测试。因为内存池、描述符数量、链路状态检测这三者之间的耦合比看上去紧得多,改小一个参数可能让另一个问题在半小时后才暴露出来,而那时候你早就忘了自己动过什么。希望这些能帮你少走点弯路。

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

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

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

立即咨询