Linux Kernel Slab Allocator 深度实战:从 kmem_cache 到内存分配器实现原理

引言

在 Linux 内核开发中,内存管理是一个核心课题。与用户空间使用 malloc/free 不同,内核需要处理更复杂的场景:物理内存碎片、DMA 约束、NUMA 拓扑、实时性要求。Slab Allocator(slab 分配器)作为内核内存管理子系统的重要组成部分,专门解决频繁创建/销毁同类型对象时的性能问题。

本文将深入剖析 slab 分配器的架构设计、核心数据结构、分配与释放流程、三种变体(SLAB/SLUB/SLOB)、性能调优技巧以及驱动开发中的最佳实践。

一、为什么需要 Slab Allocator

1.1 直接操作伙伴系统的问题

Linux 内核的 Buddy System(伙伴系统)以页(通常 4KB)为单位管理物理内存,最小分配单位为 1 页。如果内核频繁分配一个小结构体(如 128 字节的 file 结构),直接通过 __get_free_pages() 会产生严重的内存浪费:

  • 内部碎片:128 字节对象占用整页 4096 字节,利用率仅 3.1%
  • 初始化开销:每次分配都需要重新初始化对象的各个字段
  • 缓存局部性差:对象分散在不同物理页,CPU Cache 命中率低

1.2 Slab 的核心思想

Slab 分配器借鉴了 SunOS 中 Jeff Bonwick 提出的方案,核心思路是对象缓存(Object Cache):

  1. 预分配一组连续物理页(slab),在其中切分出固定大小的槽位
  2. 相同类型的对象共享同一个缓存(kmem_cache),复用已初始化状态
  3. 利用着色(Coloring)优化 Cache Line 利用率

二、核心数据结构

2.1 kmem_cache 结构体

每个对象类型对应一个 kmem_cache 实例,它是 slab 分配器的核心管理结构:

struct kmem_cache {
    struct kmem_cache_cpu *cpu_slab;      // Per-CPU 快速分配缓存
    unsigned int size;                     // 对象实际大小(含对齐)
    unsigned int object_size;              // 用户请求的裸对象大小
    unsigned long flags;                   // 标志位(如 SLAB_ACCOUNT)
    unsigned int offset;                   // 下一个空闲对象的 freelist 偏移
    const char *name;                      // 缓存名称(用于 /proc/slabinfo)
    struct list_head list;                 // 全局缓存链表
    struct kmem_cache_node **node;         // NUMA 节点数组
    struct array_cache *array[NR_CPUS];    // Per-CPU 辅助缓存(SLAB 变体)
    unsigned int limit;                    // array_cache 中最大空闲对象数
    unsigned int batchcount;               // 一次转移的对象数量
    unsigned int gfporder;                 // 每个 slab 包含的页数 (2^n)
    unsigned int colour;                   // 可用着色区域数量(偏移种类)
    unsigned int colour_off;               // 每次着色的偏移增量(Cache Line 大小)
    struct kmem_cache *slabp_cache;        // 管理 slab 描述符的专用缓存
    slab_t *freelist;                      // 全局空闲对象链表
    // ... 统计与调试字段
};

2.2 Slab 描述符

每个物理 slab 页组都有一个 slab 描述符,记录该 slab 的使用状态:

struct slab {
    struct list_head list;          // 挂载到 node 的 full/partial/free 链表
    unsigned long s_mem;            // slab 页中第一个对象的虚拟地址
    unsigned int inuse;             // 已被分配的对象数量
    unsigned int free;              // 第一个空闲对象的索引
};

根据 slab 所在 NUMA 节点的链表位置,分为三类:

  • free:所有槽位未使用
  • partial:部分已使用、部分空闲
  • full:全部槽位已被分配

2.3 Per-CPU 快速缓存:kmem_cache_cpu

SLUB 变体中使用,每个 CPU 维护一个 freelist 缓存,避免加锁:

struct kmem_cache_cpu {
    void **freelist;                // 下一个空闲对象的指针
    unsigned long tid;              // 事务 ID(防止竞争)
    struct page *page;              // 当前使用的 slab 页
    struct page *partial;           // Per-CPU partial slab 链表
};

