ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

STM32F407 LwIP裸机移植全流程:PHY/DMA/raw API避坑指南

STM32F407 LwIP裸机移植全流程:PHY/DMA/raw API避坑指南 简介一份基于STM32F429IGT6的以太网通信实验工程在Keil MDK5.32与标准库环境下完成无操作系统LwIP协议栈移植实现PING响应与TCP客户端功能适合作为STM32F4系列网络通信开发的入门与参考案例。工程包含SysTick系统滴答定时器延时、LED_R/G/BPH10/PH11/PH12与按键Key1/Key2的驱动示例并针对LAN8720A物理层芯片完成适配开发板IP、PC IP及服务器IP192.168.1.122/100/10均在代码中明确配置便于直接搭建测试环境。压缩包共335个文件含142个C语言源文件、157个头文件以及调试配置、链接脚本、批量处理脚本、PDF说明文档与烧录工具并附带可直接下载的Hex/Bin固件整体大小7.91MB。已有848人学习下载。通过这套资源可理解LwIP协议栈在无系统环境下的移植步骤与内存管理方式同时获得底层的ETH驱动、TCP客户端及PING应答代码后续开发可直接复用节省重复调试协议栈的时间。 最近把STM32F407平台上的LwIP裸机移植重新梳理了一遍把从零到TCP Client完整跑通的路径整理出来。这活儿看着不难但坑全藏在细节里PHY芯片初始化、DMA描述符链表、无OS下的raw API回调机制、lwipopts.h的参数调配任何一个环节没对齐表现出来就是网线插上没反应、PING不通、TCP连接超时。这篇东西适合手里有一块带以太网外设的STM32最好是F407或F429、准备跑LwIP但不想上RTOS的开发者也适合那些被HAL库坑得够呛、想回头用标准库快速出活的工程师。我尽量把关键代码和排查思路都写透你照着走一遍至少能省三天的调试时间。1. 项目全貌与方案选型1.1 这套方案到底解决什么问题STM32的以太网方案分两条路线一条是F103那种压根没有内置MAC的芯片只能用SPI接口挂ENC28J60或W5500这类“SPI转以太网”芯片协议栈得在MCU里跑吞吐量还受限于SPI速率另一条是F407/F429这种带内置MAC外设的芯片只需要外挂一颗PHY物理层芯片MAC通过MII或RMII接口和PHY通信数据走DMA性能和稳定性都高一个档次。我这次做的就是第二条路线主角是STM32F407VET6加一颗LAN8720A。标题里的“以太网外设”指的就是STM32内部的MAC控制器。它是一个完整的数据链路层硬件负责处理以太网帧的发送接收、CRC校验、帧过滤这些脏活累活而LwIP只是跑在CPU上的软件协议栈两者通过DMA描述符交换数据。弄清楚这个分工非常重要——它决定了你写代码时哪些事情交给硬件、哪些事情必须自己处理。还有一个关键决策是“无操作系统”。LwIP本身支持三种运行模式raw API无OS、netconn API需要RTOS、socket API需要RTOS。裸机环境下只能用raw API它本质上是事件驱动加回调函数你注册一个“收到数据”的回调协议栈底层收到包时会自动调用它。好处是省资源、无上下文切换开销坏处是代码结构没那么直观新手容易卡在回调里出不来。我的建议是如果你只是做TCP Client、UDP收发这类简单应用裸机完全够用。1.2 硬件平台与引脚分配先交代一下硬件基础。主控是STM32F407VET6PHY用Microchip的LAN8720A这是很经典的百兆以太网PHY芯片价格便宜、外围电路简单市面上大多数带以太网的F407板子用的都是它。软件环境是Keil MDK配标准外设库StdPeriph_Driver V1.8.0LwIP用1.4.1版本——这是和标准库例程配套最顺的版本网上资料也多出了问题好查。引脚分配上我走的是RMII接口模式比MII少一半信号线是F407LAN8720A的常用组合。注意RMII模式要求外部提供50MHz的参考时钟LAN8720A自身的时钟电路是基于25MHz晶振的所以需要把F407的MCO1引脚PA8配置成50MHz输出接到LAN8720A的REF_CLK引脚或者有些板子的50M时钟由PHY芯片自己输出给MCU但LAN8720A默认不是这种工作方式这点后面讲。除了时钟剩下的就是RMII的数据线TXD0/1、RXD0/1、TX_EN、RX_DV、MDC、MDIO外加一个PHY复位引脚。一个非常容易踩的坑LAN8720A的PHY地址由LED0引脚的上下拉状态决定大多数模块电路把它拉低地址就是0x00。如果你用的模块地址不是0后面通过MDIO总线读PHY寄存器时就会全部读到0xFF很容易让人误以为硬件坏了。1.3 为什么选标准库而不是HAL库这个问题每次都会被问到。我的回答直接一点标准库的代码比HAL库直观得多。HAL库为了兼容全系列芯片抽象层很厚一个简单的寄存器操作可能被包了三四层函数而且HAL库的以太网驱动里面用了大量条件编译和回调机制排查问题的时候很难看清数据的实际流向。标准库直接操作寄存器STM32F4x7的标准外设库里就有现成的以太网驱动文件stm32f4x7_eth.c配合LwIP官方的移植例程改动量很小。从稳定性上讲标准库经过ST多年的沉淀几乎没有什么隐藏的bug网上大量的老项目代码都是基于它写的遇到问题随便一搜就有答案。HAL库这些年虽然越改越好但如果你是做产品、追求快速稳定交付标准库依然是很可靠的选择。当然标准库只支持到F4系列新出的芯片比如F7、H7只能用HAL或LL库这一点要提前说明。我这个项目用F407标准库完全够用。2. 移植前的关键概念不懂这些一定踩坑2.1 LwIP的三种API模式为什么裸机只能选RAWLwIP 1.4.1的API层分为两套一套是底层的raw API也叫回调API直接操作PCB控制块另一套是netconn API和socket API它们是为RTOS环境设计的内部会创建线程阻塞等待事件。裸机环境下没有线程调度你用netconn API就是找死——connect函数会一直阻塞在那儿整个系统直接卡死。raw API的核心是“注册回调”。比如你要建立一个TCP连接就调用tcp_connect()同时传入一个connected回调函数指针因为裸机只有一个线程你不会真的阻塞等待连接建立而是让LwIP继续处理别的包等三次握手完成、收到对端SYN-ACK的时候协议栈会调用你注册的connected回调。这个模式和嵌入式中断编程的思维很像不是“你去干这件事等它干完”而是“这件事完了我叫你”。理解了这一点你就能理解为什么网上那些裸机LwIP教程里的主循环都是长这样的不断调用ethernetif_input()收包、sys_check_timeouts()检查超时剩下的逻辑全靠回调驱动。这是一套完全不同于顺序执行编程的思维但一旦习惯写网络应用会非常顺手。2.2 DMA描述符以太网数据流的“传送带”STM32的以太网MAC自带DMA控制器它通过一组位于内存中的描述符来管理数据的收发。每个描述符对应一个缓冲区描述符里记录了缓冲区地址、数据长度、状态标志等信息。发送时你把数据填进缓冲区设置描述符的OWN位交给DMADMA会自动把数据搬给MAC发出去接收时DMA把收到的数据写进缓冲区清除OWN位并触发中断告诉你“这个缓冲区有数据了”。描述符在内存中是一个环形链表收发各一组。标准库例程里默认配置了ETH_RXBUFNB和ETH_TXBUFNB各4个描述符每个缓冲区大小一般是ETH_RX_BUF_SIZE1520字节左右。这些缓冲区和描述符都必须按4字节对齐因为DMA是按字访问内存的对齐不对会直接跑飞。我第一次移植的时候直接把例程里描述符的地址空间改了个位置结果一直收不到数据查了半天发现是缓冲区数组忘记加__attribute__((aligned(4)))。这个细节说多了都是泪。如果你用的是标准库例程它默认帮你把ETH_RX_BUF和ETH_TX_BUF分配好了尽量不要改动布局改之前先想清楚DMA的对齐要求。2.3 时间基准与超时机制TCP协议能可靠传输全靠一大堆定时器在背后运作重传定时器、持久定时器、防死连接定时器、TIME-WAIT定时器等等。LwIP在裸机环境下用sys_now()这个函数获取当前毫秒数tcp_tmr()内部会检查这些定时器是否到期。所以你必须在某个地方提供毫秒级的时间戳。标准库例程通常用SysTick做1ms中断维护一个全局变量g_ul_ms然后实现sys_now()返回这个变量。注意这个函数不能关中断执行时间太长否则会影响TCP的时间精度——尤其是SYN重传和RTO计算时间不准会导致连接建立很慢或者频繁超时重传。我见过有人偷懒直接return HAL_GetTick();如果是HAL库没问题但标准库没有现成的一定要自己写。裸机下没有RTOS的tick这个小函数是整个TCP协议栈能正常工作的地基。3. 底层驱动移植ethernetif.c的改造实录3.1 标准库工程怎么搭标准库新建工程这件事本身就能劝退不少人。其实步骤不复杂到ST官网下载STM32F4xx_DSP_StdPeriph_Lib_V1.8.0里面Project/STM32F4xx_StdPeriph_Examples/ETH目录下就有现成的LwIP移植例程。把内核相关文件CoreSupport、标准外设库StdPeriph_Driver、LwIP源码ThirdParty/lwip-1.4.1加进工程再把STM32F4xx_ETH_Project里的main.c、ethernetif.c、stm32f4x7_eth.c这些核心文件拷过来改改就行。有个小技巧启动文件一定要用startup_stm32f40xx.s或者startup_stm32f407xx.s别用F429的因为两者中断向量表有差异。如果你用的是system_stm32f4xx.c注意里面有SystemInit()它会初始化Flash等待周期和时钟不要屏蔽。工程能编译通过只是第一步。我建议先把串口调通printf打点再逐步开启以太网——否则后面出了问题你根本不知道程序跑到哪里死了。3.2 PHY初始化与RMII时钟配置PHY芯片的初始化是通过MDIO接口读写PHY的寄存器来完成的。标准库例程里有ETH_Init()这个函数它内部会调ETH_Init_Structure配置MAC地址、工作模式然后通过MDIO读取PHY的ID寄存器来确认PHY芯片是否在线。这里有一个核心难点是LAN8720A的时钟配置。RMII模式下PHY的REF_CLK必须是50MHz而LAN8720A内部有PLL外部只需要25MHz晶振即可但要让它输出50MHz的REF_CLK需要把它的CLKOUT引脚配置成REF_CLK输出模式。很多板子为了简化设计直接让MCU的MCO1输出50MHz给PHY这样PHY就不需要考虑CLKOUT的问题了。如果你的原理图是后一种设计初始化顺序必须是先把PA8引脚复用成MCO1、配置RCC输出50MHz时钟然后才能给PHY芯片复位和初始化。如果你先复位了PHY而REF_CLK没起来PHY会一直处于未就绪状态MDIO读取ID时会全部读到0xFFFF。我调试时在这里卡了大半天最后用示波器量PA8才发现MCO1配置在GPIO初始化之前被覆盖了时钟根本没输出。3.3 描述符链表与MAC地址ethernetif.c里最核心的函数是low_level_init()负责初始化描述符链表、配置MAC地址。描述符链表的初始化就是把ETH_RX_DESC和ETH_TX_DESC的首尾通过TDES0/RDES0的环形标志位串起来。标准库初始化函数ETH_Init()会把描述符表地址告诉DMA之后DMA就自动开始搬运了。MAC地址要写成你板子的唯一地址别直接用例程里的随便一个值。虽然有网段内MAC冲突的概率很低但做产品的话正规做法是从厂商申请的地址段里分配。开发阶段用00:80:E1:00:00:00这种ST默认的也不是不行不过如果同一网段有多块相同地址的板子相互之间会疯狂丢包。还有一点容易被忽略low_level_init()最后要开RX描述符的接收状态让DMA进入等待接收的状态。否则即使PHY收到了数据DMA也不会把它搬进内存表现就是能link up但收不到任何包。标准库例程的ETH_Init已经帮你做了这一步但如果你自己改过描述符的初始化流程记得检查ETH_DMARxDescChainInit()之后描述符的RDES0是否带OWN位。4. LwIP配置文件与初始化流程4.1 lwipopts.h的关键参数lwipopts.h是LwIP的“总开关”所有功能裁剪和内存大小的配置全在这里。对于裸机F407我用的关键配置如下#define NO_SYS 1 // 无操作系统 #define LWIP_NETCONN 0 // 关闭netconn API #define LWIP_SOCKET 0 // 关闭socket API #define LWIP_ICMP 1 // 开启ICMP支持PING响应 #define LWIP_DHCP 0 // 先关掉DHCP用静态IP调试 #define LWIP_TCP 1 // 开启TCP #define MEM_SIZE (10 * 1024) // 堆内存池大小 #define PBUF_POOL_SIZE 16 // pbuf池数量 #define TCP_MSS 1460 // 最大报文段长度 #define TCP_WND (4 * TCP_MSS)我在内存配置上吃过亏MEM_SIZE太小TCP发送大文件时频繁内存分配失败PBUF_POOL_SIZE太小接收突发的数据包时会丢包。一般F407有192KB RAM给LwIP分配10到20KB的内存池是完全够用的千万别省。调试初期建议开LWIP_DEBUG把错误等级调到LWIP_DBG_LEVEL_WARNING能看到TCP状态机的切换日志这对排查连接不了的问题帮助巨大确认稳定后再关掉省ROM省性能。4.2 初始化流程与主循环处理主程序的初始化顺序很重要。我的标准流程是先初始化时钟、GPIO、串口然后配置MCO1输出50MHz给PHY复位并初始化LAN8720A调用ETH_Init()初始化MAC和DMA接着LwIP_Init()初始化协议栈设置IP、网关、掩码最后设置netif为up状态打开ETH中断。主循环里面只有两件事while (1) { ethernetif_input(g_netif); // 从DMA接收队列取包送进LwIP协议栈处理 sys_check_timeouts(); // 处理TCP超时定时器 }ethernetif_input()这个名字容易让人误解它其实不是“输入”而是从RX描述符中取出数据包、封装成pbuf、调用netif-input()交给LwIP处理。ETH的中断服务函数里不需要做太多事标准做法是在ISR里直接调用ethernetif_input()因为中断里处理数据包会占用较长时间但对裸机来说这是最简单可靠的方案如果对中断延迟敏感可以在中断里只置一个标志位主循环检测到标志后再去调ethernetif_input()。实测下来F407跑168MHz的时候在中断里直接处理也能扛住百兆的常规数据量。5. PING通与TCP Client实现5.1 让电脑能PING通板子很多人以为“PING通”需要自己写ICMP回复代码其实LwIP内部已经处理了ICMP Echo Request只要你的LWIP_ICMP宏是1并且网络接口处于up状态LwIP收到PING请求后会自动构造ICMP Echo Reply并回复。所以你只需要确保底层能正确把数据包交给协议栈剩下的不需要操心。要让电脑PING通板子需要给电脑的网卡设置一个同一网段的静态IP。比如板子配置的是192.168.1.30掩码255.255.255.0电脑网卡就设192.168.1.10。这里有个注意点Windows的防火墙默认会阻止ICMP回显PING不通不一定是你的板子问题。先确认板子串口日志里有没有打印出连接建立的信息或者用Wireshark抓包看有没有ARP请求发出。我调试时经常遇到板子其实工作正常但电脑就是PING不通最后发现是防火墙拦了把防火墙关掉立刻通。如果PING通了说明底层以太网接收发送链路、ARP协议、IP协议、ICMP协议全都通。这是一个非常关键的里程碑因为接下来TCP如果做不通问题就肯定出在LwIP配置或者TCP回调逻辑上排查范围大大缩小。5.2 RAW API写TCP Client的完整流程TCP Client的代码结构比UDP复杂不少因为涉及连接状态管理。我以“板子主动连接电脑上跑的TCP Server”为例完整流程是这样的struct tcp_pcb *tcp_client_pcb; // 1. 创建PCB块 tcp_client_pcb tcp_new(); if (tcp_client_pcb ! NULL) { // 2. 绑定本地端口0表示由系统分配 tcp_bind(tcp_client_pcb, IP_ADDR_ANY, 0); // 3. 注册连接建立回调 tcp_arg(tcp_client_pcb, NULL); tcp_err(tcp_client_pcb, tcp_client_error); // 4. 连接到服务器 ip_addr_t server_ip; IP4_ADDR(server_ip, 192, 168, 1, 10); tcp_connect(tcp_client_pcb, server_ip, 8080, tcp_client_connected); } // 连接建立成功后LwIP会调用这个回调 err_t tcp_client_connected(void *arg, struct tcp_pcb *tcb, err_t err) { // TCP三次握手完成可以发送数据了 tcp_sent(tcb, tcp_client_sent); // 注册发送完成回调 tcp_recv(tcb, tcp_client_recv); // 注册接收数据回调 // 首次发送数据 tcp_write(tcb, hello, 5, 1); tcp_output(tcb); return ERR_OK; }tcp_connect()函数发起连接后立即返回真正的连接建立是在三次握手完成之后由LwIP调tcp_client_connected通知你的。在这个回调里你才能安全地调用tcp_write()发送数据。如果你在tcp_connect()返回后立刻调用tcp_write()数据会被缓存在发送队列里但因为连接还没建立通常会被丢到错误回调里表现就是服务器收不到任何数据而且你的err回调会收到ERR_CONN错误。tcp_write()只是把数据复制到LwIP内部的发送缓冲区真正发出要调tcp_output()。对于小数据tcp_write(tcb, data, len, 1)最后一个参数1表示TCP_MSG_OOB不对那个参数是apiflags1表示TCP_WRITE_FLAG_COPY意思是数据会复制到协议栈内部缓冲区。如果你能保证传给tcp_write的缓冲区在发送完成前一直有效可以省掉这个标志能省一次拷贝开销——不过裸机上这点性能差异不重要建议默认开启COPY简单安全。5.3 接收数据回调与连接管理接收数据靠tcp_recv注册的回调。LwIP收到对方发来的数据后会调用这个回调并把数据放在pbuf里。你的任务是在回调里处理数据最后一定要调用tcp_recved()告诉协议栈“我已经处理了多少字节”。如果不调协议栈会认为你的接收窗口一直没有释放对端的发送窗口会被慢慢压到0导致数据发不进来。err_t tcp_client_recv(void *arg, struct tcp_pcb *tcb, struct pbuf *p, err_t err) { if (p ! NULL) { // 处理数据比如打印或解析 pbuf_free(p); tcp_recved(tcb, p-tot_len); } else { // p为NULL表示对端关闭了连接 tcp_close(tcb); tcp_client_pcb NULL; } return ERR_OK; }这里有一个裸机环境的隐藏问题在recv回调里处理数据如果耗时太长会阻塞整个协议栈因为回调是在ethernetif_input()里被调用的也就是在主循环里执行。如果你需要做复杂处理建议只把数据拷贝到自己的缓冲区置一个标志位在主循环的另一段逻辑里慢慢解析。我开发一个数据采集项目时曾在回调里直接写SD卡结果每次写卡耗时几十毫秒期间所有TCP包都处理不了直接导致连接超时断开后来改成“先入队后处理”才解决。6. 实测记录与常见问题排查6.1 调试记录一次PHY初始化失败的排查有一次换了一块板子LAN8720A的PHY地址改成了0x01我没注意到结果ETH_Init()里读取PHY ID时一直失败返回ETH_ERROR。现象很有意思板子能link up因为link状态不是通过MDIO读的但ARP请求发不出去电脑上抓包能看到板子的ARP广播一直在发但没人回应。排查步骤先量PHY的复位引脚电平正常再用示波器量MDIO时钟发现MDC在正常工作说明MAC在尝试访问PHY然后用逻辑分析仪看MDIO的数据线发现读出来的寄存器值全是0xFF。这时候怀疑是PHY地址不对查原理图确认LAN8720A的PHYAD0引脚接了高电平地址是1。把代码里的PHY地址从0x00改成0x01重新编译烧录立刻就好了。这个案例的教训是PHY芯片的地址不是固定的一定要对照原理图确认。而且MDIO读回0xFFFF也不一定代表硬件坏了地址不对、时钟没给到、复位没释放表现可能完全一样。调试时一定要先通过串口打印PHY ID确认芯片在线再进行后续操作。6.2 常见问题速查表把这段时间遇到的典型问题整理成了一张表按这个顺序排查能解决大部分问题问题现象可能原因排查方法网线插上link灯不亮PHY没复位、REF_CLK没起、网线/交换机问题量PHY复位引脚电平、用示波器看50MHz时钟输出MDIO读ID失败、全是0xFFPHY地址配置错误、MDC时序不对查原理图确认PHYAD引脚、降低MDC频率到2.5MHz能link up但PING不通MAC地址配置错误、DMA描述符未初始化、电脑防火墙拦ICMP打印PHY ID、Wireshark抓包看ARP、关防火墙测试TCP连接超时、connect回调不触发sys_check_timeouts()没调用、SYN重传超时机制失效确认主循环调用了sys_check_timeouts()、开启LwIP debug日志能连上但收不到数据tcp_recved()没调用、接收窗口满检查recv回调是否及时处理pbuf并释放窗口频繁复位或死机缓冲区对齐不对、描述符链表断裂、内存池溢出检查__attribute__((aligned(4)))、减小MEM_SIZE观察是否触发ASSERT发送数据偶尔丢包tcp_write()返回ERR_MEM但未重试涨大MEM_SIZE和TCP_SND_BUF或按流控重发最后建议你在调试阶段把串口日志做得足够详细尤其是LwIP的LWIP_DEBUG开关在早期的移植阶段一定要开。我习惯在ethernetif_input()入口打印一个字符‘R’在low_level_output()打印一个‘S’这样即使不开串口调试也能通过LED闪烁频率判断收发包有没有在跑。等一切稳定了再把这些调试输出全部关掉一个干净稳定的网络通信模块就完成了。做个简单总结这项目我前后折腾了差不多一周最大的感悟是——LwIP移植不只是把例程复制粘贴你得真正理解DMA描述符、PHY初始化、raw API回调这三根支柱。只要这三块没有盲区后面PING通和TCP通信都是水到渠成的事。如果你在移植过程中遇到问题把串口日志、PHY寄存器值、Wireshark抓包信息对齐起来分析基本上都能定位到具体环节。裸机LwIP这套方案在F407这种级别的MCU上跑起来非常稳定后续想扩展UDP广播、Modbus TCP或者MQTT都是同样的路子希望这篇整理能让你少走我走过的弯路。本文还有配套的精品资源点击获取
返回列表