STM32H7 LWIP内存优化:32KB预算下的配置与DMA调度
STM32H7上跑LWIP这件事圈里做得人不少但真正卡在内存上、甚至因为内存优化不到位导致丢包、重传、吞吐上不去的情况其实比我们想象中普遍得多。最近我在做一个基于H7系列带千兆MAC、外接千兆PHY的方案的网关项目整体功能跑通很容易遇到真问题的是内存整个应用只给以太网链路留了32KB RAM而LWIP协议栈默认配置加上DMA缓冲区随便一套就是100KB往上的胃口。这篇文章就把我在这个项目里做内存优化的完整思路和实测结论整理出来围绕LWIP协议栈的配置瘦身、DMA描述符合缓冲区排布、TCP窗口与PBUF策略权衡、以及稳定性的验证方法展开希望能给正在做STM32H7LWIP以太网项目、又对RAM预算卡得比较死的朋友一些可直接参考的方案。1. 32KB预算被拆解先搞清楚以太网链路的内存都花在哪里内存优化这件事最忌讳上来就乱调参数。你得先回答一个问题以太网这一条链路内存到底被谁吃了1.1 一条典型LWIPETH链路的RAM构成抛开应用层自己的缓冲以太网链路的内存消耗主要分成四块第一块是DMA描述符。STM32H7的以太网DMA使用描述符来管理收发缓冲区每个描述符32字节发送方向和接收方向各自需要一组。描述符数量直接决定了DMA能够“排队”多少个帧数量太少在高负载下会丢包数量太多又白占RAM。第二块是DMA缓冲区。这部分是内存消耗的大头。每个接收缓冲区通常要能装下一个完整的以太网帧也就是至少1536字节1500字节MTU加上14字节以太网头、4字节CRC校验、以及VLAN标签等余量。发送缓冲区如果不想做特殊处理同样是一帧一个缓冲区。这一块的消耗非常直观6个接收缓冲区加4个发送缓冲区就是6×15364×153615360字节占掉32KB预算的一半。第三块是LWIP协议栈本身的内存。这里面包含两个部分内存堆heap由MEM_SIZE控制用于分配PBUF结构、PCB结构、路由表条目等零散内存内存池pool由MEMP_NUM_*系列宏控制细分了TCP段、PBUF、ARP表项、UDP PCB等不同大小的专用池。第四块是协议控制块。每个启用的网络接口、每个打开的TCP连接、UDP端口都要占据一部分RAM。TCP PCB的结构体在Cortex-M7这种32位环境下大约220字节左右如果开了多个socket这部分也会成为不可忽视的开销。1.2 一个常见的误判吞吐越高缓冲区就得越大很多人在面对“千兆以太网”这个提法时第一反应是把DMA缓冲区、PBUF池尽量开大担心缓冲区不够导致吞吐上不去。这是最容易踩的坑。实际上吞吐量与缓冲区大小并不是线性关系。影响吞吐的核心因素是接收窗口TCP_WND、发送窗口、以及数据链路层的处理速度而不是单纯把RX缓冲区开得多大。缓冲区开得过大只会让可用内存迅速耗尽反而导致PCB分配失败、PBUF申请失败系统进入低内存状态后出现间接性的重传风暴和链路不稳定。换句话说在32KB的预算下你首先要做的不是“加大”什么而是把每一块内存与系统的真实需求对齐。搞清楚这个前提下文的所有优化才有意义。2. lwipopts.h定向减肥把默认配置从100KB级别压到32KB以内2.1 先厘清每个宏控制的是哪块内存LWIP的参数配置集中在lwipopts.h这一堆宏如果不理解背后的内存归宿改起来就跟瞎猜一样。我把关键宏和其对应的内存去处列出来宏定义控制的内存内存去向MEM_SIZE内存堆总大小分配给PBUF结构、PCB结构、路由信息等通过mem_malloc申请的内存MEMP_NUM_PBUFPBUF池数量专门给传输层以下的PBUF头和数据区预留的池MEMP_NUM_TCP_SEGTCP分段池数量每个TCP发送/接收分段占用的控制结构MEMP_NUM_TCP_PCBTCP控制块池数量每个TCP连接约220字节MEMP_NUM_UDP_PCBUDP控制块池数量每个UDP连接约几十字节MEMP_NUM_ARP_QUEUEARP缓存队列待解析IP时排队的网络包PBUF_POOL_SIZEPBUF池中的PBUF数量驱动层接收/发送使用的PBUF池PBUF_POOL_BUFSIZE每个PBUF池缓冲区的数据区大小通常设置为TCP_MSS协议头余量TCP_SND_BUFTCP发送缓冲总大小单个TCP连接的发送窗口缓冲TCP_WNDTCP接收窗口大小单个TCP连接的接收窗口TCP_SND_QUEUELENTCP发送队列中最大分段数影响最大排队待确认的分段数量默认的lwipopts.hCubeMX生成的工程往往使用opt.h的默认值对这些参数的设定偏向“功能完整”很多项目根本用不到那么多功能。比如默认情况下MEM_SIZE可能是16KB甚至更大PBUF_POOL_SIZE可能在10个以上加上每方向8个DMA描述符全部加起来分分钟超过100KB。2.2 我在这个项目中实际生效的参数组合上面说的这个网关项目需求不算复杂2个TCP服务器连接1个UDP收发通道数据流是UDP为主、TCP做配置和管理。基于这个场景我最终落定了一套参数// lwipopts.h 关键配置 #define MEM_ALIGNMENT 4 // 内存堆 #define MEM_SIZE (4 * 1024) // PBUF池 #define PBUF_POOL_SIZE 6 #define PBUF_POOL_BUFSIZE 1280 // TCP #define TCP_MSS 1460 #define TCP_WND (8 * TCP_MSS) // 约11680字节 #define TCP_SND_BUF (8 * TCP_MSS) // 约11680字节 #define TCP_SND_QUEUELEN (4 * TCP_SND_BUF) / TCP_MSS // PCB池 #define MEMP_NUM_TCP_PCB 4 #define MEMP_NUM_UDP_PCB 2 #define MEMP_NUM_TCP_SEG 8 // ARP #define MEMP_NUM_ARP_QUEUE 4 // 内存统计调试期打开正式版可关闭 #define MEM_STATS 1 #define MEMP_STATS 1 #define LWIP_STATS 1这套参数算下来PBUF池6 × 1280 7680字节内存堆4096字节TCP控制块4 × 220 ≈ 880字节UDP控制块2 × 约30字节TCP分段池8 × 约40字节ARP队列4 × 约120字节这部分合计约12KB。加上DMA描述符和缓冲区下一章详细算整体能控制在25KB左右给应用仍然留出了缓冲余地。2.3 第二刀关闭用不到的功能和协议参数瘦身只完成了一部分另一部分容易被忽略的是功能裁剪。LWIP默认打开了一堆“有备无患”的功能比如如果网关不提供DNS服务把LWIP_DNS关闭如果不需要从DHCP服务器获取地址且用固定IP把LWIP_DHCP关闭如果不需要IGMP组播把LWIP_IGMP关闭如果不使用socket API把LWIP_SOCKET关闭如果不使用netconn API把LWIP_NETCONN关闭如果不做WEB服务器把LWIP_HTTPD、LWIP_SNMP等统统关掉。每个功能关闭后都会释放一部分代码段内存对Flash有意义同时减少运行时的状态维护需求。对我这个项目来说真正需要保留的是RAW API因为直接在协议栈回调里处理数据省去socket层的内存与CPU开销。用RAW API代替socket API是整个优化里收益最高的决策之一socket层的存在会让每个连接多出几KB的上下文开销而这笔开销在32KB预算下几乎不可接受。提示如果你不确定某个宏是否被启用可以在lwipopts.h中显式定义所有关键项避免依赖opt.h的默认值。CubeMX生成的工程里很多配置散落在不同位置显式定义是防止“改了没生效”最稳妥的办法。3. DMA描述符与缓冲区把内存大头按帧粒度精确排布3.1 描述符数量的取舍逻辑ETH DMA收发方向上描述符数量与“能够排队处理的帧数量”直接相关。很多人会无脑配置8个RX描述符和8个TX描述符但我们可以算一笔账8个RX描述符占用的RAM8×32256字节8个RX缓冲区8×153612288字节8个TX描述符8×32256字节8个TX缓冲区8×153612288字节。光这一块就是25KB剩下的空间几乎什么也干不了。所以必须按实际流量特征来压缩。在H7的以太网DMA配置里描述符数量可以独立设置。我的做法是RX方向保留4个描述符每个缓冲区1536字节。为什么保留4个因为在接收中断响应、PBUF转移、协议栈处理这一串流程中DMA需要足够的“备份”缓冲区来承接连续到达的帧。如果中断优先级设置合理、处理时间足够短4个接收缓冲区可以应付绝大多数UDP/TCP突发流量。理论上如果流量极其平稳2个也能跑但稍微来一个微小突刺就丢包得不偿失。TX方向保留2个描述符每个缓冲区1536字节。发送方向不需要像接收方向那样“防御性”地多预留因为本机是自己决定何时发、发多少。当协议栈把数据交给DMA后只要DMA能尽快把数据搬出去2个发送缓冲区就够用了。如果遇到发送阻塞LWIP自身的发送缓冲队列会兜底。算一下RX 4×15366144字节TX 2×15363072字节描述符42×32192字节合计9408字节。相比默认的25KB直接省下15KB以上。3.2 缓冲区地址对齐与DCache维护STM32H7的Cortex-M7内核自带L1 CacheICache和DCache。一旦开启了DCache以太网DMA通过AXI总线访问RAM时如果DMA写入的数据量小于一个Cache Line32字节且CPU读取这些数据时命中的是过期的Cache就会出现“数据不一致”的诡异问题。这个问题的典型表现是丢包没有任何规律抓包工具看起来一切正常但实际收到的数据偶尔就是损坏的甚至协议栈直接卡死。解决思路分两种方案一不开启DCache。最简单粗暴适合对吞吐要求没那么极限、或者数据量不太大的项目。Cortex-M7在不开启DCache时所有数据访问都直接落到SRAM不会有缓存一致性问题。缺点是CPU访问SRAM的速度会下降但在很多100Mbps级别的应用中影响不明显。方案二开启DCache但以太网缓冲区做Cache Line对齐维护。这是更专业、也更能发挥H7性能的做法。前提是DMA缓冲区地址按32字节对齐且长度为32字节的整数倍。然后在每次DMA收发包前后调用SCB_CleanDCache_by_Addr和SCB_InvalidateDCache_by_Addr进行缓存维护。下面是我在实际工程中封装好的收发缓冲维护函数#define DMA_BUF_SIZE 1536 #define DMA_BUF_MASK (DMA_BUF_SIZE - 1) // 缓冲区地址对齐到32字节(L1 DCache Line大小) static uint8_t rx_buf[4][DMA_BUF_SIZE] __attribute__((aligned(32))); static uint8_t tx_buf[2][DMA_BUF_SIZE] __attribute__((aligned(32))); // 在DMA写入数据后、CPU读取数据前调用 static void dma_buf_invalidate(uint32_t addr, uint32_t len) { uint32_t aligned_addr addr ~0x1FUL; uint32_t aligned_len (len 0x1FUL) ~0x1FUL; SCB_InvalidateDCache_by_Addr((uint32_t *)aligned_addr, aligned_len); } // 在CPU写入数据后、DMA读取数据前调用 static void dma_buf_clean(uint32_t addr, uint32_t len) { uint32_t aligned_addr addr ~0x1FUL; uint32_t aligned_len (len 0x1FUL) ~0x1FUL; SCB_CleanDCache_by_Addr((uint32_t *)aligned_addr, aligned_len); }注意如果缓冲区没有按32字节对齐SCB_InvalidateDCache_by_Addr会多覆盖相邻的32字节区域。一旦那片区域正好是LWIP的PBUF池就可能把池中的数据擦掉造成极难排查的随机崩溃。所以对齐这件事不是“建议”而是“必须”。3.3 收发路径上的零拷贝设计在32KB的局限下零拷贝不是性能优化而是生存策略。如果每收到一帧先由DMA搬到缓冲区再从缓冲区拷到PBUF再从PBUF拷到协议栈30KB内存根本转不开。LWIP本身的设计就支持零拷贝接收以太网驱动收到帧后并不是把数据复制到另一个PBUF而是直接将DMA缓冲区“挂接”到一个PBUF上让协议栈直接读这块内存。这也意味着驱动在初始化时分配RX缓冲区的方式需要配合PBUF池。我在代码中让eth_rx回调函数直接使用DMA描述符指向的缓冲区地址构造PBUF而不是重新pbuf_allocstatic struct pbuf *eth_rx_get_pbuf(void) { struct pbuf *p; // 从DMA RX描述符拿到收到的数据地址和长度 uint32_t data_addr rx_desc-rx_buffer_addr; uint16_t data_len rx_desc-rx_frame_len; p pbuf_alloc(PBUF_RAW, data_len, PBUF_POOL); if (p ! NULL) { // 直接把DMA缓冲区内容链接到pbuf的payload上 // 注意这里用PBUF_POOL而不是PBUF_RAM配合紧急池保证可用性 memcpy(p-payload, (void *)data_addr, data_len); } return p; }这里我做了个折中虽然是memcpy但不做完整的二次分配而是在PBUF池中申请一个合适的缓冲区后直接拷贝。如果有条件做更彻底的零拷贝DMA描述符与PBUF池的地址直接映射可以把拷贝也省掉但那需要对DMA描述符的缓冲区地址做一次静态固定并且PBUF池的地址范围必须提前划好在工程初期就要规划好后期改起来成本很高。4. 在32KB约束下保吞吐TCP窗口与PBUF的策略平衡4.1 TCP_WND不是拍脑袋定的TCP的接收窗口大小本质上是告诉对端“你还最多能发多少数据”这个值决定了接收方在未确认前需要预留多少缓冲。如果TCP_WND设置得过小发送方的吞吐会被窗口卡住设置得过大接收方要为每个连接预留大量的内存。对于嵌入式设备有一层关系要先明确带宽延迟积BDP决定理论上的窗口需求但实际吞吐还受CPU处理能力、总线带宽、以及协议栈开销的限制。在一个局域网环境下RTT通常在0.2ms到几毫秒之间。假设链路从MAC到PHY的速率是1000MbpsRTT取1msBDP1000Mbps×1ms125KB。这意味着要打满千兆链路TCP窗口需要125KB以上——这在STM32H7上是不现实的也是在32KB内存约束下无法弥补的物理限制。所以我的策略很务实不做“千兆线速吞吐”的伪目标而是做稳定、低延迟、不丢包、在应用场景内跑满实际带宽的真目标。这个网关的实时数据走UDPTCP只承担配置指令下发和状态上报这类报文的特征是小包、低频。把TCP_WND定位在8×MSS约11.7KB足以覆盖配置通道的需求也不会给协议栈造成过大的缓冲负担。如果你确实需要用STM32H7跑大数据量的TCP传输我的建议是把TCP_WND设为16KB级别但关闭不必要的TCP选项如窗口缩放、SACK这些选项在嵌入式场景带来的吞吐增益非常有限占用的内存和代码复杂度却不小不要开启多个TCP连接。每个连接到来的缓冲区预留是乘数关系连接越多内存越不可控。4.2 发送缓冲与重传队列的上限计算TCP_SND_BUF和TCP_SND_QUEUELEN是一对容易混淆的参数。TCP_SND_BUF是发送缓冲区的总字节数上限TCP_SND_QUEUELEN是该发送缓冲最多能拆分成多少个分段在队列中等待确认。计算公式TCP_SND_QUEUELEN (4 * TCP_SND_BUF) / TCP_MSS这个4倍的系数来源于TCP分段在内部可能被PBUF头、重传控制结构、以及TCP/IP头预留空间“放大”的内存占用。我最终的配置是TCP_SND_BUF8×1460TCP_SND_QUEUELEN32。实测下来发送方向上从未出现因为队列溢出导致的应用层阻塞同时发送缓冲的内存占用控制住了TCP段控制结构MEMP_NUM_TCP_SEG 8每个约44字节共352字节发送队列数据缓冲需要看实际排队量一般来说8KB预算够用。如果TCP_SND_BUF设得太小比如只有1×MSS那么应用层一次要发2KB数据时协议栈会返回内存不足应用只能等了又等吞吐断崖式下跌。很多初学者的“TCP速度上不去”其实是这个参数太小导致的。4.3 PBUF池的大小绝不等于越大越好PBUF池是接收路径上最容易被“饿死”的资源。每收到一个以太网帧驱动需要从PBUF池里取一个PBUF来承接数据。如果池子空了驱动只能丢弃该帧。但PBUF池也不是越大越好。每个PBUF除了数据区payload之外还有一个约40字节的pbuf结构体头池子开多了总占用量同样可观。更关键的是PBUF池的缓冲区大小PBUF_POOL_BUFSIZE决定了单帧数据能否完整放入。我设定为1280字节而并非完全匹配1500字节MTU是基于这样的考量本项目的UDP帧最大约1024字节加上IP头、以太网头1280字节足够放下。如果你传输的帧超过这个值协议栈会走PBUF_RAM分支从MEM_SIZE堆里分配只要堆有剩余就不会出错只是性能略降。所以这里重要的不是PBUF_POOL_BUFSIZE必须等于MTU而是它必须覆盖“主流帧大小”超出部分由堆来兜底。这样PBUF池资源就能精准地服务于高频小帧而堆资源服务于低频大帧。4.4 项目实测这套配置下的性能数据在最终配置下我用Iperf和自写的UDP测试工具做了验证测试项配置实测结果UDP 发送设备-PC1Mbps持续速率1024字节帧丢包率 0%CPU占用约38%UDP 接收PC-设备1Mbps持续速率1024字节帧丢包率 0%RX描述符占用稳定在2/4左右UDP 接收PC-设备10Mbps突发速率瞬时偶发丢包但系统稳定不崩溃停止后恢复TCP 配置通道小帧交互RTT稳定无重传风暴值得说明的是10Mbps突发时的偶发丢包在预期范围内。原因是STM32H7的软件中断响应和协议栈处理速度存在物理瓶颈系统不会无故丢失而是在PBUF池暂满时采取主动丢弃保证协议栈不崩。对于网关类项目比“来多少收多少”更重要的是在弱网/瞬时压力下系统不崩、可恢复。5. 稳定性验证与排查那些必须经历一遍的坑5.1 用内存统计宏验证真实用量我们经常在“理论上”推测内存用量但真实情况必须由数据说话。LWIP提供了内存统计宏打开后可以实时查看堆和池的使用量// 在lwipopts.h中打开 #define MEM_STATS 1 #define MEMP_STATS 1 #define LWIP_STATS 1然后周期性地调用stats_display()或者在串口调试里打印extern struct stats_mem mem_stats; extern struct stats_mem memp_stats[MEMP_MAX]; printf(heap used: %d, avail: %d\n, mem_stats.used, mem_stats.avail); printf(pbuf used: %d, avail: %d\n, memp_stats[MEMP_PBUF_POOL].used, memp_stats[MEMP_PBUF_POOL].avail);我把这些统计在设备跑满24小时之后拉出来看重点观察内存堆的used是否持续增长——如果是说明存在内存泄漏PBUF池的used是否稳定在某个水位——如果稳定说明池子的数量配置是匹配流量的TCP分段池偶尔耗尽是正常的但持续耗尽就说明MEMP_NUM_TCP_SEG配置偏小。比较有意思的是打印统计本身也会消耗时间和内存我是在验证阶段开启正式固件中会把这些宏关掉以减少代码体积和运行时开销。5.2 高频运行下最容易翻车的三个故障模式在跑长期稳定性测试时我遇到了三类典型故障这里逐一复盘。第一类DCache一致性问题导致的随机数据损坏。这个前面已经提到过。现象是长时间运行后偶发收到错误数据CRC校验却没事因为CRC校验只覆盖链路层不覆盖协议层TCP一旦发现校验不对就丢弃并重传造成吞吐下降。反复定位后确认是DCache未刷新的问题解决后问题消失。第二类描述符不足导致的“假死”。当接收描述符数量只有2个时流量稍微大一点DMA会因为没有空闲描述符而停止接收。此时协议栈并不知情链路好像断了直到有外部事件触发重传才恢复。我把RX描述符提升到4个后这个问题就消失了。结论是描述符数量不能只看平均流量必须留出突发流量的余量。第三类内存池耗尽导致的重传风暴。如果把MEMP_NUM_TCP_SEG设得太小TCP发送队列会频繁申请失败发送方会不断重试看起来像是网络中出现了重传风暴。解决方式并不是把MEMP_NUM_TCP_SEG无限调大而是看TCP_SND_BUF与TCP_SND_QUEUELEN是否匹配、发送缓冲是否设置得过大——因为过大的发送缓冲意味着同时排队的TCP段更多需要的分段池也更多。5.3 一通排查到底怎么走实际案例复盘有一个调试过程特别值得分享。现象是Ping设备非常稳定但是跑TCP传输时速率会周期性地掉到0然后恢复。抓包看到TCP乱序和重传但网络本身看起来没问题。我的排查路径先打开LWIP_STATS确认是RX丢包还是TX丢包结果两边都不高再打开内存统计发现PBUF池在掉速时used接近于avail怀疑PBUF池数量不够调大PBUF_POOL_SIZE掉速依然存在继续查DMA描述符使用率发现RX描述符在掉速前已经占满开始怀疑是描述符数量问题将RX描述符从4个调大到8个问题缓解但内存告急最终定位到根本原因并不是数量而是单个缓冲区的大小当时缓冲区大小设定为1024字节小于最大的MTU帧当一次连续到达多个超过1024字节的帧时DMA需要二次搬运导致处理耗时成倍增加RX路径来不及回收描述符。把缓冲区调整为1536字节并保持4个描述符后问题彻底解决。这个案例说明调优不能只盯着数量而忽略单缓冲区容量两者是联动的。5.4 从CubeMX初始化到双缓冲区接入的落地顺序最后说一下CubeMX生成工程时的落地点。CubeMX里配置ETH和LWIP非常方便它会自动生成初始化代码包括GPIO、时钟、ETH引脚复用、PHY初始化、LWIP的lwip.c和ethernetif.c。但默认生成的配置对内存优化并不友好你需要做三件事在CubeMX的ETH参数里把描述符数量改成你计算好的数值不要用默认的8/8在LWIP的Middleware设定中把协议栈内存分配方式改成静态或者你自定义的方式不要用系统默认的堆分配在ethernetif.c的low_level_init函数中把eth-rx_buf、eth-tx_buf改为你已经对齐声明好的缓冲区数组并在low_level_input中对接好PBUF池。// ethernetif.c 中关键初始化片段 static void low_level_init(struct netif *netif) { eth_handle_t *eth heth; HAL_ETH_Init(eth); HAL_ETH_Start(eth); // 重新指定DMA描述符和缓冲区 eth-Init.RxDesc (ETH_DMADescTypeDef *)eth_rx_desc; // 静态数组 eth-Init.TxDesc (ETH_DMADescTypeDef *)eth_tx_desc; eth-Init.RxBufAddr (uint32_t)rx_buf[0]; eth-Init.TxBufAddr (uint32_t)tx_buf[0]; netif-mtu 1500; netif-flags | NETIF_FLAG_LINK_UP; }提示eth_handle_t是HAL层的句柄具体成员名在不同版本的HAL库中略有差异。如果你发现属性名对不上优先查对应版本HAL库的头文件而不是硬套网上的旧代码。写在最后32KB实现稳定以太网靠的是“每字节可解释”做完这个项目我最大的感受是在内存极度受限的情况下跑LWIP核心不是你会不会开大缓冲区而是你能不能把每一个字节的去向都解释清楚。打开静态检查前我也曾经以为“多开点池子总没错”事实证明在32KB这个量级每个宏都必须在“应用场景”和“物理资源”之间找到精确的平衡点。我个人的建议是优化时按这个顺序来先裁剪功能关socket、关DHCP、关用不到的协议再调整DMA描述符和缓冲区然后匹配PBUF池与TCP参数最后用内存统计宏持续观测一个长周期的运行状态。整个过程不要跳步因为每一步的优化都会影响下一步的参数空间。32KB确实不多但只要你愿意把默认配置逐项拆开、算清账、再针对自己的流量特征重新组合LWIP在STM32H7上稳稳定定地跑起来是完全不靠运气的。