2.4 NUMA 感知:kmem_cache_node

在 NUMA 系统中,每个 NUMA 节点都有一套独立的部分满/空闲 slab 列表:

struct kmem_cache_node {
    spinlock_t list_lock;           // 节点级保护锁
    unsigned long nr_partial;       // partial slab 数量
    struct list_head partial;       // partial slab 链表
    unsigned long nr_slabs;         // 节点上的总 slab 数
    unsigned long total_objects;    // 节点上的总对象数
    struct list_head full;          // full slab 链表
};

三、分配与释放流程详解

3.1 分配流程(kmem_cache_alloc)

以现代 SLUB 分配器为例,分配路径分为三级快速缓存:

kmem_cache_alloc(cache, flags)
    │
    ▼
kmem_cache_alloc_trace()  →  添加 kasan/kfence 插装
    │
    ▼
slab_alloc_node()
    │  ┌─ 第一级: kmem_cache_cpu->freelist(无锁,per-CPU)
    │  │   └─ 命中: 直接返回 freelist 顶部对象
    │  │
    │  ├─ 第二级: Per-CPU partial 链表
    │  │   └─ 有可用 page: 切换到该 page,更新 freelist
    │  │
    │  ├─ 第三级: kmem_cache_node->partial(加节点锁)
    │  │   └─ 取出一个 partial slab,移至 CPU 页
    │  │
    │  └─ 慢路径: 通过 Buddy System 分配新页
    │      └─ new_slab() → 返回一个 free slab
    ▼
返回对象指针

关键优化点:热路径上(第一级命中),仅需几条汇编指令即可完成分配,无需任何原子操作或锁。

3.2 释放流程(kmem_cache_free)

释放是分配的逆过程,同样优先走 Per-CPU fast path:

kmem_cache_free(cache, objp)
    │
    ▼
slab_free()
    │  ┌─ 判断 obj 是否属于当前 CPU 的 kmem_cache_cpu->page
    │  │    ├─ 是: 直接归还到 per-CPU freelist(无锁)
    │  │    └─ 否: 进入慢路径
    │  │
    │  ├─ 第三级: 若 slab 从未满变为 empty → 归还给 node
    │  │         若 slab 从 full 变为 partial → 挂载到 node->partial
    │  │
    │  └─ 慢路径: 若 slab 变回 free → 可归还给 Buddy System
    │           (通常保留在 partial 链表中以加快下次分配)
    ▼
释放完成

3.3 Freelist 指针存储技巧

SLUB 使用 union 技巧存储空闲指针:未使用的槽位复用为 freelist 指针,无需额外链表结构。

// 空闲槽位的内存布局(Union 复用):
// [freelist 指针] 指向下一个空闲索引/地址
// 分配的槽位:存储实际对象数据,完全覆盖 union 区域

// 获取下一个空闲对象
void *next = freelist[offset];
// 更新 freelist
freelist = next;

四、三种 slab 变体对比

特性SLAB(传统)SLUB(默认)SLOB(精简)
设计目标高性能、全功能简化、可维护极低内存开销(嵌入式)
内核引入2.1 早期2.6.23 起默认2.6 早期
元数据占用较高(每 slab 额外数组)内嵌在 struct page极低(仅 next/prev 指针)
调试支持Red Zone, PoisoningKASAN 集成, Fuzzer基本无
NUMA 支持完善优化良好有限
典型场景服务器系统通用默认内存 < 64MB 的嵌入式设备
当前状态已废弃(5.x 后移除)活跃开发中保留但较少使用

4.1 SLUB 的核心优化

  • Freelist 内嵌:SLAB 中 slab 描述符的 freelist 是单独数组,SLUB 将其嵌入到 struct page 的 mapping/freelist 字段中,减少了内存开销
  • CPU 亲和:per-CPU page 缓存减少跨节点访问和锁竞争
  • 合并小缓存:对于小对象(< 1/8 页大小),kmem_cache_create 时尝试合并到已有相近大小缓存中,减少缓存数量

五、SLAB 着色(Cache Coloring)

5.1 问题背景

现代 CPU 的 L1 Cache 通常采用直接映射或组相联映射。如果多个 slab 中相同偏移的对象恰好映射到同一个 Cache Line,就会产生 Cache Line 冲突(Cache Thrashing),导致性能急剧下降。

