引言

Linux 内核内存管理子系统是整个操作系统最底层的基石之一。在用户态程序中,我们习惯于直接调用 malloc/free 来完成内存的动态分配,但在内核态,事情远比这复杂——内核不能直接使用用户态的内存分配器,必须有一套专门为内核设计的、更加高效且可靠的内存管理机制。SLAB(由 Jeff Bonwick 最初为 Solaris 设计)及其改进版本 SLUB 就是这一需求的产物。

本文将从 SLAB 分配器的设计哲学入手,深入剖析其核心数据结构、工作流程、SLUB 的改进之处,并结合实际的调试手段与最佳实践,帮助读者全面理解 Linux 内核内存分配机制。

一、为什么需要 SLAB 分配器?

1.1 物理页分配的局限性

Linux 内核的物理内存管理以页(通常 4KB)为最小单位。伙伴系统(Buddy System)负责在物理页层面进行分配与回收,但它只能以 2 的幂次页数为粒度(1页、2页、4页、8页...)。当内核需要分配一个 task_struct(约 1.7KB)或者一个 inode 对象(约 600B)时,如果使用伙伴系统直接分配整页,会造成严重的内部碎片浪费。

1.2 内核对象的特征

内核中具有大量频繁创建和销毁的同类型对象,如:

  • task_struct:进程描述符,进程创建/销毁时频繁操作
  • dentry:目录项缓存,文件操作频繁
  • inode:索引节点,文件系统操作频繁
  • file:文件对象,open/close 频繁
  • sk_buff:网络数据包,网络收发极其频繁
  • 各种锁对象:mutex、rw_semaphore、spinlock 等

这些对象有两个共同特征:大小固定(同类对象大小相同)和生命周期短(频繁创建销毁)。SLAB 分配器正是针对这种场景设计的——它在页(伙伴系统分配的大块内存)的基础上进一步细分为小对象,同时利用着色(coloring)来优化缓存利用率,避免了反复初始化/销毁对象的开销。

1.3 SLAB 的设计目标

Jeff Bonwick 在 1994 年的论文《The Slab Allocator: An Object-Caching Kernel Memory Allocator》中提出了 SLAB 的四大设计目标:

  1. 快速分配与释放:通过空闲链表和缓存设计实现 O(1) 时间复杂度的操作
  2. 极低碎片:对象大小精准匹配,无内部碎片;通过着色减少外部冲突
  3. 硬件缓存友好:着色机制使对象均匀分布在缓存行上,减少缓存冲突未命中
  4. 可检查与可调试:支持毒化(poisoning)、红区(red zone)、所有权追踪等调试手段

二、SLAB 核心数据结构

2.1 三层结构总览

SLAB 分配器采用三层结构,从高到低分别是:


┌─────────────────────────────────────────────┐
│  kmem_cache (cache 层)                       │
│  - 每种对象类型一个 cache                      │
│  - 管理三个 slab 链表                         │
├─────────────────────────────────────────────┤
│  slab 层                                     │
│  - 每个 slab 由一个或多个连续页组成             │
│  - 包含空闲对象链表                           │
├─────────────────────────────────────────────┤
│  object 层                                    │
│  - 实际分配给用户的同尺寸小对象                 │
└─────────────────────────────────────────────┘

2.2 kmem_cache 结构体

struct kmem_cache 是 SLAB 分配器最重要的数据结构,每种被缓存的对象类型对应一个 kmem_cache 实例。以下是精简后的关键字段:


struct kmem_cache {
    struct kmem_cache_cpu *cpu_slab;    // Per-CPU 快速缓存(每CPU一个)
    unsigned long flags;                // 缓存标志(如 SLAB_POISON)
    unsigned int object_size;           // 单个实际对象大小
    unsigned int size;                  // 对齐后大小(可能含元数据)
    unsigned int offset;                // 空闲链表指针偏移
    unsigned int cache_color;           // 当前着色偏移
    unsigned int colour_off;            // 颜色步长(通常为缓存行大小)
    
    // 三个 slab 管理链表
    struct list_head slabs_full;        // 已用满的 slab
    struct list_head slabs_partial;     // 部分空闲的 slab
    struct list_head slabs_free;        // 完全空闲的 slab
    
    unsigned int num;                   // 每个 slab 包含的对象数量
    gfp_t alloc_gfp;                    // 分配页面时的 GFP 标志
    const char *name;                   // 缓存名称(如 "task_struct")
    
    // 构造函数
    void (*ctor)(void *obj);
};

