4. LwIP 内存管理:内存池与内存堆的配置与调优

内存管理,说白了就是 LwIP 的命根子。你想想看,一个嵌入式设备总共就那么点 RAM,协议栈收发包、建连接、处理数据,全得在这巴掌大的地方折腾。搞不好就内存泄漏,搞不好就碎片爆炸,设备跑几天就挂了。

我个人习惯,拿到一个新平台,第一件事不是调网络参数,而是先把内存管理摸透。这章咱们就聊聊 LwIP 的两种内存分配方式——内存池和内存堆,以及怎么根据实际场景去配、去调。

4.1 内存池(PBUF_POOL)

内存池,说白了就是预先切好的一堆固定大小的内存块。LwIP 里最常用的就是 PBUF_POOL 类型的 pbuf。每个 pbuf 大小固定,分配和释放都是 O(1) 的,速度极快,而且不会产生碎片。

我在项目中遇到过一个问题:某个设备跑 MQTT,平时好好的,一旦上报数据频率高了,就频繁丢包。查了半天,发现是内存池太小,申请不到 pbuf 了。嗯,这就是典型的池子不够用。

4.1.1 配置参数

内存池的大小和数量,由 lwipopts.h 里的两个宏控制:

// 每个内存池 pbuf 的大小(字节)
#define PBUF_POOL_SIZE  16

// 内存池中 pbuf 的数量
#define PBUF_POOL_BUFSIZE  256

这里有个容易踩的坑:PBUF_POOL_BUFSIZE 是每个 pbuf 的有效载荷大小,但 LwIP 内部还会加上协议头开销。比如你配了 256 字节,实际每个 pbuf 占用的内存可能是 256 + 几十字节的头部。

我曾经踩过的坑:把 PBUF_POOL_BUFSIZE 配成 1514,以为能装下一个完整以太网帧。结果 LwIP 内部还要加 PBUF_LINK_HLEN 等头部,实际分配的内存远超预期,MCU 直接 OOM。后来我改成 1514 - PBUF_LINK_HLEN - PBUF_IP_HLEN - PBUF_TCP_HLEN,才稳定下来。

4.1.2 如何估算池子大小

我一般按这个公式估算:

场景 推荐 PBUF_POOL_SIZE 说明
低负载 MQTT(几秒发一次) 8 ~ 16 够用,省 RAM
中等负载(每秒几十个包) 32 ~ 64 留点余量
高负载(视频流、大量并发) 128 ~ 256 根据实际压测调整
小技巧:可以在代码里加个统计,打印出 lwip_stats.pbuf.used 的最大值。跑几天看看峰值用了多少池子,然后按峰值 + 20% 余量配置。我习惯在调试阶段把这个统计打开,上线前再关掉。

4.2 内存堆(PBUF_RAM)

内存堆就不一样了。它是一大块连续内存,按需分配,大小灵活。像 TCP 的发送窗口、大块数据缓存,通常都用 PBUF_RAM 类型的 pbuf。

但堆有个致命问题——碎片。频繁分配释放,内存会碎成一片一片的。你明明还有 10KB 空闲,但最大连续块只有 1KB,申请 2KB 就失败。

4.2.1 堆的大小配置

// 内存堆的总大小(字节)
#define MEM_SIZE  4096

这个值怎么定?我一般这么算:

  • 先算协议栈本身的开销:TCP 控制块、路由表、ARP 表等,大概几百字节到 1KB。
  • 再加应用层需求:比如 MQTT 的发送缓冲区、接收缓冲区。
  • 最后留 20%~30% 余量,应对突发流量。

举个例子,一个 MQTT 客户端,最大发 2KB 的消息,收 2KB 的消息,再加上协议栈开销:

MEM_SIZE = 1024(协议栈) + 2048(发送) + 2048(接收) + 1024(余量) = 6144 字节

4.2.2 堆的分配策略