5.2 着色原理

着色通过在每个 slab 的起始添加不同大小的偏移(colour_off,通常是 L1 Cache Line 大小),打散相同偏移对象的 Cache 映射:

slab 布局(无着色):
[obj0][obj1][obj2][obj3]...
  偏移: 0, 128, 256, 384 → 全部映射到同一 Cache Line

slab 布局(有着色):
[跳过colour_off_0][obj0][obj1][obj2]  // offset = 0
[跳过colour_off_1][obj0][obj1][obj2]  // offset = 64
[跳过colour_off_2][obj0][obj1][obj2]  // offset = 128
→ 不同 slab 的 obj0 映射到不同 Cache Lines

5.3 相关参数

  • cache_line_size():返回当前 CPU 的 L1 Cache Line 字节数
  • colour_off = cache_line_size()
  • 最大可用偏移种类数 colour = (end - addr) / colour_off

六、kmem_cache API 实战

6.1 创建和使用自定义缓存

// include/linux/slab.h

// 1. 定义私有结构体
struct my_device_desc {
    struct list_head list;
    char name[32];
    void *reg_base;         // 映射的寄存器基地址
    dma_addr_t dma_handle;  // DMA 地址
    spinlock_t lock;
    wait_queue_head_t wq;
};

// 2. 全局缓存指针
static struct kmem_cache *my_desc_cache;

// 3. 模块初始化时创建缓存
static int __init my_driver_init(void)
{
    my_desc_cache = kmem_cache_create("my_device_desc",
                    sizeof(struct my_device_desc),
                    0,                    // 对齐(0 表示自然对齐)
                    SLAB_HWCACHE_ALIGN,   // 硬件 Cache 对齐
                    NULL);                // 构造函数(可为 NULL)
    if (!my_desc_cache)
        return -ENOMEM;
    pr_info("My driver: kmem_cache created\n");
    return 0;
}

// 4. 分配对象
struct my_device_desc *desc = kmem_cache_alloc(my_desc_cache, GFP_KERNEL);
if (!desc)
    return -ENOMEM;

// 正常使用 desc...
desc->reg_base = ioremap(REG_BASE, REG_SIZE);

// 5. 释放对象(返回给缓存,不会立即归还给 Buddy System)
kmem_cache_free(my_desc_cache, desc);

// 6. 模块退出时销毁缓存
static void __exit my_driver_exit(void)
{
    kmem_cache_destroy(my_desc_cache);
}

6.2 kmem_cache 家族的变体函数

// GFP 标志语义:
GFP_KERNEL      // 标准分配,可能睡眠(可回收内存)
GFP_ATOMIC      // 原子上下文,不可睡眠(紧急内存池)
GFP_DMA         // 分配 ISA DMA 可用内存(< 16MB)
GFP_DMA32       // 分配 32-bit DMA 可用内存(< 4GB)
GFP_USER        // 分配给用户空间的内存(需可映射)
GFP_NOFS        // 分配时不触发文件系统操作
GFP_NOIO        // 分配时不触发 I/O 操作
GFP_NOWAIT      // 绝不睡眠(同 GFP_ATOMIC)

6.3 查看 slab 缓存

所有通过 kmem_cache_create() 创建的缓存都会显示在 /proc/slabinfo 中:

$ cat /proc/slabinfo | grep my_device
my_device_desc     256    384    512   16    2 : tunables ...
//              active  total   size  perslab 页序

七、通用内存分配:kmalloc 系列

7.1 kmalloc 与 slab 的关系

kmalloc() 本质上是一组预创建的 kmem_cache 集合(kmalloc_caches[]),按 2 的幂次大小分类:

// mm/slab.c 中的内置缓存表(简化)
static struct kmem_cache *kmalloc_caches[NR_KMALLOC_TYPES][KMALLOC_SHIFT_HIGH + 1];

// 典型大小: 32, 64, 96, 128, 192, 256, 384, 512, 768, 1024,
//           1536, 2048, 4096, 8192, 16384, 32768, 65536, 131072

