Zephyr 内存管理这个系列,我写到第 16 篇了。前面聊过 k_heap、内核静态数组,以及几篇围绕 DMA buffer 的分配策略。今天这篇专门讲 Memory Slabs——Zephyr 里为“固定大小、固定数量”的内存块准备的最顺手方案。
如果你写过网络协议栈驱动、串口命令解析、USB transaction、I2C 事务描述符,或者任何需要“反复分配同一尺寸结构体”的场景,大概率会遇到两类烦心事:一是通用 heap 分配时间不可控,最坏情况跟碎片状态强相关;二是自己用静态数组管理空闲块,写到最后又是一堆手动链表。Memory Slab 就是用来同时解决这两件事的。它维护一个空闲块链表,分配就是 O(1) 从链表头取一个,释放就是 O(1) 塞回链表头,而且内部不会产生碎片。适合谁?做嵌入式 RTOS 应用、驱动、协议栈的同学,基本每行代码都在跟缓冲区打交道,这块内容绕不开。
1. 先搞清楚一个前提:为什么嵌入式里要单独有一种内存板
1.1 从一个“报文缓冲区反复分配”的真实需求说起
设想一个很常见的边缘数据采集 Demo:某设备主板上挂了好几个传感器节点,周期上报固定长度的状态帧。每帧描述一个通道的运行状态,包含通道号、时间戳、状态码、负载数据和校验值。协议层收到一帧之后,不能直接把接收缓冲区的地址丢给业务线程,因为接收缓冲区马上会被下一帧复写,所以必须把帧内容复制到一块“属于自己的内存”里,再通过队列传给业务线程。等业务线程处理完,这块内存还要还回去。
这类需求非常典型,代码量不大,但里面藏着一个关键问题:这个“属于自己的内存”到底从哪来。
方案一是每帧都用 k_heap 动态分配。好处是灵活,坏处是分配时间不确定,而且不同帧大小一多,堆里就开始出现碎片。短时间跑看不出问题,跑个十天半月就可能出现明明内存还有不少、但就是分配失败的尴尬局面。
方案二是直接定义一个全局数组,比如static struct msg_t bufs[8],再用一个位图或者“借出标志”来管理。能用,但每次都要写不少管理代码,而且并发情况下容易出问题,比如两个线程同时拿到同一个块。
方案三就是 Memory Slabs。把 8 个 64 字节的块钉死在内存里,空闲块之间用链表串好。谁要用,就从链表头拿一块;谁用完了,就把块放回链表头。所有操作都是链表头操作,不涉及遍历、不涉及合并,时间完全确定。内核提供的 API 也省去了你自己管理链表的麻烦。
1.2 Memory Slab 的核心价值:O(1)、零碎片、可等待
Memory Slab 翻译成“内存板”或者“内存板块”都有人用,但我自己在代码评审里更喜欢直接叫它“固定大小内存池”。
它的内部结构并不神秘:一块连续内存被等分成 N 个固定大小的块,空闲块通过一个链表串起来。分配的时候从链表头 pop 一个,释放的时候 push 回链表头。这个操作的时间复杂度是 O(1),不随块数量变化,这是它跟通用堆最大的区别。通用堆在最坏情况下需要搜索可用区,甚至触发合并和扩展;slab 永远只做两件事:取头、放头。
还有一个容易被忽略的细节:链表指针是“借用”空闲块自己头几个字节来存的,并不是单独分配一段元数据。这句话值得细品。比如一个 64 字节的空闲块,前面几个字节用来存 next 指针;一旦分配出去,这几个字节就完全归使用者使用。所以 slab 不会因为要管理 N 个块而额外多占 N 个指针的内存,内核真正额外花掉的只是一个struct k_mem_slab控制块,大概几十字节。
零碎片也需要给一个准确的表述。固定大小分配天然不会有“外部碎片”,也就是不会出现明明总空闲内存够用、但因为没有连续区间而分配失败的情况。但它会有“内部碎片”:如果业务对象平均 60 字节,你配置的块是 64 字节,每块浪费 4 字节。这是固定池的通病,选块大小的时候要自己掂量。
1.3 什么时候不该用它
不是所有内存分配都适合 slab。
如果对象大小差异很大,比如一个几百字节、一个几 KB,用固定块池子会浪费大量内存,这时候应该考虑 k_heap。如果对象生命周期差异极大,有些对象申请一次要活到系统剩余生命,有些对象生命周期又短到只在几个调用之间,放在同一个 pool 里也会因为块数设置不合理而浪费。再有一种情况是动态扩容需求,只有运行到某个分支才会用到大量缓冲,slab 静态定死之后不具备伸缩能力,这种场景更适合其他内存方案。
我在实际选型时的判断标准很简单:能不能接受“所有内存块一个尺寸”。能接受,slab 就是最稳的选择;不能接受,再去考虑通用堆。切忌一开始就追求“万能分配器”,那往往两头都不讨好。
2. 常用 API 与两种定义方式
2.1 静态定义:K_MEM_SLAB_DEFINE 一行搞定
这是最常用的方式,宏定义一次解决“缓冲区声明 + 对象初始化”所有工作。
K_MEM_SLAB_DEFINE(frame_slab, sizeof(state_frame_t), CONFIG_FRAME_POOL_NR, __alignof__(state_frame_t));四个参数依次是:符号名、块大小、块数量、对齐方式。
对齐方式我建议直接写__alignof__(state_frame_t),或者写__alignof__(void *)。因为如果返回的块最终要作为结构体指针使用,至少得保证指针宽度对齐;如果再做 DMA 或 cache 操作,可以改成CONFIG_DCACHE_LINE_SIZE或某个 DMA 对齐大小。
定义完成之后,直接拿&frame_slab使用即可。这个宏默认会准备一块全局静态内存,容量就是block_size * slab_num_blocks,生命周期跟随整个系统。
这里有两个注意点:
第一,block_size 不是任意大小都行,最小得能放一个空闲链表节点。在 32 位系统上这个节点通常是 4 字节,64 位是 8 字节。你要是配置一个 1 字节的块,初始化断言会直接报出来。
第二,对齐参数不要盲目传 1。Zephyr 的宏内部通常会强制一个最低对齐要求,但显式写清楚让你和读你代码的人更放心。特别在某些没有对齐容忍度的架构上,一个未对齐的块地址可能导致memcpy或者结构体成员访问直接 fault。
2.2 动态定义:k_mem_slab_init 的拆解与资源要求
极端情况下,你希望 slab 的缓冲区跟某个共享内存区域重叠,或者希望运行期才决定池子建在哪,就用动态定义。
static uint8_t frame_buf[8][sizeof(state_frame_t)] __aligned(__alignof__(state_frame_t)); struct k_mem_slab frame_slab; void frame_pool_init(void) { k_mem_slab_init(&frame_slab, frame_buf, sizeof(state_frame_t), 8); }这里的frame_buf必须是静态变量,不能放在某个线程栈上,否则函数返回后池子就悬空了。这是一个非常容易踩的坑,尤其刚从动态语言转过来的同学经常在局部数组上直接初始化。
初始化时内核会把frame_buf按块大小切成 N 块,全部塞进内部空闲链表。函数参数要求buffer非空、block_size > 0。如果block_size无法容纳链表节点或者对齐不对,运行断言会给出提示。
我个人觉得,动态定义真正的好处不是省代码,而是可以绕过编译期的宏布局,比如池子落在某个特殊 extern 区域,这一点在部分系统级内存分区管理时很有用。
2.3 分配与释放:k_mem_slab_alloc / k_mem_slab_free 的等待语义
分配接口长这样:
ret = k_mem_slab_alloc(&frame_slab, (void **)&node, K_NO_WAIT);第二个参数必须是void **:内核会把分配到的块地址写入你传入的这个指针变量。这一点非常容易写错,很多人第一次用传了(void *)&node而不是(void **)&node,编译直接报警告。
第三个参数是等待时间,语义和 Zephyr 其他内核对象的等待语义一致:
K_NO_WAIT:没有空闲块立刻返回-EAGAIN;K_FOREVER:没有空闲块就把当前线程挂起来,等另一线程释放后唤醒;K_MSEC(5)、K_USEC(x):带超时的等待,超时还没等到就返回-EAGAIN。
分配成功返回 0,失败返回负数。
释放接口同样值得注意:
k_mem_slab_free(&frame_slab, (void **)&node);第二个参数传入的是“指针变量的地址”,释放成功后内核会把node这个指针变量本身置为 NULL。这个设计能防止你释放之后继续使用已经归还的块。所以释放完再去写if (node)判断是没用的,因为它已经被清成 NULL 了。这个行为在官方文档里写得清楚,但我在代码评审里见过至少三次有人拿这个 NULL 判断做业务逻辑,最后都会困惑为什么永远走不了那个分支。
3. 实操示例:一个状态帧缓存池的实现与实测
3.1 场景设定与内存预算
继续用前面的状态帧场景。定义这样一个结构体:
typedef struct { uint32_t channel_id; /* 4 */ uint32_t timestamp_ms; /* 4 */ uint32_t status; /* 4 */ uint8_t payload[48]; /* 48 */ uint32_t checksum; /* 4 */ } state_frame_t; /* sizeof = 64B,4字节对齐 */结构体经过自然对齐后,sizeof(state_frame_t)正好 64 字节。
内存预算如下:块大小 64 字节,块数量如果开 8,那么池子本身的内存开销是64 * 8 = 512字节,再加上一个struct k_mem_slab控制块几十字节。这个账非常透明。对比通用堆,每分配一个 64 字节块,至少还要额外带一个 chunk 管理头,一般是 8~16 字节;如果碎片累积,实际占用还可能更高。因此在“尺寸固定”这个前提下,slab 在内存层面通常是更省的方案。
还有一个容易被忽略的设计点:如果后续把块指针塞进消息队列,消息队列只搬运指针,不复制整个结构体。这样业务线程从队列拿到的是指针,用完指针后从 slab 归还,整个过程没有二次拷贝。如果你反其道而行,在队列里直接拷贝 64 字节结构体,slab 就白做了,队列的内存和拷贝耗时反而成为瓶颈。
3.2 完整代码与运行逻辑
下面是可直接抄作业的代码组织方式。生产侧可以是接收中断,也可以是某个接收线程,这里用函数封装:
#define FRAME_POOL_SIZE 8 K_MEM_SLAB_DEFINE(frame_slab, sizeof(state_frame_t), FRAME_POOL_SIZE, __alignof__(state_frame_t)); K_MSGQ_DEFINE(frame_q, sizeof(state_frame_t *), FRAME_POOL_SIZE, 4); /* 接收路径:拿到原始帧后,丢进slab和队列 */ static int rx_push_frame(state_frame_t *raw) { state_frame_t *node; if (k_mem_slab_alloc(&frame_slab, (void **)&node, K_NO_WAIT) != 0) { stats.alloc_failed++; return -ENOMEM; } memcpy(node, raw, sizeof(*node)); /* 消息队列只传递指针,不拷贝整块 */ if (k_msgq_put(&frame_q, &node, K_NO_WAIT) != 0) { k_mem_slab_free(&frame_slab, (void **)&node); return -EAGAIN; } return 0; } /* 消费线程 */ static void consumer_main(void *a, void *b, void *c) { state_frame_t *node; while (1) { k_msgq_get(&frame_q, &node, K_FOREVER); handle_frame(node); k_mem_slab_free(&frame_slab, (void **)&node); } } K_THREAD_DEFINE(consumer_tid, 2048, consumer_main, NULL, NULL, NULL, CONFIG_MAIN_THREAD_PRIORITY - 1, 0, 0);这里有三个非常关键的细节。
第一,K_MSGQ_DEFINE的队列容量最好等于 slab 块数,或者小于等于块数。如果队列容量设置成块数的两倍,就会出现一个非常隐蔽的问题:生产者在队列里积压了很多指针,但这些指针对应的块仍然被占用,slab 自己是不知道“业务还没消费”这个信息的,最终 slab 满、队列也满,生产者开始丢帧。容量等于块数时,消息队列本身就成了天然的背压机制。
第二,k_msgq_put失败时,必须把已经分配出来的node释放回去。这一行代码漏掉,就是典型的内存泄漏,而且很难查。
第三,如果接收路径运行在中断上下文,k_msgq_put和k_mem_slab_alloc都必须用K_NO_WAIT,绝对不能用阻塞等待。
3.3 实测记录与内存开销对照
我做过一个不算严谨但很有参考价值的对比测试,在同一块开发板上模拟高频状态帧收发,分别用 k_heap 和 k_mem_slab 作为缓存池。
| 对比项 | k_heap 动态分配 | k_mem_slab |
|---|---|---|
| 单块内存额外开销 | 每个 chunk 有头部,通常 8~16 字节 | 无,空闲块前几字节借给链表节点 |
| 分配耗时 | 随堆状态波动,碎片越多越慢 | 链表头操作,时间基本恒定 |
| 碎片风险 | 有,长期运行会出现外部碎片 | 没有外部碎片 |
| 等待能力 | 支持,线程可挂起等待 | 支持,线程可挂起等待 |
| 中断上下文使用 | 只能 K_NO_WAIT | 只能 K_NO_WAIT |
实际感受是,k_heap 在低负载下也能跑,但连续运行一段时间后,最坏分配时间明显比初启动时要差;而 slab 无论运行多久,每次分配就是“取链表头”,时间可预测。
内存占用方面,如果块数量是 100:k_heap 每个 64 字节块实际要占用 72~80 字节,slab 则是每个块 64 字节加上控制块几十字节。不要小看这 8~16 字节,在 RAM 按 KB 计算的主控上,积累起来非常可观。
4. 并发、中断与实时性:进阶使用绕不开的细节
4.1 中断上下文分配:带等待就是事故现场
在中断上下文里用 slab 是完全允许的,但你不能让中断“睡”在等待队列上。Zephyr 的中断上下文没有可切换的线程上下文,所以在 ISR 里用K_FOREVER或任何非K_NO_WAIT的超时调用,都会触发内核 fault 或直接卡死。
这个错误很经典:串口接收中断里本来写着k_mem_slab_alloc(..., K_NO_WAIT),后面某次重构时担心对端释放不及时,改成K_MSEC(10),结果一跑系统直接挂。
正确做法是:
- ISR 里永远用
K_NO_WAIT; - 如果确实需要等待,把申请动作放到 ISR 触发的
k_work里,workqueue 上下文是线程上下文,可以带超时等待; - 如果在 ISR 里分配失败,先丢包或者标记错误,绝不能卡住中断。
另外一个容易被忽略的点:k_mem_slab_free是可以在中断上下文调用的,它可能会唤醒正在等待块的线程。因为唤醒涉及调度决策,Zephyr 会延后调度,直到中断退出再执行。低优先级中断里频繁唤醒高优先级线程,可能让高优先级线程反反复复被低优先级中断打断,实时性反而变差。这类问题一旦出现,往往表现为“某线程的 deadline 总是差一点点”,排查起来很花时间。
4.2 多线程竞争与唤醒顺序:并不是“先来后到”
当多个线程同时等一个 slab 的空闲块时,块被释放后,内核会从等待队列里唤醒优先级最高的线程。如果两个线程优先级相同,才按照进入等待队列的先后顺序。所以,如果你依赖“谁先申请谁就先拿到”的公平性来设计业务逻辑,会出问题。
举个例子:线程 A 优先级 10,线程 B 优先级 20,两者同时在池子耗尽时等待;释放一个块之后,系统一定会先唤醒 A 而不是 B。对业务来说,低优先级任务可能长时间等不到内存,也就是“饥饿”问题。
我自己处理过的一个实际 case 是,某套配置解析模块里,一个低优先级后台任务偶尔需要申请配置块,但由于它是整个系统里优先级最低的,当池子紧张时它几乎拿不到块,导致配置更新永远失败。解决方法是给池子多留几个余量块,并把这个关键线程的优先级适当提高,或者用超时等待而不是K_FOREVER来做降级处理。
在设计阶段就要想清楚:slab 耗尽时,你的系统是“低优先级任务失败”还是“高优先级任务失败”,不同设计对系统稳定性影响完全不同。
4.3 泄漏与水位监控:善用计数器接口
内核提供k_mem_slab_num_used_get()和k_mem_slab_num_free_get(),这是排查问题最好的入口。调试日志可以这样打:
LOG_INF("slab used=%d/%d free=%d", k_mem_slab_num_used_get(&frame_slab), k_mem_slab_num_free_get(&frame_slab), FRAME_POOL_SIZE);如果num_used长时间等于总块数、num_free长时间为 0,说明释放链某个环节断了。常见的泄漏位置有三个:
- 分配到节点后,信息还没入队,队列满或者
k_msgq_put失败时忘了 free; - 业务线程在异常分支提前 return,忘了执行后面的 free;
- 节点从队列取出后,处理函数断言失败或者自己死循环,导致后续 free 永远执行不到。
排查时可以在 alloc 和 free 往返处加计数日志,对照“谁申请了、谁释放了”。固定池子比通用堆好查得多,因为块数有限,水位一眼就能看出来。
另外,开发阶段强烈建议打开内核断言。往一个 slab 释放一块不属于它的 buffer 地址,会被断言直接抓住,这比静默的内存破坏好定位得多。Zephyr 也支持对象追踪,打开之后可以在 shell 或调试器里直接罗列当前有多少个 slab、各自状态如何,对跑现场很有帮助。
5. 源码视角:free_list 与 wait_q 是怎么协同工作的
5.1 struct k_mem_slab 内部结构
在头文件里,struct k_mem_slab大概长这样(不同小版本字段略有差异):
struct k_mem_slab { struct _wait_q_t wait_q; sys_slist_t free_list; size_t num_blocks; size_t block_size; size_t num_used; struct k_spinlock lock; ... };两个关键成员:
free_list:所有空闲块的链表头。每个空闲块的起始地址被当作sys_snode_t,存放下一块地址。所以分配/释放只是在改这个链表的头尾指针。wait_q:当没有空闲块并且 timeout 不是K_NO_WAIT时,当前线程会挂到这个等待队列上。等待队列由内核调度器统一管理,支持优先级排序和超时唤醒。
num_used在每一次分配成功后加一、释放后减一,所以num_used_get立刻能反映池子水位。
5.2 alloc 与 free 的执行路径
把源码逻辑简化成伪码,方便理解整个协作过程:
k_mem_slab_alloc(slab, mem, timeout): lock(slab->lock) if (slab->free_list 非空): block = sys_slist_get(slab->free_list) slab->num_used++ unlock *mem = block return 0 if (timeout == K_NO_WAIT): unlock return -EAGAIN /* 挂起当前线程,等待另一个线程释放块 */ ret = z_pend_curr(&slab->lock, &slab->wait_q, timeout) if (ret != 0): return -EAGAIN /* 被唤醒后,重新上锁,再次从 free_list 取块 */ lock(slab->lock) block = sys_slist_get(slab->free_list) slab->num_used++ unlock *mem = block return 0k_mem_slab_free(slab, mem): lock(slab->lock) sys_slist_append(slab->free_list, *mem) slab->num_used-- if (wait_q 非空): z_unpend_first_thread(wait_q) z_reschedule(slab->lock) *mem = NULL return 0这个流程里,最有价值的信息是“等待不是轮询”。线程申请不到块时会被调度器挂起,并不消耗 CPU。另一个价值在于你能看出为什么释放路径要唤醒线程,以及为什么被唤醒的线程必须要再次上锁检查。内核源码里的实际逻辑比我这里稍微复杂一点,会处理超时、信号、抢占等分支,但骨架就是这样。
明白这套机制以后,再遇到“释放了一个块,但等待线程没有及时被唤醒”这类现象,你就能顺着锁和 wait_q 的方向排查,而不是在应用逻辑里瞎猜。
5.3 与内核其他对象的配合
slab 最经典的搭档是k_msgq。用队列传指针而不是拷贝内容,这已经在第 3 章讲过了。另一个常见搭档是k_fifo,Zephyr 的k_fifo本身就是链表队列,可以直接放指针。
如果业务线程同时在等待“新消息”和“池子空闲度”,可以考虑内核的k_poll接口,做多对象等待。不过大多数场景只用消息队列就足够了。这里想提醒的是:不要因为花哨就过度组合。消息队列加 slab 设计已经能覆盖绝大多数“生产者-消费者”模型,再加一层信号量或事件标志,往往只是增加死锁风险。
还有一个值得知道的东西:在开启了CONFIG_USERSPACE的架构上,slab 对象同样受内核对象权限管理。如果用户线程需要访问某个 slab,需要通过k_object_access_grant()显式授权。主流的内核态嵌入式项目用不到,但做多权限组件集成时一旦遇到,排查起来会懵。
6. 选型对照与我的实战经验
6.1 一张表完成最终决策
| 决策点 | k_heap | k_mem_slab | 静态数组 / 自定义对象池 |
|---|---|---|---|
| 申请尺寸 | 任意大小 | 固定大小 | 固定大小 |
| 分配时间 | 最坏情况与碎片、分配器状态相关 | O(1),恒定 | O(1) |
| 外部碎片 | 会有 | 无 | 无 |
| 内部碎片 | 有,chunk 对齐产生 | 有,块比对象大时产生 | 无,按对象定义 |
| 等待能力 | 支持 | 支持 | 需自己实现 |
| 中断上下文 | 只用 K_NO_WAIT | 只用 K_NO_WAIT | 可用 |
| 元数据开销 | 每个 chunk 有头部 | 仅控制块,块头复用 | 无 |
| 适合场景 | 大小不固定的命令/临时缓存 | 固定报文、对象池、协议帧缓冲 | 极少全局结构、不释放 |
我的结论很简单:
- 如果对象尺寸明确固定,例如某种状态帧、某种协议包、I2C 事务描述符,首选 slab;
- 如果大小可能很杂,不要硬套 slab,选 k_heap;
- 如果只想用静态数组、不做动态分配,slab 依然是更安全的做法,因为你不用自己维护链表,还免费获得等待能力。
6.2 我踩过的几个坑
第一个坑是“队列容量和 slab 容量不匹配”。某次我把消息队列容量配成 slab 块数的两倍,表面上看生产队列能缓冲更多指针,但实际上没有业务及时消费时,slab 满、队列也满,所有生产者一起失败。后来我把队列容量改回跟块数相等,系统反而稳定了。
第二个坑是“释放地址不属于这个 slab”。有一次网上流传的示例代码里,直接朝 slab 释放一个栈上局部变量的地址,内核断言在烧录后立刻炸出来。原因是k_mem_slab_free内部会对地址范围做校验,但你如果不看日志,很容易以为是中断优先级配置出问题。记住:释放的地址必须来自同一个 slab 的 alloc 返回值。
第三个坑是“忘记约定谁释放”。slab 块通常跨模块传递,比如接收模块 alloc、解析模块 free、上层模块又 free 一次。双释放虽然不会立刻崩,但会把空闲链表搞乱,导致后续分配拿到错误的块。我的习惯是:每次 alloc 在一行内就要想好“谁负责释放”;如果跨模块,就必须在接口注释里写明所有权转移关系。
第四个坑是“隐藏的等待”。有个项目里线程 A 在K_FOREVER等 slab 块,线程 B 在等信号量,结果信号量的释放端又在同一深度上等 slab 块,直接形成循环等待。排查难度比普通死锁高,因为它不在同一个锁的临界区里,而是跨了两个内核对象。
6.3 个人体会
我在实际使用中对 Memory Slab 最大的感受是:它把“内存管理”这件事,从“分配器黑盒”变成了“看得见的池子”。池子里有几个块、用了几个、谁在等,全部可以通过 API 查出来。这一个特性在排查问题时的价值,远超过了 O(1) 分配本身。
如果你刚开始迁移到 Zephyr,我建议从一个小模块开始试水:挑一个固定大小的报文结构,用K_MEM_SLAB_DEFINE定义池子,替换掉原来的全局数组。你会很快体会到,这种“固定大小、固定数量”的设计,让内存预算变得特别清晰,也让长期运行的稳定性更有底气。等用得顺手了,再进一步组合消息队列、多线程消费,整个数据通路都会顺畅很多。