内存管理系列写到第 17 篇,今天专门看 Zephyr 里的 Memory Pools。这套机制在驱动、协议栈和子系统的代码里出镜率相当高,一句话解释它的用途:让任务从同一块静态内存池里申请不同大小的 buffer,池子内部按多级块组织,分配时能把大块拆成小块,空闲时再还回去,整个过程支持超时等待,最坏行为比普通堆更容易分析。适合正在读 Zephyr 驱动源码、做嵌入式协议栈缓冲管理,或者被动态内存碎片坑过的人看。
我先把话说在前面:Zephyr 现在主推的通用动态内存是 k_heap,新业务能用 k_heap 我也不会拦你。但历史代码和部分外设接口里,Memory Pools 并没有消失;而且它这种“固定档位、按级拆分”的思路,在很多对最坏情况敏感的场景里仍然值得理解。这篇就把它的设计逻辑、实际用法、容量计算和坑一次性讲完。
1. 为什么会有 Memory Pools 这种东西
1.1 一次“内存明明够,却分配失败”的现场
之前做一个协议栈 Demo,协议包长度不是固定的,短包几十字节,长包可能有 1KB 多。最初图省事,所有 buffer 都从系统 heap 里k_malloc。跑单线程测试一切正常,一上多任务就开始出问题:系统 heap 总剩余量看着还有十几 KB,但某个任务要申请 1KB 时竟然返回空指针。
原因不复杂,就是外部碎片。任务 A 反复申请 100 字节左右的小 buffer,用完释放;任务 B 偶尔申请 2KB 的大 buffer。分配器虽然会在释放时尝试合并相邻空闲块,但小 buffer 的“窟窿”分布太散,1KB 连续空间被夹在中间,凑不出来。最后我把这部分 buf 从系统 heap 里摘掉,换成 Memory Pool,按 64 / 256 / 1024 三档固定规格管理,问题立刻消失。这个场景就是 Memory Pool 最典型的使用位置。
它不像通用堆那样追求“任意大小都能给”,而是先把内存切成若干固定档位。申请 200 字节时,如果档位是 64 / 256 / 1024,就给你 256 字节那档。浪费一点内部碎片,但换来了分配行为的高度可控,也基本杜绝了“有总内存但没连续块”的尴尬。
1.2 和 heap、mem_slab 放在一起看
Zephyr 里常见的内存管理原语有三个:k_mem_slab、k_mem_pool、k_heap。很多人刚接触时容易混淆,特别是 mem_slab 和 memory pool 名字接近,但定位完全不同。
| 维度 | k_mem_slab | k_mem_pool | k_heap |
|---|---|---|---|
| 块大小 | 固定 | 多级固定规格 | 任意大小 |
| 外部碎片 | 无 | 可控制 | 有可能 |
| 内部碎片 | 无 | 有,向上取整 | 少量 |
| 分配等待 | 支持超时 | 支持超时 | 支持超时 |
| 典型场景 | 固定大小的对象池 | 混合大小的短生命周期 buffer | 通用动态内存 |
k_mem_slab 适合对象大小完全一致的场景,比如网络描述符、信号量控制块。k_heap 适合大小分布无法事先建模的场景。Memory Pool 夹在中间,适合“大小种类不多、每种规格的并发数量能估算”的场景。
有一点必须提醒:Memory Pool 不是万能的。它比 mem_slab 灵活,但比 heap 粗。如果业务里申请的内存大小很离散,从 17 字节到 1500 字节都有,硬套 Memory Pool 反而会造成大量内部碎片,这种情况下可能不如 k_heap。选型时要先看对象的“尺寸分布”,再看“并发存活数量”,两步分析完再决定。
2. 多级块:Memory Pools 的核心设计
2.1 四级拆分到底是怎么一回事
Memory Pool 内部把内存按“级”组织,相邻两级之间的块大小是 4 倍关系。一个 1024 字节的大块,可以拆成 4 个 256 字节的块;其中一个 256 字节的块又可以拆成 4 个 64 字节的块。所以一个顶层大块,相当于一棵四叉树。
用K_MEM_POOL_DEFINE(cmd_pool, 64, 1024, 8, 8)举例,池子里的可用规格就是 64、256、1024 三档。为什么不是按 2 倍往上走而是按 4 倍?因为二进制系统里 4 倍对齐和地址计算都非常方便,同时级数少,管理开销更小。64 到 1024 只用两级拆分就能覆盖 16 倍的范围,如果按 2 倍拆分,要多一层。
这也带来一个直观感受:档位非常粗。申请 300 字节,如果只有 64 / 256 / 1024 三档,系统会给你 1024 字节那档。表面看很浪费,但这就是设计取舍。Memory Pool 的目标是宁可损失一点空间,也要让“某个大小的块是否可用”变得可预期,而不是像通用 heap 那样去追求极致空间利用率。
2.2 从“要找 260 字节”看分配路径
当一个任务请求 260 字节时,分配器先算出需要哪一档:260 大于 256,必须走 1024 那档。如果当前 1024 档有完全空闲的块,直接取一块返回。如果没有,但顶层还有更大的块,分配器会把大块向下拆分,一路拆到目标档,然后把其中一块挂到对应档位的空闲链表,另一块返回给任务。
拆分的粒度不是一次只拆一层。假设现在有一个 4096 字节的空闲块,而请求需要 64 字节,内核可能会沿着 4096 → 1024 → 256 → 64 逐层拆到底,把链路上的兄弟块都挂到各自的空闲链表,这样后续小请求就不用再拆大块了。这个过程很像伙伴分配器,但是按四叉树做的。
如果当前没有合适大小的空闲块,也没有更大的块可以拆,分配就会失败。此时K_NO_WAIT会立即返回错误;K_MSEC(n)会让调用线程在池子的等待队列上挂起最多 n 毫秒;K_FOREVER会一直等。等某个任务释放块之后,内核优先唤醒等待队列里合适大小的任务。
所以分配路径的最坏情况是清晰的:先算档位,再查空闲链表,没有就拆,拆不了就等。这一点比通用 heap 更容易做最坏情况耗时分析,对实时性要求高的路径很有价值。
2.3 释放时不是简单扔回链表
释放一个 Memory Pool 块,调的是k_mem_pool_free(ptr)。释放时不需要传池子指针,因为分配器能从块本身找到池子信息,重新把块挂到对应规格的空闲链表里。
但要注意,释放块并不意味着“父块一定能被合并回去”。只有同一父块下面的四个兄弟块都空闲时,系统才可能把它们合成一个上级块;如果其中任何一个兄弟块还被任务占着,就没法向上合并。而且不同 Zephyr 版本对合并的处理并不完全一致,所以我不建议写代码时假设“释放完 4 个小块后,马上就能申请一个大块”。
这一节的实操结论是:Memory Pool 的碎片问题并没有从根上消失,只是变得“分级可控”。长期满负荷运行后,仍然可能出现总空闲空间够、但高位档块不足的情况。所以容量规划比 API 本身更重要。
3. 在 Zephyr 里实操 Memory Pools
3.1 用 K_MEM_POOL_DEFINE 预定义一块池
最常见的定义宏是K_MEM_POOL_DEFINE,参数大概是:
K_MEM_POOL_DEFINE(pool_name, minsz, maxsz, n_max, align);minsz:最小块字节数maxsz:最大块字节数n_max:最大块的数量align:对齐字节数
例如下面这段定义了一个 64 字节到 1024 字节的池子,最大块数量为 8,按 8 字节对齐:
K_MEM_POOL_DEFINE(cmd_pool, 64, 1024, 8, 8);这行宏会在静态区申请一块 8 × 1024 = 8192 字节的内存作为数据区。由于 64 到 1024 是 16 倍,内部会拆成 64、256、1024 三档。理论上,这 8 个顶层块可以全部拆成 128 个 64 字节块,也可以按需组合分配,但总容量就是 8KB。
关于maxsz和minsz,建议保持“倍数等于 4 的整数次幂”的关系。比如 64 对应 1024,128 对应 2048,32 对应 512。如果定义一些奇奇怪怪的比值,宏在部分版本上可能编译不过,就算能过,拆分逻辑也会很别扭。我写这块代码时习惯先写注释,把设计档位列出来,方便后人改。
3.2 运行时分配与释放 API
分配的核心 API 是:
int k_mem_pool_alloc(struct k_mem_pool *pool, void **mem, size_t size, k_timeout_t timeout);使用方式是这样:
void *buf = NULL; int ret = k_mem_pool_alloc(&cmd_pool, &buf, 256, K_MSEC(10)); if (ret == 0 && buf != NULL) { /* 拿到 256 字节那档的块 */ }注意mem传的是二级指针,不是直接返回块指针。这个 API 带 timeout,所以任务可以等待。释放用:
k_mem_pool_free(buf);释放没有 timeout,也不返回状态码,调用前必须保证指针确实来自某个 Memory Pool。如果你把k_malloc得到的指针或 mem_slab 的块指针传给k_mem_pool_free,后果很难查。
另外,不要在中断里带非K_NO_WAIT的超时去分配,中断里不允许阻塞等待。实际产品里遇到中断临时需要 buffer,通常的做法是中断里用K_NO_WAIT抢一块,抢不到就用预先分配好的备用对象,或者直接把数据拷贝到静态缓冲区再交给线程处理。
3.3 一个可以跑起来的小例子
我贴一个简单但完整的使用方式,模拟“命令消息从生产者到消费者”的场景:
#include <zephyr/kernel.h> struct cmd_msg { uint8_t type; uint16_t len; uint8_t data[192]; }; K_MEM_POOL_DEFINE(cmd_pool, 64, 1024, 8, 8); void cmd_producer(void) { struct cmd_msg *msg = NULL; int ret = k_mem_pool_alloc(&cmd_pool, (void **)&msg, sizeof(*msg), K_MSEC(50)); if (ret < 0) { printk("alloc cmd msg failed, ret=%d\n", ret); return; } msg->type = 1; msg->len = sizeof(msg->data); fill_data(msg->data); /* 丢给消费者线程 */ queue_add(msg); } void cmd_consumer(void) { struct cmd_msg *msg = queue_get(); parse_cmd(msg->len, msg->data); k_mem_pool_free(msg); }sizeof(struct cmd_msg)大约是 196 字节,落在 256 字节档。每次分配会拿走一档块,释放后回到对应档位列表。这里尽量不要把msg->data指向外部可变数据,因为 pool 只管理这块内存本身,不管理里面的指针。
3.4 与 k_malloc 的关系
早期 Zephyr 里k_malloc的底层就是挂在系统内存池上的,这也是为什么有些人看到CONFIG_HEAP_MEM_POOL_SIZE这个配置名会误以为 Memory Pool 就是系统 heap。其实现在很多版本里通用堆已经演进成 k_heap / sys_heap 那套了,配置名的含义更多是给系统堆预留容量。
我的建议是:自定义的、需要严格控量的场景用K_MEM_POOL_DEFINE;业务范围内杂七杂八的小对象用k_heap。不要把k_malloc当成 Memory Pool 的等价包装去用,两者的碎片控制模型不一样。
4. 内存池大小怎么算、配置项怎么设
4.1 一个 pool 到底吃掉多少静态内存
K_MEM_POOL_DEFINE定义出来的数据区总大小,最直接的计算方法是:
总内存 ≈ n_max × maxsz因为所有顶层块共享一份数据区,底层小块都是从顶层块拆出来的。n_max说的并不是“总共能分配 n_max 次”,而是“最多同时存在 n_max 个顶层块”。一个顶层块可以整块分配出去,也可以拆成多个小块分配。但无论怎么拆,占用容量总和不能超过总内存。
做容量规划时,我通常会画一张表,把系统里所有会申请 pool buffer 的任务列出来,统计每个时刻“存活的 buffer”数量。假设有 10 个 64 字节短事务和 2 个 1024 字节长事务,理论上不能简单写n_max = 12。因为 10 个短事务如果都能从一个 1024 顶层块里拆出来,只需要 1 个顶层块;而两个 1024 长事务需要 2 个顶层块。如果这两类事务可能同时发生,你可能需要 3 个顶层块,总内存就是 3KB,不是 2KB。
换个说法:你要按最坏情况下“所有存活对象折算回顶层块的数量”来选择n_max。如果计算后发现n_max要填 6,但总内存 6KB 又接受不了,那说明 Memory Pool 不适合这个场景,要么换 k_heap,要么重新设计并发模型。
4.2 对齐参数 align 的坑
align参数影响的是分配返回地址的对齐边界。普通 CPU 上取 4 或 8 就够;如果 block 要交给 DMA 或加密硬件用,就得按硬件要求的对齐值来。
我在一个音频采集 demo 里踩过坑:DMA 缓冲区要求 32 字节对齐,结果 pool 定义时 align 只写了 4,跑了一段时间后偶发硬件异常,查到最后是 buffer 地址不满足 DMA 描述符要求。后来把align改成 32,这个现象就消失了。
还要注意,align 不是越大越好。它会影响池子内部结构的对齐开销,过大的对齐可能让每块的实际可用空间缩水。如果需求是“个别大 buffer 要 DMA 对齐”,更好的做法是单独建一块专用对齐池,而不是让所有 buffer 都跟着 32 字节对齐。
4.3 相关的 Kconfig 开关
Zephyr 版本不同,Kconfig 选项也会有差异。我在几个常用版本上使用K_MEM_POOL_DEFINE时,没有额外打开特别冷门的开关,只要内核包含 memory pool 相关源码就能编译。但如果你的构建系统把内核模块裁剪得很狠,可以搜一下MEM_POOL相关配置项,看是不是有总开关没使能。
比较容易混淆的是CONFIG_HEAP_MEM_POOL_SIZE。这个名字看起来像给 Memory Pool 用的,实际影响的是系统k_malloc/k_heap背后的容量。项目里如果发现k_malloc分配失败,先查的应该是这个配置项,而不是自定义 pool 的大小。
5. 我踩过的坑和排查方法
5.1 在中断里用 K_FOREVER 分配直接卡死
中断上下文不能阻塞等待,这是 RTOS 常识,但实际编码时很容易顺手就把 timeout 写成K_FOREVER。Memory Pool 在分配不到时会让当前执行上下文进入等待,中断里没有调度器介入条件,结果是直接触发内核 panic 或者行为未知。
我的习惯是写一个公共分配函数,里面强制约束 timeout 只能是K_NO_WAIT或短超时,并加断言:
static int shared_pool_alloc(void **buf, size_t size) { __ASSERT_NO_MSG(!k_is_in_isr()); return k_mem_pool_alloc(&shared_pool, buf, size, K_MSEC(20)); }中断路径真要分配,单独走K_NO_WAIT分支,绝不复用这个公共函数。
5.2 反复分配后出现“明明有内存却分配失败”
这个现象我在讲碎片时提过。某个模块频繁申请 64 字节小块,用完就释放;另一个模块偶尔申请 1024 字节大块。跑一段时间后,大块申请开始失败,但 pool 总空闲量其实不少。
原因是 64 字节小块分散在很多个原来的 1024 大块里,释放时兄弟块不完整,无法合并回 1024 档。此时最直接的临时解决办法是重启相关子系统或整机复位,但从设计上要调整n_max或改换分配策略。
真正要治本,需要让小块不占用所有顶层块。一个常见的做法是把不同生命周期、不同规格的 buffer 拆到多个独立 pool 里,避免小块污染大块区域。虽然总内存会上升,但稳定性和可分析性都能改善。
5.3 要不要 defrag
有些老版本或特定分支会提供内存池整理操作,可以把空闲块尝试合并。我看到过的做法都是在系统空闲阶段或掉电前维护时调用,没有谁在正常业务路径里高频 defrag。它属于重型操作,耗时不确定,和 Memory Pool“时间可控”的初衷冲突。
如果你发现系统隔几天就分配失败,不要指望 defrag 能解决一切。先做容量分析,再考虑调整 pool 布局,最后才是维护期的整理操作。
5.4 排查内存池状态
Memory Pool 没有像通用 heap 那样直观的“当前最大空闲块”查询接口,至少我常用的版本里不直观。调试时我一般自己加统计计数器:
static atomic_t alloc_count; static atomic_t alloc_fail_count; int debug_pool_alloc(void **buf, size_t size) { int ret = k_mem_pool_alloc(&cmd_pool, buf, size, K_MSEC(10)); if (ret == 0) { atomic_inc(&alloc_count); } else { atomic_inc(&alloc_fail_count); } return ret; }然后通过 shell 或日志把两个计数器打出来。如果alloc_fail_count持续增长,说明 pool 容量不够或碎片化严重。再配合每档块的占用率手动统计,基本能定位到是哪个规格的块在长时间被占用。
踩过几次坑之后,我对 Memory Pool 的态度是:它是一个“规格化管理内存”的工具,不是更高级的 malloc。用之前先想清楚 buffer 的尺寸分布和并发模型,用的时候严格按照档位算容量,宁可多留一个顶层块,也不要让系统在深夜触发那个隐蔽的分配失败。这个设计思想不只在 Zephyr 里适用,换到其他 RTOS 或者自研内存管理器,思路都是相通的。