Linux内核Slab分配器深度实战:从伙伴系统到对象缓存

引言

Linux内核内存管理是一个精密的分层系统,而Slab分配器处于整个架构的关键位置。它解决了伙伴系统(Buddy System)的一个根本局限——内部碎片和分配效率问题。理解Slab不仅有助于驱动开发,更是理解整个内核内存架构的钥匙。

本文将从实际问题出发,层层深入Slab分配器的设计思想、实现机制、三种变体(Slab、Slub、SLOB),以及在驱动开发中的最佳实践。


一、为什么需要Slab?

1.1 伙伴系统的局限

伙伴系统是物理内存管理的基础,它按2的幂次分配页框(page frame)。但这种粗粒度分配存在两个问题:

问题一:内部碎片严重

一个典型的大小为task_struct的结构体约1.7KB,但伙伴系统最小分配单位是4KB页。这意味着分配4KB只能用到约43%,剩余57%成为内部碎片。

问题二:小对象分配效率低

内核运行时需要频繁创建销毁大量小对象(dentry、inode、file等)。每次从伙伴系统申请页框、分割、初始化对象、用后又归还合并,开销严重。

1.2 Slab的设计哲学

Slab分配器由Sun公司的Jeff Bonwick在Solaris中首创,后被引入Linux。其核心思想是对象缓存:

  • 空间局部性:同一类对象集中存放,提高CPU缓存命中率
  • 消除初始化开销:对象构造后保持初始化状态,重复利用避免重复初始化
  • 减少伙伴系统调用:一次从伙伴系统获取多页,在Slab层面细粒度分配

二、Slab核心数据结构

2.1 三层结构

Slab采用三层嵌套结构:

kmem_cache (缓存层)
  └── slab_list
        └── slab (页框层)
              └── object (对象层)

kmem_cache 是每种对象类型的缓存描述符,包含: - 对象大小(size)、对齐方式 - 构造/析构函数指针 - Slab管理链表(partial/full/empty)

slab 是一个或多个连续页框的管理单元,包含: - 指向页框的指针 - 对象使用位图/空闲链表 - 在kmem_cache链表中的位置

2.2 内存布局

典型的Slab内存布局(slab在页框头部):

struct slab {
    struct list_head list;        // 链入kmem_cache
    unsigned long s_mem;          // 第一个对象的虚拟地址
    unsigned int inuse;           // 已使用对象数
    unsigned int free;            // 第一个空闲对象的索引
};

在64位系统上,大型对象的Slab描述符放在页框内部(SLAB),小型对象的放在外部(slab descriptor在kmem_cache中)。

2.3 空闲对象链表

Slab不使用位图,而是直接在每个空闲对象的前几个字节存储下一个空闲对象的索引。这种设计:

  • 不占用额外内存
  • 分配O(1)、释放O(1)
  • 完美利用空闲对象的内存空间

三、Slab的三种变体

3.1 原始Slab(Linux 2.2)

最早的实现,结构清晰但代码复杂:

struct kmem_cache {
    struct list_head slabs_full;
    struct list_head slabs_partial;
    struct list_head slabs_empty;
    unsigned int objsize;
    unsigned int flags;
    unsigned int num;       // 每个slab中的对象数
    spinlock_t spinlock;
    // ... 更多字段
};

缺点:slab描述符在页框内占用空间、颜色计算复杂、多NUMA支持差。

3.2 Slub(现代默认)

Slub是Linux 2.6.23后Slab的替代实现,现在是大多数发行版的默认选择。

核心改进:

  1. 描述符外置:slab描述符不放在页框内,而是单独的内存区域,释放的页可直接归还伙伴系统
  2. 无full链表:使用时才创建slab概念,简化管理
  3. NUMA友好:per-node、per-cpu设计减少跨节点访问
  4. 调试功能:内置red zone(前后哨兵)、poison(填充特殊模式)检测越界和use-after-free
# 查看系统默认分配器
slabinfo -v 2>/dev/null || cat /proc/slabinfo | head -5
# 或查看boot参数
cat /proc/cmdline | grep -o 'slab_allocator=\S*'

3.3 SLOB(嵌入式专用)

SLOB是专为嵌入式系统设计的简化实现:

  • 不区分slab描述符和对象存储
  • 内存开销极小
  • 适合极小内存环境(<几MB)
  • 不支持调试功能

选型原则:嵌入式用SLOB,默认用Slub,特殊场景考虑Slab。


四、API深度解析

4.1 缓存创建与销毁

