4. slab分配器
在嵌入式Linux系统中,内核需要频繁分配和释放大量的小型数据结构(如task_struct、inode、dentry等)。如果直接使用基于页的伙伴系统(Buddy System)来管理这些对象,会产生严重的内部碎片和性能开销。slab分配器正是为了解决这一问题而设计的——它提供了一种对象缓存机制,将内存划分为特定大小的对象池,实现高速、低碎片的动态内存管理。
4.1 三种分配器:slab / slub / slob 对比
Linux内核目前支持三种slab分配器实现:slab(传统实现)、slub(默认、精简版)和slob(极简版,用于极小内存系统)。它们在设计目标、内存开销和性能上各有侧重。
| 特性 | slab(传统) | slub(默认) | slob(极简) |
|---|---|---|---|
| 设计目标 | 通用、功能完整 | 减少元数据开销、简化代码 | 极小内存占用(< 64MB) |
| 元数据开销 | 高(每个slab有描述符) | 低(slab描述符合并到页结构) | 极低(链表+简单偏移) |
| 对象缓存 | 每CPU缓存 + 共享缓存 | 每CPU缓存 + 部分链表 | 无专用缓存,直接遍历 |
| 碎片控制 | 好(着色、对齐) | 较好(简化着色) | 一般(首次适应) |
| 适用场景 | 大型服务器、复杂NUMA | 嵌入式、桌面、服务器(默认) | 极低内存MCU、SBC |
| 代码复杂度 | 高(~6000行) | 中(~2000行) | 低(~600行) |
下图展示了三种分配器在对象分配/释放路径上的核心差异:
4.2 对象缓存机制
slab分配器的核心优势在于对象缓存。内核为每种常用数据结构创建一个专用的“缓存”(kmem_cache),缓存中预先分配好多个对象。当内核需要该结构时,直接从缓存中取出一个已初始化(或清零)的对象,无需重新分配物理页;释放时,对象返回缓存,下次分配可直接复用。
4.2.1 缓存层次结构
以slub分配器为例,对象缓存分为三个层级:
- 每CPU缓存(cpu_slab): 每个CPU维护一个freelist,指向当前CPU可直接使用的空闲对象。分配时无锁操作,性能最高。
- 部分链表(partial): 每个节点(node)维护一个部分空闲的slab链表。当每CPU缓存耗尽时,从partial链表批量搬运对象到CPU缓存。
- 全满链表(full): 所有对象都被分配的slab,暂时不参与分配,仅用于释放时回退。
slub_min_objects和slub_max_order来控制每个slab的大小,避免大块连续内存的浪费。
4.2.2 对象状态与着色
每个slab内的对象可以处于以下三种状态之一:
- 空闲(free): 对象在freelist中,可被分配。
- 使用中(used): 对象已被分配,正在被内核使用。
- 缓存(cached): 对象已被释放,但保留在缓存中,下次分配可直接复用。
着色(coloring): 传统slab分配器通过“着色”技术,让不同slab中对象的起始地址在cache line上错开,以减少CPU cache冲突。slub简化了着色,但在嵌入式场景中,着色对性能影响较小,可以忽略。
4.3 kmalloc与kfree实现
kmalloc和kfree是内核中最常用的内存分配/释放接口。它们底层依赖slab分配器(或slub/slob)来管理通用大小的内存块。
4.3.1 kmalloc实现流程
kmalloc(size, flags)的工作流程如下:
- 确定缓存索引: 根据请求的
size,查找对应的通用缓存(如kmalloc-32、kmalloc-64等)。内核预定义了多个2的幂次大小的缓存(8B、16B、32B、64B、128B、256B、512B、1KB、2KB、4KB、8KB)。 - 调用slab分配: 从对应的kmem_cache中分配一个对象。如果缓存为空,则从伙伴系统分配新的slab页。
- 返回虚拟地址: 返回对象的虚拟地址(线性映射区或vmalloc区,取决于大小和标志)。
// 简化版 kmalloc 核心路径(slub实现)
void *kmalloc(size_t size, gfp_t flags)
{
struct kmem_cache *s;
void *ret;
// 1. 根据size找到对应的通用缓存
if (size >= KMALLOC_MAX_CACHE_SIZE)
return kmalloc_large(size, flags); // 大块走伙伴系统
s = kmalloc_caches[kmalloc_index(size)];
// 2. 从slab缓存分配对象
ret = slab_alloc(s, flags, _RET_IP_);
// 3. 追踪调试(可选)
trace_kmalloc(_RET_IP_, ret, size, s->size, flags);
return ret;
}
4.3.2 kfree实现流程
kfree(ptr)的流程相对简单:
- 获取对象所属slab: 通过虚拟地址找到对应的
page结构(通过virt_to_page或直接地址运算)。 - 确定缓存: 从page结构中的
slab_cache指针获取对应的kmem_cache。 - 释放对象: 将对象放回slab的freelist,并更新slab状态(可能从full变为partial,或从partial变为free)。
// 简化版 kfree 核心路径(slub实现)
void kfree(const void *x)
{
struct page *page;
struct kmem_cache *s;
if (unlikely(ZERO_OR_NULL_PTR(x)))
return;
// 1. 获取对象所在的page
page = virt_to_head_page(x);
// 2. 从page获取对应的slab缓存
s = page->slab_cache;
// 3. 将对象释放回slab
slab_free(s, page, (void *)x, _RET_IP_);
trace_kfree(_RET_IP_, x);
}
- kmalloc分配的内存必须用kfree释放,不能混用vfree或直接释放页。
- kmalloc最大分配大小受限于KMALLOC_MAX_SIZE(通常为4MB),超过此大小应使用vmalloc或alloc_pages。
- 在嵌入式系统中,频繁的kmalloc/kfree会导致slab缓存碎片,建议使用内存池(mempool)或对象池来管理高频分配的对象。
4.4 嵌入式场景下的slab调优
在嵌入式Linux中,slab分配器的默认配置可能不适合低内存环境。以下是一些实用的调优参数:
| 参数 | 默认值 | 嵌入式建议 | 说明 |
|---|---|---|---|
slub_min_objects |
4 | 2 或 1 | 减少每个slab的最小对象数,降低内存浪费 |
slub_max_order |
3(8页) | 1 或 2 | 限制slab最大连续页数,避免大块分配失败 |
slab_max_cpus |
NR_CPUS | 实际CPU数 | 减少每CPU缓存数量,节省内存 |
slub_debug |
关闭 | 关闭(默认) | 开启调试会大幅增加内存开销,生产环境务必关闭 |
slub_min_objects=2和slub_max_order=1,将kmalloc-128的占用降低到3MB,同时分配性能仅下降5%。这是典型的“以时间换空间”策略。
下图展示了slab分配器在嵌入式系统中的完整工作流:
4.5 本章小结
- slab/slub/slob 三种分配器分别适用于不同场景:slab功能完整但开销大,slub是嵌入式首选,slob适合极低内存。
- 对象缓存 是slab分配器的核心,通过每CPU缓存、部分链表和全满链表三级结构实现高速分配。
- kmalloc/kfree 底层依赖通用缓存(kmalloc-xx),分配路径短,释放路径直接。
- 嵌入式调优 应重点关注
slub_min_objects、slub_max_order和slab_max_cpus,在内存和性能之间找到平衡点。