简介面向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 接口接 LAN8720AHAL 库完成 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 的 REFCLKOETH_MDIOPA2双向管理接口数据线需 1.5k~2.2k 上拉ETH_CRS_DVPA7输入载波侦听/数据有效ETH_MDCPC1输出管理接口时钟HAL 分频后约 1~2MHzETH_RXD0PC4输入接收数据位 0ETH_RXD1PC5输入接收数据位 1ETH_TX_ENPB11输出发送使能ETH_TXD0PB12输出发送数据位 0ETH_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_SYS1意味着没有线程、没有信号量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选 RMIIPHY Address填硬件实际值。参数面板里几个关键项参数典型取值说明Auto NegotiationEnable交给 PHY 自协商99% 的场景都该开Speed / Duplex由自协商决定关闭自协商时才手填Rx ModeInterrupt Mode裸机必须用中断轮询会阻塞主循环Rx Buff Len1524 或 1536要能装下最大以太网帧Rx/Tx Desc4DMA 环形描述符数量够用且省 RAMChecksum 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 0PBUF_POOL_BUFSIZE是整个配置里最不能省的。它定义每个 pbuf 池元素能装多少字节如果小于最大帧长ethernetif.c里分配接收缓冲时会返回 NULLDMA 拿不到缓冲区帧直接被丢弃现象是「有时能 ping 通、大包必丢」。给 1524 或者 1536 都行别为了省那点 RAM 设成 512。MEM_SIZE在纯 ping 场景下 4KB 就够但凡计划后面跑 TCP直接给 16KB 起步。LwIP 用的是自己的内存池跟 C 库的malloc完全隔离MEM_SIZE不够时mem_malloc返回 NULL上层只会看到「连接建立失败」不会有任何报错提示属于典型的难以排查的问题。NO_SYS1还有个连带影响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/24PC 就得是 192.168.1.x/24中间不能隔路由器。很多人笔记本上同时开着 WiFi 和有线路由表默认走 WiFiping 请求根本没从网口发出去。测试时把 WiFi 断开最省心。第四步正式 ping# Windows ping 192.168.1.100 -t # Linux / macOS ping 192.168.1.100 # 看 ARP 表里有没有板子的 MAC判断是链路层还是网络层的问题 arp -a | grep 192.168.1.100arp -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 占满时返回 NULLHAL 收到 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_SYS1下调用它整个协议栈就停在那儿。LwIP 的 TCP 重传超时默认最小 200ms 起你HAL_Delay(1000)一下重传定时器就齐刷刷全到期一口气重传一堆包。看起来是「网络时通时不通」实际是主循环被阻塞了。要么换成状态机要么把延时拆成很多个短延时在每个短延时里穿插调用MX_LWIP_Process()。注意调试阶段如果开了LWIP_DEBUGLWIP_PLATFORM_DIAG一般会重定向到串口。串口打印是阻塞的115200 波特率下一行日志就要几毫秒打印量一大就会严重拖慢MX_LWIP_Process的调用频率。定位问题时开日志跑性能时必须关掉。5. 常见问题排查网口灯、ARP、丢包与死机这一节把上面零散提到的坑集中一下按「现象 → 原因 → 解决」记录。这几条基本覆盖了裸机以太网 90% 的调试时间。5.1 网口灯完全不亮link 一直 down现象上电后 RJ45 的连接指示灯不亮MDIO 读 PHY ID 返回0xFFFFFFFF或0x00000000netif_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_inputARP 请求收到了但不回第三是 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不串nextHAL 会以为只有一段后面几段数据根本没发出去表现出来就是「小包能回、大包不回」。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_COPYLwIP 只存指针不拷数据你pbuf_free之后那块内存就被复用了发出去的是乱码tcp_listen会返回一个新的 pcb原来的 pcb 已经失效用旧指针tcp_accept就是野指针。6.2 静态 IP 换 DHCP、参数调优与验证业务跑通之后再做这些调整会舒服很多。换成 DHCP 只要把LWIP_DHCP设为 1netif_add时 IP 传IP_ADDR_ANY然后周期性调dhcp_fine_tmr()短周期和dhcp_coarse_tmr()长周期或者直接用dhcp_start(gnetif)配合 LwIP 自带的定时调用。DHCP 失败时netif会停在无地址状态建议加一个超时回退到静态 IP 的逻辑避免现场路由器没开 DHCP 就彻底失联。调优的参数就那么几个按场景选参数小数据量控制场景大流量传输场景PBUF_POOL_SIZE1632 ~ 64PBUF_POOL_BUFSIZE15241524ETH_RX_DESC_CNT48ETH_TX_DESC_CNT48MEM_SIZE8 ~ 16 KB32 KB 以上TCP_SND_BUF2 * MSS8 * MSSTCP_WND2 * MSS8 * MSS验证方法我一般用两步先用ping -t跑十分钟看有没有丢包这是最直接的链路健康度指标再用iperf或者自己写个循环发数据的脚本跑吞吐看能稳定在多少。如果吞吐上不去先看主循环的调用频率够不够再看MEM_SIZE和TCP_SND_BUF是不是卡住了窗口。改完这些别忘了最初那个教训每次动lwipopts.h之后我都会重新跑一遍「拔插网线 长 ping」的组合测试。因为内存池、描述符数量、链路状态检测这三者之间的耦合比看上去紧得多改小一个参数可能让另一个问题在半小时后才暴露出来而那时候你早就忘了自己动过什么。希望这些能帮你少走点弯路。本文还有配套的精品资源点击获取