关键字段说明:

  • cpu_slab:每个 CPU 的快速分配路径(不要等待指针),这是 SLAB 高性能的关键之一
  • 三个 slab 链表:按使用率对 slab 进行分类管理,分配时优先从 partial 链表取对象
  • colour_off / cache_color:着色相关,用于使得不同 slab 中的对象映射到不同的硬件缓存行

2.3 kmem_cache_cpu:Per-CPU 加速

这是 SLAB 分配器最直接的性能优化手段:


struct kmem_cache_cpu {
    void **freelist;    // 指向下一个可用对象的指针(空闲链表头)
    unsigned long tid;  // 全局事务 ID(用于同步)
    struct page *page;  // 当前使用的 slab 页
    struct page *partial; // Per-CPU partial slab 链表
};

分配优先级策略:

  1. 先尝试从 cpu_slab->freelist(Per-CPU 空闲对象链表)分配 —— 无锁、极快
  2. 若 freelist 为空,从 cpu_slab->partial(Per-CPU partial slab)补充 freelist
  3. 若 partial 也为空,从 kmem_cache->slabs_partial(节点级 partial 链表)获取一个 slab
  4. 如果 partial 链表也空,则从伙伴系统获取新的页组成新 slab

2.4 slab 结构与空闲链表

每个 slab 由一个或多个连续的物理页组成(通常为 1-4),这些页的 struct page 中特定字段指向 slab 管理结构:


struct slab {
    union {
        struct list_head slab_list; // 链接在 kmem_cache 的 slabs_* 链表中
        struct {
            void *freelist;         // 空闲对象链表头
            unsigned inuse;         // 已使用对象计数
        };
    };
    unsigned int s_mem;             // 第一个对象的起始地址
};

空闲对象通过隐式链表连接——在每个空闲对象的起始位置嵌入一个 void* 指针,指向下一个空闲对象(由 kmem_cache->offset 字段控制位置):


slab 页面内存布局:
┌──────────────────────────────────────────┐
│ object 0 │ object 1 │ ... │ object N-1  │
│          │ [ptr→2]  │     │             │
└──────────────────────────────────────────┘
 freelist → 1 → 2 → NULL

每个空闲对象的起始 8 字节存储 next 指针

三、分配与回收流程深度解析

3.1 kmem_cache_alloc 完整路径

当一个内核组件调用 kmem_cache_alloc(gfp_mask) 时,完整的流程如下:


kmem_cache_alloc(cachep, flags)
  │
  ├─ 1. 关中断 + 抢占(进入 Per-CPU 临界区)
  │
  ├─ 2. 获取当前 CPU 的 kmem_cache_cpu
  │
  ├─ 3. 检查 freelist 是否有空闲对象
  │     ├─ 是:直接 pop 一个对象,goto done
  │     │        freelist = object[0] // 读 next 指针
  │     │        return object
  │     │
  │     └─ 否:→ 步骤 4
  │
  ├─ 4. 检查 cpu_slab->partial(Per-CPU partial)
  │     ├─ 有:将 page 切换为当前 page,跳转到步骤 3
  │     └─ 无:→ 步骤 5
  │
  ├─ 5. 从 node slab_partial 链表取一个 slab(需要加锁)
  │     ├─ 有:摘除 slab,加入 cpu_slab,跳转到步骤 3
  │     └─ 无:→ 步骤 6
  │
  ├─ 6. 从伙伴系统分配新页面,创建新 slab
  │     ├─ alloc_pages(cachep->gfpflags, cachep->order)
  │     ├─ 按对象大小切割初始化
  │     ├─ 构建 freelist 链表
  │     └─ 如果指定了 ctor,对每个对象调用构造函数
  │
  └─ 7. 开中断 + 抢占,返回对象

3.2 kmem_cache_free 完整路径


kmem_cache_free(cachep, objp)
  │
  ├─ 1. 通过 virt_to_page(objp) 找到对应 page
  │     验证该 page 有效(属于某个 slab)
  │
  ├─ 2. 关中断
  │
  ├─ 3. 将对象插入 Per-CPU freelist 头部
  │     *(void**)objp = cachep->cpu_slab->freelist
  │     cachep->cpu_slab->freelist = objp
  │
  ├─ 4. 更新 inuse 计数
  │     inuse--
  │
  ├─ 5. 判断 slab 状态变更
  │     ├─ inuse==0(变空): 移入 slabs_free 链表
  │     │    → 系统内存紧张时归还页面给伙伴系统
  │     ├─ inuse==num(刚放满): 从 partial 移入 slabs_full
  │     └─ 其他: 保持 partial 链表
  │
  └─ 6. 开中断

