ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32F429+LAN8720A+LwIP以太网通信调试与避坑

STM32F429+LAN8720A+LwIP以太网通信调试与避坑

简介:面向STM32F429平台以太网开发与LWIP移植的学习者,这份压缩包围绕STM32F429ZGxx、LAN8720A百兆PHY与轻量级TCP/IP协议栈的协同实现,适合已具备C语言和STM32外设基础、希望打通MAC/PHY初始化、DMA收发及网络协议接入的中级开发者参考。包内文件总数与类型明细暂缺,压缩包整体约46.15MB,需按实际解压结果核对示例代码、CubeMX配置、README说明以及LWIP/HAL相关头文件与库文件等素材。已有968人学习下载,说明其在嵌入式以太网入门与工程复现方面具有一定关注度。读者可据此理解GPIO复用、MIIM寄存器访问、MAC地址与DMA配置、LWIP网络接口和回调处理之间的衔接关系,并借助配置工程与说明梳理编译、烧录和连通性测试思路,为物联网终端、工业控制等场景的网络通信开发积累可复用的排错经验。

1. 板子已经焊好,网线插上后灯不亮,先别急着怀疑协议栈

板子焊回来,网线一插,网口灯不亮,串口里也 ping 不通——很多人第一次做 STM32 以太网,都会卡在这里两三天。真正出错的地方往往不在 LwIP 的配置,而在 RMII 的 50MHz 参考时钟和 PHY 地址这两个硬件细节上。这份 STM32F429 + LAN8720A + LwIP 的工程,把内置 MAC、外置 PHY、协议栈三层串成了一条完整链路,适合两类人:一类是想给自家板子补一个网口做数据上报,一类是第一次碰 STM32 以太网、需要一份从 ping 通一路跑到 TCP 收发的参考实现。

2. 技术选型拆解:F429 的 MAC、LAN8720A 的 PHY、LwIP 的协议栈各管哪一段

STM32F429 内部集成了以太网 MAC(媒体访问控制器),但它只管数字部分——组帧、CRC、DMA 搬运,物理层的电平、变压器、双绞线驱动它一概不管,所以必须外挂一颗 PHY。LAN8720A 就是这颗 PHY,10/100M 自适应,RMII 接口,成本低、外围简单,是 F429 板子上出现频率很高的一颗料。再往上,LwIP 负责 ARP、IP、ICMP、UDP、TCP 这些协议逻辑,跑在 MAC 驱动之上。

三层各管一段,排错时就必须按层来,不能一上来就翻lwipopts.h。我一般的顺序是:先用 SMI 读出 PHY 的 ID 寄存器,确认 MDIO/MDC 这条管理通道是通的;再看 BSR 里的链路状态位,确认自协商完成;然后看 MAC 有没有收到包、ARP 有没有应答;最后才去怀疑 TCP 参数。这个顺序能把大部分“玄学”问题压到某个具体层里。

2.1 硬件链路:RMII 只用了七八根线,但每根都不能错

RMII 相比 MII 最大的好处是省引脚,收发各两根数据线,2bit 并行跑在 50MHz 上,正好凑出 100Mbps。代价是 MAC 和 PHY 必须共用一个 50MHz 时钟,这个时钟通常由 PHY 从 25MHz 晶振倍频出来后,从 REFCLKO 引脚送给 MCU 的 PA1。这一根线是整个链路的前提条件,它不存在,后面什么都免谈。

信号F429 引脚方向(相对 MCU)说明
REF_CLKPA1输入50MHz 参考时钟,由 PHY 提供
MDIOPA2双向SMI 管理数据,需要 1.5k~2.2k 上拉
MDCPC1输出SMI 管理时钟,最高 2.5MHz
CRS_DVPA7输入载波侦听/数据有效
RXD0 / RXD1PC4 / PC5输入接收数据
TX_ENPB11输出发送使能
TXD0 / TXD1PB12 / PB13输出发送数据
nRST任意 GPIO输出PHY 复位,低有效

