STM32H743裸机跑LwIP全流程:MPU配置与Cache一致性避坑指南
2026/9/24 13:21:49 网站建设 项目流程

做过H7系列以太网的老哥一定懂那种感觉:芯片性能拉满,外设看着啥都有,可真把以太网、DMA、Cache这几样叠在一起,翻车概率直接翻倍。我最近在一个项目里用STM32H743做设备联网,业务不复杂,一个百兆网口要跑起来,能拿到IP、能稳定收发数据就算完成任务。网上教程十个有九个是FreeRTOS+LwIP的组合,可我这项目里压根没有多任务要调度,纯裸机完全够用,多塞一个内核反而增加排查成本。于是我把FreeRTOS砍掉,直接用LwIP的no-os模式跑通了LAN8742和DHCP,中间还栽进了一个教科书级的MPU配置坑——就是那个让无数人抓耳挠腮的Cache一致性问题。这篇文章把整套流程和踩坑记录完整写出来,帮你少走几个月的弯路。

1. 方案选型分析:为什么裸机跑LwIP比FreeRTOS更省心

1.1 裸机 vs RTOS:砍掉FreeRTOS,项目反而更清楚

先说结论:LwIP本身确实是一个为多线程设计的协议栈,但它专门提供了一个NO_SYS(no operating system)模式。在这个模式下,协议栈跑在裸机主循环里,没有信号量、没有邮箱,也不需要任务调度。

很多人一听到LwIP就觉得必须配RTOS,其实这是个误区。协议栈内部的多线程被NO_SYS模式下的轮询机制替代了,数据收发变成“中断收包、主循环处理”的模型,对于大多数单网口设备来说,性能完全够用。

我砍掉FreeRTOS的理由很实在:

  • 项目里没有并发任务,唯一的重活就是网络协议栈,裸机轮询足够。
  • 少一个RTOS就少一套调试维度。裸机出现问题,打断点、串口日志、单步执行,定位路径非常直接。上了FreeRTOS之后,时不时还得怀疑是不是任务栈溢出、优先级翻转、临界区没关干净。
  • H743跑裸机省下的RAM和Flash资源虽然不多,但堆大小可以减少,配置简单。
  • DHCP客户端本身就靠定时器驱动状态机,裸机循环里sys_check_timeouts()就能推进,完全不需要额外线程。

如果项目里有多个实时性要求不同的任务,比如一边抓以太网包一边做音频采集,那FreeRTOS肯定更合适。但单纯做“拨号上网、收发报文”这种活儿,裸机方案反而更清爽。

1.2 LAN8742是什么,为什么要选它

LAN8742是NXP(原Microchip)推出的一款低功耗10/100M以太网PHY芯片,支持RMII和MII两种接口模式。ST官方不少评估板,比如NUCLEO-H743ZI2,板载的PHY就是LAN8742A。

选这颗PHY的原因很简单:资料多、例程多、寄存器操作简单。H7系列的HAL库里甚至自带了LAN8742的BSP驱动(在ST的Cube包里能看到lan8742.c),做基本初始化、link检测、自动协商配置都非常方便。

另外,LAN8742的功耗控制做得不错,适合做电池供电或对功耗有要求的物联网设备。它内部集成了电压调节和线路驱动,外围电路只需要一个25MHz晶振和少量电容电阻。

从实操角度说,这颗PHY和STM32的MAC层对接,走RMII接口时引脚占用少,总共只需7根信号线(TXD[1:0]、RXD[1:0]、TX_EN、CRS_DV、REF_CLK)。相比MII接口动辄十几根线,硬件设计友善太多。

1.3 硬件连接与最小系统盘点

以太网链路的最小硬件系统是:STM32H743的MAC控制器 + PHY芯片LAN8742 + 带网络变压器的RJ45座。

在NUCLEO-H743ZI2这类官方板卡上,PHY和MCU已经通过RMII方式直连,用户不需要改任何硬件。如果是自研板,则要重点核对三块:

  • RMII信号线:确保TXD0、TXD1、RXD0、RXD1、TX_EN、CRS_DV一一对应,且走线长度尽量接近。
  • REF_CLK:RMII模式需要50MHz参考时钟。常见两种接法:由PHY从25MHz晶振倍频后输出给MCU,或者由MCU的MCO2引脚输出50MHz给PHY。NUCLEO官方板是前一种,即PHY输出REF_CLK给MCU的ETH_RMII_REF_CLK引脚。
  • PHY地址:LAN8742A的PHY地址由PHYAD[2:0]引脚决定,NUCLEO板上通常配置为0x000,所以代码里heth.Init.PhyAddress = 0

