简介:一套面向嵌入式网络开发者的 STM32F407+FreeRTOS+LWIP 移植工程,以正点原子探索者开发板为硬件平台,基于标准库与 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_CLK | PA1 | 输入 | 50MHz参考时钟,由PHY或外部晶振提供 |
| ETH_RMII_CRS_DV | PA7 | 输入 | 载波侦听/数据有效,RX线上有数据时为高 |
| ETH_RMII_RXD0 | PC4 | 输入 | 接收数据位0 |
| ETH_RMII_RXD1 | PC5 | 输入 | 接收数据位1 |
| ETH_RMII_TX_EN | PB11 | 输出 | 发送使能,TX有效期间拉高 |
| ETH_RMII_TXD0 | PB12 | 输出 | 发送数据位0 |
| ETH_RMII_TXD1 | PB13 | 输出 | 发送数据位1 |
| ETH_MDC | PC1 | 输出 | 管理接口时钟,用于读写PHY寄存器 |
| ETH_MDIO | PA2 | 双向 | 管理接口数据线 |
可能还要加上NRST复位引脚和中断引脚,不同板子接法不一样。我强烈建议你画板或接线前,先对着自己开发板的原理图查一遍这几个脚位,因为PB11、PB12、PB13这几个脚也可能被其他外设复用,比如DCMI、SPI、I2C。我自己就遇到过一次,板子上PB12还接了别的功能,导致TX数据线被拉死,百思不得其解。
2.2 50MHz时钟的两个来源,以及为什么不能直接用MCO
RMII接口有个鲜明的特点:它要求一个50MHz的参考时钟,而且是供整个收发逻辑使用的。这个时钟从哪里来,是整个工程最容易被忽略、又最容易致命的问题。
F407系统时钟最高168MHz,MCO输出引脚(PA8)虽然能输出时钟,但168MHz分频不出50MHz整倍数,所以MCO直接出50MHz这条路在F407上行不通。我看到不少朋友想省一个晶振,最后卡在时钟上。
实际可行的方案有两个:
方案A:LAN8720外接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的寄存器1(BMSR)的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->hwaddr(MAC地址)。注意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_SYS=0,意味着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_trypost,1.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定时器):优先级2
- UDP应用任务:优先级1
- 空闲任务:默认
这里有个反常识的点:ETH中断的优先级不要设成0。因为LWIP 1.4.1的接收路径有时会在中断里处理pbuf分配和数据拷贝,如果中断优先级太高,会延迟其他关键中断。设成中等优先级就够了。
另外,不要让应用任务的优先级高过tcpip_thread。因为应用任务里如果调用了udp_sendto,最终还是要等tcpip_thread处理ARP和IP分片,优先级倒挂会导致高优先级任务死等低优先级任务执行。
5. 结合实测:高频故障的定位过程与解决办法
5.1 MDK5编译器:AC5还是AC6
LWIP 1.4.1是2012年左右的代码,C89风格浓重,在MDK5的AC6编译器下会有大量警告,有些甚至直接编译失败。AC6在语法检查上更严格,对隐式类型转换和struct alignment的容忍度低。
我的建议是:如果是这个组合,直接把工程设置为AC5(ARM 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和3,LAN8720的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.x,1.4.1在IPv6、性能和API便利性上已经明显落后。
最后再分享一个我个人的调试习惯:任何网络问题,先把链路切成三段验证。第一段是PHY/MAC物理层,看link状态和ARP请求响应,确认底层打通;第二段是LWIP协议栈,静态IP配合Ping,确认IP层和ARP正常;第三段才是UDP或DHCP这类上层协议。每次只验证一段,出了问题按段缩小范围,基本都能在半小时内定位。这套方法我用了很多年,远比在代码里加一堆printf瞎猜高效。
本文还有配套的精品资源,点击获取