还有一根容易被忽略的:LAN8720A 的 PHYAD0 引脚(和 RXER 复用)决定 SMI 地址,拉低是 0x00,拉高是 0x01。代码里写的PHY_ADDRESS必须和板上实际电平对上,对不上就是读什么都是 0xFFFF。

2.2 软件分层:三个断点,从下往上排

工程里的软件大致分四层,每层之间都有一个可以下断点观察的位置:

  • MAC 驱动层,HAL_ETH_Init加 DMA 描述符,负责把一帧数据搬进搬出内存;
  • netif 适配层,ethernetif.c,把 HAL 的帧收发包装成 LwIP 认识的netif结构;
  • 协议栈层,LwIP 内核,跑在tcpip_thread里,处理 ARP/IP/TCP;
  • 应用层,UDP 或 TCP 的收发回调。

分层之后,问题定位就变成了“看哪一层的计数器不动”。PHY 读不到 ID,问题在第 0 层;能读到 ID 但 BSR 的 link 位一直是 0,问题在自协商或者网线/变压器;link 起来了但 ARP 没反应,多半是 MAC 地址或 IP 配置问题;ARP 通了 TCP 连不上,才轮到lwipopts.h。这套顺序看着笨,但省时间。

3. 目录结构与启动链路:从上电到第一次 ping 通要经过哪些函数

这份工程是典型的“CubeMX 生成 + 手工补 ethernetif”的结构。CubeMX 负责时钟树、GPIO、ETH 外设和 LwIP 的初始化骨架,ethernetif.c是 ST 提供的那份适配模板,需要按自己的板子改 PHY 地址和复位逻辑。真正要读懂的文件其实不多,剩下的都是可以当黑匣子用的 HAL 库。

3.1 工程目录长什么样

路径内容是否常改
Drivers/STM32F4xx_HAL_DriverHAL 库,含stm32f4xx_hal_eth.c不改
Drivers/CMSIS内核与器件头文件、启动文件不改
Middlewares/Third_Party/LwIP/srcLwIP 内核源码(core / ipv4 / api)极少改
Middlewares/Third_Party/LwIP/systemlwipopts.h、ethernetif.c等适配层常改
Src/main.c时钟、外设初始化、主循环常改
Src/ethernetif.cPHY 初始化、帧收发、netif 注册常改
Src/lwip.ctcpip_init、netif_add、DHCP 或静态 IP常改
Inc/lwipopts.h内存、pbuf、TCP 参数常改

第一次上手,先看main.c里MX_ETH_Init()的调用位置,再看lwip.c里netif_add传了哪些参数,最后进ethernetif.c看low_level_init里那片 PHY 复位和自协商的代码。这三处看完,整条链路就串起来了。

3.2 时钟与 GPIO:先把 50MHz 和 SMI 配好

ETH 的引脚必须配成复用推挽、AF11、高速模式,速率等级给低了在 100M 全双工下会出现莫名其妙的 CRC 错误。下面是 GPIO 和 PHY 硬复位这一段,顺序很关键:先给时钟,再配引脚,最后才拉复位。