额外提醒一句,自制板务必检查PHY的复位电路。有的板子把PHY的复位引脚直接接到MCU的复位线上,这样MCU复位时会同时复位PHY,比较省事;有的用GPIO控制,那就必须在初始化PHY之前给一个可靠的复位脉冲,否则后面读PHY寄存器各种灵异现象。

2. CubeMX一步步配置:LAN8742、DHCP和LwIP的完整设置

2.1 时钟与ETH外设配置

打开STM32CubeMX,选择STM32H743ZI芯片。

首先在Connectivity > ETH里,把Mode从Disabled改成RMII

这一步CubeMX会自动分配RMII用到的GPIO引脚。如果你用的是NUCLEO-H743ZI2官方板,引脚已经被默认锁死,不需要手动调整。如果是自研板,需要去Pinout & Configuration里逐个核对信号映射到你的原理图引脚。

ETH外设的时钟来源需要在Clock Configuration页里检查。H743的以太网MAC时钟一般来自PLL1的某个输出,CubeMX会根据你选的HSE晶振频率自动算出可用配置。重点确认两个值:一个是ETH的内核时钟,一个是RMII参考时钟的配置是否出现在正确选项里。

我踩过一个坑:用别的芯片工程模板改到H743上,结果ETH时钟源没选对,现象是PHY能初始化,但link状态一直不稳定,收发偶尔成功偶尔失败。后来回到CubeMX时钟页面,把ETH时钟源重新选好,问题就没了。所以时钟配置这一步,不要跳过。

2.2 LwIP中间件参数设置与DHCP开关

接着在Middleware > LWIP里:

  • 勾选Ethernet作为底层接口。
  • 注意不要选择带RTOS的选项,裸机用户选Bare Mode或类似标记的配置。
  • General Settings > IP Mode里选择DHCP

这里的IP Mode选择很关键。选DHCP后,CubeMX生成的代码里会自动定义LWIP_DHCP 1,并且在lwip.c的初始化代码中进入DHCP分支。如果你想先调通静态IP,也可以暂时选Static,填一个192.168.1.x的地址,测试通过后再改回DHCP。

内存参数方面,建议把MEM_SIZE从默认值适当调大一点。DHCP协议虽然报文量不大,但LwIP在解析处理过程中需要动态内存,内存太紧张会导致DHCP交互超时。我自己习惯把MEM_SIZE设为1024 * 30左右,PBUF_POOL_SIZE保持或适度增加,这个量级足够应对普通百兆网络环境下的数据吞吐。

生成代码后,打开Core > Inc > lwipopts.h,确认以下宏定义:

#define NO_SYS 1 #define LWIP_DHCP 1 #define LWIP_NETCONN 0 #define LWIP_SOCKET 0

NO_SYS为1说明当前是裸机模式,LWIP_DHCP为1表示启用DHCP客户端。如果NO_SYS不是1,说明你之前选错了LwIP运行模式,回去CubeMX里把RTOS相关选项都取消。

2.3 生成代码后的基础检查清单

代码生成之后,先不急着编译下载,花两分钟检查这几处:

  1. main.c里初始化顺序是否合理。CubeMX默认顺序是MPU_Config()CPU_CACHE_Enable()SystemClock_Config(),最后才是外设初始化。MPU配置必须在Cache使能之前完成,这个顺序不要乱改。
  2. 打开stm32h7xx_hal_msp.c,确认ETH的GPIO和中断引脚已经初始化。
  3. ethernetif.c里看一眼low_level_init(),里面会设置MAC地址,默认可能是00:00:00:00:00:00之类。建议改成你自己的合法MAC地址,避免局域网内冲突。
  4. 检查链接脚本(.ld.icf)里有没有以太网DMA描述符和缓冲区的section定义。后面第4节会详细聊这部分。

这四点如果都没有问题,再往下走。

