简介:面向STM32F429IGT6的以太网开发工程包,围绕硬件抽象层库、LAN8720A物理层芯片和轻量级网络协议栈,给出无操作系统下完成PING通信的一套参考实现。压缩包约31.16MB,共1300个文件,主体是六百余个C源文件与四百余个头文件,另含汇编启动代码、链接脚本、静态库以及图形化配置和集成开发环境工程文件,目录覆盖外设初始化、协议栈移植和物理层驱动。已有1989人学习下载,适合嵌入式网络入门者、课程实验学生与需要移植协议栈的工程师。完整工程便于对照理解精简媒体独立接口的数据通路、串行管理接口读写寄存器、无系统环境下的网络适配与连通性验证,也能为排查链路建立和芯片初始化问题、形成可复用工程模板提供参考,减少从搭建环境到联调排错的摸索成本。
1. 一块 F407 核心板 + LAN8720A,PING 到底卡在哪
先说一个场景:板子上电,网口绿灯常亮、黄灯闪烁,交换机那头也识别到了链路,可 ping 192.168.1.10 就是 100% 丢包。很多人第一反应是 LwIP 配置错了,实际上十有八九卡在 PHY 地址、50MHz 参考时钟或者 DMA 描述符放在了 CCM RAM 这几个地方。这套 STM32 + C 语言 的裸机以太网方案,就是用 HAL 库的 ETH 外设驱动 LAN8720A,再挂 LwIP 协议栈,不跑任何操作系统,最终目标很朴素:板子能被同一网段的电脑 ping 通。适合正在做工业采集板、裸机网关、或者想把网络功能塞进一个没有 RTOS 的小项目里的开发者;如果你已经在用 FreeRTOS,这套代码的很多思路能直接平移,但没必要照抄。
2. 硬件链路与工程骨架:RMII 九根线、50MHz 时钟和 CubeMX 里的几个开关
动手之前先把物理层捋清楚,后面调代码才不会瞎猜。LAN8720A 是一颗 10/100M 自协商 PHY,通过 RMII 接口和 STM32 的 ETH MAC 相连,RMII 相比 MII 把数据线从 16 根砍到 9 根,代价是必须有一路共同的 50MHz 参考时钟。
2.1 RMII 的九根信号线,少一根都不通
以 STM32F407 为例,CubeMX 里选中 ETH 并把 Media Interface 设成 RMII 之后,引脚会自动分配成下面这组。这组对应关系建议抄在纸上,量板子的时候一根一根对:
| 信号 | 引脚 | 方向 | 说明 |
|---|---|---|---|
| ETH_RMII_REF_CLK | PA1 | 输入 | 50MHz 参考时钟,必须由外部提供 |
| ETH_MDIO | PA2 | 双向 | 管理数据线,读写 PHY 寄存器 |
| ETH_MDC | PC1 | 输出 | 管理时钟,最高 25MHz |
| ETH_RMII_CRS_DV | PA7 | 输入 | 载波侦听/数据有效 |
| ETH_RMII_RXD0 | PC4 | 输入 | 接收数据 bit0 |
| ETH_RMII_RXD1 | PC5 | 输入 | 接收数据 bit1 |
| ETH_RMII_TX_EN | PG11 | 输出 | 发送使能 |
| ETH_RMII_TXD0 | PG13 | 输出 | 发送数据 bit0 |
| ETH_RMII_TXD1 | PG14 | 输出 | 发送数据 bit1 |
血泪经验是第一块板子把 TXD1 错接到了 PB13,飞线之前无论怎么改代码都没用。检查方法很土但有效:用万用表蜂鸣档从 MCU 引脚量到 PHY 引脚,一根一根确认,顺便确认这些引脚在 CubeMX 里没有被别的外设复用掉。
2.2 50MHz 参考时钟从哪来
LAN8720A 支持两种常见接法,选错直接导致 MAC 侧一个时钟都没有,HAL_ETH_Init 会一直卡在自协商等待里。
第一种是让 LAN8720A 自己产生 50MHz。它内部有一个 PLL,只要外部挂一颗 25MHz 晶振,就能在 nINT/REFCLKO 引脚上输出 50MHz。关键在nINTSEL 引脚:上电时把它拉低,这个引脚就变成 REFCLK 输出模式,把 50MHz 送给 STM32 的 PA1;拉高则变成中断输出,PA1 就收不到时钟。很多模块默认把 nINTSEL 拉高了,于是你得自己改电阻。
第二种是板上放一颗 50MHz 有源晶振,同时喂给 PHY 的 XTAL1/CLKIN 和 STM32 的 PA1,两颗芯片共用一个时钟源。这种方式抖动小,做产品我更倾向这个,但手上调试板一般没有,所以第一种更常见。
还有一个容易被忽略的点:F4 系列的 ETH 时钟来自 AHB1,要在 RCC 里使能__HAL_RCC_ETHMAC_CLK_ENABLE(),同时还有ETHMACRX和ETHMACTX两路。CubeMX 会自动加,但如果你是手工移植旧工程,这三句漏一句就会出现“MAC 配置全对就是不收包”的玄学现象。
2.3 CubeMX 勾选项和生成目录的对应关系
工程结构上,我一般把网络相关的代码固定在几个位置,方便后面改:
Core/ Inc/ main.h ethernetif.h // 网卡抽象层对外接口 lwip.h // LwIP 初始化入口 Src/ main.c // 只放 MX_ETH_Init / MX_LWIP_Init / 主循环 ethernetif.c // LwIP 与 ETH 驱动的胶水层,核心在这 lwip.c // netif_add、IP 配置、DHCP 开关 eth.c // 若把 ETH 初始化单独抽出,放这里 LWIP/ App/ // ping、tcp echo 之类的应用 Target/ lwipopts.h // 协议栈裁剪和内存参数,最重要的一个头文件 Middlewares/Third_Party/LwIP/CubeMX 里几个必须手工确认的开关:在 ETH 参数里把Rx Mode设成 Polling(先跑通,后面再换中断)、Checksum Mode若勾了硬件校验和,就要保证 LwIP 那边不要重复算;在 LwIP 里把LWIP_DHCP关掉先用静态 IP,LWIP_ICMP和LWIP_ARP必须打开,否则 ping 根本没戏。
生成代码之后不要急着编译,先把lwipopts.h打开看一眼,CubeMX 默认给的MEM_SIZE往往只有几 KB,跑 ping 够用,但稍微加点应用就翻车。
3. ETH 外设初始化:HAL_ETH_Init 背后做了什么,PHY 自协商怎么盯
这一章是整篇文章里最容易出问题的地方。HAL 把 DMA 描述符、MAC 配置、PHY 自协商全塞进了HAL_ETH_Init一个函数里,调用一次就返回,看起来简单,实际上内部顺序很讲究。
3.1 描述符、缓冲和 HAL_ETH_Init 的初始化顺序
ETH 外设靠 DMA 收发,DMA 靠描述符环找缓冲。描述符和缓冲必须是连续的、4 字节对齐的、DMA 能访问到的 RAM。F4 系列有个 CCM RAM(地址 0x10000000 起),CPU 访问快,但 DMA 根本访问不到,如果你的链接脚本把.bss放进了 CCM,恭喜你,收包永远是 0。
/* eth.c —— 描述符和缓冲由应用自己定义,不能交给编译器随便放 */ ETH_DMADescTypeDef DMARxDscrTab[ETH_RXBUFNB] __attribute__((aligned(4))); ETH_DMADescTypeDef DMATxDscrTab[ETH_TXBUFNB] __attribute__((aligned(4))); uint8_t Rx_Buff[ETH_RXBUFNB][ETH_MAX_PACKET_SIZE] __attribute__((aligned(4))); uint8_t Tx_Buff[ETH_TXBUFNB][ETH_MAX_PACKET_SIZE] __attribute__((aligned(4))); #define LAN8720A_PHY_ADDRESS 0x00U /* 由 PHYAD0 引脚电平决定:拉低为 0,拉高为 1 */ static const uint8_t MAC_ADDR[6] = {0x02, 0x00, 0x00, 0x12, 0x34, 0x56}; 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.PhyAddress = LAN8720A_PHY_ADDRESS; heth.Init.MACAddr = (uint8_t *)MAC_ADDR; heth.Init.RxMode = ETH_RXPOLLING_MODE; /* 先用轮询,降低调试变量 */ heth.Init.ChecksumMode = ETH_CHECKSUM_BY_HARDWARE; heth.Init.MediaInterface = ETH_MEDIA_INTERFACE_RMII; heth.Init.RxBuffLen = ETH_MAX_PACKET_SIZE; /* 1524,别小于 MTU + 14 */ if (HAL_ETH_Init(&heth) != HAL_OK) { Error_Handler(); /* 卡在这,优先怀疑 PHY 地址和 REF_CLK */ } }逐段说。ETH_RXBUFNB和ETH_TXBUFNB一般是 4,缓冲多几个能扛住短时突发,但也更吃 RAM,F407 的 192KB 完全够。MAC_ADDR首字节我写的是 0x02,这是刻意的:以太网 MAC 地址首字节的最低位是单播/组播标志位,为 1 表示组播,交换机会把它当成组播帧处理,普通 PC 不会回 ARP,你就永远 ping 不通。同理全 0 和全 FF 也是重灾区,别图省事。
RxBuffLen这个参数很多模板里写ETH_MAX_PACKET_SIZE,如果你把它改小到 1500,理论上够,但一旦网卡或交换机发来带 VLAN tag 的帧就超了,实际现象是“小包通、大包丢”,很迷惑。老老实实留 1524。
ChecksumMode设成硬件校验和之后,MAC 会帮你填 IP/TCP/UDP 的校验和,但要注意 LwIP 侧对应的CHECKSUM_GEN_IP等宏也要关掉,两边都算不冲突,但两边都不算就全废,这个坑后面排查章节还会提。
参数说明清楚了,再看HAL_ETH_Init内部干了什么,理解这个顺序对定位问题很有帮助:
- 调用
HAL_ETH_MspInit,里面使能 GPIO、时钟、NVIC; - 关掉 MAC 的收发,配置 MAC 地址、双工、速率、RMII 接口;
- 把应用传进来的
DMARxDscrTab/DMATxDscrTab串成环形链表,写进DMARxDescToSet和DMATxDescToSet; - 调用
HAL_ETH_WritePHYRegister配置 PHY 的 BCR 寄存器,启动自协商; - 阻塞等待自协商完成,超时时间是
PHY_CONFIG_DELAY(默认 1000ms 左右)。
第 5 步是整个函数唯一会“卡住”的地方。如果 PHY 地址填错,读回来全是 0xFFFF 或 0x0000,自协商永远不完成,函数返回 HAL_TIMEOUT,你就知道该去查 PHY 地址了。
3.2 用 MDIO 读 PHY:地址扫描与链路状态判断
不确定 PHY 地址时,最省事的办法是写个扫描函数,把 0x00 到 0x1F 全读一遍,打印 PHYID1 和 PHYID2。LAN8720A 的这两个寄存器固定是 0x0007 和 0xC0F1,读出来对得上就说明 MDIO 通了、地址找对了。
/* phy.c —— 用 MDIO 扫地址,确认 PHY 是否活着 */ void PHY_Scan(void) { uint32_t id1, id2; for (uint8_t addr = 0; addr < 32; addr++) { if (HAL_ETH_ReadPHYRegister(&heth, addr, PHY_ID1, &id1) != HAL_OK) { continue; } if (HAL_ETH_ReadPHYRegister(&heth, addr, PHY_ID2, &id2) != HAL_OK) { continue; } /* 全 0 或全 F 说明这条 MDIO 通道上没有器件响应 */ if ((id1 == 0xFFFF && id2 == 0xFFFF) || (id1 == 0 && id2 == 0)) { continue; } printf("PHY found at 0x%02X, ID1=0x%04lX ID2=0x%04lX\r\n", addr, id1, id2); } }PHY_ID1是 0x02,PHY_ID2是 0x03,这是 IEEE 802.3 规定的标准寄存器编号,所有 PHY 通用。扫到之后如果 ID2 的低 4 位不是 1,说明这颗不是 LAN8720A 系列,寄存器行为可能有差异。
链路状态要看 BSR(0x01)的 bit2。这里有个细节坑:Link Status 位是 latch-low 的,链路曾经断过,这一位会一直保持 0,直到你把它读两次。标准做法是连续读两次,第二次才是当前值。ST 的模板代码里就写了这个逻辑,直接抄没问题:
/* phy.c —— 链路状态检测,Link Status 是 latch-low,需要读两次 */ uint8_t PHY_LinkUp(void) { uint32_t bsr; HAL_ETH_ReadPHYRegister(&heth, LAN8720A_PHY_ADDRESS, PHY_BSR, &bsr); HAL_ETH_ReadPHYRegister(&heth, LAN8720A_PHY_ADDRESS, PHY_BSR, &bsr); if (bsr & PHY_LINKED_STATUS) { /* 顺便把自协商状态也读出来,方便调试时打印速率和双工 */ return 1; } return 0; }PHY_LINKED_STATUS就是 0x0004。如果你的板子拔插网线之后 LwIP 里netif->flags的NETIF_FLAG_LINK_UP不更新,说明你没做周期检测,也没有开LWIP_NETIF_LINK_CALLBACK。裸机下我一般放在主循环里每 500ms 查一次,链路掉了就netif_set_link_down(),恢复就netif_set_link_up(),配合LWIP_NETIF_LINK_CALLBACK能让协议栈立刻重新发 ARP,恢复速度快很多。
4. LwIP 无操作系统移植:ethernetif.c 的三个函数和 lwipopts.h 的内存账
LwIP 有两种模式,NO_SYS = 1是无操作系统模式,所有协议栈代码跑在你的主循环上下文里,不创建任何任务。优点是省 RAM、没并发问题,代价是你必须周期性地喂它,不然 ARP 老化、TCP 重传定时器全停摆。
4.1 ethernetif.c 的三个入口:init、output、input
这份资源的核心就在ethernetif.c这个文件,它本质上是 LwIP 的netif结构和 HAL 的 ETH 驱动之间的翻译官。LwIP 只认三个函数:初始化、发一帧、收一帧。
/* ethernetif.c —— 网卡初始化,netif_add 时被回调 */ static err_t low_level_init(struct netif *netif) { netif->hwaddr_len = ETHARP_HWADDR_LEN; memcpy(netif->hwaddr, heth.Init.MACAddr, ETHARP_HWADDR_LEN); netif->mtu = 1500; netif->flags = NETIF_FLAG_BROADCAST | NETIF_FLAG_ETHARP | NETIF_FLAG_LINK_UP; /* 关键一步:启动 MAC 的收发,把描述符环真正交给 DMA */ if (HAL_ETH_Start(&heth) != HAL_OK) { return ERR_IF; } return ERR_OK; }netif->flags里NETIF_FLAG_ETHARP必须置上,否则 LwIP 收到 ARP 请求会直接丢,ping 的第一步就断在这。NETIF_FLAG_LINK_UP在这里是硬置的,因为我们已经确认过 PHY 连上了,后续靠定时检测去动态改。
发送路径要做的事情只有一件:把 pbuf 链里分散的 payload 拷进一个连续的 DMA 缓冲,然后让 MAC 发出去。
/* ethernetif.c —— 发送一帧:pbuf 链拼成连续缓冲,交给 HAL */ static err_t low_level_output(struct netif *netif, struct pbuf *p) { struct pbuf *q; uint32_t len = 0; /* F4 的 HAL 把当前发送描述符的缓冲首地址放在 TxFrameInfos 里; 若你的 HAL 版本没有 Seg 字段,改用 DMATxDescToSet->Buffer1Addr */ uint8_t *buf = (uint8_t *)heth.TxFrameInfos.Seg[0]->Buffer; for (q = p; q != NULL; q = q->next) { memcpy(buf + len, q->payload, q->len); /* 逐段拷贝,注意别越界 */ len += q->len; } if (len > ETH_MAX_PACKET_SIZE) { return ERR_BUF; /* 超长帧直接拒,避免踩坏描述符 */ } if (HAL_ETH_TransmitFrame(&heth, len) != HAL_OK) { return ERR_IF; } return ERR_OK; }HAL_ETH_TransmitFrame内部会把描述符的 OWN 位交还给 DMA。这是回声测距式的操作:写完缓冲,置 OWN,等硬件发完再还给你。所以这里隐含着一次等待,100M 全双工下一帧 1500 字节大约 120 微秒,可以接受,但如果你的应用本身对时延敏感,就得考虑多缓冲队列。
接收路径反过来,从描述符里取出一帧,装进 pbuf 池,交给协议栈。
/* ethernetif.c —— 接收一帧:轮询方式,没帧就返回 NULL */ static struct pbuf *low_level_input(struct netif *netif) { struct pbuf *p = NULL, *q; uint8_t *buf; uint32_t len; if (HAL_ETH_GetReceivedFrame(&heth) != HAL_OK) { return NULL; /* 没有新帧,主循环继续干别的 */ } len = heth.RxFrameInfos.length; buf = (uint8_t *)heth.RxFrameInfos.buffer; if (len > 0) { p = pbuf_alloc(PBUF_RAW, len, PBUF_POOL); } if (p != NULL) { for (q = p; q != NULL; q = q->next) { memcpy(q->payload, buf, q->len); buf += q->len; } } HAL_ETH_ReleaseRxFrame(&heth); /* 无论成败都要释放,否则描述符耗尽 */ return p; }调用HAL_ETH_ReleaseRxFrame这一步千万别漏,漏几次之后接收描述符全被占满,现象是“刚开始能 ping 通,几秒后彻底没反应”。这个坑我在两块板子上都踩过,第二次还是忘了,只能说人脑缓存太小。
最外层再包一个ethernetif_input,把 pbuf 送进协议栈:
/* ethernetif.c —— 供主循环调用的一层薄封装 */ err_t ethernetif_input(struct netif *netif) { struct pbuf *p = low_level_input(netif); if (p == NULL) { return ERR_OK; } /* NO_SYS=1 时 netif->input 指向 ethernet_input */ if (netif->input(p, netif) != ERR_OK) { pbuf_free(p); /* 协议栈拒收,这里负责回收,否则内存泄漏 */ return ERR_IF; } return ERR_OK; }4.2 lwipopts.h 里的内存账与 sys_now 的补丁
裸机跑 LwIP,最容易翻车的是内存。LwIP 有两条内存通路:MEM_SIZE管的是mem_malloc的堆,pbuf 池PBUF_POOL_SIZE * PBUF_POOL_BUFSIZE是独立的固定池。这两个数字算不对,现象要么是编译过、运行一会儿就 hardfault,要么是 ping 大包全丢。
| 宏 | 我常用的值 | 作用与调整建议 |
|---|---|---|
| NO_SYS | 1 | 无操作系统模式,必须为 1 |
| LWIP_SOCKET / LWIP_NETCONN | 0 | 裸机用不上 socket API,关掉省几 KB |
| MEM_ALIGNMENT | 4 | Cortex-M 对齐要求,别设成 1 |
| MEM_SIZE | 16 * 1024 | 协议栈堆,加了 TCP 之后建议 24K 以上 |
| PBUF_POOL_SIZE | 16 | pbuf 池个数,收包突发大时加到 24 |
| PBUF_POOL_BUFSIZE | 1524 | 单个 pbuf 大小,得能装下一整帧 |
| MEMP_NUM_PBUF | 16 | pbuf 结构体数量,和上面配合 |
| ETH_PAD_SIZE | 0 | LwIP 里的老参数,新版本已废弃,保持 0 |
| LWIP_ARP / LWIP_ICMP | 1 | ping 的两条命脉,必须开 |
| LWIP_DHCP | 0(先) | 调试期用静态 IP,通了之后再开 |
PBUF_POOL_BUFSIZE小于 1524 时,一个以太网帧会被拆成多个 pbuf,虽然协议栈能处理,但拷贝次数增加,而且一旦PBUF_POOL_SIZE不够,大包就直接丢。我一般直接设成 1524 让一帧占一个 pbuf,简单粗暴但稳。
另一个必须手工补的函数是sys_now()。NO_SYS = 1时 LwIP 需要一个毫秒级时间源,用来驱动超时和 ARP 老化,但它不知道你的 MCU 用什么计时器,所以得自己实现:
/* lwip.c —— 裸机时间源,用 HAL 的 1ms 滴答即可 */ u32_t sys_now(void) { return (u32_t)HAL_GetTick(); }如果你忘了写这个函数,链接阶段就会报undefined reference to sys_now,这还算好的;更坑的是某些模板里写了个返回 0 的空实现,编译链接全过,运行起来 ARP 永远学不会、ping 永远超时,因为协议栈认为时间从来没走。
4.3 主循环怎么写才不饿死协议栈
裸机下没有任务调度,主循环就是调度器。要把三件事轮着做:收包、跑协议栈定时器、跑你自己的应用。
/* main.c —— 裸机主循环,顺序别乱 */ while (1) { /* 1. 有帧就收,没有就立刻返回,不阻塞 */ ethernetif_input(&gnetif); /* 2. 驱动 LwIP 内部定时器:ARP 老化、TCP 重传、IP 重组超时 */ sys_check_timeouts(); /* 3. 链路状态每 500ms 查一次,掉线重连能自动恢复 */ if (HAL_GetTick() - last_link_check > 500) { last_link_check = HAL_GetTick(); netif_set_link_up(&gnetif); /* 实际项目里要按 PHY_LinkUp() 结果分支 */ } /* 4. 应用层:闪烁 LED、采样、上报,注意别塞长延时 */ app_poll(); }sys_check_timeouts()这一句是裸机 LwIP 的灵魂。它内部维护一个next_timeout时间戳,每次调用时比较当前时间,到点了就把对应定时器的回调跑一遍。如果你在主循环里塞了个HAL_Delay(1000),这一秒内协议栈完全停摆,ARP 表项老化不了、TCP 超时判不出来,PC 侧可能已经把连接判死。
ethernetif_input的调用频率也要注意。轮询模式下它每次调用最多取一帧,主循环转得越快,吞吐越高。100M 线速下满负荷大约每 84 微秒一帧,你的循环一圈要是超过这个时间就会丢包。这也是后面从轮询换中断的根本原因。
5. 排查清单:PING 不通时先看这五处,从现象到根因
调以太网最难受的地方在于现象高度雷同——都是 ping 不通,但原因可能从焊盘到协议栈隔了十万八千里。下面这五条是按我自己的排查顺序排的,从硬件到软件,越往后越少见。
5.1 现象:网口灯亮着,ping 一直 100% 丢包
原因:链路层通了但网络层没通,头号嫌疑是 IP 不在同一网段,二号是 MAC 地址非法。
解决:先确认 PC 和板子的 IP、掩码、网关。PC 是 192.168.1.100/24,板子配成 192.168.0.10/24,ARP 请求根本到不了对端。用ipconfig看清楚,别凭记忆。然后检查MAC_ADDR首字节的最低位是不是 1,是 1 就是组播地址,交换机会把它当组播帧转发,普通 PC 的协议栈不会回应。改成 0x02 开头这类本地管理单播地址,再试。
5.2 现象:小包能 ping 通,ping -l 1400全丢
原因:接收缓冲或 pbuf 池不够大,大帧被截断或分配失败。
解决:三个地方一起查。heth.Init.RxBuffLen必须 ≥ 1524;PBUF_POOL_BUFSIZE必须 ≥ 1524,否则一帧要拆成两个 pbuf,池子瞬间见底;PBUF_POOL_SIZE至少 8,突发时 16 更稳。还有一个隐蔽原因:交换机发来的帧带 VLAN tag,比普通帧长 4 字节,缓冲设成刚好 1514 就会溢出,所以别抠这 4 个字节。
5.3 现象:编译下载都正常,HAL_ETH_GetReceivedFrame永远返回失败
原因:描述符或缓冲放在了 DMA 访问不到的 RAM 区,F4 上最常见的就是 CCM RAM。
解决:看链接脚本(.ld)里.bss和.data的落点,确认0x10000000这个 CCM 区域没有被分配给普通变量。如果用了自定义 section,确认 MPU 里该区域配置成 non-cacheable 且 DMA 可达。STM32H7 上还多一层:D-Cache 没做 invalidate,DMA 写进去的数据 CPU 读到的是旧值,表现和上面一模一样,需要在对描述符和缓冲操作前后调SCB_InvalidateDCacheByAddr()。
5.4 现象:第一次 ping 必超时,后面几次才通
原因:主循环里没有调用sys_check_timeouts(),或者调用间隔太长,ARP 请求发出去了但重传定时器没跑。
解决:确认主循环里有这一句,且循环里没有HAL_Delay。另外检查是否开了LWIP_NETIF_LINK_CALLBACK,开了之后链路 UP 的瞬间协议栈会自动发一次免费 ARP,能省掉第一次超时。如果连接用直连网线、PC 侧网卡自协商慢,也有类似现象,通常等两三秒就好,不算故障。
5.5 现象:换一台电脑或换个交换机就通不了
原因:双工/速率不匹配,或者硬件校验和与 LwIP 的软件校验和打架。
解决:先看 PHY 自协商结果,读 BSR 的 bit3、bit4 判断速率,bit2 判断双工。如果协商出来是半双工 10M,但代码里heth.Init.Speed硬写 100M 全双工,MAC 侧配置和 PHY 侧不一致,就会出现“对端能收到我的包,我收不到对端”的单向通信。另一个原因是ChecksumMode开了硬件校验和,但 LwIP 的CHECKSUM_GEN_IP还开着,两个算出来的结果被后写的覆盖,抓包会看到校验和错误。要么统一交给硬件,要么统一交给软件,不要两头下注。
6. 二次开发的手上功夫:从 PING 到 TCP 回环,以及我固定走的验证流程
PING 通只是证明链路和协议栈能用,真正要做产品,下一步是把轮询换掉、把应用挂上去。这两块改造不难,但顺序错了会很难受。
第一个改造是把接收从轮询换成中断。heth.Init.RxMode改成ETH_RXINTERRUPT_MODE,实现HAL_ETH_RxCpltCallback回调,在回调里只做一件事:把gnetif标记成待处理,真正的ethernetif_input仍然放在主循环里跑。为什么不在中断里直接跑协议栈?因为 LwIP 的很多函数不是可重入的,中断里调ethernet_input会和主循环里的sys_check_timeouts打架,症状是随机死机或数据错乱,查起来极其难受。
/* stm32f4xx_it.c / eth.c —— 中断只打标记,处理留给主循环 */ volatile uint8_t eth_rx_pending = 0; void HAL_ETH_RxCpltCallback(ETH_HandleTypeDef *heth) { eth_rx_pending = 1; /* 就这一句,别的什么都别干 */ } /* main.c —— 主循环里消费这个标记 */ while (1) { if (eth_rx_pending) { eth_rx_pending = 0; ethernetif_input(&gnetif); } sys_check_timeouts(); }这个改动的收益在吞吐上很明显:100M 满速从大约 3Mbps 能提到 60Mbps 以上,因为不再受主循环单帧处理时间的限制。
第二个改造是挂一个 TCP echo 服务做端到端验证。裸机下用 raw API,不用 socket,代码量很小:
/* tcp_echo.c —— raw API 最小回环,验证 TCP 通路 */ 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_recved(tpcb, p->tot_len); /* 必须调用,否则接收窗口不推进 */ tcp_write(tpcb, p->payload, p->tot_len, TCP_WRITE_FLAG_COPY); tcp_output(tpcb); pbuf_free(p); return ERR_OK; } void echo_server_init(void) { struct tcp_pcb *pcb = tcp_new(); tcp_bind(pcb, IP_ADDR_ANY, 7); /* 7 是 echo 端口 */ pcb = tcp_listen(pcb); tcp_accept(pcb, echo_accept); }tcp_recved这行如果漏了,接收窗口会一直停在初始值,客户端发几次数据之后连接就卡住,用telnet连上去看就是“发了没回”的假死状态。这个坑的迷惑之处在于 ping 完全正常,只有 TCP 大流量才暴露。
至于验证方法,我固定走一遍:先ping -n 20看丢包率,再用ping -l 1472 -f测路径 MTU,接着用抓包工具看 ARP 交互是否干净(有没有重复 ARP、有没有广播风暴),最后跑一次 TCP 回环传 1MB 数据看速率。这套流程跑下来,能覆盖从物理层到传输层的绝大多数问题,比逐个猜快得多。
从那以后我每次新板子上电,都会先跑一遍 PHY 地址扫描、读两次 BSR、再HAL_ETH_Start之前打印一遍描述符地址,确认它们不在 CCM 里,才去碰 LwIP 的配置。希望帮到你。
本文还有配套的精品资源,点击获取