static void ETH_GPIO_Config(void) { GPIO_InitTypeDef gpio = {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_GPIOB_CLK_ENABLE(); __HAL_RCC_GPIOC_CLK_ENABLE(); /* RMII 全部信号:AF11,推挽,高速。速率等级不能省 */ gpio.Mode = GPIO_MODE_AF_PP; gpio.Pull = GPIO_NOPULL; gpio.Speed = GPIO_SPEED_FREQ_VERY_HIGH; gpio.Alternate = GPIO_AF11_ETH; gpio.Pin = GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_7; /* REF_CLK / MDIO / CRS_DV */ HAL_GPIO_Init(GPIOA, &gpio); gpio.Pin = GPIO_PIN_11 | GPIO_PIN_12 | GPIO_PIN_13; /* TX_EN / TXD0 / TXD1 */ HAL_GPIO_Init(GPIOB, &gpio); gpio.Pin = GPIO_PIN_1 | GPIO_PIN_4 | GPIO_PIN_5; /* MDC / RXD0 / RXD1 */ HAL_GPIO_Init(GPIOC, &gpio); } static void PHY_HardReset(void) { GPIO_InitTypeDef gpio = {0}; gpio.Pin = PHY_RESET_PIN; /* 板上接到 LAN8720A 的 nRST */ gpio.Mode = GPIO_MODE_OUTPUT_PP; gpio.Pull = GPIO_NOPULL; gpio.Speed = GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(PHY_RESET_PORT, &gpio); HAL_GPIO_WritePin(PHY_RESET_PORT, PHY_RESET_PIN, GPIO_PIN_RESET); HAL_Delay(50); /* 手册要求复位低电平保持 >= 25ms */ HAL_GPIO_WritePin(PHY_RESET_PORT, PHY_RESET_PIN, GPIO_PIN_SET); HAL_Delay(100); /* 放开后等内部寄存器就绪再访问 SMI */ }

GPIO_SPEED_FREQ_VERY_HIGH这一行不是可有可无的装饰,50MHz 的 REF_CLK 和 25MHz 的 TX 数据沿,配成中速就会看到发送方向丢包。HAL_Delay(100)也不能省,复位刚放开时 SMI 访问会返回全 1,很多“读不到 PHY”的现场就是差这 100ms。至于 50MHz 时钟从哪来,如果板上是 25MHz 晶振接 PHY、PHY 输出 50MHz 给 PA1,那么 PA1 只能配成输入复用,千万别在 CubeMX 里给它选成 MCO 输出。

3.3 LwIP 初始化:netif_add 之前必须做完的三件事

netif_add之前,MAC 必须已经初始化完成、PHY 已经 link up、ethernetif_init里的low_level_init已经把hwaddr、mtu、output这些字段填好。顺序错了就会出现 netif 挂上了但永远收不到包的情况。

void LwIP_Init(void) { struct ip4_addr ipaddr, netmask, gw; tcpip_init(NULL, NULL); /* 先起协议栈线程和 mailbox */ IP4_ADDR(&ipaddr, 192, 168, 1, 50); IP4_ADDR(&netmask, 255, 255, 255, 0); IP4_ADDR(&gw, 192, 168, 1, 1); /* 最后一个参数 tcpip_input:收上来的包丢进 mailbox,由协议栈线程处理 */ netif_add(&gnetif, &ipaddr, &netmask, &gw, NULL, &ethernetif_init, &tcpip_input); netif_set_default(&gnetif); if (netif_is_link_up(&gnetif)) { netif_set_up(&gnetif); /* link 没起来之前不要 set_up */ } }

tcpip_init必须在netif_add之前,因为tcpip_input依赖内部的 mailbox 已经创建。netif_set_up建议和 link 状态联动,link down 时调netif_set_down,否则网线拔掉再插回来,ARP 表里全是过期条目,ping 会一直超时。

lwipopts.h里几个真正影响能不能跑的宏,先按这张表配,跑通之后再谈优化:

宏建议值影响
MEM_SIZE16*1024LwIP 堆,pbuf 和 TCP 缓冲都从这出
PBUF_POOL_SIZE12pbuf 池个数,收包突发时靠它顶着
PBUF_POOL_BUFSIZE1524单池块大小,要能装下一整个以太网帧
MEMP_NUM_PBUF16pbuf 结构体数量,链式包多时不够会返回 ERR_MEM
TCP_MSS14601500 MTU 减去 20 IP 头、20 TCP 头
TCP_SND_BUF2*1460发送窗口,太小吞吐上不去
TCPIP_THREAD_STACKSIZE1024协议栈线程栈,回调里递归深了就往上加
LWIP_DHCP调试期先设 0先用静态 IP 确认链路,再开 DHCP

4. 核心代码拆解:一帧数据从网线进到应用层的完整链路

ethernetif.c是整个工程里唯一需要自己动手写的部分,其余都是标准件。它要实现的接口就那么几个:low_level_init、low_level_output、low_level_input、ethernetif_input。搞清楚这四个函数之间的数据流,STM32 以太网这块基本就没有秘密了。

4.1 发送路径:pbuf 链怎么变成一个连续缓冲区

LwIP 交下来的数据是pbuf链表,可能一个包被切成好几段,而 DMA 描述符只认一段连续内存加一个长度。所以发送的第一步永远是拼接。

static err_t low_level_output(struct netif *netif, struct pbuf *p) { struct pbuf *q; uint8_t *buf = (uint8_t *)eth_tx_buf; /* 连续发送缓冲,4 字节对齐 */ uint32_t len = 0; /* pbuf 链逐段拷进连续缓冲 */ for (q = p; q != NULL; q = q->next) { memcpy(buf + len, q->payload, q->len); len += q->len; } /* 以太网最小帧 60 字节(不含 FCS),不够要补 0,否则收端直接丢 */ if (len < 60) { memset(buf + len, 0, 60 - len); len = 60; } /* 交给 HAL 去写描述符、触发 DMA;返回前要保证描述符是空闲的 */ if (HAL_ETH_TransmitFrame(&heth, len) != HAL_OK) { return ERR_IF; } return ERR_OK; }

eth_tx_buf必须放在内部 SRAM 里(0x2000_0000 段),并且 4 字节对齐,这一点后面避坑那章会细说。HAL_ETH_TransmitFrame内部是等描述符 OWN 位清零后才写入的,如果发送频率高,这个函数会阻塞,所以它的返回值要判,不能当 void 用。补 60 字节那一段看着不起眼,但少了它,某些交换机会把短帧当冲突碎片丢掉,表现就是小包通、大包偶尔通。

4.2 接收路径:中断、pbuf 分配和 netif->input

接收方向上,HAL 已经把帧搬进描述符指向的缓冲区了,剩下的事是申请 pbuf、拷贝、然后交给协议栈。

static struct pbuf *low_level_input(struct netif *netif) { struct pbuf *p = NULL, *q; uint8_t *src; uint32_t len; if (HAL_ETH_GetReceivedFrame(&heth) != HAL_OK) { return NULL; /* 没有新帧,直接返回 */ } len = heth.RxFrameInfos.length; src = (uint8_t *)heth.RxFrameInfos.buffer; /* 用池内 pbuf,避免每次收包都从堆上要内存 */ p = pbuf_alloc(PBUF_RAW, len, PBUF_POOL); if (p != NULL) { for (q = p; q != NULL; q = q->next) { memcpy(q->payload, src, q->len); src += q->len; } } else { /* 池耗尽,会计数;这个数字一直涨说明 PBUF_POOL_SIZE 太小 */ LINK_STATS_INC(link.memerr); } HAL_ETH_ReleaseFrame(&heth); /* 把描述符还给 DMA */ return p; } err_t ethernetif_input(struct netif *netif) { struct pbuf *p = low_level_input(netif); if (p == NULL) { return ERR_MEM; } /* 走 tcpip_input 时会投递到 mailbox,在协议栈线程里跑,不占中断上下文 */ return netif->input(p, netif); } /* 中断回调:帧收完了通知上层,这里不做任何耗时操作 */ void HAL_ETH_RxCpltCallback(ETH_HandleTypeDef *heth_ptr) { ethernetif_input(&gnetif); }

netif->input在netif_add时被设成tcpip_input,它只做一件事:把 pbuf 指针塞进 mailbox 就返回。真正的解析在tcpip_thread里发生,所以这个回调必须足够短。如果你在回调里直接 printf 或者写 Flash,收到的就是随机丢包和偶发死机。

4.3 一个能验证的最小应用:UDP 回环

链路通了之后,别急着上 TCP,先用 UDP 回环确认“收得到、发得出”。这个例程只有二十行,但能把low_level_output和low_level_input两条路径都跑一遍。

static void udp_echo_recv(void *arg, struct udp_pcb *pcb, struct pbuf *p, const ip_addr_t *addr, u16_t port) { if (p != NULL) { udp_sendto(pcb, p, addr, port); /* 原样回给发送方的地址和端口 */ pbuf_free(p); } } void udp_echo_init(void) { struct udp_pcb *pcb = udp_new(); if (pcb == NULL) { return; } udp_bind(pcb, IP_ADDR_ANY, 8080); udp_recv(pcb, udp_echo_recv, NULL); }

PC 端验证:

ping -c 4 192.168.1.50 printf 'hello-eth' | nc -u -w1 192.168.1.50 8080

ping走的是 ICMP,能通说明 ARP、IP、MAC 三层都对了;nc能收到回显,说明low_level_output也没问题。这两步都过不去再看应用层,纯粹是浪费时间。反过来,如果ping通而 UDP 没回显,问题基本就锁定在udp_sendto的缓冲或者发送描述符没及时归还上。

5. 避坑与排查:ping 不通、丢包、跑几小时就死机的定位套路

这一章是踩过的坑攒出来的。STM32 以太网的问题有个特点:现象都长得差不多(不通、丢包、卡死),但原因分散在硬件、DMA、协议栈三层,光看现象猜不出来。下面几条按发生频率排序,每条都给了现象、原因和解决方式。

5.1 链路层:PHY 读不到、link 起不来

现象一:读 PHY ID 返回 0xFFFF 或 0x0000,自协商永远不完成。原因通常是三个:MDIO 没上拉,LAN8720A 要求 1.5k~2.2k 上拉,很多板子漏了;MDC 时钟超过 2.5MHz,HAL 默认分频在 180MHz 主频下会超;PHY 地址和板上 PHYAD0 电平不一致。解决方式是按公式重算 MDC 分频——用HCLK/1024之类的大分频先跑通,再往回收;然后用HAL_ETH_ReadPHYRegister读 0x02、0x03 两个 ID 寄存器,正常应该返回 0x0007 和 0xC0F1,读到这两组值就说明 SMI 通道没问题了。

现象二:SMI 能读到 ID,但 BSR 的 link 位一直是 0,网口灯不亮。先量 PA1 上有没有 50MHz。没有的话,检查 PHY 的 MODE 引脚配置,LAN8720A 需要把这几个引脚设成 REFCLK 输出模式才会驱动 REFCLKO;另外如果板上是 MCU 的 MCO1 给 PHY 供 25MHz,那 PA8 要配成 MCO 输出、时钟源选 HSE,别配成复用推挽,配错了就是一个方波都出不来。

现象三:link 起来了,ping也能通,但换个电脑就通不了。检查 MAC 地址。全 0 的 MAC 或者第一个字节最低位为 1(组播地址)都会让交换机不转发。自己定 MAC 时,把第一个字节的 bit1 置 1 表示本地管理地址,比如02:00:00:00:00:01,这样既不会和真实厂商的地址冲突,也能保证是单播。同时确认 PC 和板子的 IP、掩码在同一网段,网关填不填倒是不影响同网段通信。

5.2 DMA 与内存:变量放错段,跑一天都不通

现象四:HAL_ETH_Init返回 OK,一开接收就 HardFault,或者描述符内容全乱。这是最容易翻车的一条:描述符和收发缓冲被链接器分配到了 CCM RAM(0x1000_0000 那段)。F429 的 64KB CCM 只有内核能访问,DMA 根本读不到,你的代码写得再对也没用。解决方式是在链接脚本或者内存分配上,把 ETH 的描述符数组、eth_tx_buf、eth_rx_buf显式放到0x2000_0000段,并且 4 字节对齐。用__attribute__((section(".RAM_D1")))或者直接定义成全局数组然后确认 map 文件里的地址,这一步建议养成习惯,画个框看一眼地址再下载。

现象五:把 F7/H7 工程的代码抄过来,编译报错找不到SCB_CleanDCache。F429 是 Cortex-M4F,没有 D-Cache,也就不需要收发前后的 cache 维护。F7/H7 上必须有那几行,F4 上写了也没用。这个坑常见于“参考了另一个项目的 ethernetif.c”,两个型号混着抄,编译能过就跑得莫名其妙。

现象六:大包偶尔卡死,小包正常。先看PBUF_POOL_SIZE和MEMP_NUM_PBUF。一个 1500 字节的帧在PBUF_POOL模式下会占多个池块,池子给小了,pbuf_alloc返回 NULL,low_level_input直接丢包,而且不会有任何报错。把 LwIP 的link.memerr、link.drop计数打印出来,跑一段时间看哪个在涨,比猜快得多。

5.3 协议栈与应用:通了但用不了

现象七:ping通,TCP 连接一直 time out。先确认lwipopts.h里LWIP_TCP打开了、MEMP_NUM_TCP_PCB不是默认的 5 以下、TCP_SND_BUF大于TCP_MSS。再确认监听端口是在tcpip_thread上下文里创建的——在main里netif_add之后直接调tcp_new是可以的,但在中断里调就会出问题。还有一种情况是开了校验和卸载但 PHY 侧不支持,表现就是 TCP 握手包到了、回包被丢,把ChecksumMode改成软件校验试一次就能定位。

现象八:跑几个小时到一天,随机死机或者网络停止响应。八成是两件事:一是在 Rx 回调里做了阻塞操作(比如往 Flash 写参数、阻塞式 printf),拖住了协议栈线程;二是栈溢出,TCPIP_THREAD_STACKSIZE给小了,或者在回调里定义了大数组。把configCHECK_FOR_STACK_OVERFLOW打开,死在哪个任务一目了然。另外记得喂狗,网线被拔掉之后netif状态没更新,也属于“看起来死机”的一类。

6. 二次开发:把回环 Demo 改成能上报数据的采集节点

工程跑通之后,绝大多数人的下一步都是“把它变成我要的产品”。这里给一条我常用的改造路线,从改配置到加业务,每一步都能单独验证,出了问题也知道退回到哪一步。

第一步是地址和标识。MAC 地址从固定值改成一个按芯片 UID 派生的值,用HAL_GetUIDw0/1/2凑出后三个字节,第一个字节固定0x02,这样批量出货不会撞地址。IP 先保持静态,等业务跑顺了再开 DHCP,LWIP_DHCP打开后记得在netif的回调里处理DHCP_EVENT相关的状态迁移,别只调个dhcp_start就完事。

第二步是链路状态自管理。这块是长期稳定运行的关键,很多“拔了网线再插上就再也不通”的现场,缺的就是下面这十几行:

void ethernet_link_check(void) { uint32_t bsr = 0; /* BSR 的 link 位是 latched-low,连读两次取第二次才准 */ 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) && !netif_is_link_up(&gnetif)) { netif_set_link_up(&gnetif); /* 恢复:ARP 表会重新学习 */ netif_set_up(&gnetif); } else if (!(bsr & PHY_LINKED_STATUS) && netif_is_link_up(&gnetif)) { netif_set_link_down(&gnetif); netif_set_down(&gnetif); } }