3.3 自动扩容与收缩机制

SLAB 分配器内置了基于内存压力的自动调整机制:

  • 扩容:当 partial 链表为空且 CPU 空闲链表也为空时,从伙伴系统分配新页
  • 收缩:当系统内存紧张时(通过 shrinker 机制),释放 slabs_free 链表中的所有 slab 页面
  • 平衡:部分内核线程(如 kswapd)会间接触发缓存收缩

四、着色(Cache Coloring)机制

什么是着色?

由于硬件缓存是直接映射或组相联映射的,如果每个 slab 的起始位置都从页面起始开始切割对象,那么不同 slab 中相同索引的对象会映射到相同的缓存行,导致缓存冲突未命中(conflict miss)。

着色通过在不同 slab 起始处加入不同的偏移量(color * colour_off),使得对象起点位置分散开来。colour_off 通常等于一个缓存行大小(如 64 字节),color 是一个循环递增的计数器。


无着色时:
┌─Slab A──┐  ┌─Slab B──┐  ┌─Slab C──┐
│obj0@0x0 │  │obj0@0x0 │  │obj0@0x0 │  ← 都映射到同一缓存行!
│obj1@0x20│  │obj1@0x20│  │obj1@0x20│
└─────────┘  └─────────┘  └─────────┘

有着色时(colour_off = 64):
┌─Slab A──┐  ┌─Slab B──┐  ┌─Slab C──┐
│△64 │obj0 │  │△128│obj0│  │△192│obj0│  ← 分散到了不同的缓存行
│obj1@0x40 │  │obj1@0x60 │  │obj1@0x80 │
└─────────┘  └─────────┘  └─────────┘

五、SLUB:默认的现代分配器

5.1 SLUB 的设计动机

随着系统规模增大(多核 NUMA 架构),SLAB 的元数据开销和锁竞争成为了瓶颈:

  • SLAB 每个 slab 都需要独立的 struct slab 结构和 kmem_bufctl_t 控制数组
  • 每个 CPU 的 partial 链表管理增加了内存消耗
  • 复杂的链表操作使得代码维护困难

Vegard Nossum 在 Linux 2.6.23 引入 SLUB 作为默认分配器,大幅简化了设计。

5.2 SLUB 的关键简化

SLUB 采用了以下核心设计改进:

  1. 取消独立的 slab 管理结构:直接利用 struct page 的 lru、freelist 等字段复用为 SLUB 管理
  2. 无 Per-CPU partial 链表:SLUB 将 partial slab 全部挂载在 NUMA node 链表上,由 node 锁保护
  3. 简化空闲链表:使用 page->freelist → object1 → object2 → ... 的一级链表
  4. 合并 kmem_cache_cpu 和 page 结构:减少指针跳转,降低缓存未命中

5.3 SLUB 数据布局


SLUB 中的 struct page 关键字段复用:
┌──────────────────────────────┐
│ struct page                  │
│  .freelist  → 空闲对象链表头  │
│  .inuse     → 已使用对象数    │
│  .objects   → 总对象数        │
│  .frozen    → 是否冻结(PerCPU)│
│  .next/prev → node partial 链表│
└──────────────────────────────┘

空闲对象的 next 指针存储在对象自身内存中:
  第一个 8 字节 = 指向下一个空闲对象的指针

5.4 SLUB vs SLAB 性能对比

对比维度SLABSLUB
元数据开销每个 slab 需额外 struct slab + bufctl 数组0 额外元数据,复用 page 结构
Per-CPU partial支持,消耗额外内存不支持,简化管理
大对象页支持需要特殊处理透明巨页(THP)原生支持
NUMA 支持较复杂较贵本地 node 分配 + fallback
调试功能需额外模块支持内置更完善(redzoning、poisoning)

 存在格式问题,已修复

六、kmalloc 系列函数

6.1 kmalloc 与 SLAB 的关系

kmalloc() 是内核通用内存分配接口,它并非直接与伙伴系统交互,而是通过预定义的 kmalloc_caches 数组(由 SLUB 管理的一个小型 cache 集群)来实现:


// mm/slab_common.c
struct kmem_cache *kmalloc_caches[KMALLOC_SHIFT_HIGH + 1] __ro_after_init;