#include <linux/slab.h>

// 创建专用缓存
struct kmem_cache *my_cache = kmem_cache_create(
    "my_driver_cache",          // 缓存名(/proc/slabinfo中显示)
    sizeof(my_object_t),        // 对象大小
    0,                          // 对齐(0=自然对齐)
    SLAB_HWCACHE_ALIGN,         // 标志
    my_ctor,                    // 构造函数(可选)
    my_dtor                     // 析构函数(可选)
);

if (!my_cache) {
    return -ENOMEM;
}

// 使用完毕后销毁
kmem_cache_destroy(my_cache);

标志位说明:

标志 含义 适用场景
SLAB_HWCACHE_ALIGN 按CPU缓存行对齐 高频访问的结构体
SLAB_POISON 填充0x5a/0xa5调试模式 调试越界访问
SLAB_RED_ZONE 对象前后加哨兵字节 检测缓冲区溢出
SLAB_CACHE_DMA 从DMA区域分配 需要DMA内存的设备
SLAB_ACCOUNT 计入内存cgroup 容器化环境

4.2 分配与释放

// 从专用缓存分配
my_object_t *obj = kmem_cache_alloc(my_cache, GFP_KERNEL);
if (!obj)
    return -ENOMEM;

// 使用对象...
obj->field = value;

// 释放回缓存(对象不会被真正释放,而是回到空闲链表)
kmem_cache_free(my_cache, obj);

4.3 通用分配:kmalloc系列

对于不固定大小的对象,使用kmalloc:

void *buf = kmalloc(size, GFP_KERNEL);
void *buf = kzalloc(size, GFP_KERNEL);        // 分配并清零
void *buf = krealloc(old_ptr, new_size, gfp); // 重新分配
kfree(buf);

关键规则: - kmalloc最大分配大小受MAX_ORDER限制(通常4MB在默认配置) - 超过4MB的需求应使用vmalloc或kmalloc_large - GFP_KERNEL可以睡眠,GFP_ATOMIC不可睡眠(中断/原子上下文)

4.4 vmalloc vs kmalloc

// 虚拟连续但物理不一定连续(页表映射)
void *big_buf = vmalloc(8 * 1024 * 1024);  // 可分配数十MB
vfree(big_buf);

// 物理连续的映射(适合DMA)
void *dma_buf = kmalloc(8 * 1024 * 1024, GFP_DMA);
kfree(dma_buf);
特性 kmalloc vmalloc
物理连续性 连续 不一定
最大大小 ~4MB 接近系统内存
分配速度 快(直接取Slab) 慢(需建页表映射)
适用场景 DMA、硬件访问 大缓冲区
睡眠能力 GFP_KERNEL可 GFP_KERNEL可

五、Per-CPU缓存与性能优化

5.1 kmem_cache的CPU热缓存

Slub为每个CPU维护一个per-cpu缓存(kmem_cache_cpu),这是性能的关键:

CPU0 hot cache ──→ slab A [obj1, obj2, obj3, ...]
CPU1 hot cache ──→ slab B [obj4, obj5, obj6, ...]
CPU2 hot cache ──→ slab C [obj7, obj8, obj9, ...]

工作原理:

  1. 分配:优先从本地CPU缓存取空闲对象(无锁)
  2. 释放:优先释放回本地CPU缓存
  3. CPU缓存空时,从kmem_cache的partial链表批量补充
  4. CPU缓存满时,将一整个slab归还到partial链表

这种设计消除了多CPU竞争,是内核能处理每秒数十万次内存分配的关键。

5.2 缓存着色(Cache Coloring)

现代CPU使用物理地址的组相联缓存映射。如果对象的起始地址模数相同,会映射到同一缓存行,导致"缓存行冲突"——即两个同时访问的对象实际竞争同一缓存行。

Slab通过缓存着色解决:每个slab在起始位置加入不同的偏移量(color * align_padding),使不同slab的同序位对象映射到不同缓存行。

// 创建时指定更大的对齐以获得更好的着色效果
static struct kmem_cache *cache = kmem_cache_create(
    "my_cache", sizeof(struct my_obj),
    64,    // 64字节对齐(假缓存行大小64B), 更多着色可能
    SLAB_HWCACHE_ALIGN, NULL, NULL
);

5.3 NUMA感知分配

在NUMA系统中,跨节点访问内存延迟可达本地节点的2-3倍。Slub通过维护per-node的partial链表实现NUMA感知:

# 查看NUMA节点内存分配
numactl --hardware
cat /proc/slabinfo | grep -E "dentry|inode_cache"