3. 裸机代码实现:从接收到DHCP拿IP的完整链路

3.1 以太网初始化链路:HAL_ETH_Init到netif_add

裸机模式下,LwIP的初始化入口是MX_LWIP_Init()。这个函数看起来不起眼,但内部完成了几件大事:初始化MAC和PHY、创建netif接口、注册底层收发函数、设置默认网口,最后启动DHCP。

核心代码大致长这样:

void MX_LWIP_Init(void) { /* IP地址先清零,后面交给DHCP分配 */ ip_addr_t ipaddr = {0}; ip_addr_t netmask = {0}; ip_addr_t gw = {0}; /* 注册网卡并和底层驱动绑定 */ netif_add(&gnetif, &ipaddr, &netmask, &gw, NULL, &ethernetif_init, &ethernet_input); /* 设置默认网口 */ netif_set_default(&gnetif); /* 把网络接口状态置为up,此时底层PHY会被初始化并开始自动协商 */ netif_set_up(&gnetif); #ifdef USE_DHCP /* 启动DHCP客户端,进入Discover状态 */ dhcp_start(&gnetif); #else /* 如果不用DHCP,这里就是手动指定静态IP */ #endif }

这里的ethernetif_init是底层驱动,在ethernetif.c中实现。它内部会调用HAL库的HAL_ETH_Init(),初始化H743自带的MAC控制器,并启动DMA收发描述符。

HAL_ETH_Init()执行过程中会向PHY写入控制寄存器,触发RGMII/RMII自动协商。此时如果PHY没有复位好或者是坏片,返回的错误码会是HAL_ERRORHAL_TIMEOUT。所以建议在MX_LWIP_Init()之后加一句打印,读取PHY ID确认链路健康:

uint32_t phy_id = 0; HAL_ETH_ReadPHYRegister(&heth, 2, &phy_id); printf("PHY ID: 0x%04X\r\n", (unsigned int)phy_id);

LAN8742A的PHY ID寄存器地址是2和3,正常读出寄存器2应该得到0x0007这个厂商编号段。如果你读出来是0xFFFF或者0,说明PHY通信还没建立,先回头查复位和时钟。

3.2 主循环轮询机制:ethernetif_input与sys_check_timeouts

裸机LwIP没有协议栈线程,所以必须在主循环里主动喂协议栈。

CubeMX生成的MX_LWIP_Process()就是这个轮询入口,它在main()的while(1)里被反复调用:

uint32_t lwip_process(void) { ethernetif_input(&gnetif); sys_check_timeouts(); }

先解释ethernetif_input。H743的以太网DMA在收到报文后会发生接收中断,但裸机模式下我们不在中断服务函数里直接处理协议栈逻辑,而是在主循环里检查DMA描述符中是否有新数据,有的话就拷贝到LwIP的pbuf结构中,然后ethernet_input会把pbuf交给协议栈去解析。

实际上,HAL库的接收中断会设置标志位,ethernetif_input轮询时发现标志位就调用HAL_ETH_GetRxDataBuffer等接口拿数据。所以你的中断回调里不需要写复杂逻辑,只要确保HAL的ETH_IRQHandler被调用即可。

再解释sys_check_timeouts。这一行是裸机模式下LwIP的“软时钟”,负责驱动ARP老化、TCP重传、DHCP重发等定时任务。比如DHCP发出Discover之后,如果没收到Offer,就会靠着这个函数推进重传超时。没有这一行,DHCP会永远卡在某一步。

主循环的写法,我的习惯是:

while (1) { MX_LWIP_Process(); /* 业务逻辑到这里 */ ... }

这里有个细节:主循环里千万别做耗时太长的事。如果有某个函数执行了上百毫秒,网络接收缓冲会堆积,DHCP报文也可能会被协议栈处理不及时。实在有重活,建议拆成状态机分多次执行,或者把MX_LWIP_Process()的调用频率提高。

3.3 如何确认DHCP真的拿到了IP

DHCP从发出Discover到最终拿到IP,通常耗时1到3秒,如果网络环境有重传,可能会更长。很多新手第一次跑的时候,看到串口没输出IP就以为程序挂了,其实只是DHCP还没完成。

我习惯用两种方式确认DHCP状态:

第一种,在MX_LWIP_Init()后面加轮询循环,周期性检查dhcp_app之类的状态,或者直接看netif的IP地址是否非零:

ip4_addr_t *ip = (ip4_addr_t *)&gnetif.ip_addr; if (ip->addr != 0) { printf("DHCP OK, IP: %lu.%lu.%lu.%lu\r\n", (unsigned long)(ip->addr & 0xFF), (unsigned long)((ip->addr >> 8) & 0xFF), (unsigned long)((ip->addr >> 16) & 0xFF), (unsigned long)((ip->addr >> 24) & 0xFF)); }

第二种,在主机上ping设备的IP地址,能通就说明DHCP已经分配完毕。如果你在路由器管理后台能看到一个新设备接入,也说明DHCP成功了。

如果确认DHCP已经拿到IP,但主机ping不通,问题大概率出在第4节的MPU配置上,继续往下看。

4. MPU配置避坑指南:Cache一致性是H743以太网的头号杀手

4.1 典型故障现象:link起来了,但包就是收发不了

圈子里有个典型症状:LAN8742的link状态正常,自动协商成功,网线插上和拔下都有检测日志,但就是ping不通。要么收不到包,要么收到的包内容是乱码,MAC层计数看着也不对,忙活半天不知道问题在哪。

我最初也遇到这个问题,甚至怀疑是PHY配置不对、中断没配置好、甚至芯片本身有问题。折腾了两天之后,终于锁定罪魁祸首是MPU和Cache配置。

如果你在H743、H750这类Cortex-M7内核芯片上做以太网,遇到这种怪异现象,第一反应就应该是Cache一致性。

4.2 根因拆解:D-Cache与DMA的一致性矛盾

Cortex-M7内核带一级指令缓存(I-Cache)和数据缓存(D-Cache)。D-Cache是个高速缓冲,CPU往内存写数据时,真实数据可能只写入了Cache,物理内存里的旧值还没被更新;CPU读数据时,读到的也可能只是Cache里暂存的内容。

问题来了,以太网DMA控制器是直接读写物理内存的,它不经过Cache。于是两种情况发生:

  • CPU往发送缓冲区写好了数据,Cache还没回写到物理内存,DMA去搬运的时候读到的还是旧数据,发出去自然是一堆乱码。
  • DMA从网络收到数据,写入了物理内存,CPU去读的时候读的是Cache里的旧内容,等于没看到新数据。

这个矛盾在H743上特别明显,因为H743的SRAM布局比较复杂,支持Cache的区域不多,但CPU默认把整个RAM都当Cacheable访问。于是以太网的DMA描述符和报文缓冲区就悬在“CPU视角”和“DMA视角”不一致的尴尬位置。

有一件事必须先声明:TCM(比如0x20000000区域的DTCM)虽然直连CPU核,是确定不会被Cache污染的,但DMA控制器也访问不到TCM。所以如果你把以太网DMA缓冲区放到DTCM,那效果更惨——DMA直接罢工,连描述符都初始化不了。这么坑的配置我也试过,后来老老实实去看数据手册里的内存映射表。

4.3 解法一:用MPU把DMA缓冲区配置为Non-cacheable

要解决一致性问题,最彻底的方法是让CPU访问DMA缓冲区时跳过D-Cache,直接读写物理内存。这正是MPU(内存保护单元)的用途之一:把指定内存区域设置为非缓存属性。

CubeMX里配置MPU的路径是System Core > CORTEX_M7 > Cache & MPU。勾选Instruction cacheData cache,然后在MPU Region里新增一个Region,Base Address填以太网缓冲区所在的地址段。

CubeMX生成代码后,mpu.c里的核心代码大致如下:

void MPU_Config(void) { MPU_Region_InitTypeDef MPU_InitStruct = {0}; HAL_MPU_Disable(); /* 把D2 SRAM (0x30000000) 配置为Non-cacheable */ MPU_InitStruct.Enable = MPU_REGION_ENABLE; MPU_InitStruct.BaseAddress = 0x30000000; MPU_InitStruct.Size = MPU_REGION_SIZE_512KB; MPU_InitStruct.AccessPermission = MPU_REGION_FULL_ACCESS; MPU_InitStruct.IsBufferable = MPU_ACCESS_NOT_BUFFERABLE; MPU_InitStruct.IsCacheable = MPU_ACCESS_NOT_CACHEABLE; MPU_InitStruct.IsShareable = MPU_ACCESS_NOT_SHAREABLE; MPU_InitStruct.TypeExtField = MPU_TEX_LEVEL0; MPU_InitStruct.SubRegionDisable = 0x00; MPU_InitStruct.DisableExec = MPU_INSTRUCTION_ACCESS_ENABLE; HAL_MPU_ConfigRegion(&MPU_InitStruct); HAL_MPU_Enable(MPU_PRIVILEGED_DEFAULT); }

这里的关键在于MPU_ACCESS_NOT_CACHEABLE。注意MPU的Region默认情况下可能没有覆盖0x30000000,或者覆盖了但属性是Cacheable,一定要手动改。

为什么Base Address用0x30000000?因为NUCLEO-H743ZI2的默认链接脚本将以太网DMA描述符和缓冲区放置在D2 SRAM(0x30000000开始)。如果你把缓冲区挪到了AXI SRAM(0x24000000),MPU Base Address就改到0x24000000。

更稳妥的做法,是用两个MPU Region分别配置D2 SRAM的前256KB和剩余部分,但直接用一个512KB Region覆盖从0x30000000开始的整块地址区域,在绝大多数场景下也够用。虽然会超出实际物理RAM的范围,但MPU对不存在的内存区域不会造成副作用,唯一的代价是占用了MPU region编号。

配置好MPU之后,Cache会让开这块区域,CPU和DMA访问内存就已经“一致”了。重新编译下载,再ping一下,通了。

4.4 解法二:手动Clean/Invalidate Cache(备选方案)

如果你不方便改MPU,或者不想牺牲整块内存的Cache性能,还有另一条路:在每次DMA收发前后手动维护Cache一致性。

LwIP的网卡驱动在low_level_output()发送前,和low_level_input()接收后,都需要显式调用CMSIS提供的Cache操作函数:

/* 发送前,确保数据已经从Cache回写到物理内存 */ SCB_CleanDCache_by_Addr((uint32_t *)buffer, length); /* 接收后,让CPU重新从物理内存加载数据 */ SCB_InvalidateDCache_by_Addr((uint32_t *)buffer, length);

这里面有几个坑:

  • 传入的地址必须32字节对齐,长度也最好是32字节的整数倍。Cache line的大小在Cortex-M7上是32字节,如果你传了一个奇数地址,Clean/Invalidate操作可能会误伤相邻内存。
  • 发送缓冲区在写完数据之后、交给DMA之前必须Clean,接收缓冲区在处理完数据后要Invalidate,顺序错了照样出问题。
  • 如果使用RTOS,还需要考虑多任务并发下的Cache操作时序,裸机反而简单一些。

说实话,手动Clean/Invalidate这种方式适合缓冲区数据量不大、频率不高的场景。以太网这种高频收发场景,手动维护Cache既容易漏,又很难调试。比较下来,我还是推荐优先用MPU做成Non-cacheable,长痛不如短痛。

4.5 缓冲区放置与链接脚本的坑

还有一个经常被忽略的坑:以太网DMA描述符和缓冲区到底放在了哪块内存里。

H743的内存布局里,能被DMA访问的RAM分布很广,但每种RAM的Cache属性和访问路径都不一样:

区域地址范围特点以太网DMA缓冲区能否放
DTCM0x20000000-0x2001FFFFCPU直连,DMA访问不了不行
AXI SRAM0x24000000-0x2407FFFF走AXI总线,DMA可访问,默认Cacheable可以,但需MPU配置
D2 SRAM0x30000000-0x30047FFFDMA可访问,默认Cacheable可以,需MPU配置
D3 SRAM0x38000000-0x3800FFFFDMA可访问,默认Cacheable可以,需MPU配置

如果你用CubeMX默认配置,以太网描述符会被放到一个特殊的段里,连接脚本会把这个段安排到D2 SRAM。打开链接脚本,你会看到类似下面的定义:

.eth : { . = ALIGN(4); *(.RxDescripSection) *(.TxDescripSection) *(.RxArraySection) *(.TxArraySection) } > RAM_D2

这里就解释了为什么MPU Region的Base Address要用0x30000000。如果换了一块自研板,或者手动把网络缓冲区链接到了AXI SRAM,对应的MPU配置也得跟着改。所以最稳妥的办法,是先看一眼链接脚本,确认缓冲区在哪块RAM,再去配置MPU,而不是照抄例程。

5. 常见问题与调试技巧实录

5.1 PHY读不到ID、link不上的排查思路

遇到网口完全不工作的情况,先检查PHY有没有正常复位和上电。

第一步,读PHY的ID寄存器,确认SPI/MDIO总线能通。如果不能通,优先排查PHY地址是否正确。LAN8742A的地址由PHYAD[2:0]引脚决定,很多官方板设的是0,但也有板子设成1或者其他值。把heth.Init.PhyAddress从0到31都试一遍,配合串口打印,能快速定位。

第二步,测量PHY的25MHz晶振有没有起振。没晶振或者晶振幅度不够,PHY内部PLL无法工作,REF_CLK自然也没有,整个RMII链路废掉。

第三步,检查RMII的REF_CLK方向。如果设计是PHY输出REF_CLK给MCU,MCU那边的ETH_RMII_REF_CLK引脚必须配置为输入功能。如果搞反了,时钟会冲突,link始终起不来。

5.2 静态IP能通但DHCP永远超时的原因

如果你把IP静态配置成192.168.1.10能ping通,但切到DHCP后始终拿不到IP,原因通常集中在三个地方:

第一,DHCP客户端没有真正启动。检查LWIP_DHCP宏是不是1,以及初始化代码里是否确实调用了dhcp_start(&gnetif)。CubeMX如果只开了PHY的DHCP而没开LwIP的DHCP宏,代码里不会有dhcp_start调用。

第二,网络环境里没有DHCP服务器。很多调试时用的工业交换机或者直连电脑网口,默认不会分发IP,需要在电脑上开启Internet连接共享,或者用一个普通家用路由器做DHCP服务器。

第三,sys_check_timeouts()没有被调用,DHCP状态机没法重传Discover。看你的主循环while(1)里是不是漏调用了MX_LWIP_Process()

5.3 刷DHCP日志和寄存器定位问题

LwIP本身提供了调试输出能力,开启LWIP_DEBUGDHCP_DEBUG宏之后,串口会打印DHCP状态机的每一步变化:

#define LWIP_DEBUG 1 #define DHCP_DEBUG LWIP_DBG_ON #define ETHARP_DEBUG LWIP_DBG_ON

这样就能在串口上看到dhcp_discoverdhcp_recv这类日志,协助判断是Discover没发出去,还是Offer没回来,还是Request之后被拒绝。日志信息量足够定位大部分网络层问题了。

另外,用网络抓包工具(比如Wireshark)在PC端抓取DHCP四步交互过程,能直观看到报文的源MAC、目的MAC、Option字段。抓包结果是客观证据,比代码里debug半天更高效。

5.4 几个容易被忽略的细节汇总

最后再汇总几个我踩过的细节坑:

  • MAC地址不要全零。有些网络设备会把全零MAC的报文直接丢弃,DHCP都发不出去,表现和没配置网络一样。
  • 中断优先级别开太高。以太网中断优先级如果高于SysTick,并且频繁触发,可能会饿死延时函数,导致系统表现异常。
  • DMA描述符数量不够也会出问题。如果ETH_RX_DESC_CNT设得过小,在高流量下会频繁丢包。调试期建议保持默认或者适当增大。
  • 如果使用外部PHY且板上有LED指示link/activity,可以观察LED状态辅助判断。LAN8742的LED引脚可以用来显示link状态、活动状态,硬件上接好后非常直观。

这些细节单个看都是小问题,但堆在一起足以让人排查好几天。建议接到新板子时,把硬件连接、PHY地址、MAC地址、MPU配置这几项先过一遍,比盲目调试省时得多。

回头想想,H743以太网这块最终能跑通,核心就是理解“CPU和DMA是两个不同视角的读写者,Cache只是中间一个会骗人的缓存层”这件事。MPU配置其实不难,但不知道这个原理的时候,再简单的代码都能把自己困住。希望这篇记录能帮正在折腾H7以太网的朋友省下几个通宵。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询