// 划分为两个区间:
// 1. kmalloc-8, kmalloc-16, kmalloc-32, ..., kmalloc-8K  (普通区,KMALLOC_NORMAL)
// 2. kmalloc-rcl-8, ..., kmalloc-rcl-8K  (备用紧急区,KMALLOC_RECLAIM)

分配时根据请求大小向上取整到最近的 cache 档位,然后通过 SLUB 分配。这意味着 kmalloc 分配的内存不大于 8KB(KMALLOC_MAX_SIZE),更大的分配需要使用 vmalloc() 或 kmalloc_large()(高版本支持更大的kmalloc)。

6.2 kmalloc GFP 标志详解

标志含义典型场景
GFP_KERNEL标准内核分配,允许睡眠大多数内核代码
GFP_ATOMIC原子分配,不允许睡眠中断处理、持有 spinlock
GFP_NOIO不会触发 I/O 操作块 I/O 层内分配
GFP_NOFS不会触发文件系统操作文件系统代码内分配
GFP_HIGHUSER分配用户空间高端内存用户缓冲区映射
GFP_ZERO分配后清零需要零初始化场景

6.3 kmalloc 最佳实践


// ✅ 推荐:使用 kzalloc 获取零初始化内存
struct task_info *info = kzalloc(sizeof(*info), GFP_KERNEL);

// ✅ 推荐:使用数组分配宏
struct item *arr = kmalloc_array(count, sizeof(*arr), GFP_KERNEL);

// ✅ 推荐:size_t 防止整数溢出
buf = kmalloc(sizeof(*buf) + extra_len, GFP_KERNEL);

// ❌ 错误:使用硬编码大小(忽略结构体变化)
info = kmalloc(256, GFP_KERNEL);

// ❌ 错误:遗漏 GFP 标志
info = kmalloc(sizeof(*info));

七、调试与故障排查

7.1 SLUB 调试工具

SLUB 以 SLAB 更强的调试能力自启,主要通过以下方式开启:


// 编译时开启:CONFIG_DEBUG_SLUB
// 启动参数添加:slub_debug=FZP
//   F - fault injection(故障注入)
//   Z - red zoning(红区标记)
//   P - poisoning(毒化填充)

// 运行时对特定缓存开启:
echo 1 > /sys/kernel/debug/slab/task_struct/validate
cat /sys/kernel/debug/slab/slabinfo
cat /sys/kernel/slab/task_struct/alloc_from_overflow // 查看分配溢出

7.2 常见的 SLUB 错误场景


// 【错误1】Use-After-Free
// SLUB 毒化模式使用 0x6b (FREE_FILL_PATTERN) 填充已释放对象
// 如果后续代码访问已释放对象,调试信息中包含 poison 值即可定位

// 【错误2】Out-of-Bounds 读写
// SLAB 在对象边界设置 redzone(通常为 0xbb),
// 覆盖 redzone 会触发 BUG_ON()

// 【错误3】Double-Free
// SLUB 空闲链表会检测重复插入同一对象
// 输出类似 "object was already free" 的警告

7.3 slabinfo 输出解读


root@linux:~# cat /proc/slabinfo
task_struct      234    240    7360   12    8 : tunables  ...
                    ↑     ↑
              active   total
              objs    objs

// 参数含义:
// name:         缓存名称
// active_objs:  当前已使用对象数
// num_objs:     总对象数(包括空闲的)
// objsize:      每个对象大小(字节)
// objperslab:   每 slab 对象数
// pageperslab:  每个 slab 占用页数

7.4 ftrace 追踪 slab 事件


# 开启 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
echo 1 > /sys/kernel/debug/tracing/events/kmem/kmem_cache_free/enable

# 实时查看:
cat /sys/kernel/debug/tracing/trace_pipe

# 输出示例:
// ls-1234  [001] 456.789: kmalloc: call_site=0xffff88800a1b3c40 
//          ptr=0xffff88800b2a1000 bytes_req=256 bytes_alloc=256 
//          gfp_flags=GFP_KERNEL

八、性能优化实战

8.1 缓存行对齐优化

对于频繁访问的结构体,建议使用 ____cacheline_aligned 宏确保其起始地址落在缓存行边界上,避免 False Sharing(错误共享):


// 计数器结构——确保每个 CPU 的计数器独占一行
struct percpu_counter {
    raw_spinlock_t lock;
    s64 count;
} ____cacheline_aligned_in_smp;

// 高频访问的锁对象
struct my_device {
    spinlock_t lock;
    char padding1[64 - sizeof(spinlock_t)];
    atomic_t counter;
    char padding2[64 - sizeof(atomic_t)];
};

