
简介面向STM32嵌入式开发者尤其适合无操作系统以太网通信场景这是一份基于HAL库的LWIP移植工程主控采用STM32F429IGT6PHY芯片为LAN8720A通过RMII接口连接ETH外设用SMI接口读写PHY寄存器最终实现PING通主机。工程覆盖CubeMX配置、Keil MDK5.27编译环境及LWIP2.1.2协议栈集成资源包共1300个文件、31.16MB以C源文件678个、头文件450个和汇编文件78个为主并包含Keil工程文件、CubeMX项目配置、链接脚本及hex烧录文件可直接在MDK中打开验证。已有1988人学习下载说明该工程的参考价值已被广泛认可。通过学习可掌握STM32 ETH外设与LAN8720A的硬件连接方式、SMI寄存器读写流程、LWIP协议栈在无操作系统环境下的移植步骤以及使用PING命令验证网络通信的完整方法。工程目录结构清晰源码注释完整适合直接在此基础上二次开发快速搭建自己的以太网应用。1. 给裸机板通以太网一个LAN8720A加LWIP的PING实验把一块带以太网MAC的STM32开发板、一个LAN8720A模块和一根网线凑在一起想看到电脑上ping通的那一刻最快路径不是上RTOS而是用HAL库配合LWIP裸机协议栈。这个项目就是一套完整的参考实现CubeMX生成工程骨架、RMII接口挂接PHY、无操作系统环境下跑协议栈最终在PC端连续收到回包开发板串口同步打印链路状态。它解决的是嵌入式以太网入门最常见的困惑PHY到底初始化了什么LWIP裸机到底谁来调度。适合正在做以太网通信的嵌入式开发也适合串口玩腻了、想升级到网络通信的工程师。照着接线、配置、编译、烧录一个晚上就能点亮链路。2. 硬件与工程初始化RMII引脚的接法、PHY地址和CubeMX配置2.1 RMII接口解析11根线为什么够用STM32内部集成了以太网MAC但物理层收发必须交给外部PHY芯片。LAN8720A是典型的RMII接口百兆PHY它把MAC侧信号压缩成几个功能组发送数据TXD0/TXD1加TX_EN接收数据RXD0/RXD1加CRS_DV50MHz参考时钟REF_CLK再加上MDC/MDIO管理总线和硬件复位脚总共不过十几根线。相比MII模式的20根数据与控制线RMII把时钟从25MHz提高到50MHz用两根线分时传输四比特数据大幅节省引脚对没有太多空闲GPIO的板子非常友好。连线时要注意信号方向TXD0/TXD1和TX_EN是STM32输出给PHY的RXD0/RXD1和CRS_DV是PHY输出给STM32的REF_CLK可以由外部时钟源同时供给两侧也可以由STM32的MCO1输出50MHz直接喂给LAN8720A。见过不少第一次搞以太网的人把TX和RX接反结果link灯都不亮。接线完成后先对着原理图核对一遍方向再上电能省掉后面一整夜的排查。2.2 CubeMX配置外设、时钟、协议栈一次点完工程生成用STM32CubeMX主要步骤按这个顺序走RCC里打开HSE外部高速时钟左侧Categories选中Connectivity打开ETH外设模式选RMIIPHY Address填0x00中间件里勾选LWIP运行模式选No OS打开ICMP静态IP填192.168.1.10掩码255.255.255.0网关192.168.1.1MAC地址填一个局域网内唯一的自定义值比如02:00:11:22:33:44时钟树里把MCO1配置为输出50MHz用于给LAN8720A提供REF_CLKGPIO设置里分配PHY复位引脚为推挽输出模式。PHY地址值得单独强调。LAN8720A的PHYAD[0]引脚决定其MDIO地址大多数小模块把PHYAD0接地地址就是0x00。CubeMX里ETH配置面板的PHY Address如果填0x01后续所有PHY寄存器读写都会失败。这个字段必须与硬件实际跳线一一对应错了调多久都是白费。所以上电第一件事一定是读PHY ID别急着进协议栈。2.3 PHY复位与ID自检先让芯片说人话在调用MX_LWIP_Init之前先把PHY从复位里拉出来。常见做法是给复位引脚一个低电平脉冲再释放代码这样写#define PHY_RESET_PIN GPIO_PIN_0 #define PHY_RESET_PORT GPIOB void PHY_Reset(void) { GPIO_InitTypeDef gpio_init {0}; __HAL_RCC_GPIOB_CLK_ENABLE(); gpio_init.Pin PHY_RESET_PIN; gpio_init.Mode GPIO_MODE_OUTPUT_PP; gpio_init.Pull GPIO_PULLUP; gpio_init.Speed GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(PHY_RESET_PORT, gpio_init); HAL_GPIO_WritePin(PHY_RESET_PORT, PHY_RESET_PIN, GPIO_PIN_RESET); HAL_Delay(50); HAL_GPIO_WritePin(PHY_RESET_PORT, PHY_RESET_PIN, GPIO_PIN_SET); HAL_Delay(10); }逻辑很直白先把复位脚配置为推挽输出拉低50ms让PHY完成内部上电复位再拉高等它释放。50ms和10ms是经验值前者保证电源稳定和复位时间后者确保PHY内部寄存器可访问。复位之后我习惯立刻读一次PHY ID确认MDIO链路可用uint32_t id1 0, id2 0; HAL_ETH_ReadPHYRegister(heth, 0x02, id1); HAL_ETH_ReadPHYRegister(heth, 0x03, id2); printf(PHY ID: 0x%04X 0x%04X\r\n, id1, id2);LAN8720A的两个ID寄存器拼起来是0x0007FFFF读到这个值说明复位时序和MDIO配置都正常。如果读出来是0x0000或0xFFFF先别往下走回头查PHY地址和复位引脚电平。这一步能挡住后面大量无意义的协议栈排查。3. LWIP裸机移植重点lwipopts参数、ethernetif驱动与主循环调度3.1 lwipopts.h里的几个生死开关无操作系统模式下LWIP的运行方式与RTOS版本完全不同它没有自己的线程一切靠主循环轮询驱动。工程的移植代码核心是C语言函数指针与结构体关键运行参数集中在lwipopts.h宏取值作用NO_SYS1关闭RTOS支持协议栈不创建线程LWIP_TIMERS1启用时间轮询裸机下由主循环调用sys_check_timeoutsLWIP_ICMP1允许PING回包LWIP_DHCP0裸机调试阶段用静态IP更可控MEM_SIZE1600堆内存大小影响TCP/UDP控制块分配PBUF_POOL_SIZE20接收缓冲池数量太小时大包会丢我习惯把MEM_SIZE开得比CubeMX默认值稍大一点因为UDP/TCP控制块、ARP表项都要从MEMP池或堆里分配。PBUF_POOL_SIZE要和最大接收帧总长匹配一个1500字节的帧在池里可能占2到3个pbuf池子太小就会在高速接收时丢帧。后续加TCP服务时还要再调大先记住这个参数。3.2 ethernetif_init与DMA描述符初始化CubeMX生成的工程里LWIP移植核心集中在ethernetif.c。ethernetif_init做三件事初始化PHY到100M全双工或自适应状态、配置MAC地址寄存器、完成DMA发送与接收描述符的环形队列初始化。DMA描述符部分常被忽视但它是最容易出问题的内存结构static err_t ethernetif_init(struct netif *netif) { HAL_StatusTypeDef hal_status; netif-hwaddr_len 6; netif-hwaddr[0] 0x02; netif-hwaddr[1] 0x00; netif-hwaddr[2] 0x11; netif-hwaddr[3] 0x22; netif-hwaddr[4] 0x33; netif-hwaddr[5] 0x44; hal_status HAL_ETH_Init(heth); if (hal_status ! HAL_OK) { return ERR_IF; } netif-output etharp_output; netif-linkoutput low_level_output; return ERR_OK; }这段代码把MAC地址写入netif结构体然后调用HAL_ETH_Init配置底层外设。HAL_ETH_Init内部会根据ETH_MspInit里设置的描述符地址把每个描述符的缓冲区地址、长度、OWN位初始化好并让接收描述符指向可用的RAM缓冲区。如果描述符数量或缓冲区地址配错表现就是接收中断频繁触发但数据全是乱的。3.3 low_level_input与low_level_outputDMA和pbuf之间的搬运工接收路径的核心是把DMA缓冲区里的数据转成LWIP的pbuf。CubeMX生成的low_level_input通常有两种写法一种直接把DMA缓冲区地址当作pbuf payload用省一次拷贝另一种分配新pbuf再拷贝稳妥但费点时间。我倾向于先用拷贝方式把正确性做出来再考虑零拷贝优化。发送路径low_level_output要更谨慎因为DMA是异步的pbuf释放和DMA完成之间的时序容易翻车static err_t low_level_output(struct netif *netif, struct pbuf *p) { struct pbuf *q; uint8_t *buffer; uint16_t framelen 0; if (HAL_ETH_GetTxDataFreeDescCnt(heth) 1) { return ERR_IF; } buffer (uint8_t *)heth.TxDesc[heth.TxDescIndex].Buffer1Addr; for (q p; q ! NULL; q q-next) { memcpy(buffer framelen, q-payload, q-len); framelen q-len; } if (HAL_ETH_TransmitFrame(heth, (uint32_t *)buffer, framelen, 0) ! HAL_OK) { return ERR_IF; } return ERR_OK; }先从发送描述符里拿一块空闲缓冲区把pbuf链表逐段拼成完整以太网帧再交给DMA发送。framelen在拷贝过程中累加得到最终帧长度调用HAL_ETH_TransmitFrame后DMA会从Buffer1Addr地址读取数据并发出去。注意发送前检查可用描述符数量避免连续快速发包时覆盖同一个描述符这是裸机LWIP丢包的高频原因之一。3.4 主循环里的调兵遣将无系统LWIP的全部驱动都浓缩在主循环里。CubeMX生成的main.c中while(1)部分就是这个形态while (1) { MX_LWIP_Process(); ethernetif_input(gnetif); sys_check_timeouts(); }MX_LWIP_Process负责周期性检测链路状态网线拔插时更新netif的link标志ethernetif_input是接收入口它从DMA接收描述符里取帧转换成pbuf后交给LWIP的ethernet_input做协议分发sys_check_timeouts驱动协议栈内部定时器比如ARP缓存老化、TCP重传定时器。三者缺一不可。之前只保留了前两个结果第一包能通几十秒后再ping就断了原因是ARP缓存到期后一直没机会重新发起ARP请求时间轮询才是裸机LWIP的心脏。4. PING的完整收发链路从DMA中断到ICMP回包再到网线出口PING能通说明一整条链路上的每个环节都正常。这一章从一次ping request的视角把数据在MAC与LWIP之间行走的路径完整拆开。4.1 网线上的帧如何进入内存PC发出的ping request到达LAN8720A后PHY恢复出数据比特流和载波信号MAC收到完整帧并做CRC校验合法的帧被DMA写入接收描述符指向的缓冲区同时触发以太网中断。HAL库在中断服务程序里调用用户回调void HAL_ETH_RxCpltCallback(ETH_HandleTypeDef *heth) { struct pbuf *p; uint8_t *buffer; uint32_t len; if (heth-RxDesc[0].Status ETH_DMARXDESC_OWN) { return; } buffer (uint8_t *)heth-RxDesc[0].Buffer1Addr; len ((heth-RxDesc[0].Status ETH_DMARXDESC_FRAME_LENGTH_MASK) ETH_DMARXDESC_FRAME_LENGTH_SHIFT); p pbuf_alloc(PBUF_RAW, len, PBUF_POOL); if (p ! NULL) { pbuf_take(p, buffer, len); if (gnetif.input(gnetif, p) ! ERR_OK) { pbuf_free(p); } } }回调里先判断描述符的OWN位它表示DMA是否还拥有这个缓冲区如果OWN位为1说明这一帧还没接收完直接返回。接着从描述符状态字里取出帧长度按长度分配pbuf用pbuf_take把数据从DMA缓冲区复制进pbuf最后交给gnetif.input进入协议栈。参数上PBUF_RAW表示不需要协议头预留空间PBUF_POOL表示从接收缓冲池分配换成PBUF_RAM则走堆分配在中断里调用堆分配有阻塞风险不建议。4.2 ARP解析第一跳的地址翻译协议栈拿到帧后先看目的MAC是否匹配本机再看EtherType。若为0x0806则交给ARP模块处理ARP缓存里没有PC的IP对应MAC时会先发一个ARP request拿到应答后把映射记入缓存表。这个步骤往往发生在第一次ping时所以调试时会看到第一个包超时、第二个包通的假象。LWIP中IP包发出前的封装逻辑绑定在netif-output上netif-output etharp_output;etharp_output先查ARP表命中则调用netif-linkoutput未命中则把IP包暂存在队列里先发ARP request收到应答后再把排队的包送出去。裸机环境下这套流程由sys_check_timeouts周期驱动链路自动完成但使用者必须理解它的存在。遇到前几包超时可以在串口打印ARP表状态或者观察第二次ping时是否有ARP请求发出。4.3 ICMP echo的应答生成ARP解析成功后IPv4层剥掉IP头protocol字段为1的包被送到icmp_input。icmp_input判断类型是echo request后把数据部分原样保留、交换源目的地址、重新计算校验和生成echo reply塞回发送路径。LWIP里校验和计算有专门的函数static uint16_t inet_chksum(const void *data, u16_t len) { uint32_t sum 0; const uint16_t *ptr data; while (len 1) { sum *ptr; len - 2; } if (len 1) { sum *(const uint8_t *)ptr; } while (sum 16) { sum (sum 0xFFFF) (sum 16); } return (uint16_t)(~sum); }这里采用按16位字累加、回卷求和、最后取反的经典算法IP头、ICMP头和载荷都各算一遍。从这段代码可以验证协议栈的处理逻辑回包和请求包内容几乎一致只是src和dst互换校验和重算后DMA再把帧发出去。链路走到这里一次PING的闭环就完整了。4.4 PC端验证与参数观察在PC的cmd里连续发ping观察TTL和往返时间如果每次都通且时间稳定在1ms以内说明链路质量很好。若想看得更细可以用抓包工具看ARP、ICMP交互过程或者在板子串口打印每次RX/TX计数。我一般会在板子上加一个每秒打印的计数变量记录收到与发出的帧数两边一对比就知道丢包发生在MAC侧还是协议栈侧。5. 踩坑排查手册PHY识别失败、ARP超时与丢包的五条实录5.1 现象PHY ID读出来0xFFFF原因十有八九是PHY地址不对或MDIO接线错误。有些小模块把PHYAD0用上拉电阻接高电平地址是1另一些默认接地地址是0。CubeMX里的PHY Address参数必须匹配这个硬件事实。解决方法是先用万用表量PHYAD0引脚的电平再回填对应地址若复位引脚一直为低PHY报出的ID也是0xFFFF这时先确保复位时序完成后再读。5.2 现象link up但前几个ping包全部超时这个现象最容易让人怀疑协议栈坏了实际上多半是ARP解析还没完成。第一次ping时系统发ARP requestPC回应后缓存建立之后报文才通畅。解决方法是耐心等待第一个包通过或者在初始化后手动发一次ARP预热。我习惯在网络调试阶段用静态IP配合主动ARP请求把预热时间缩短避免测试时误判为链路故障。5.3 现象1472字节以上的大包ping不通这个阈值非常典型。超过MTU的ICMP包会被分片或直接丢弃如果小包正常、大包不通先查PBUF_POOL_SIZE和MEM_SIZE是否够大。遇到过把PBUF_POOL_SIZE设成10导致1472字节包链式分配失败的情况因为一帧要拆成多个pbuf池子不够就分配失败。把池子调到20以上、MEM_SIZE适当加大后大包立刻正常。改完参数记得重新编译并看map文件确认RAM没爆。5.4 现象串口打印的MAC地址全是0这个现象说明MAC地址并没有写入以太网MAC寄存器或写入的位置不对。CubeMX生成代码中netif-hwaddr是在ethernetif_init里赋值的但有些移植版本把MAC地址只写在静态变量里没有同步到netif。解决方法是启动后立即打印netif-hwaddr六个字节如果全零就手动在ethernetif_init里按要求重新赋值并确认初始化顺序在MAC寄存器使能之前。5.5 现象连续ping下来偶尔掉两三个包偶发丢包比全断更难查通常原因是主循环调度太挤。ethernetif_input在高优先级中断里频繁占用总线发送DMA的时隙被挤压或者接收描述符队列太短短时突发流量让队头一直占着。解决方法是把接收描述符数量调大同时把MX_LWIP_Process的链路轮询间隔改成定时器触发降低MDIO读寄存器的频率给收发DMA让出带宽。这五条坑基本覆盖了从硬件识别到协议栈上层的各个阶段先硬件后软件每一条都能单独定位问题归属。6. 二次改造方向UDP echo、TCP server与自测流程固化PING通了链路收发已经验证接下来是让这个裸机以太网干点正事。我改造时第一步会加一个UDP echo服务代码量最少却能把板上网络收发、缓冲区管理、协议栈分发完整再验证一遍。创建UDP PCB并绑定本地端口注册回调函数之后每一笔从PC发来的数据报都会进到回调里static void udp_echo_init(void) { struct udp_pcb *pcb udp_new(); udp_bind(pcb, IP_ADDR_ANY, 6000); udp_recv(pcb, udp_echo_recv, NULL); } 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); } }udp_new从内存池申请一个UDP控制块udp_bind把端口6000绑定到所有本地接口udp_recv注册回调收到数据后直接调用udp_sendto原样返回发送完立即pbuf_free释放缓冲。注意arg参数暂时传NULL即可port是远端端口回包时直接用收到的目标端口。UDP echo跑通后再上TCP思路类似只是把udp_pcb换成tcp_pcb多一个accept回调来管理连接状态。TCP涉及listen、accept、recv三段回调代码量会翻倍但核心收发逻辑与UDP一致。做完TCP后建议再查阅LWIP的raw API文档用自己的业务协议对接而不是去改协议栈内部源码。从那以后我手里任何以太网工程在加新功能前都强制先过一遍固定流程上电读PHY ID、按顺序跑PING、再跑UDP echo、最后才接业务。这套自测顺位帮我挡下了无数次应用层背锅协议栈的冤枉排查也把复现门槛降到了最低。下次不管换什么板子第一件事都是先确认这条链路的四个环节希望帮到你。本文还有配套的精品资源点击获取