调用 kmalloc(200, GFP_KERNEL) 时:

  1. 向上取整到匹配的内置 slab 大小(256 字节)
  2. 调用 kmem_cache_alloc(kmalloc_caches[KMALLOC_NORMAL][9], GFP_KERNEL)
  3. 走标准 slab 分配路径

7.2 选择合适的分配函数

场景推荐函数说明
频繁创建同类型对象kmem_cache_create + alloc/free最佳性能,避免反复初始化
大小 < 页、不规律分配/释放kmalloc/kfreefallback 到 slab,简单方便
大小 > 4KB(大对象)alloc_pages + kmem_cache大对象用连续物理页更高效
需要虚拟地址 + DMA 映射dma_alloc_coherent确保 DMA Cache 一致性

八、调试与问题排查

8.1 SLAB 调试选项

内核配置启用调试选项后,slab 分配器会增加额外的检测机制:

CONFIG_DEBUG_SLAB         // 基础调试(启用后性能下降 50%+)
CONFIG_DEBUG_SLAB_LEAK    // 内存泄漏追踪
CONFIG_DEBUG_OBJECTS       // 对象生命周期追踪
CONFIG_KMEMLEAK            // 自动检测未释放内存
CONFIG_KASAN               // Kernel Address Sanitizer(检测越界/UAF)
CONFIG_KFENCE              // 采样型越界/Use-After-Free 检测

8.2 Red Zone 检测

启用调试时,slab 分配器会在每个对象的前后添加哨兵区(Red Zone),填充固定魔术字。释放时检查哨兵是否被覆盖,可检测越界写操作。

8.3 Poison 检测

释放后的对象用特定字节填充:

  • 0x5a:已释放(POISON_FREE)
  • 0x6b:已分配(POISON_ALLOC)

分配前检查槽位是否全为 0x6b,否则触发 Bug。

8.4 使用 KASAN 和 MEMLEAK

# 查看 KMALLOC 分配统计
$ cat /proc/slabinfo | sort -k3 -n | tail -20

# 使用 kmemleak 检测泄漏
$ echo scan > /sys/kernel/debug/kmemleak
$ cat /sys/kernel/debug/kmemleak

# 使用 SLUB_DEBUG 追踪特定缓存
$ echo 1 > /sys/kernel/slab/my_device_desc/trace

九、性能调优实战

9.1 关键可调参数(/sys/kernel/slab/)

/sys/kernel/slab/<cache_name>/
├── limit              # array_cache 中最大空闲对象数
├── batchcount         # 一次从 slab 转移的对象数
├── object_size        # 对象设计大小
├── objects_per_slab   # 每个 slab 中的对象数
├── order              # slab 大小(2^n 页)
├── partial            # partial slab 数量
├── cpu_partial        # per-CPU partial 阈值
└── validate           # 强制验证 slab 一致性(调试)

9.2 减少内存碎片策略

  • 批量操作:将 batchcount 适当调大,减少 slab 与 Buddy System 的交互频率
  • 对象对齐:使用 SLAB_HWCACHE_ALIGN 标志保证对象 Cache Line 对齐
  • 避免频繁创建/销毁缓存:缓存创建有开销,尽量在模块初始化时一次性创建
  • NUMA 亲和:在大规模 NUMA 系统中,缓存分配到正确节点

9.3 监控 slab 使用状况

# 查看单个 slab 详细状态
$ slabtop -o

# 查看前 20 个 slab 缓存(按活跃对象排序)
$ cat /proc/slabinfo | head -5
$ cat /proc/slabinfo | sort -k2 -n | tail -20

# 查看内核内存使用(快速估算 slab 占用)
$ cat /proc/meminfo | grep -i slab
Slab:             3456000 kB
SReclaimable:     2340000 kB  // 可回收(dentry/cache)
SUnreclaim:       1116000 kB  // 不可回收(自定义缓存)

十、内核驱动开发最佳实践

10.1 对象生命周期管理

驱动中的字符设备和块设备常需要管理数百个实例。为每个实例使用独立的 kmem_cache 不仅提高性能,还便于调试:

// 好的做法:一个设备类型一个缓存
static struct kmem_cache *nic_priv_cache;