8.2 NUMA 感知分配

在大规模 NUMA 系统中,访问远端内存节点会造成大幅性能下降(通常 2-3 倍延迟)。内核通过以下方式优化:

  • 首选 node 分配:SLUB 默认从请求 CPU 所在的 node 分配内存(node 级 freelist + partial 链表)
  • NUMA 平衡:通过 mbind() 或 set_mempolicy() 设置用户进程的内存绑定策略
  • 手动指定:64 位进程可通过 alloc_pages_node(nid, gfp, order) 指定 node

8.3 使用 percpu 变量避免间接分配

对于每个 CPU 频繁访问的计数器或状态,直接定义 per-CPU 变量可以避免从 SLUB 缓存分配:


// 定义并声明
DEFINE_PER_CPU(struct cpu_stats, cpu_stats);

// 分配(初始化时)
struct cpu_stats *stats;
for_each_possible_cpu(cpu) {
    stats = per_cpu_ptr(&cpu_stats, cpu);
    memset(stats, 0, sizeof(*stats));
}

// 访问(抢占安全但不需要加锁)
this_cpu_inc(cpu_stats.packet_count);
// 或
get_cpu_var(cpu_stats).packet_count++;
put_cpu_var(cpu_stats);

8.4 使用 slab 的 RCU 释放

对于 RCU 保护的对象,释放需要延迟到 grace period 结束,避免读者还在访问就被回收:


// 错误:直接 kfree
// kfree(my_rcu_protected_obj); // 可能导致 use-after-free!

// 正确:使用 kfree_rcu 或 call_rcu
kfree_rcu(my_rcu_protected_obj, rcu_head);
// 等价于:
call_rcu(&my_rcu_protected_obj->rcu_head, my_release_function);

九、实际案例分析

9.1 案例一:高并发下 SLUB 碎片优化

某网络服务器(8 核、16GB 内存)在大量并发连接/断开时性能下降,查看 slabinfo 发现 kmalloc-256 缓存了大量 partial slab:


kmalloc-256      45200   98304    256   16    1 : tunables ...
                     ^^^^^  ↑ 很多 active 远小于 total

分析原因:连接突发期间分配大量 256 字节带宽对象,突发结束后只释放了部分,但所有 slab 都残留少量 inuse 对象而无法归还,造成内存浪费。

解决方案:


// 1. 通过 /sys 接口手动收缩(仅归还完全空闲的 slab)
echo 1 > /sys/kernel/slab/kmalloc-256/shrink

// 2. 优化应用层:使用对象池预分配,减少临时分配
// 3. 考虑开启 CONFIG_SLUB_TINY(极简 SLUB),对于内存紧张的系统更合适

9.2 案例二:内核模块内存泄漏排查

自定义内核模块加载后内存持续增长,使用 kmemleak 追踪:


// 启用 kmemleak:启动参数 kmemleak=on 或 CONFIG_DEBUG_KMEMLEAK
// 或运行时:echo scan > /sys/kernel/debug/kmemleak

// 查看泄漏报告:
cat /sys/kernel/debug/kmemleak
// 输出包含未释放对象的分配调用栈

// 模块中的典型泄漏模式:
static int __init mymod_init(void) {
    my_obj = kmalloc(sizeof(*my_obj), GFP_KERNEL);
    // 忘记在 exit 中释放
    return 0;
}

// 修复:
static void __exit mymod_exit(void) {
    kfree(my_obj);
}

十、总结与展望

SLAB 分配器自 1994 年诞生以来,经历了 SLAB → SLUB + SLOB 的演进,目前 SLUB 是绝大多数 Linux 系统的默认分配器。理解其原理对于内核开发、驱动开发、高性能系统调优都有重要意义:

  • 设计哲学:针对固定大小、高频创建销毁的小对象场景,通过缓存设计实现零锁分配和极低碎片
  • 核心机制:Per-CPU 快速路径 + 二级缓存 + 三级链表的层次化设计
  • 性能关键:着色优化缓存行利用率、Per-CPU 消除锁竞争、分层缓存减少延迟
  • 调试能力: poison/red zone/fault injection 等机制使内存错误可定位

对于未来的发展方向,Linux 内核社区正在探索 SLUB 的进一步精简,以及在新型硬件(持久内存、CXL 内存)时代的适配优化。内存管理作为操作系统最底层的子系统,始终是性能调优和稳定性的关键所在。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部
0.389311s