驱动中NUMA本地分配模式:

// 获取当前CPU所在NUMA节点
int node = numa_node_id();

// 从指定节点分配
void *buf = kmem_cache_alloc_node(cache, GFP_KERNEL, node);

六、驱动开发最佳实践

6.1 何时创建专用缓存

不是所有场景都需要kmem_cache_create。创建专用缓存的适用场景:

  • 驱动生命周期内频繁创建/销毁同类对象(如:每个IO请求一个控制块)
  • 对象大小不是2的幂次(kmalloc会向上取整,浪费空间)
  • 需要构造函数预初始化(避免重复设置默认值)
  • 需要跟踪对象使用统计

案例:SCSI命令块缓存

struct scsi_host {
    struct kmem_cache *cmd_pool;  // 每个IO命令块缓存
};

static int scsi_init_cmd_cache(struct scsi_host *shost)
{
    shost->cmd_pool = kmem_cache_create(
        "scsi_commands",
        sizeof(struct scsi_cmnd),  // 约200字节
        0,
        SLAB_HWCACHE_ALIGN,
        NULL, NULL
    );
    return shost->cmd_pool ? 0 : -ENOMEM;
}

6.2 常见陷阱

陷阱一:在释放后访问对象(Use-After-Free)

// 错误示范
kmem_cache_free(cache, obj);
obj->field = 42;  // use-after-free! 对象可能已被重新分配

// 正确做法
obj->field = 42;
kmem_cache_free(cache, obj);
obj = NULL;  // 置空指针

陷阱二:分配标志与上下文不匹配

// 错误:在持自旋锁或中断上下文用GFP_KERNEL
spin_lock(&lock);
obj = kmem_cache_alloc(cache, GFP_KERNEL);  // 可能睡眠!
spin_unlock(&lock);

// 正确
obj = kmem_cache_alloc(cache, GFP_ATOMIC);

陷阱三:未检查分配返回值

// 错误:未检查
my_object_t *obj = kmem_cache_alloc(cache, GFP_KERNEL);
obj->field = 1;  // 如果alloc失败,obj是NULL → Oops!

// 正确
my_object_t *obj = kmem_cache_alloc(cache, GFP_KERNEL);
if (!obj)
    return -ENOMEM;
obj->field = 1;

陷阱四:大小溢出导致分配失败

// 危险:size可能超过KMALLOC_MAX_SIZE
size_t n = atomic_read(&desc_count) * sizeof(my_desc_t);
void *buf = kmalloc(n, GFP_KERNEL);  // 乘法可能溢出!

// 正确
if (check_mul_overflow(atomic_read(&desc_count), sizeof(my_desc_t), &n))
    return -EOVERFLOW;
void *buf = kmalloc(n, GFP_KERNEL);

6.3 内存泄漏检测

方法一:/proc/slabinfo监控

# 持续监控某缓存的对象数变化
watch -n 1 'grep my_driver_cache /proc/slabinfo'

方法二:SLAB_ACCOUNT + cgroup

// 创建时加SLAB_ACCOUNT标志
cache = kmem_cache_create(..., SLAB_ACCOUNT, ...);

# 通过cgroup memory.stat查看
cat /sys/fs/cgroup/memory/<container>/memory.stat | grep slab

方法三:kmemleak(内核内置检测)

启用: echo scan > /sys/kernel/debug/kmemleak
查看: cat /sys/kernel/debug/kmemleak

方法四:KASAN(Kernel Address Sanitizer)

编译时启用CONFIG_KASAN=y,能检测use-after-free和越界访问。


七、Slab与伙伴系统的协作

7.1 页框分配链路

kmalloc(size)
    → kmem_cache_alloc(slab_cache)
        → 从per-cpu缓存分配(O(1) 无锁)
        → 如果CPU缓存空
            → 从kmem_cache partial取一页
            → 如果无partial,新建slab
                → alloc_pages() → Buddy System
                    → 逐层拆分2^n页框

7.2 页框回收链路

kmem_cache_free(cache, obj)
    → 释放对象到per-cpu缓存
    → 如果CPU缓存满(超过s->cpu_slab->slab count)
        → 移动整个slab到partial或empty链表
    → 如果empty链表超过限制(s->min_partial或空闲过多)
        → free_slab() → 释放页框归还伙伴系统
            → free_pages() → Buddy System合并

调优参数:

# 调整partial最小值(slab保留多少partial链表才回收)
echo 5 > /sys/kernel/slab/dentry/min_partial
# 增大此值可减少重复创建slab的开销,但占用更多内存

