做嵌入式网络通信这几年,STM32F407+LAN8720这个组合我前前后后折腾过不少次。说它“程序员友好”,是因为F407片内自带以太网MAC和DMA,代码层面可玩性很高;说它“容易卡壳”,是因为很多人第一步就败在50MHz的RMII参考时钟上,紧接着又会被CubeMX、LwIP、FreeRTOS三者之间的配置联动整到怀疑人生。这篇文章以CubeMX 6.4 + STM32CubeF4固件包为主线,把从硬件接线、时钟路由、PHY驱动、协议栈配置到Ping通全过程的坑点一次性说清楚。内容偏实战,适合做物联网网关、工业数据采集器、协议转换模块的开发者参考。
1. 为什么这个组合值得折腾:硬件架构和RMII时钟的三条路
1.1 硬件选型逻辑
先捋一下为什么大家都在用F407配LAN8720,而不是直接上F429或者外挂一个MAC芯片。STM32F407内部集成了完整的以太网MAC控制器,自带DMA和描述符管理,唯独缺一个物理层PHY芯片。LAN8720是当今市面上性价比非常高的一款百兆PHY,QFN封装修脚面积小,3.3V单电源供电,功耗也低,非常适合做带以太网口的小型嵌入式设备。
相比DP83848,LAN8720的优势在于寄存器体系和TI、ST官方例程的匹配度较好,而且目前市面上大多数开发板、核心板都预留了兼容接口,资料好找。相比YT8512H这类国产PHY,LAN8720的坑虽然也多,但别人踩过坑之后留下的解决方案更丰富,对新手更友好。
整体成本算下来,F407+LAN8720方案比外挂MAC芯片的方案便宜不少,而且MCU的算力也够承载LwIP协议栈加FreeRTOS调度。F407拥有1MB Flash和192KB SRAM,跑LwIP 2.1.2这种轻量协议栈完全不需要为内存发愁。
1.2 RMII接口信号与接线表
F407的以太网MAC对外支持MII和RMII两种接口模式。MII需要16根信号线,RMII只需要7根,节省的IO全部可以挪作他用。但RMII是有代价的:它把时钟频率从MII的25MHz提高到50MHz,数据线从4位收/4位发压缩成2位收/2位发,对信号质量的要求更高。
F407在RMII模式下,I/O引脚是固定的,这也是很多人画板子时容易抄错的地方。下面这张表是我验证过多次的引脚映射,全部复用在AF11功能上。
| 信号名 | MCU引脚 | 说明 |
|---|---|---|
| ETH_RMII_REF_CLK | PA1 | 50MHz参考时钟输入,必须由外部供给 |
| ETH_RMII_CRS_DV | PA7 | 载波侦听/数据有效 |
| ETH_RMII_RXD0 | PC4 | 接收数据位0 |
| ETH_RMII_RXD1 | PC5 | 接收数据位1 |
| ETH_RMII_TX_EN | PB11 | 发送使能 |
| ETH_RMII_TXD0 | PB12 | 发送数据位0 |
| ETH_RMII_TXD1 | PB13 | 发送数据位1 |
| ETH_MDC | PC1 | 管理接口时钟 |
| ETH_MDIO | PA2 | 管理接口数据 |
硬件布线时有一个容易被忽略的点:REF_CLK必须尽量短,不要和DCDC功率电感、晶振这些强干扰源并行走线。发送组的TXD0、TXD1、TX_EN尽量等长,接收组同理。虽然百兆以太网对等长要求不如千兆那么苛刻,但走线太随意会导致高温或长距离传输时出现偶发丢包。
1.3 50MHz参考时钟的三种来源
RMII模式下LAN8720和F407的MAC都必须工作在同一路50MHz参考时钟上,这是整个项目最容易出问题的地方。根据时钟来源,一共有三种做法。
方案A:F407的PA8(MCO1)直接输出50MHz。这种方式省掉了一颗有源晶振,但代价是时钟树会被一起绑架。要理解这一点,先看F407的PLL结构:HSE经过PLLM分频后进入VCO,VCO输出经过PLLP分频得到SYSCLK,经过PLLQ分频得到USB和以太网参考时钟。如果想用PLLQ得到50MHz,经典配置是HSE为8MHz时,PLLM=8、PLLN=400、PLLQ=8,此时VCO输出400MHz,PLLQ分频得到50MHz。但PLLP一旦确定为2,SYSCLK就是200MHz,超过了F407的168MHz上限,所以只能把PLLP调到4,让系统主频降到100MHz。也就是说,MCO方案通常以牺牲主频到100MHz为代价。
方案B:LAN8720外接25MHz无源晶振,由PHY内部PLL倍频后从REF_CLK引脚输出50MHz给F407。这是我最推荐的方式,因为完全不干扰MCU的时钟树,F407可以稳定跑168MHz。但前提是你的LAN8720模块把REF_CLK或CLKOUT引出来了,有些精简版模块没有引出这个引脚,就只能换方案。选购模块时留意一下丝印,能省不少麻烦。
方案C:外部50MHz有源晶振,直接给PHY和MAC同时供时钟。这种方式最稳,硬件排查时可以用来快速验证问题是否出在时钟上。缺点是增加BOM成本,量产时不到万不得已一般不用。
这里必须提一个经典误区:有人直接把F407的PLLQ分频到48MHz给LAN8720当REF_CLK用,因为48MHz和50MHz接近,看起来协议栈也能初始化。但RMII对时钟精度有明确要求,48MHz会导致数据错帧,现象是偶尔能Ping通、马上又超时,非常难排查。如果发现这种“时好时坏”的情况,先检查REF_CLK是不是标准的50MHz,用示波器或者频率计一量便知。
2. CubeMX 6.4从零配置:外设参数和PHY驱动的正确姿势
2.1 工程初始化选项
打开CubeMX 6.4,选择具体型号(我用的是STM32F407VGT6,其他F407型号操作一致)。进入RCC配置页,HSE选项务必选Crystal/Ceramic Resonator,否则以太网没有时钟源。SYS配置页把Debug选成Serial Wire,否则下载程序后一旦断开调试器,芯片会一直停在上电状态,这个问题虽然和以太网无关,但很容易让人误判。
在Connectivity列表中找到ETH,勾选RMII接口。如果这里选成MII,后续引脚分配会乱掉,生成的代码也不是RMII模式的。
注意看一下引脚分配图,确认PA1、PA2、PA7、PB11、PB12、PB13、PC1、PC4、PC5这些引脚都变成了绿色复用状态。如果某个引脚被其他外设占用,CubeMX会提示冲突,此时需要先处理资源共享。
2.2 ETH外设参数详解
在ETH配置页里,有几个参数不能稀里糊涂跳过去。
第一个是PHY Address。LAN8720A的默认PHY地址是0x00,因为它的PHYAD0和PHYAD1引脚内部下拉,模块上如果没做上拉,地址就是0。CubeMX里默认值也恰好是0,这个不用改。但如果你用的是其他PHY芯片,比如DP83848默认地址可能是0x01,必须按手册改。
第二个是Mac Address。CubeMX默认填的可能是00:00:00:00:00:00,这个地址在局域网里直接用会出问题。建议手动填一个以02开头的本地管理地址,比如02:00:11:22:33:44,这样量产多块板子时也不会冲突。
第三个是PHY Clock。如果你决定走MCO方案,在时钟配置页把MCO1时钟源选为PLLCLK,分频系数按上一节推导配置。要是走LAN8720自身晶振方案,这里就直接交给外部PHY提供,CubeMX侧不需要额外配置。
2.3 FreeRTOS和LwIP的联动配置
Middleware这一层是CubeMX集成的核心价值所在。打开FREERTOS选项,接口选CMSIS_V1即可,CMSIS_V2在F407上也跑得通,但很多现成例程基于V1,移植起来少踩一个版本差异的坑。LwIP选Enabled,此时CubeMX会自动把LwIP配置成“带操作系统”模式,底层会生成一个tcpip_thread和对应的信号量、邮箱,不需要你手动去移植。
LwIP参数页里有几个关键项直接影响能不能Ping通:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| MEM_SIZE | 20480 | 协议栈内存池总量,太小会导致分配失败 |
| PBUF_POOL_SIZE | 20 | 收发包缓冲区数量 |
| PBUF_POOL_BUF_SIZE | 1518 | 一个以太网帧的最大长度 |
| TCP_WND | 8192 | TCP接收窗口 |
| TCP_SND_BUF | 8192 | TCP发送缓冲 |
| TCPIP_THREAD_STACKSIZE | 2048 | tcpip_thread任务栈大小 |
| TCPIP_THREAD_PRIO | 4 | tcpip_thread优先级 |
| LWIP_DHCP | Disabled | 直连电脑建议关闭,用静态IP |
静态IP我用的是192.168.1.10,网关192.168.1.1,掩码255.255.255.0。如果你的局域网网段是192.168.0.x,要自己对应调整。
2.4 生成代码后必须手动处理的三件事
CubeMX生成的代码并不能直接拿去编译,至少有三件事需要手动检查。
第一件事是PHY ID校验。CubeMX 6.4的PHY库里没有LAN8720对应的驱动选项,很多人图省事会选LAN8742模板,因为两者的寄存器体系高度相似。但LAN8742驱动在初始化时会读取PHY的ID寄存器,和LAN8742的ID做比对,LAN8720的ID和它不一致,初始化会直接失败,表现就是网络起不来。解决办法是把ethernetif.c里low_level_init函数对PHY ID的校验注释掉,或者直接改成LAN8720对应的ID。更干净的做法是选择User PHY模板,自己用HAL_ETH_ReadPHYRegister读写基本寄存器。
第二件事是检查ETH中断是否使能。CubeMX生成NVIC配置时不一定把ETH_IRQn打开,如果中断没开,LwIP收包就完全依赖轮询,大部分默认配置下会表现为收不到数据、Ping不通。去CMSIS配置页确认ETH全局中断勾选,并且把优先级设成一个合理的值,我一般放在6到8之间,优先级太高会干扰FreeRTOS的临界区保护。
第三件事是确认lwipopts.h里的LWIP_DHCP宏。如果你在CubeMX图形界面关掉了DHCP,生成代码里也会同步关闭;但有时候因为文本模板版本不一致,生成出来的宏会残留DHCP开启状态,而设备端又没有一个DHCP服务器,导致板子一直无法获取IP。检查一下这个宏,同时确认netif_config里的静态IP、网关、掩码是预期的值。
3. Keil编译环境与代码生成的隐藏坑
3.1 ARM编译器版本和C99开关
CubeMX生成的工程拿到Keil MDK里,第一轮编译大概率会刷屏报错。最经典的一个坑是ARM编译器版本不对。MDK 5.36及以上版本默认使用AC6编译器,而STM32CubeF4固件包如果是较老版本,HAL库的某些内联汇编和编译器特性在AC6下会报奇怪错误。我自己的习惯是先把编译器切回AC5,虽然老,但兼容性最稳。如果你特别想用AC6,需要把固件包升级到和AC6兼容的新版本,然后清理掉老工程的History信息。
C99也必须打开。CubeMX生成的代码里大量使用for(int i = 0; ...)这种C99语法,Keil默认的C90模式下会直接编译失败。在Options for Target的C/C++页里勾选C99,或者直接在Misc Controls里加--c99。没有这个开关,你会在前20个error里就卡死。
3.2 MicroLIB和内存映射
MicroLIB是MDK里一个经常被忽略的选项,但对于LwIP这种依赖大量字符串处理和动态内存分配的协议栈来说至关重要。如果不勾选MicroLIB,printf和malloc相关的代码会链接到标准库的semihosting版本,程序运行到格式化输出时容易直接卡死。勾选MicroLIB的路径是Options for Target里的Target页,把Use MicroLIB勾上。
内存映射这块,F407的192KB SRAM对LwIP来说其实是够用的,但要避免一个经典错误:把大数组放进CCM RAM。F4系列有一块内核直连的CCM RAM,不能给DMA访问。ETH DMA描述符和收发缓冲区必须放在普通SRAM中,默认的链接脚本一般不会犯这个错,但如果你自己写了分散加载文件,要特别小心。
启动文件里的Stack_Size和Heap_Size也建议加大:Stack_Size给0x2000,Heap_Size给0x1000。硬盘不够和F407的SRAM总量比虽然看起来占用多了,但实际运行中能避免很多莫名其妙的HardFault。
3.3 常见编译报错列表及解法
| 报错信息 | 原因 | 解法 |
|---|---|---|
| identifier "ETH_DMARxDesc_t" is undefined | HAL库版本与CubeMX版本不匹配 | 重新生成工程或升级固件包 |
| L6218E: Undefined symbol ... | 某些源文件未加入工程 | 把LWIP/App、LWIP/Target下的.c全部加进工程 |
| unknown type name "bool" | 缺少stdbool头文件 | 在lwipopts.h里包含<stdbool.h> |
| #error "Unsupported compiler" | LwIP检测到不认识的编译器 | 检查是否用了AC5且定义正确 |
| HardFault after netconn_write | MicroLIB未勾选或栈不足 | 勾选MicroLIB,增大Stack_Size |
遇到编译链接错误时,我的排查顺序是:先确认所有LWIP源文件都被正确加入工程,再看编译器版本和C99开关,最后才去逐条看报错信息。多数时候前两个问题修完,后面的报错会一次性消失一大半。
4. Ping不通排查链路:从物理层到协议栈逐段定位
4.1 第一关:物理层与PHY状态
程序写完,板子插上网线,第一个动作不是去折腾代码,而是确认物理链路。先看LAN8720模块上的Link指示灯是否点亮。如果灯不亮,95%的可能是硬件问题,剩下的5%是PHY初始化没完成。
硬件检查顺序如下:
- 确认LAN8720的供电:VDD 3.3V、VDDCR 1.2V是否正常。特别是nINT/REGOFF引脚,这个脚如果被拉低,芯片内部1.2V稳压器会被关闭,芯片直接不工作。很多开发板模块上这个引脚默认拉高,但如果自己画板子,最容易漏的就是这个细节。
- 确认复位电路:NRST引脚有没有正确复位。
- 用示波器或频率计量PA1引脚的REF_CLK,必须是标准50MHz,频率偏差在正负50ppm以内。看到48MHz、25MHz还是根本没有波形,基本就能定位问题方向了。
- 量LAN8720的X1/X2晶振两端,如果是外接25MHz晶振方案,这里应该有25MHz起振波形。
硬件检查通过后,把调试器接上去,在main函数初始化完成后读一次PHY的寄存器。ST的HAL库提供了HAL_ETH_ReadPHYRegister,读取PHY_BSR(寄存器地址0x01)时,Bit2(Link Status)应该为1。再读PHY_IDR1和PHY_IDR2,正常情况下LAN8720的ID是0x0007和0xA120,能读出ID就说明MDIO/MDC管理接口工作正常,LAN8720已经被正确枚举。
4.2 第二关:MAC和中断配置
物理层正常、PHY寄存器也能读,但就是Ping不通,下一步检查MAC层和DMA路径。
在调试器里给HAL_ETH_IRQHandler或者etharp_input打断点,观察有没有数据包进到协议栈。如果没有中断触发,优先怀疑ETH中断优先级和FreeRTOS的互斥问题。FreeRTOS要求中断优先级数值不能小于configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY,也就是说不能在高于这个优先级的中断里调用带FromISR后缀的API。如果ETH中断优先级设置得太高,信号量发不出去,接收流程就会卡死。把ETH优先级放到6到8之间能避免这个问题。
还要检查DMA收发描述符是否被正确初始化。在ethernetif.c的low_level_init里,接收描述符链表如果没形成闭环,DMA根本不会收包。正常情况下生成代码不用改,但如果你手动改过描述符数量RX_DESC_CNT,要确认数组大小和初始化循环次数一致。
4.3 第三关:IP地址与电脑端设置
协议栈层面最常见的Ping不通原因,其实是电脑和板子不在同一个网段。板子静态IP是192.168.1.10,电脑网卡却是自动获取,恰好在192.168.0.x网段,那肯定不通。手动把电脑网卡IP改成192.168.1.100,掩码255.255.255.0,网关不填或填192.168.1.1,再ping一次。
Windows防火墙也经常参与搅局。关闭防火墙再测试,能通再考虑加白名单。
在电脑上执行arp -a命令,如果能看到板子的IP和MAC对应关系,说明ARP层面已经通了,此时再Ping不通,问题大概率出在ICMP或者更高层。用Wireshark抓包时,观察有没有从板子MAC地址发出来的ARP应答。这个信息对判断问题层级非常关键。
4.4 第四关:协议栈配置与调试宏
前三关都过了还不通,就要开协议栈调试信息了。CubeMX生成的LwIP工程中,lwipopts.h里默认关闭了大部分调试宏。把LWIP_DEBUG和LWIP_ICMP_DEBUG、LWIP_ARP_DEBUG打开,重新编译下载,串口会输出大量协议栈运行日志。
我曾经遇到过一个非常隐蔽的问题:MAC地址模块里配了一个全零地址,ARP请求发出去没有应答,Wireshark里能看到电脑疯狂发ARP,但板子毫无响应。后来查出来是CubeMX生成代码时MAC地址没有写进netif结构体,重新在MX_LWIP_Init里手动设置netif->hwaddr才解决。如果你改过MAC地址仍然无效,优先怀疑这个字段是不是真的被写入了。
最后检查一下MX_LWIP_Init函数是否在osKernelStart之前被调用。带FreeRTOS的工程必须保证LwIP协议栈先初始化,再启动调度器,顺序反了会导致tcpip_thread没有正常工作,现象同样是Ping不通。
5. FreeRTOS+LWIP的任务级调优与TCP回显实战
5.1 关键内存参数调整
Ping通只是第一步,真正向TCP收发迈进时,内存参数就要认真调了。LwIP的内存分配方式有两种:内存堆(memp)和内存池(memp)。收发包缓冲区走内存池,TCP连接控制块走内存堆。如果MEM_SIZE设得只有1600,一个TCP连接建起来后就再也没法创建第二个连接,表现为第一次连接正常,断开后重连失败。
推荐参数组合如下:
| 参数 | 初始值 | 调优后 |
|---|---|---|
| MEM_SIZE | 1600 | 20480 |
| PBUF_POOL_SIZE | 8 | 20 |
| PBUF_POOL_BUF_SIZE | 1518 | 1518 |
| TCP_MSS | 1500 | 1460 |
| TCP_WND | 4096 | 8192 |
| TCP_SND_BUF | 4096 | 8192 |
| MEMP_NUM_TCP_SEG | 8 | 24 |
调整参数时会发现LwIP对内存的需求和FreeRTOS的任务栈是竞争关系。F407的SRAM虽然大,但也不能无限堆。如果开TCP多连接,内存堆至少保证30KB左右;如果只是UDP裸收发,可以适当减小。
5.2 tcpip_thread优先级和ETH中断优先级
LwIP在带RTOS环境下,所有网络操作都集中在tcpip_thread里。这个线程的优先级不能設得太高,否则会抢占实时控制任务的CPU时间;也不能设得太低,否则网络吞吐量会明显下降。我在实际项目里一般放在一个中等偏上的位置,比如比普通数据处理任务高一级,但低于硬实时控制任务。
ETH中断方面,CubeMX里默认生成的优先级如果不合理,务必手动改。经验值是优先级数值6到8,这样既能保证网络中断及时响应,又不会在FreeRTOS临界区里造成不可控的嵌套。如果发现接收丢包严重,可以尝试关掉ETH中断改回轮询模式。轮询模式虽然效率低一些,但稳定性高,排错时也更容易定位问题。
5.3 TCP回显服务器Demo(socket API)
Ping通之后,下一步值得做的是一个TCP回显服务器,用标准socket API实现,代码很短,但能验证协议栈双向收发是否正常。
先创建一个独立的FreeRTOS任务:
void tcp_echo_server(void *argument) { int sock_fd = lwip_socket(AF_INET, SOCK_STREAM, 0); if (sock_fd < 0) { vTaskDelete(NULL); } struct sockaddr_in local; local.sin_family = AF_INET; local.sin_addr.s_addr = INADDR_ANY; local.sin_port = lwip_htons(8080); if (lwip_bind(sock_fd, (struct sockaddr *)&local, sizeof(local)) != 0) { lwip_close(sock_fd); vTaskDelete(NULL); } lwip_listen(sock_fd, 5); while (1) { int new_fd = lwip_accept(sock_fd, NULL, NULL); if (new_fd < 0) { vTaskDelay(10); continue; } char buf[1024]; int len = lwip_recv(new_fd, buf, sizeof(buf), 0); if (len > 0) { lwip_send(new_fd, buf, len, 0); } lwip_close(new_fd); } }然后在main函数里创建这个任务:
osThreadDef(TcpEchoServer, tcp_echo_server, osPriorityAboveNormal, 0, 1024); osThreadCreate(osThread(TcpEchoServer), NULL);电脑端用telnet或者nc工具测试。Windows上在命令行执行telnet 192.168.1.10 8080,连上后随便输入一行字符,板子原样发回来就说明整个链路完全打通了。Linux下用nc 192.168.1.10 8080也可以。
这个Demo虽然简单,却覆盖了socket创建、绑定、监听、接收、发送、关闭的完整流程,后续改成HTTP服务器、MQTT客户端都只是在这个骨架上扩展。
5.4 实测稳定性和性能心得
TCP回显跑通后,可以做一轮稳定性测试。电脑端持续ping 1000个包,正常情况丢包率为0,延迟稳定在1ms左右。再用脚本连续建立和断开TCP连接上百次,观察内存是否泄漏。LwIP如果参数配置合理,长时间运行不会出现内存耗尽。
实际项目里我做过的性能测试中,F407的MAC DMA在RMII百兆模式下可以跑到70Mbps以上的TCP吞吐量,但对任务调度和中断延迟很敏感。如果同时跑着大量高优先级任务,吞吐会明显下降,这时候就要在实时性和网络性能之间做权衡。对大多数物联网场景来说,LwIP处理远程配置、状态上报的流量绰绰有余。
最后分享一个我踩过的比较深的坑:一块板子用PB13做某个传感器片选,同时又配了RMII的TXD1,结果就是网络时通时断,因为PB13的复用功能优先级覆盖了普通GPIO功能。CubeMX在引脚分配时其实会提示冲突,但如果没仔细看确认表格,很容易忽略。遇到网络时序不稳定的问题,先把所有和RMII引脚有关的GPIO复用关系过一遍,往往能省下半天排查时间。