尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

STM32F407+FreeRTOS+LWIP 1.4.1以太网实战:从RMII到DHCP/UDP调通指南

STM32F407+FreeRTOS+LWIP 1.4.1以太网实战:从RMII到DHCP/UDP调通指南 简介一套面向嵌入式网络开发者的 STM32F407FreeRTOSLWIP 移植工程以正点原子探索者开发板为硬件平台基于标准库与 MDK5 构建。工程参考了知名 LWIP 移植教程及 ALIENTEK 官方 LWIP 开发手册在 FreeRTOS 环境下完成协议栈集成实现 DHCP 动态获取 IP 与 UDP 网络通信可直接编译下载运行特别适合需要快速上手 RTOS 以太网开发的中高级工程师。压缩包共 548 个文件大小 16.1MB其中包含 156 个 .h 头文件、138 个 .c 源文件、70 个 .o 及 .d/.crf 等编译中间文件并带有 MDK 工程配置、链接脚本、构建批处理与 readme 说明文档覆盖从源码到工程的完整目录结构。已有 1612 人学习浏览这份可运行工程可帮助用户深入理解 LWIP 底层驱动与 FreeRTOS 任务调度之间的配合同时也能作为 UDP 通信及 DHCP 功能二次开发的可靠起点。 做嵌入式网络通信的应该都经历过这么一段板子上啥都正常就是网口死活不通代码在电脑上跑没问题一搬到单片机上就各种玄学。我这套组合——STM32F407 FreeRTOS LAN8720 LWIP 1.4.1 DHCP UDP 标准库 MDK5几乎是国内STM32以太网开发最常见的搭配之一很多开发板的网络例程就是这个底子。但这套东西牵涉到MAC、PHY、DMA、RTOS、协议栈五层协作每一层都埋着坑。这篇东西我不会讲那些照抄手册都能写出来的步骤而是把我在实际调通这套组合时踩过的坑、梳理过的原理、以及最终稳定运行的配置都摊开讲。适合两类人看一类是第一次在F407上跑以太网想快速有个全局认知的另一类是已经能Ping通但一上DHCP或UDP就出问题的老哥。1. 为什么是这套搭配需求倒推出来的选型结论1.1 F407自带的MAC与独立PHY的分工很多人第一次接触以太网开发时会懵为什么F407芯片上已经写了以太网三个字还要再外挂一个LAN8720其实STM32F407内部集成的只是Ethernet MAC层负责数据链路层的工作比如组帧、CRC校验、DMA搬运。但物理层PHY不在芯片里物理层要把CPU发出的并行数字信号调制成差分模拟信号再通过网线发出去。所以必须外接一颗PHY芯片。F407的自带MAC通过MII或RMII接口和外部PHY通信。选LAN8720有三个现实原因一是便宜量足量产成本压得住二是支持RMII模式引脚占用少只要9个左右的信号脚不像MII要20多个脚三是3.3V供电可以和F407直接接不需要额外电平转换。同类替代还有其他PHY但LAN8720在国内供应链成熟二手板子、开源工程遍地都是出问题也好找参考。1.2 标准库和LWIP 1.4.1的存量理由你要是觉得2025年了还写标准库和LWIP 1.4.1不够时髦我理解。但事实是存量市场非常大。很多量产产品的代码库就是基于标准库建的你要维护或二次开发就必须在这套体系里工作。LWIP 1.4.1是老版本但稳定API和2.x差别不小网上教程资料又多又杂反而容易把两个版本的代码混着抄。所以这篇文章明确锁定标准库 LWIP 1.4.1这条线。换成HAL库和LWIP 2.x的朋友原理相通接口类似但细节千万别照着抄两个版本的内核实现和配置项差别不小。2. 硬件连接与时钟关系RMII模式下最容易翻车的地方2.1 引脚分配表与RMII信号说明RMII接口一共就没几根线但每一根都关键。F407标准外设库的以太网例程里通常使用以下引脚分配信号名MCU引脚方向说明ETH_RMII_REF_CLKPA1输入50MHz参考时钟由PHY或外部晶振提供ETH_RMII_CRS_DVPA7输入载波侦听/数据有效RX线上有数据时为高ETH_RMII_RXD0PC4输入接收数据位0ETH_RMII_RXD1PC5输入接收数据位1ETH_RMII_TX_ENPB11输出发送使能TX有效期间拉高ETH_RMII_TXD0PB12输出发送数据位0ETH_RMII_TXD1PB13输出发送数据位1ETH_MDCPC1输出管理接口时钟用于读写PHY寄存器ETH_MDIOPA2双向管理接口数据线可能还要加上NRST复位引脚和中断引脚不同板子接法不一样。我强烈建议你画板或接线前先对着自己开发板的原理图查一遍这几个脚位因为PB11、PB12、PB13这几个脚也可能被其他外设复用比如DCMI、SPI、I2C。我自己就遇到过一次板子上PB12还接了别的功能导致TX数据线被拉死百思不得其解。2.2 50MHz时钟的两个来源以及为什么不能直接用MCORMII接口有个鲜明的特点它要求一个50MHz的参考时钟而且是供整个收发逻辑使用的。这个时钟从哪里来是整个工程最容易被忽略、又最容易致命的问题。F407系统时钟最高168MHzMCO输出引脚PA8虽然能输出时钟但168MHz分频不出50MHz整倍数所以MCO直接出50MHz这条路在F407上行不通。我看到不少朋友想省一个晶振最后卡在时钟上。实际可行的方案有两个方案ALAN8720外接25MHz无源晶振PHY内部PLL倍频到50MHz再从REF_CLK引脚输出给STM32的PA1。大多数开发板包括正点原子、野火的F407板子就是这种接法。STM32此时作为RMII的从机接收PHY送来的50MHz时钟。这种方式走通后你会发现只要PA1有持续稳定的50MHz方波RMII链路就成功了一大半。方案B外部有源50MHz晶振同时接到LAN8720的XI/CLKIN引脚和STM32的PA1。适合你自己设计PCB、想要更高时钟精度的场景。另外说个细节LAN8720内部没有像有些人想的那样“自动产生50MHz”。如果是25MHz晶振方案必须确认PHY工作在RMII模式而不是MII模式因为两种模式下PHY内部对时钟的处理不一样。LAN8720通过REGRMAP引脚设置RMII/MII模式大多数RMII电路将其上拉到VDD。2.3 PHY地址、复位和link检测LAN8720的SMI地址默认是0x00因为它只有一个PHYAD0引脚硬件上默认下拉为0。这个地址在标准库ETH_Init时用得上如果你的板子上SMI总线上只挂了这一个PHY直接用0就好。但如果你把MDIO线和另一块板子并联或者换了不同PHY地址的片第一件事就是确认这个值。复位也很讲究。LAN8720的nRST建议用MCU的GPIO控制而不是单纯RC复位。原因是PHY上电复位后内部寄存器需要一段时间稳定最好在代码里显式完成拉低-延时-拉高-延时的复位时序延时建议至少10ms再开始读寄存器。我习惯用一串简单的GPIO翻转和vTaskDelay完成。Link状态的检测有两种方式一是读PHY的寄存器1BMSR的bit 2看是否协商出链路二是直接看网口LED灯。代码里标准库的ETH_Get_LINK_State()就是走SMI读寄存器的路子注意这个函数内部用了阻塞延时在FreeRTOS环境里如果放在低优先级任务里偶尔调一次可以但别放到中断里。3. 移植的三个层次标准库驱动、LWIP内核、FreeRTOS接口3.1 标准库ETH驱动从GPIO到DMA描述符用标准库做以太网驱动核心文件是stm32f4xx_eth.c和stm32f4xx_eth.h。初始化步骤大体是这样开启GPIOA、GPIOB、GPIOC时钟把对应引脚配置为复用功能复用号为GPIO_AF_ETH。开启ETH的时钟。标准库里有RCC_AHB1Periph_ETH_MAC、RCC_AHB1Periph_ETH_TX、RCC_AHB1Periph_ETH_RX这几个选项要一起开。定义DMA描述符数组和接收缓冲区数组。注意这两个数组必须4字节对齐且不能放在CCM RAM里。用attribute直接标static ETH_DMADescTypeDef RxDMABuff[ETH_RXBUFNB] __attribute__((aligned(4))); static ETH_DMADescTypeDef TxDMABuff[ETH_TXBUFNB] __attribute__((aligned(4))); static uint8_t RxBuff[ETH_RXBUFNB][ETH_RX_BUF_SIZE] __attribute__((aligned(4)));调用ETH_StructInit填充默认配置修改MAC地址、全双工、自动协商等参数后ETH_Init。ETH_DMACmd使能DMA的接收和发送。这里有个非常典型的死法ETH_Init内部会通过SMI读取PHY的ID寄存器如果PHY地址不对或者PHY没复位完成ETH_Init会一直等不到正确回应现象就是程序卡死在初始化里。我之前一度以为是晶振没起振后来用逻辑分析仪抓MDIO波形才发现是PHY地址和板子的实际PHY地址不一致。还有F407的CCM RAM这段64KB内存比较特殊CPU可以高速访问但DMA外设访问不了。很多人想用CCM RAM当缓冲区省主RAM结果以太网DMA描述符放进去表现为一收包就hardfault或数据全错。这是一个非常隐蔽且常见的坑排查方法是用标准库默认RAM区编译一遍对比现象。CCM RAM适合放FreeRTOS任务栈和不用DMA的普通变量。3.2 ethernetif.c连接LWIP和MAC的关键桥LWIP本身不关心你的MCU是STM32还是别的它只定义了netif结构体和netif-output、netif-linkoutput这类函数指针。ethernetif.c就是把这些接口和你的MAC驱动粘起来的适配层。这个文件里需要重点改三个函数low_level_init()设置netif-hwaddrMAC地址。注意MAC地址不能全0全0会直接导致电脑或路由器不回应ARP请求。网络调试中最典型的现象就是Ping不通抓包发现电脑发了一堆ARP but MCU没回应。设置MTU为1500并且根据PHY的实际link状态设置NETIF_FLAG_LINK_UP标志。如果PHY还没有协商出网线连接不要急着置这个标志否则DHCP会立刻失败。low_level_output()上层要发一个pbuf链时调用的发送函数。pbuf可能不是一个连续内存块而是链成串的必须循环遍历整个pbuf链把每个数据段都拷到DMA发送缓冲区再启动发送。只拷贝第一个pbuf是常见低级错误。low_level_input()从DMA的接收描述符中取出收到的数据填到新分配的pbuf里然后交给上层处理。3.3 sys_arch给LWIP一套FreeRTOS的系统调用如果LWIP_NO_SYS0意味着LWIP以多线程模式运行它需要下面这些东西信号量、互斥锁、邮箱、线程创建、获取当前时间。sys_arch.c就是做这件事的相当于给LWIP适配FreeRTOS的Runnable接口。我的做法是邮箱用FreeRTOS的队列实现信号量用二值/计数信号量实现互斥锁用互斥量。在sys_arch_mbox_fetch里timeout单位是毫秒要用pdMS_TO_TICKS换算成tick数等到0就直接用portMAX_DELAY。有个经历让我印象很深一开始为了省事我把sys_arch里所有等待超时都写成portMAX_DELAY结果某次网络事件异常时整个tcpip_thread和所有应用任务全部阻塞板子看起来像死了。实际上协议栈里有些操作是需要超时返回的不能全设永久等待。LWIP源码contrib目录下本来就带FreeRTOS移植参考但LWIP 1.4.1和2.x的sys_arch接口有一处关键差异2.x用sys_mbox_trypost1.4.1的邮箱接口是sys_mbox_post和sys_mbox_fetch返回值语义也不完全一样。抄代码时务必看清版本别混。3.4 lwipopts.h功能裁剪和内存预算lwipopts.h是LWIP的全局配置文件。在FreeRTOS UDP DHCP场景下我一般这样设#define NO_SYS 0 #define LWIP_SOCKET 0 #define LWIP_UDP 1 #define LWIP_TCP 0 #define LWIP_DHCP 1 #define MEM_ALIGNMENT 4 #define MEM_SIZE (40 * 1024) #define PBUF_POOL_SIZE 16 #define MEMP_NUM_UDP_PCB 8不用TCP就关掉LWIP_TCP节省大量内存。DHCP依赖UDP所以LWIP_UDP必须为1。MEM_SIZE是LWIP内部堆的大小给UDP应用至少留40KB。PBUF_POOL_SIZE决定接收缓冲池能同时放多少个数据包太小了UDP一多就丢包。这个值要结合RAM余量来调F407有192KB SRAM只要不做太夸张的应用足够用。4. DHCP到UDP收发的完整调通路径4.1 LWIP 1.4.1的DHCP定时器陷阱依赖LWIP 1.4.1的朋友最容易踩的坑打开LWIP_DHCP宏调用dhcp_start(netif)然后就干等结果半小时都拿不到IP。原因很简单LWIP 1.4.1的DHCP没有内置在tcpip_thread里自动调度需要你手动周期性调用dhcp_fine_tmr()和dhcp_coarse_tmr()。前者通常每250ms调用一次处理DHCP的细粒度超时后者每5000ms调用一次处理粗粒度超时。这个机制在LWIP 2.x里已经变了但1.4.1就是要你手动喂。我在FreeRTOS里是这样做的void vNetTimerTask(void *param) { uint32_t count 0; for (;;) { vTaskDelay(pdMS_TO_TICKS(250)); dhcp_fine_tmr(); count; if (count % 20 0) { dhcp_coarse_tmr(); } } }一段代码解千愁。还有初始化的顺序。正确的调用顺序是netif_add → netif_set_default → netif_set_up → dhcp_start。顺序颠倒DHCP请求发不出去同样表现为获取不到IP。4.2 RAW API实现UDP收发LWIP在无操作系统或嵌入式小工程中最常用的是RAW API也叫回调API。不用像socket那样select、bind一堆代码核心就是注册一个回调函数。下面是一段可用的UDP回环代码骨架static struct udp_pcb *udp_pcb_ins; void udp_app_init(void) { udp_pcb_ins udp_new(); udp_bind(udp_pcb_ins, IP_ADDR_ANY, 8888); udp_recv(udp_pcb_ins, udp_rx_callback, NULL); } void udp_rx_callback(void *arg, struct udp_pcb *upcb, struct pbuf *p, struct ip_addr *addr, u16_t port) { if (p ! NULL) { // 处理数据 udp_sendto(upcb, p, addr, port); // 原样回给发送方 pbuf_free(p); } }注意两点回调里拿到的pbuf用完必须pbuf_free否则内存池被慢慢耗干跑几小时突然收不到数据就是这种泄漏导致的回调里不要做耗时操作比如串口打印大量日志或阻塞在队列等待这会让缓冲区积压丢包率飙升。如果确实需要复杂处理把数据拷到自己的缓冲区通过队列交给应用任务。发送方向类似udp_sendto(upcb, p, remote_addr, remote_port)。这里有个经验如果应用任务里直接调用udp_sendto而同时tcpip_thread也在跑LWIP 1.4.1的RAW API本身不保证线程安全。如果只有这一个网络应用任务独占使用该pcb实测问题不大但如果多个任务都要收发建议都通过信号量串行化或者改用netconn API。4.3 任务划分与优先级设计FreeRTOS里的优先级设计直接决定网络实时性。我的经典配置是ETH中断优先级NVIC分组2占优先级2不要最高给系统滴答留空间tcpip_thread优先级3定时器任务喂DHCP定时器优先级2UDP应用任务优先级1空闲任务默认这里有个反常识的点ETH中断的优先级不要设成0。因为LWIP 1.4.1的接收路径有时会在中断里处理pbuf分配和数据拷贝如果中断优先级太高会延迟其他关键中断。设成中等优先级就够了。另外不要让应用任务的优先级高过tcpip_thread。因为应用任务里如果调用了udp_sendto最终还是要等tcpip_thread处理ARP和IP分片优先级倒挂会导致高优先级任务死等低优先级任务执行。5. 结合实测高频故障的定位过程与解决办法5.1 MDK5编译器AC5还是AC6LWIP 1.4.1是2012年左右的代码C89风格浓重在MDK5的AC6编译器下会有大量警告有些甚至直接编译失败。AC6在语法检查上更严格对隐式类型转换和struct alignment的容忍度低。我的建议是如果是这个组合直接把工程设置为AC5ARM Compiler 5编译。但要注意MDK从5.37版本开始不再默认安装AC5需要在Pack Installer里手动安装ARM Compiler 5.06 update 7。安装完成后在Project → Options → Target → ARM Compiler里选Version 5。如果你非要AC6那就做好心理准备要修一堆第三方代码的warning比如void*隐式转换、函数声明缺失、packed结构体对齐等。为了一个网络驱动投入产出比不高。5.2 DHCP死等从PHY到MAC再到LWIP的逐层排查DHCP获取不到IP是这类项目中最高频的问题。我总结了一个固定的排查链路看PHY link状态网口绿灯/黄灯是否亮读PHY寄存器1的bit2。如果link都没起来后面全白搭。网线、对端设备、PHY供电逐一检查。看SMI通信是否正常读PHY寄存器2和3LAN8720的ID应该读到0x0007和0xC0F1左右。读不到就查MDC/MDIO引脚和PHY地址。关掉DHCP改用静态IP测试。电脑上抓包工具看arp请求电脑发出ARP request后MCU有没有回ARP reply。如果MCU没回检查MAC地址是否设置正确、low_level_output是否正常工作。如果ARP正常但DHCP还是不行那就90%是忘了调dhcp_fine_tmr和dhcp_coarse_tmr或者初始化顺序错了。射频问题用肉眼看不见但网络问题好在能抓包。尽量让数据说话别瞎猜。5.3 跑一段时间死机优先查DMA内存UDP收发都正常跑个几分钟到几小时突然hardfault或什么都不动了。这种情况我遇到过三次三次最终原因各不相同但都集中在两类一是DMA描述符或缓冲区没对齐。F407的ETH DMA要求描述符4字节对齐没对齐的话长时间运行后触发一个特定角度的内存访问异常。二是放到了CCM RAM。前面说过DMA无法访问CCM RAM一旦描述符或缓冲区在CCM里数据搬运时会触发总线错误。排查方法很粗暴把所有以太网相关数组都加上aligned(4)并放在默认SRAM里再跑一遍。如果问题消失那就找到根了。另一类隐蔽原因是pbuf泄漏。回调里不pbuf_free、或者在某条异常分支里提前return忘了free、或者发送失败没有及时处理都会导致内存池逐渐变空。代码里加一个诊断任务每隔几秒读取mem_free_count或其他内存统计量输出到串口是防止这种问题的有效手段。5.4 UDP丢包与缓冲区底层还是上层问题电脑能Ping通说明底层MAC和PHY没问题。UDP丢包就要分开看如果是大量小包突发第一怀疑PBUF_POOL_SIZE。默认16个pbuf池如果应用层处理慢一点池子就被占满了新来的包直接被DMA丢弃。我一般调到32实测突发1200字节包、每秒1000个时稳定不丢。如果丢的是大包先检查是不是超过了MTU。以太网最大帧1500字节减去IP头20字节和UDP头8字节一个UDP包最多1472字节。发超过这个数会在IP层被分片而LWIP处理分片重组有额外开销还会更多占缓冲区。开发阶段统一发不超过1400字节的包最省心。还有一种可能就是上层应用在回调里耗时太久。比如回调里直接调用函数往SD卡写日志写一次卡几十毫秒这期间所有接收都堵死。解决方式是回调里只做数据拷贝和入队让独立任务慢慢处理。6. 性能摸底与后续优化方向6.1 回环测试和吞吐观察网络功能调通后先别急着上业务逻辑做个最基础的性能摸底。最简单的做法是电脑发什么MCU原样回什么同时统计每个包的时间戳和接收计数。我常用的方法是电脑端用网络调试助手或者自己写个小工具每个UDP包里带上序号MCU收到后原样回传电脑端统计连续收到的序号就能算出丢包率和平均往返时间。再用一个高精度计数器统计每秒收到的包数量就能粗略估算吞吐量。在默认配置、F407跑168MHz、编译优化级别为-O2的情况下UDP单向吞吐做到几Mbps是很正常的如果物理层是100Mbps、包又大实际应用关注的是丢包率而不是线速。6.2 能进一步提升的几个方向如果后面想继续深挖有这么几个方向接收路径从轮询中断改成中断里直接调netif-input减少一次任务切换的开销。优化DMA描述符数量和缓冲区大小减少内存拷贝次数。把FreeRTOS的任务栈放到CCM RAM把主SRAM让给LWIP的缓冲区。如果业务变复杂比如要加TCP、MQTT、HTTP建议直接升级到LWIP 2.x1.4.1在IPv6、性能和API便利性上已经明显落后。最后再分享一个我个人的调试习惯任何网络问题先把链路切成三段验证。第一段是PHY/MAC物理层看link状态和ARP请求响应确认底层打通第二段是LWIP协议栈静态IP配合Ping确认IP层和ARP正常第三段才是UDP或DHCP这类上层协议。每次只验证一段出了问题按段缩小范围基本都能在半小时内定位。这套方法我用了很多年远比在代码里加一堆printf瞎猜高效。本文还有配套的精品资源点击获取
返回列表