把LWIP协议栈塞进32KB内存,还要在STM32H7上跑千兆以太网,这事儿一开始听着像硬凑,但我手头这个项目确实是被逼到了这一步。H7的RAM本来很宽裕,可AXI SRAM被图形界面和采集算法占走了大半,最后留给网络协议栈的,只有一块32KB的SRAM4。我花了两天晚上把常规LWIP工程从七八十KB内存一路压到32KB以内,跑通了基于RGMII的千兆链路,TCP连接能稳稳挂一整天不掉线。这篇就把完整的优化思路和参数配置抄出来,适合正在抠RAM、被LWIP内存折腾的人参考。
1. 项目背景与整体设计思路
1.1 为什么是32KB:一个真实的内存困局
先说清楚,这不是H7没内存,而是系统里各模块都在抢。H743有一块AXI SRAM(0x24000000,512KB),带宽高,适合大块连续数据,图形层和高速采集理所当然放在这里;SRAM1/2/3总共约256KB,被RTOS任务栈、DSP算法库占掉大半;最后能专门划给以太网的,是我挑的SRAM4区域。SRAM4在0x38000000,一共64KB,其中一半还要给别的功能用,以太网极限预算就是32KB。初听很离谱,但嵌入式项目里这种“你只能分到这么多”的约束太常见了,越早接受,越早开始想办法。
那32KB到底能不能跑千兆?实话实说,跑千兆线速(125MB/s级别)需要很大的缓冲窗口,32KB铁定不够。但我把目标定在“稳定千兆以太网通信”:链路协商到1000M Full,TCP连接长期稳定,1KB以内的小包不丢,突发大流量不把系统搞死。这个目标下,32KB是可行的。很多工业设备说白了就是心跳、参数配置、命令上报,不需要拿iperf跑满带宽。
1.2 方案选型:RAW API比Netconn省在哪
LWIP常见三种接口:RAW、Netconn、Socket。Netconn和Socket是线程安全封装,开发爽,但每个连接都要配套队列、信号量、netbuf,额外占用少则几百字节多则1KB以上,在32KB的预算里扛不住。RAW API写起来繁琐,要自己管理各种回调,可它没有那层封装,tcp_pcb + pbuf就是绝大部分开销,内存可控得多。
所以我的方案很明确:采用RAW API + NoSys模式,在lwipopts.h里直接关掉LWIP_SOCKET和LWIP_NETCONN。如果项目里还有RTOS,也不冲突,把网络接收处理放在一个独立线程里,用信号量唤醒ethernetif_input,数据路径完全绕开Socket层。CubeMX生成LWIP代码时,如果选了NoSys,很多OS封装也不会编译进来,内存省了一大截。
2. 内存消耗全解:优化前先算清楚账
2.1 32KB都去哪了:一份完整的内存支出清单
不夸张地说,CubeMX默认生成一个带LWIP的工程,内存占用轻松超过80KB。其中几大块是:
| 内存项目 | 默认/常见值 | 占用估算 |
|---|---|---|
| ETH DMA描述符 | 4 RX + 4 TX,每个一二十字节 | 约0.2KB |
| ETH RX DMA缓冲 | 4 × 2048B | 8KB |
| ETH TX DMA缓冲 | 4 × 2048B | 8KB |
| LWIP堆 MEM_SIZE | 默认可到64KB | 实际分配数十KB |
| PBUF池 | 16 × 1568B | 约25KB |
| TCP/UDP PCB池等 | 默认数值偏大 | 若干KB |
| Netconn/Socket层(若开) | 每连接数百字节 | 若干KB |
要把这些压进32KB,等于砍掉一半以上,甚至砍到三分之一。我建议把所有开销分成两条线:第一,DMA描述符和收发缓冲区是硬件刚性开销,只能压缩数量和单缓冲大小;第二,协议栈内部的堆、内存池、并发连接数是软开销,按实际业务逐项裁剪。先算清楚账,再动手改,不然就是盲调。
2.2 千兆链路对缓冲的特殊要求
千兆PHY通过RGMII接口和MCU的MAC直连,链路速率由PHY决定,但“本站能不能连续接收突发帧”由DMA缓冲和PBUF池共同决定。我建议接收路径优先保证:RX DMA缓冲数量保持4个,单缓冲长度取1536字节,正好覆盖标准以太网帧1518字节还带一点点余量,这样RX路径合计约6KB。发送端尽量利用LWIP的PBUF池参与DMA发送,不保留整块大TX缓冲,或者把TX缓冲数量压到2个,又能省出3KB左右。
注意,1536字节的RX缓冲意味着带VLAN Tag的1522字节帧或巨型帧无法完整接收。如果你的业务里明确有这类帧,这项优化就不成立。我在项目里确认过所有下行帧都是标准1500字节以内的IP包,才敢这么砍。优化之前先明确业务帧的最大长度,这是不能跳过的步骤。
3. CubeMX配置与LWIP裁剪:把不用的功能全扔掉
3.1 ETH外设与PHY的CubeMX配置
我用STM32CubeMX 6.x生成基础工程。芯片选STM32H743,ETH接口这里要特别提醒:开发板常见的RMII接口只能跑到100Mbps,千兆必须走RGMII,并搭配一颗千兆PHY。我手里这块板用的PHY是RTL8211F,PHY地址被硬件拉到0,所以在CubeMX的ETH配置里PHY Address填0,同时勾选ETH的DMA中断。
时钟配置同样关键。H7的MAC时钟来自RCC里的ETH相关时钟,RGMII模式下PHY的125MHz参考时钟必须有稳定来源,要么外部晶振芯片,要么由MCU的MCO输出。这个时钟不对,表现极其诡异:PHY状态寄存器能读通但千兆链路就是协商不上。生成代码后,HAL_ETH_Init里会读PHY寄存器,PHY地址写错的话连ID都读不到。
CubeMX生成的代码默认4个RX描述符、4个TX描述符,RX缓冲长度由stm32h7xx_hal_eth.h里的ETH_RX_BUFFER_SIZE控制。这些值可以直接改,但更重要的一步是把它们对应的数组放到正确的内存段,这是第4节的重头戏。
3.2 lwipopts.h逐项裁剪(核心)
CubeMX的LWIP配置界面能设一部分参数,但真正细致的裁剪还得直接改lwipopts.h。我在32KB预算下最终落地的一套关键配置如下:
| 参数 | 32KB优化值 | 说明 |
|---|---|---|
| MEM_SIZE | 8192 | 协议栈动态堆,别超过12KB |
| MEMP_NUM_PBUF | 8 | 报文等待队列 |
| MEMP_NUM_TCP_PCB | 4 | 并发TCP连接,业务主要用1条 |
| MEMP_NUM_TCP_SEG | 4 | TCP分段队列,内存占用大头之一 |
| MEMP_NUM_UDP_PCB | 2 | 给局域网检测协议留的 |
| MEMP_NUM_NETBUF | 0 | RAW模式不需要 |
| MEMP_NUM_NETCONN | 0 | 不开Netconn |
| PBUF_POOL_SIZE | 8 | 接收关键池,建议不低于6 |
| PBUF_POOL_BUFSIZE | 1568 | 覆盖最大标准帧 |
| TCP_SND_BUF | 16384 | 发送窗口 |
| TCP_WND | 16384 | 接收窗口 |
| TCP_QUEUE_OOSEQ | 0 | 关闭乱序队列 |
| LWIP_IPV6 | 0 | 关掉IPv6 |
| LWIP_SNMP | 0 | 关掉SNMP |
| LWIP_IGMP | 0 | 关掉组播 |
| LWIP_DHCP | 1 | 继续用DHCP |
| LWIP_DNS | 1 | 按需开启 |
这里面我最想强调TCP_QUEUE_OOSEQ。默认开启时,LWIP会把乱序到达的TCP段缓存起来重新排序,功能是好的,但每个乱序段都要占内存,在弱网环境尤其费。千兆有线这种低乱序场景,我直接关掉,省下不少内存。业务场景要是有大量丢包和重传,那这个决定需要重新评估。
3.3 PBUF池与DMA缓冲的参数联动
PBUF池大小和接收稳定性是强相关的。网卡收到帧后,ETH驱动从PBUF池拿一个PBUF_POOL类型的pbuf,把DMA缓冲里的数据复制进去,再交给协议栈。PBUF_POOL_SIZE太小,突发流量时池子一空,新到的帧直接被丢,上层表现为偶发丢包。我最终用的是RX DMA缓冲4×1536字节,加上PBUF_POOL_SIZE 8,这一块总共约12KB,实测跑了一整天控制帧零丢失。
PBUF_POOL_BUFSIZE也有讲究。LWIP计算这个值的时候,已经把链路层头、IP头、TCP头以及payload对齐全算进去了,所以1568这类值不是拍脑袋定的,是满足1520字节左右实际帧体量的安全值。我曾看人为了省内存把它改成1400,结果协议栈解析时payload错位,TCP三次握手都完不成。这种硬性开销不能省,省了就是在给自己埋雷。
4. 实操过程与核心环节实现:在32KB里把链路跑起来
4.1 DMA描述符与缓冲区的内存放置
这是H7专属的大坑。ETH DMA是总线主机,它访问不了CPU核心的DTCM区域。很多人把DMA描述符数组定义在普通RAM段,编译一版发现运行随机死机,大概率就是内存落到了DMA不可达的地方。稳妥的做法,是把DMA描述符和RX/TX缓冲放到0x38000000的SRAM4,并用链接脚本固定。
C语言里这样定义:
ETH_DMADescTypeDef dma_rx_desc[ETH_RX_DESC_CNT] __attribute__((section(".eth_dma"), aligned(4))); ETH_DMADescTypeDef dma_tx_desc[ETH_TX_DESC_CNT] __attribute__((section(".eth_dma"), aligned(4))); uint8_t rx_buff[ETH_RX_DESC_CNT][ETH_RX_BUFFER_SIZE] __attribute__((section(".eth_buf"), aligned(32))); uint8_t tx_buff[ETH_TX_DESC_CNT][ETH_TX_BUFFER_SIZE] __attribute__((section(".eth_buf"), aligned(32)));链接脚本里添加对应的段:
.eth_dma (NOLOAD) : { . = ALIGN(4); *(.eth_dma) } > SRAM4 .eth_buf (NOLOAD) : { . = ALIGN(32); *(.eth_buf) } > SRAM4描述符要求4字节对齐,缓冲区我统一32字节对齐,这正好和Cache line长度一致。为什么选SRAM4而不是AXI SRAM?因为SRAM4的地址范围可以被M7核的MPU配置成Non-cacheable,收发时不用每次去手动Clean和Invalidate Cache,省一堆隐患。
MPU配置示意如下:
MPU_Region_InitTypeDef mpu = {0}; mpu.Enable = MPU_REGION_ENABLE; mpu.BaseAddress = 0x38000000; mpu.Size = MPU_REGION_SIZE_64KB; mpu.TypeExtField = MPU_TEX_LEVEL_1; mpu.AccessPermission = MPU_REGION_FULL_ACCESS; mpu.DisableExec = MPU_INSTRUCTION_ACCESS_DISABLE; mpu.IsShareable = MPU_ACCESS_NOT_SHAREABLE; mpu.IsCacheable = MPU_ACCESS_NOT_CACHEABLE; mpu.IsBufferable = MPU_ACCESS_NOT_BUFFERABLE; HAL_MPU_ConfigRegion(&mpu);如果你非要把缓冲放在AXI SRAM里,也不是不能跑,但收发路径里必须成对做Cache Clean和Invalidate,描述符和缓冲一个都不能落。内存紧张的项目,我强烈建议直接走Non-cacheable路线,少写几十行Cache维护代码,少踩一堆随机性死机。
4.2 LWIP与ETH驱动的衔接:零拷贝发送
放进32KB之后,默认HAL流程会显得很笨重:CPU先把TCP报文复制进TX DMA缓冲,DMA再发出去。这一份拷贝占掉不小的TX缓冲。我为了挤出空间,把发送改成了零拷贝:low_level_output里不让数据进固定发送数组,而是让DMA描述符直接指向当前pbuf的payload,发送完成中断里再释放这个pbuf。
零拷贝有个前提:pbuf的payload地址必须满足DMA的4字节对齐。LWIP的PBUF池在PBUF_POOL_BUFSIZE设计时已经做了对齐,PBUF_RAM类型也满足MEM_ALIGNMENT,所以可以用。如果你开着Cache,发送前需要调SCB_CleanDCache_by_Addr,描述符的buffer地址也要更新成当前pbuf地址。HAL库默认的HAL_ETH_TransmitFrame会自己拷贝,想要零拷贝就得改底层驱动,改动量不小。
我的建议是:时间紧、项目新,先用默认拷贝方式跑通,把TX DMA缓冲数量从4减到2,先省出3KB;等整体稳定了,再回头改成零拷贝。零拷贝省下的不光是RAM,还减少一次大包复制,对发送时序更有帮助,属于“收益大于操作成本”的优化。
4.3 实测数据:稳定PING与长期TCP连接
按上面这套配置,最终LWIP相关内存加ETH DMA缓冲,统计下来约29KB,留了约3KB余量。测试环境是STM32H743主频480MHz,RTL8211F千兆PHY,RGMII连接,PC机千兆网卡直连。
第一轮测试,连续ping 1000个包,包长1420字节,平均延迟0.4ms左右,零丢包。第二轮测试,板子作为TCP客户端,每50ms向PC上位机发一条100字节心跳,连续跑26小时,TCP连接一次没断。第三轮做压力测试,用iperf灌数据,吞吐稳定在10Mbps左右,再往上会开始丢包。这个结果符合32KB小缓冲的预期——控制类通信完全够用,网络存储之类的高吞吐场景就别指望了。
测试过程中有个意外发现:TCP_WND是从16384调到32768之后,接收缓冲区一下吃掉一大片MEM_SIZE,整体内存逼近32KB上限。所以每调一个窗口参数,都要看一遍mem_stats和memp_stats,别凭感觉加。内存优化是总账,牵一发而动全身。
5. 常见问题与排查技巧实录
5.1 链路协商不上、PHY地址不对
现象很典型:ETH_FLAG_LINK一直不置位,HAL_ETH_GetState返回错误。第一步查PHY地址。RTL8211F的地址由硬件引脚决定,常见的是0或1,CubeMX里填的PHY Address必须和板子实际一致。可以用HAL_ETH_ReadPHYRegister读PHY ID寄存器,读出来全是0xFFFF就是地址不对,或者PHY根本没上电。另一个坑是RGMII的125MHz参考时钟,有的板子需要MCU输出,有的需要外部时钟源,时钟不对时PHY寄存器都能读通但千兆从不上线,只能拿示波器量时钟脚。
5.2 接收乱码、CRC错误、偶发丢包
这类问题九成出在Cache和内存属性上。如果DMA缓冲放在AXI SRAM又开着Cache,接收中断里没有做Invalidate,CPU读到的很可能还是旧缓存行。我换成SRAM4并配置Non-cacheable之后,问题直接消失。另外描述符的初始化也要注意:增强描述符模式下,描述符里的保留位必须清零,否则DMA状态机可能跑飞。我建议初始化时用memset把所有描述符整体清零,再逐字段赋值,不要只给用到的字段赋值就完事。
5.3 内存池耗尽导致的“不死不活”
表现是系统没死机,但连续收发大流量后,网络线程开始卡住,或者出现分配失败日志。这个时候最有效的手段是打开LWIP统计宏:lwipopts.h里把LWIP_STATS设为1,LWIP_STATS_DISPLAY设为1,周期调用stats_display(),就能看到pbuf池、heap的当前占用和峰值。我踩过一次很冤枉的坑:MEMP_NUM_TCP_SEG设成2,结果一次TCP窗口发送就分配不出seg,发送队列卡死。调大后马上恢复。这属于内存省过头,排查方向要从“哪里够用”调成“哪里不够用”。
5.4 我的避坑清单(快查版)
- 不要把DMA描述符和缓冲放在DTCM,ETH DMA访问不到。
- 描述符必须4字节对齐,缓冲区至少4字节对齐,建议32字节对齐。
- PHY地址、125MHz时钟、PHY供电,上电先确认这三样。
- 使用Cache时,收发路径要成对做Clean/Invalidate,嫌麻烦就改用Non-cacheable内存。
- 每次改动LWIP配置后,用stats_display看heap和pbuf池峰值,别等死机再猜。
- TCP_WND、TCP_SND_BUF、MEMP_NUM_TCP_SEG会互相放大内存,别单独猛调一个。
- 标准帧按1518字节算,PBUF_POOL_BUFSIZE不要低于1560附近,硬砍会毁掉协议栈解析。
把LWIP从动辄几十KB内存压到32KB,最核心的不是背下哪几个参数,而是心里永远有一张内存账本:哪些是DMA硬开销,哪些是并发资源,哪些是当前场景根本用不到可以直接关掉的功能。做完这个项目我最大的体会是,内存优化永远是一笔总账,单独调哪个参数都可能顾此失彼,但把它们放在同一张表里,取舍就清晰了。最后再分享一个小技巧:配置完所有参数后,一定多留出2到3KB余量,线上问题排查时你会感谢这2KB的。