最近在调试STM32F407板卡的以太网功能,方案用的是STM32CubeMX生成工程、FreeRTOS做任务调度、LAN8720作为PHY芯片、协议栈走LwIP。整套跑通之后,UDP和TCP的收发都能稳定工作,过程中也踩了不少坑——PHY地址搞错、时钟配置不对、LwIP内存太小、Keil编译报错,每个问题都够让人折腾一阵。这篇文章把从CubeMX配置到实际收发的完整过程整理出来,把我验证过的接线、参数、代码和排查思路都写清楚,希望帮你少走弯路。
这个方案适合正在做设备联网、板卡数据采集上传、局域网通信等项目的开发者。无论你用的是正点原子、野火还是自己画的板子,只要主控是STM32F407,PHY是LAN8720,思路和配置都通用。哪怕你只是第一次接触LwIP和FreeRTOS,按照这里的步骤也能把一个能通UDP和TCP的工程跑起来。
1. 项目概述与硬件连接解析
1.1 方案选型:为什么是STM32F407 + LAN8720?
STM32F407内置了10/100M以太网MAC,但PHY芯片需要外挂。选LAN8720的原因很直接:它支持RMII接口,引脚占用少,整个通信链路只需要9个左右的IO,不像MII接口那样要二十几个引脚。另外LAN8720体积小、成本低,是各种F407工控板和评估板上非常常见的PHY方案。
LwIP则是一个轻量级TCP/IP协议栈,专门为嵌入式系统设计,内存占用可控,在RAM只有192KB的STM32F407上也能流畅运行。配合FreeRTOS做任务划分,网络接收、协议栈处理、业务逻辑可以分成不同优先级和不同任务,不会互相阻塞。
这个组合的典型应用场景包括:设备状态上报、远程参数配置、本地局域网内的传感器数据采集、与上位机或云网关通信等。我们这次的目标是实现UDP和TCP的双向收发,为后续业务扩展打基础。
1.2 硬件接线表:RMII模式引脚分配
STM32F407的以太网部分使用RMII接口与LAN8720相连。RMII只需要TXD[1:0]、RXD[1:0]、TX_EN、CRS_DV、REF_CLK这几个信号,相比MII大大简化了电路。具体引脚分配如下表所示。
| STM32F407引脚 | 复用功能 | 连接LAN8720模块引脚 | 说明 |
|---|---|---|---|
| PA1 | ETH_RMII_REF_CLK | REF_CLK | 50MHz参考时钟 |
| PA2 | ETH_MDIO | MDIO | 管理数据IO |
| PA7 | ETH_RMII_CRS_DV | CRS_DV | 载波监听/数据有效 |
| PB11 | ETH_RMII_TX_EN | TX_EN | 发送使能 |
| PB12 | ETH_RMII_TXD0 | TXD0 | 发送数据位0 |
| PB13 | ETH_RMII_TXD1 | TXD1 | 发送数据位1 |
| PC1 | ETH_MDC | MDC | 管理时钟 |
| PC4 | ETH_RMII_RXD0 | RXD0 | 接收数据位0 |
| PC5 | ETH_RMII_RXD1 | RXD1 | 接收数据位1 |
| 任意GPIO(如PC3) | 普通GPIO输出 | nRST | PHY硬件复位 |
需要特别提醒的是,PHY 复位引脚在CubeMX中不一定要配置,很多LAN8720模块把复位引脚直接拉到了高电平,上电即工作。但如果你想做更可靠的上电复位,建议用一个GPIO控制PHY的NRST引脚,复位时序这样处理:先拉低至少100us,再拉高,然后等待至少150us,之后再去访问PHY寄存器。这个细节在某些板卡上直接决定了PHY能不能正常启动。
另外要注意电平匹配。STM32F407的IO是3.3V,LAN8720也是3.3V器件,两者可以直接连接。模块上的VCC按厂家要求接3.3V即可,不要接到5V。
1.3 时钟方案与PHY地址确认
RMII接口的REF_CLK必须是50MHz。根据实际硬件设计,有两种常见做法:
第一种是LAN8720模块自带50MHz晶振,PHY自己产生50MHz时钟并通过REF_CLK引脚送给STM32F407。这种情况下,PA1是输入模式,CubeMX会自动生成相应配置,不需要额外处理。这也是我在项目中使用的方案,最省心。
第二种是由MCU提供50MHz给PHY。有些模块没有板载晶振,或者你希望用MCU的MCO输出50MHz。STM32CubeMX中可以把PA8配置为MCO1输出,但这里有个坑:MCO1的输出频率取决于系统时钟和PLL配置,不是随便就能得到50MHz。如果你的HSE是25MHz晶振,可以把PA8配置成MCO输出HSE,但输出的是25MHz,25MHz直接作为RMII参考时钟是不行;如果系统时钟168MHz,MCO1经过PLL分频得到42MHz,也不是50MHz。所以我强烈建议优先选择自带50MHz晶振的LAN8720模块,否则你需要单独设计一个50MHz时钟源,会额外增加成本和调试时间。
还有一个关键参数:PHY地址。LAN8720的PHY地址由PHYAD0引脚决定,拉低为0,拉高为1。市面上常见的模块有默认0也有默认1,具体要看原理图。在STM32CubeMX里配置的PhyAddress必须和硬件一致,否则HAL_ETH_Init初始化时会失败,后面LwIP也起不来。
如果你手头没有原理图,可以用一个小技巧验证:把PHY地址先设为0,如果初始化失败就改成1重试,或者在CubeMX生成后用示波器/逻辑分析仪抓MDIO引脚上的访问波形,看PHY是否回复。很多板子的LAN8720地址是0,但CubeMX里如果选择LAN8742模板,默认给的可能是0,具体情况以实际为准。
2. STM32CubeMX 工程配置全流程
2.1 基础工程与时钟树配置
打开STM32CubeMX,选择你手上的具体型号,比如STM32F407VET6或STM32F407ZGT6。在RCC中把HSE设为Crystal/Ceramic Resonator,这是外部高速晶振入口。SYS里面把Debug设为Serial Wire,否则调试器会连不上。
时钟树是第一个容易出错的地方。我用的板子外部晶振是8MHz,配置目标如下:
- SYSCLK = 168MHz
- AHB1/AHB2等总线时钟为168MHz
- APB1 = 42MHz
- APB2 = 84MHz
- PLLQ产生48MHz,给USB和SDIO用
在CubeMX的Clock Configuration界面里,把HSE设为8MHz,PLLM=8,PLLN=336,PLLP=2,PLLQ=7,这样SYSCLK就是168MHz。设置完后按回车,CubeMX会自动计算各总线频率。注意ETH并不是靠这些PLL输出跑RMII时钟的,RMII的REF_CLK来自外部PHY或MCO,这一点不要混淆。
2.2 ETH外设配置细节
在Connectivity中找到ETH,勾选Activate。接口选择RMII,这是和LAN8720协同工作的前提。
关键配置项如下:
Phy Address:根据1.3节确认,常见为0或1Mac Address:随便填一个合法的单播MAC,注意不要全零,比如00:80:E1:00:00:01,很多驱动用这个作为默认MACPHY用作的驱动:CubeMX中可选择LAN8742、DP83848或Custom。LAN8720和LAN8742的寄存器访问逻辑基本一致,但地址不同,建议选择LAN8742模板后手动修改PHY地址,或者选择Custom直接自己指定
ETH配置完成后,CubeMX会自动把PA1、PA2、PA7、PB11、PB12、PB13、PC1、PC4、PC5设置为复用功能。这里不要手动去改动这些引脚模式,否则可能因为复用配置错误导致RMII不可用。
2.3 LwIP协议栈配置参数
在Middleware and Software Packs中选择LWIP,勾选启用。参数按下面的思路设置。
首先是协议版本,选IPv4即可。IP地址建议在调试阶段使用静态IP,例如192.168.1.10,子网掩码255.255.255.0,网关192.168.1.1。等静态IP能ping通了,再去开启DHCP也不迟。
然后看几个关键内存参数:
MEM_SIZE:LwIP堆大小,建议不低于1600,如果UDP和TCP并发量比较大,可以调到8192甚至更高。内存太小时,申请pbuf会失败,现象就是UDP发不出去或者TCP连接不稳定。PBUF_POOL_SIZE:pbuf池数量,默认16,接收大数据量时可以调大到32或64。PBUF_POOL_BUFSIZE:单个池pbuf大小,默认1500可满足标准以太网MTU,不需要改。
协议支持里,确保TCP和UDP都是启用状态。如果你要用socket或者netconn API,还需要确认LWIP_NETCONN和LWIP_SOCKET已经打开。
FreeRTOS集成时,NO_SYS这个选项很关键。CubeMX生成的标准LwIP代码在内部默认是NO_SYS=0,也就是有操作系统环境。如果你希望直接使用LwIP的raw API和回调机制,同时配合FreeRTOS任务调度,其实保持NO_SYS=0并使用互斥锁保护也可以;如果完全只用raw API,也可以把NO_SYS设为1手动绕过系统调用。为了简单起见,我这次以LwIP的raw API回调为主,保持CubeMX默认的NO_SYS=0,并发访问注意加锁即可。
2.4 FreeRTOS任务与线程安全
FreeRTOS的配置可以直接在CubeMX的Middleware -> FREERTOS中进行。我会创建三个核心任务:
- 以太网接口处理任务
- UDP收发任务
- TCP收发任务
任务参数大致如下:
- 以太网接口任务:优先级较高,运行周期短,栈大小512字(2048字节)
- UDP任务:正常优先级,栈大小1024字(4096字节),因为UDP回调或业务处理需要一定栈空间
- TCP任务:同样1024字
这里需要解释一下为什么以太网接口任务优先级要最高。LwIP裸机下其实是在主循环中通过MX_LWIP_Process()完成协议栈处理和数据包分发,FreeRTOS环境下,如果这个任务得不到及时调度,接收缓冲区就可能堆积,导致上层应用感知到延迟。所以建议把该任务的优先级设为High或者osPriorityHigh。
线程安全方面,如果在多个FreeRTOS任务中都调用LwIP API,最好统一封装一个互斥锁。CubeMX生成的LwIP代码本身提供了sys_mutex相关接口,但在NO_SYS=0模式下,某些raw API接口并不是完全线程安全的。实际项目中我采用了一个简单的做法:以太网接口任务只负责调用MX_LWIP_Process(),所有业务任务通过队列把数据交给一个独立任务处理,避免多个任务同时调用LwIP核心函数。
3. 代码实现:UDP/TCP 数据收发实战
3.1 网络任务框架搭建
CubeMX生成工程后,在main.c里已经调用了MX_LWIP_Init()完成协议栈和网卡初始化。接下来要做的是创建FreeRTOS任务。
下面是用CMSIS-RTOS v2接口创建任务的简化示例:
#include "cmsis_os.h" #include "lwip.h" #include "lwip/ip_addr.h" #include "lwip/udp.h" #include "lwip/tcp.h" #include "lwip/netif.h" extern struct netif gnetif; void ethernet_if_task(void *argument) { for (;;) { MX_LWIP_Process(); osDelay(1); } }这里直接用MX_LWIP_Process()处理LwIP相关的收包、定时器、链路状态检查。延迟1ms是为了防止该任务占满CPU。
在MX_FREERTOS_Init()中创建这些任务:
osThreadId_t ethTaskHandle, udpTaskHandle, tcpTaskHandle; const osThreadAttr_t ethTask_attr = { .name = "ethTask", .stack_size = 512 * 4, .priority = (osPriority_t)osPriorityHigh, }; ethTaskHandle = osThreadNew(ethernet_if_task, NULL, ðTask_attr);有了基础任务框架后,再往下就是UDP和TCP的具体功能。
3.2 UDP服务端与发送端实现
LwIP的UDP接口有很多种,我选择用raw API在回调函数中处理收包。回调方式的好处是代码结构清晰,只要pbuf数据到达,协议栈就会调用注册的接收函数,非常适合在FreeRTOS环境下做事件驱动处理。
先定义UDP端口,比如9000:
#define UDP_SERVER_PORT 9000 static struct udp_pcb *udp_server_pcb; static void udp_server_recv(void *arg, struct udp_pcb *upcb, struct pbuf *p, const ip_addr_t *addr, u16_t port) { if (p != NULL) { /* 这里可以处理收到的数据,比如回显或者转给业务任务 */ udp_sendto(upcb, p, addr, port); pbuf_free(p); } } void udp_server_init(void) { udp_server_pcb = udp_new(); if (udp_server_pcb != NULL) { udp_bind(udp_server_pcb, IP_ADDR_ANY, UDP_SERVER_PORT); udp_recv(udp_server_pcb, udp_server_recv, NULL); } }这段代码的作用是创建一个UDP服务端,绑定9000端口,收到数据后原样回复给发送方,相当于一个最简单的UDP回声服务器。这个功能非常实用,调试时可以用网络调试助手测试链路是否通。
如果要在业务任务中主动发送UDP数据,可以在另一个FreeRTOS任务中调用下面的函数:
void udp_send_data(const char *ip, u16_t port, const uint8_t *data, uint16_t len) { struct udp_pcb *pcb = udp_new(); if (pcb == NULL) { return; } ip_addr_t remote_addr; ipaddr_aton(ip, &remote_addr); struct pbuf *p = pbuf_alloc(PBUF_TRANSPORT, len, PBUF_RAM); if (p != NULL) { memcpy(p->payload, data, len); udp_sendto(pcb, p, &remote_addr, port); pbuf_free(p); } udp_remove(pcb); }注意,如果发送频率较高,不要频繁创建和销毁pcb,建议把pcb定义成静态变量,初始化时创建一次,发送时直接用udp_sendto即可。
3.3 TCP服务端实现与简单交互
TCP比UDP复杂一些,因为有连接管理和状态回调。我把TCP服务端的常用结构整理成代码,它是基于raw API的回调模式,逻辑是这样的:
- 创建TCP控制块
tcp_pcb - 绑定监听端口
- 进入监听状态
- 有新客户端连接时,触发
tcp_server_accept回调 - 在连接回调中注册接收回调,之后每次收到数据都会触发
tcp_server_recv
下面是完整示例:
#define TCP_SERVER_PORT 8080 static struct tcp_pcb *tcp_server_pcb; static err_t tcp_server_recv(void *arg, struct tcp_pcb *tpcb, struct pbuf *p, err_t err) { if (p != NULL) { /* 回显收到的数据给客户端 */ tcp_write(tpcb, p->payload, p->len, 1); tcp_output(tpcb); tcp_recved(tpcb, p->len); pbuf_free(p); } else if (err == ERR_OK) { /* 对端关闭连接 */ tcp_close(tpcb); } return ERR_OK; } static err_t tcp_server_accept(void *arg, struct tcp_pcb *newpcb, err_t err) { tcp_recv(newpcb, tcp_server_recv); return ERR_OK; } void tcp_server_init(void) { tcp_server_pcb = tcp_new(); if (tcp_server_pcb != NULL) { tcp_bind(tcp_server_pcb, IP_ADDR_ANY, TCP_SERVER_PORT); tcp_server_pcb = tcp_listen(tcp_server_pcb); tcp_accept(tcp_server_pcb, tcp_server_accept); } }如果要做TCP客户端主动连接远端服务器,思路类似,只是把tcp_connect的流程加上。核心回调包括tcp_connected、tcp_recv、tcp_sent。开发时注意区分回调执行的上下文:这些回调都是在LwIP协议栈上下文中执行的,不要在回调里做耗时操作,比如阻塞读串口或大延时。耗时业务应该通过队列或信号量交给其他FreeRTOS任务处理。
3.4 编译下载与上位机调试
代码写完编译下载后,先用网线把STM32F407板卡和电脑直连,或者接到同一个局域网交换机。确保电脑的网卡IP设置为和板卡同一网段,比如板卡是192.168.1.10,电脑就设置192.168.1.20,掩码255.255.255.0。
在电脑上打开命令提示符,先ping一下:
ping 192.168.1.10如果ping通,说明LwIP和PHY已经正常工作。接下来用网络调试助手分别测试UDP和TCP。
UDP测试方法:
- 调试助手选择UDP协议
- 本地IP为电脑IP,端口任意
- 远端IP填192.168.1.10,端口填9000
- 点击连接,发送任意字符串,板卡会原样返回
TCP测试方法:
- 调试助手选择TCP Client
- 远端IP填192.168.1.10,端口填8080
- 点击连接成功后,发送数据,板卡会原样返回
这里有一个小经验:第一次测试建议关闭电脑防火墙,或者至少把网络调试助手加入防火墙白名单。很多新手遇到UDP能通、TCP死活连不上的情况,多半是防火墙拦了TCP连接。
4. 调试经验与问题排查
4.1 链接不上 / 获取不到IP的排查思路
这是我遇到最多的问题。整套系统跑不起来,首先不要急着改代码,先顺着链路从硬到软排查。
第一步,测PHY供电。LAN8720需要3.3V电源,部分模块还有1.2V内核电压由板载LDO产生。用万用表量一下模块上的3.3V是否正常。
第二步,量REF_CLK。用示波器测量LAN8720输出给PA1的50MHz时钟,或者PA1引脚上的波形。如果这里没有50MHz,那么LwIP永远起不来,RMII帧头和时钟对齐都是问题。
第三步,检查PHY复位。确认复位引脚电平正常。如果复位引脚悬空,PHY内部状态可能不对。
第四步,确认PHY地址。在CubeMX里把PhyAddress改为0或1试一下。很多情况下初始化报错HAL_ETH_Init: PHY not ready,就是因为地址不对。
第五步,检查网络助手/电脑端IP配置。板卡是静态IP时,电脑不要开DHCP自动获取,老老实实手动设置同网段IP,减少不必要的干扰。
第六步,看串口日志。CubeMX生成的HAL库如果开启printf重定向,可以在LwIP初始化前后打印关键状态。特别是HAL_ETH_Init的返回值是否为HAL_OK。
我在实际调试中还遇到过一种情况:PHY的链接状态检测需要时间。LwIP的任务如果只执行一次MX_LWIP_Process(),可能还没来得及检测到网线插入,链路就是down的。解决方法是保证以太网接口任务持续运行,不要阻塞。
4.2 LwIP内存参数调整
LwIP的消息吞吐性能和内存池配置强相关。常见现象是数据量一大,UDP偶发丢包,TCP连接报内存不足。
优先看这几个参数:
| 参数 | 默认值 | 调优建议 |
|---|---|---|
| MEM_SIZE | 1600 | 调大到8192或更大 |
| PBUF_POOL_SIZE | 16 | 调大到32或64 |
| TCP_SND_BUF | 默认 | 保持或调大两倍 |
| TCP_WND | 默认 | 保持或调大 |
修改完这些参数后,要重新编译,确保LwIP的lwipopts.h中宏定义与CubeMX图形界面同步。CubeMX有个习惯,你虽然在界面上改了参数,但有时不会立即更新到工程头文件,需要重新生成代码。
另外FreeRTOS的任务栈大小对LwIP也有影响。如果把UDP/TCP的业务处理放在任务栈里,栈太小会导致栈溢出,系统运行一段时间后随机死机。CubeMX中可以先给1024字,如果发现不稳定,再加大到1536或2048字。
4.3 Keil编译报错Q0147E的处理
编译时如果看到类似这样的报错:
.\obj\freertos.hex: error: Q0147E: failed to create directory .\obj\freertos这个错误和代码本身无关,是Keil工程的输出目录配置问题。工程里设置了输出HEX文件到.\obj\目录,但工程路径下不存在obj文件夹,或者Keil没有权限在那里自动创建文件夹。
解决方法很简单:在工程目录下手动新建一个obj文件夹,同时确认Options for Target -> Output中的Select Folder for Objects指向这个目录,Create HEX File选项可以勾选,但前提是目录存在。还有一个更省事的办法:把输出目录改到.\Objects\或者当前目录,避免目录嵌套带来的问题。
4.4 个人实操心得与性能建议
这套方案跑通之后,总体性能表现是够用的。单路UDP收发、单路TCP长连接,在100Mbps以太网环境下做常规业务完全没压力。但如果要做大流量吞吐,有几个地方需要注意。
第一,RMII接口本身只支持100Mbps,理论吞吐大概在8~10MB/s级别。对于大多数传感器上传、命令下发场景,完全够用。
第二,LwIP的接收处理不要放在中断里。以太网中断收到数据后,只是做标记和唤醒,真正的协议栈解析在以太网接口任务中执行。CubeMX生成的中断服务函数里已经有HAL_ETH_IRQHandler,它会调用类似ethernetif_input的入口,不要刻意为接收流程添加额外耗时逻辑。
第三,要做UDP广播或组播时,注意检查LwIP的广播标志是否打开。IP_FORWARD、IP_MULTICAST这些宏如果没有使能,广播包可能发不出去。
第四,PCB布线时LAN8720和STM32F407的RMII信号要尽量短,等长走线,避免信号反射。这个影响更多在高速传输时显现,100Mbps下,质量一般的杜邦线也能工作,但长时间稳定性会打折扣。
第五,量产前建议把PHY地址、MAC地址做成可以从配置文件或UID读取的结构,不要写死在代码里。这样可以避免多个板卡在同一网络中因MAC冲突导致的问题。
最后再分享一个小技巧
调试过程中我最常用的是一个钩子函数,每秒从板卡主动向电脑发送一帧UDP状态数据,里面包含系统运行时间、自由堆栈大小、PHY链接状态和IP地址。这样每次程序出问题,我先看这帧数据,基本能判断是网络层问题还是业务层问题。LwIP调试时把信息可视化,比瞎猜高效得多。
这个方案目前已经稳定运行了一段时间。个人感觉整个链路中,最容易出问题的不是软件配置,而是时钟和PHY地址这类“硬参数”。如果你也正在调,建议拿着原理图把PHY地址、复位脚、REF_CLK来源逐项核对一遍,八成的问题都能提前规避。