放到一个 200ms 的软件定时里调用,netif_set_link_down之后 LwIP 会把现有的 PCB 状态压回去,重新插线能自愈。这个函数别放中断里,SMI 读寄存器本身有微秒级耗时。

第三步是把回环换成业务。UDP 回环那二十行代码的骨架可以直接复用,把udp_echo_recv里的udp_sendto换成“解析请求 → 读 ADC/传感器 → 组包 → 回发”。数据量大了就换 TCP,服务端用netconn的 accept 循环最省事,netconn_recv拿到的netbuf记得netbuf_delete,不然内存一晚上就耗干了。

第四步是验证。别只看“能 ping 通”就交付,我的习惯是三条线一起看:PC 上ping -t跑十分钟看丢包率;板子串口每秒打印一次link.memerr、link.drop、当前pbuf池剩余数;有条件的话用抓包工具挂在交换机镜像口上,看 ARP 请求有没有被正确应答。丢包率一旦超过千分之一,回头查 5.2 那几条,基本都能找到根因。

最后说个血泪经验:改任何一版ethernetif.c之后,先确认描述符和缓冲的地址还在 SRAM 段里,再下载。我有一次改完发送逻辑,顺手加了个大数组做调试缓冲,链接器把它挤到了 CCM 上,结果整块板子表现成“网口灯亮但一个包都不通”,查了两天才想起来看 map 文件。从那以后我每次动这个文件都强制走一遍“看地址、量 50MHz、读 PHY ID”这三步,花不了五分钟,省下的可能是两天。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进