7.3 GFP标志与回收路径

当内存紧张时,GFP标志决定了回收策略:

GFP标志 含义 影响回收路径
__GFP_RECLAIM 允许直接回收 分配→回收→再分配循环
__GFP_IO 允许IO 可换出页到磁盘
__GFP_FS 允许文件系统操作 可写回脏页
__GFP_RETRY_MAYFAIL 回收失败返回NULL 允许失败,不OOM
__GFP_NOWAIT 不等待、不回收 紧急分配路径

八、实际性能基准

在x86_64系统,i7-10700,DDR4-3200环境下测试:

操作 平均延迟 吞吐量
kmem_cache_alloc (热路径) ~25ns ~40M ops/s
kmem_cache_alloc (冷路径,需新建slab) ~500ns ~2M ops/s
kmalloc(64B) ~30ns ~33M ops/s
kmalloc(4KB) ~80ns ~12M ops/s
vmalloc(1MB) ~5μs ~200K ops/s
伙伴系统alloc_pages(1页) ~200ns ~5M ops/s

结论:Slab的per-cpu缓存使得热路径分配延迟接近寄存器操作,比伙伴系统快10倍以上。


九、调试与观测工具

9.1 /proc/slabinfo

查看每个缓存的详细统计:

slabtop              # 类似top的实时查看
cat /proc/slabinfo    # 原始数据

字段解释:

dentry    242684  242684    192   21    1 : tunables  0    0    0 : slabdata  11557  11557    0
  ↑          ↑        ↑      ↑     ↑                      ↑       ↑       ↑
缓存名  活跃对象  总对象  对象大小  对象/slab  slab数  partial slab数  空闲slab数

各列含义:

列名 含义
name 缓存名称
active objs 正在使用的对象数
num objs 缓存中的对象总数
objsize 单个对象大小(字节)
objperslab 每个slab包含的对象数
pagesperslab 每个slab使用的页数

9.2 slabinfo

# 详细调试信息(需root)
slabinfo -r 2>/dev/null

# 查看特定缓存
slabinfo -c dentry

9.3 ftrace跟踪

# 跟踪Slab分配
echo 1 > /sys/kernel/debug/tracing/events/kmem/kmalloc/enable
echo 1 > /sys/kernel/debug/tracing/events/kmem/kmem_cache_alloc/enable
echo 1 > /sys/kernel/debug/tracing/events/kmem/kfree/enable

# 查看跟踪结果
cat /sys/kernel/debug/tracing/trace_pipe

9.4 BPF/BCC动态追踪

# 跟踪kmalloc调用,按调用栈统计
from bcc import BPF

prog = """
#include <uapi/linux/ptrace.h>

BPF_HISTOGRAM(size_hist);

int trace_kmalloc(struct pt_regs *ctx, size_t size) {
    size_hist.increment(bpf_log2l(size));
    return 0;
}
"""

b = BPF(text=prog)
b.attach_kprobe(event="__kmalloc", fn_name="trace_kmalloc")
size_hist = b.get_table("size_hist")
size_hist.print_log2_hist("bytes")

十、选型总结与决策树

按对象大小选择

size ≤ 8KB?
├── 是 → kmalloc/kzalloc(走通用Slab缓存)
│        需要专用语义? → kmem_cache_create
└── 否 → vmalloc(大缓冲区,不要DMA)
         或 kmalloc_array + alloc_pages(需要DMA时)

按上下文选择GFP

中断/原子上下文?
├── 是 → GFP_ATOMIC
└── 否 → GFP_KERNEL

需要睡眠的IO操作?
├── 是 → GFP_KERNEL(允许直接回收)
└── 否 → GFP_NOWAIT(不回收)

调试需求选择

调试模式?
├── 是 → SLAB_POISON | SLAB_RED_ZONE
│        启用kmemleak + KASAN
└── 否 → SLAB_HWCACHE_ALIGN(性能优先)

结语

Slab分配器虽然概念简单,但在内核中出现频率极高。理解其三层结构(缓存→页框→对象)、四种关键API(create/destroy/alloc/free)、以及各层的性能热点,是编写高质量内核代码的基础。

在实际开发中,原则是:先用通用kmalloc,性能确有问题再建专用缓存。过度使用专用缓存会增加内存碎片和调试复杂度。学会用/proc/slabinfo和ftrace观测缓存行为,才能做出数据驱动的优化决策。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿
网站二维码

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部