4、内存块分配器:k_mem_slab机制、固定大小块分配、slab与pool对比
好,咱们接着聊内存管理。前面讲了堆和内存池,这次我重点说说内存块分配器——也就是 k_mem_slab。这东西在Zephyr里挺常用的,尤其适合那些固定大小数据块的场景。
说白了,k_mem_slab 就是给你准备好一堆大小完全一样的内存块。你申请的时候,它直接拿一块给你;你用完了,还回去。没有碎片,没有复杂的管理开销。嗯,就是这么干脆。
4.1 固定大小块分配:为什么需要它?
你想想看,在嵌入式系统里,很多数据结构的大小是固定的。比如网络协议栈里的数据包、蓝牙的HCI命令、传感器数据帧……这些玩意儿大小基本不变。
如果用堆来管理,频繁申请释放会产生碎片。时间一长,内存就乱成一锅粥。我早期做的一个项目就吃过这个亏——系统跑了几天后,突然分配不出内存了,查了半天,全是碎片闹的。
所以,固定大小块分配器就是专门解决这个问题的。它把一整块内存切成N个等大的小块,每个小块就是一个“槽位”。你申请,它给你一个槽;你释放,它把槽还回去。整个过程O(1)复杂度,快得很。
核心思想: 用空间换时间,用确定性换可靠性。固定大小块分配没有外部碎片,只有内部碎片(如果申请的大小比块小,浪费一点空间)。但在实时系统里,这点代价完全值得。
4.2 k_mem_slab 机制详解
k_mem_slab 是Zephyr里实现固定大小块分配的核心机制。它的数据结构很简单,就是一个链表,串起所有空闲块。
初始化的时候,你告诉它:每块多大、一共多少块。它就把一整块内存划分好,把空闲块串成链表。
申请的时候,从链表头部取一块。释放的时候,把块放回链表头部。就这么简单。
我记得有一次调试一个音频处理任务,数据缓冲区频繁申请释放,用堆的时候总是出现莫名的延迟。换成 k_mem_slab 后,延迟一下子就消失了。为什么?因为 slab 分配是确定性的,没有遍历、没有合并、没有碎片整理。
4.2.1 定义与初始化
有两种方式定义 slab:静态和动态。
静态定义:
// 定义一个slab,每块64字节,共10块
K_MEM_SLAB_DEFINE(my_slab, 64, 10, 4);
// 使用
void *block;
k_mem_slab_alloc(&my_slab, &block, K_NO_WAIT);
// ... 使用block ...
k_mem_slab_free(&my_slab, &block);
注意最后一个参数 4,这是对齐字节数。我一般设成4或者8,跟CPU字长对齐,访问效率最高。
动态初始化:
struct k_mem_slab my_slab;
char slab_buffer[64 * 10];
k_mem_slab_init(&my_slab, slab_buffer, 64, 10);
动态初始化适合那些运行时才知道大小的场景。比如根据配置参数决定slab大小。
4.2.2 申请与释放
申请接口:
int k_mem_slab_alloc(struct k_mem_slab *slab, void **mem, k_timeout_t timeout);
返回值:0表示成功,-ENOMEM表示没空闲块了。timeout 可以设 K_NO_WAIT、K_FOREVER 或者一个具体超时时间。
释放接口:
void k_mem_slab_free(struct k_mem_slab *slab, void **mem);
释放的时候要注意:必须确保 mem 指向的地址是从这个 slab 申请出来的。你要是乱传一个地址进去,后果嘛……嗯,我曾经见过有人把栈上的地址传进去,结果系统直接挂了。
警告: 不要跨slab释放内存。从 slab A 申请的内存,必须还回 slab A。混用会导致链表混乱,内存损坏。
4.2.3 状态查询
有时候你想知道 slab 的使用情况,可以用这两个接口:
int k_mem_slab_num_used_get(struct k_mem_slab *slab);
int k_mem_slab_num_free_get(struct k_mem_slab *slab);
我在做内存监控的时候,会定期打印空闲块数量。如果发现空闲块越来越少,就得警惕是不是有内存泄漏了。
4.3 slab 与 pool 对比
好,接下来咱们对比一下 slab 和 pool。这两个都是Zephyr里的内存分配机制,但适用场景完全不同。
| 特性 | k_mem_slab | k_mem_pool |
|---|---|---|
| 块大小 | 固定 | 可变(2的幂次) |
| 分配速度 | O(1),极快 | O(log N),较慢 |
| 碎片 | 无外部碎片 | 可能有内部碎片 |
| 适用场景 | 固定大小数据块 | 大小不固定的数据 |
| 内存利用率 | 高(无碎片) | 中等(有内部碎片) |
| 实时性 | 确定性强 | 确定性较弱 |
我个人习惯是:能用 slab 就用 slab。只有在数据大小确实变化很大、没法预估的时候,才考虑 pool。
举个例子:网络驱动收包。以太网帧最大1518字节,但大部分是小包。如果用 slab,你得按最大帧大小来定义块,浪费空间。这时候 pool 更合适——它可以根据实际大小分配,减少浪费。
但反过来,如果你处理的是固定长度的消息队列条目,比如每个消息128字节,那 slab 就是最佳选择。
我的建议: 设计初期,先分析一下你的数据对象大小分布。如果80%以上的对象大小相同或接近,就用 slab。如果大小分布很散,再考虑 pool。
4.4 避坑指南
最后,分享几个我踩过的坑:
- 块大小没对齐: 我曾经定义 slab 时,块大小设成了30字节。结果每次访问都多花几个周期做非对齐访问。后来改成32字节,性能提升明显。
- 忘记检查返回值:
k_mem_slab_alloc返回 -ENOMEM 时,如果你没处理,直接使用空指针……嗯,后果你懂的。 - 中断上下文用 K_FOREVER: 在中断里调用
k_mem_slab_alloc并设超时为K_FOREVER,会导致死锁。因为中断不能阻塞。记得用K_NO_WAIT。 - 多线程竞争: slab 本身是线程安全的,但如果你在多个线程里同时操作同一个 slab,要注意优先级反转问题。我一般配合信号量或互斥锁使用。
好了,关于 k_mem_slab 就讲这么多。下一节咱们聊聊更高级的内存管理技巧——内存池的深度优化。到时候见。