struct nic_priv {
    struct net_device *ndev;
    struct sk_buff *rx_skb[RING_SIZE];
    struct sk_buff *tx_skb[RING_SIZE];
    dma_addr_t rx_dma[RING_SIZE];
    dma_addr_t tx_dma[RING_SIZE];
    spinlock_t rx_lock, tx_lock;
    unsigned int rx_next, tx_next;
};

// 分配 netdev_priv 区域
struct nic_priv *priv = netdev_priv(ndev);
priv = kmem_cache_zalloc(nic_priv_cache, GFP_KERNEL);

// 好习惯:使用 zalloc 变体避免信息泄露
// kmem_cache_zalloc() 分配后 memset 为 0

10.2 DMA 场景下的特殊处理

当驱动需要与 DMA 配合使用时,应结合 dma_alloc_coherent() 分配 DMA 安全缓冲区,配合 kmem_cache_alloc() 分配描述符:

struct dma_desc {
    u32 src_addr;
    u32 dst_addr;
    u32 ctrl;
    struct list_head list;
};

struct dma_priv {
    struct kmem_cache *desc_cache;     // 描述符缓存
    dma_addr_t desc_ring_dma;          // DMA 环形描述符总线地址
    struct dma_desc *desc_ring;        // 虚拟地址
};

static int setup_dma_ring(struct dma_priv *priv, int count)
{
    // 分配 DMA 安全的环形缓冲区
    priv->desc_ring = dma_alloc_coherent(dev,
                    count * sizeof(struct dma_desc),
                    &priv->desc_ring_dma, GFP_KERNEL);
    if (!priv->desc_ring)
        return -ENOMEM;

    // 分配单个描述符使用 slab 缓存
    priv->desc_cache = kmem_cache_create("dma_desc_cache",
                    sizeof(struct dma_desc), 0,
                    SLAB_HWCACHE_ALIGN, NULL);

    INIT_LIST_HEAD(&priv->free_list);
    for (int i = 0; i < count; i++) {
        struct dma_desc *d = kmem_cache_alloc(priv->desc_cache, GFP_KERNEL);
        list_add(&d->list, &priv->free_list);
    }
    return 0;
}

10.3 RCU 延迟释放

对于需要 RCU 保护的 slab 对象,使用 kfree_rcu() 延迟释放,避免 Read Side 竞态:

struct rcu_protected_obj {
    struct rcu_head rcu;
    // 数据字段...
};

struct rcu_protected_obj *obj = kmem_cache_alloc(my_cache, GFP_KERNEL);
// ... 读操作 (rcu_read_lock)
// 释放时延迟到宽限期结束
kfree_rcu(obj, rcu);

10.4 SLUB 与大页(Huge Page)优化

Linux 5.x 中,SLUB 开始支持 Transparent Huge Pages(THP)优化,将 slab 透明地映射到 2MB 大页:

// 创建时可请求大页(需硬件支持)
kmem_cache_create("my_large_cache", 64 * 1024, 0,
                  SLAB_HWCACHE_ALIGN | SLAB_PANIC, NULL);

// 监控
$ cat /sys/kernel/slab/my_large_cache/flags
$ cat /sys/kernel/slab/my_large_cache/oo  // 阶数详情

十一、总结

Slab Allocator 是 Linux 内核内存管理的关键组件,理解其工作原理对驱动开发者和内核开发者至关重要:

  • 架构:kmem_cache 管理 Per-CPU 快速路径 + NUMA 节点级二级缓存 + Buddy System 慢路径
  • 优化:着色优化 Cache Line 利用、per-CPU 无锁快速分配、freelist 内嵌
  • 变体:SLUB 是默认首选(简单高效),SLOB 仅用于极端嵌入式环境
  • API:高频同类型对象用 kmem_cache_create/alloc/free,通用分配用 kmalloc/kfree
  • 调试:KASAN + KFENCE + SLUB_DEBUG + /proc/slabinfo + slabtop
  • 监控:重点关注 SUnreclaim 增长趋势和特定缓存的 active/total 比率

通过合理使用 slab 分配器,可以在驱动开发中实现接近 O(1) 的分配速度、有效控制内存碎片,并利用 NUMA 亲和性优化多核系统的内存访问性能。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部