LwIP 默认用 首次适应(First Fit)算法。说白了就是从头扫描,找到第一个够大的空闲块就分配。速度快,但容易产生碎片。

如果你对实时性要求高,可以换成 最佳适应(Best Fit)或 最差适应(Worst Fit)。不过我个人建议,除非你特别清楚自己在做什么,否则别动分配算法。默认的 First Fit 在大多数场景下够用了。

我的经验:如果堆碎片严重,优先考虑增大 MEM_SIZE,而不是换算法。把堆从 4KB 加到 8KB,碎片问题往往就缓解了。换算法可能引入新的 bug,得不偿失。

4.3 内存池 vs 内存堆:怎么选?

很多新手会问:到底用池还是用堆?我一般这么区分:

维度 内存池 内存堆
分配速度 极快(O(1)) 较慢(O(n))
碎片问题 无碎片 容易碎片
灵活性 固定大小 任意大小
适用场景 小包、高频收发 大块数据、变长数据
典型用途 接收 pbuf、ARP 包 TCP 窗口、应用缓存

说白了,小包走池,大包走堆。MQTT 的 PUBLISH 消息如果小于 256 字节,完全可以用池;如果消息体有几十 KB,那就得用堆。

4.4 调优实战:一个 MQTT 场景

我记得有一次帮客户调一个智能网关。设备跑 MQTT,上报传感器数据,每条消息 200 字节左右,每秒上报 10 条。客户反映设备运行 3 天后必死机。

我一看配置:

PBUF_POOL_SIZE = 8
PBUF_POOL_BUFSIZE = 128
MEM_SIZE = 2048

问题很明显:

  • 池子只有 8 个,每个才 128 字节。200 字节的消息需要 2 个 pbuf 链起来,一次上报就要占 2 个池子。10 条/秒就是 20 个池子,池子早被抢光了。
  • 堆只有 2KB,TCP 窗口稍微大点就爆了。

我改成:

PBUF_POOL_SIZE = 32
PBUF_POOL_BUFSIZE = 256
MEM_SIZE = 8192

同时把 MQTT 的发送缓冲区从堆里分配,改成用池子(因为消息大小固定)。改完后设备连续跑了 30 天,再没死过机。

避坑指南:调内存参数时,一次只改一个变量。改完跑压力测试,看 lwip_stats 里的内存使用情况。我习惯用 stats_init() 初始化统计,然后定期打印 lwip_stats.mem.used 和 lwip_stats.pbuf.used。

4.5 内存管理架构图

下面这张图,是我自己画的内存管理决策流程。每次调优时,我就照着这个思路走:

LwIP 内存管理决策流程 LwIP 内存管理决策流程 开始配置内存 数据大小固定? 是 使用内存池 否 使用内存堆 配置 PBUF_POOL_SIZE 和 PBUF_POOL_BUFSIZE 配置 MEM_SIZE 和 MEM_LIBC_MALLOC 运行压力测试验证 检查 lwip_stats 内存使用 💡 小包固定用池,大包变长用堆。调完参数一定要跑压力测试,看峰值内存使用。 我习惯在调试阶段打开 lwip_stats,上线前再关掉,省 ROM。

4.6 总结几个要点

  • 内存池优先:能走池子就别走堆。池子快、无碎片、好调试。
  • 堆要留余量:MEM_SIZE 至少是理论最大需求的 1.3 倍。
  • 统计是眼睛:lwip_stats 里的 mem.used、pbuf.used、pbuf.lost 是调优的导航仪。
  • 别贪多:池子不是越多越好。配多了浪费 RAM,配少了丢包。找到平衡点。
最后说一句:内存管理调优没有银弹。每个项目都有自己的脾气。我见过 2KB 堆跑得飞起的设备,也见过 64KB 堆还频繁 OOM 的。关键是你得理解你的数据流,然后对着统计数字一点点调。别怕麻烦,这一步省了,后面调试网络问题会让你更头疼。