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_WAITK_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 就讲这么多。下一节咱们聊聊更高级的内存管理技巧——内存池的深度优化。到时候见。