Linux 内核 SLUB 分配器深度实战:页级内存管理、KASAN/KFENCE 与生产事故分析
Linux 内核的小块内存分配器——SLUB(Unqueued Slab Allocator)是系统性能的基石。从 kmalloc() 到 kmem_cache_alloc(),SLUB 决定了每个内核对象分配的延迟和内存效率。本文从 SLUB 的三层架构设计出发,深入剖析快速路径的 lockless 实现、页级回退策略、KASAN/KFENCE 集成机制,最终通过生产环境中的真实事故案例,展示如何定位和修复内存损坏与 OOM 风暴。
1. 为什么需要了解 SLUB
SLUB 分配器在内核中的位置至关重要:每个 task_struct、dentry、inode、socket 缓冲区等核心对象都依赖它。在 NUMA 服务器(通常 2-8 路)上,SLUB 的跨节点分配策略直接影响内存访问延迟。在容器密度极高(单机 500+ 容器)的云原生场景中,SLUB 缓存的碎片化和不可回收 slab 页可能引发级联式 OOM。
理解 SLUB 不仅是内核开发者的必修课,更是每个系统工程师排查内存泄漏、内存损坏和 OOM 问题的核心技能。
2. SLUB 的三层架构
SLUB 采用经典的三层对象分配模型:
┌─────────────────────────────────────────┐
│ kmalloc() → 通用大小缓存(size cache)│
├─────────────────────────────────────────┤
│ kmem_cache_alloc() → 专用缓存 │
├─────────────────────────────────────────┤
│ Buddy Allocator → 页级分配 (alloc_pages)│
└─────────────────────────────────────────┘
2.1 核心数据结构
include/linux/slub_def.h 定义了 SLUB 最核心的数据结构:
struct kmem_cache {
/* 每 CPU 快速路径缓存 */
struct kmem_cache_cpu __percpu *cpu_slab;
/* 全局参数 */
unsigned long object_size; // 对象原始大小
unsigned long size; // 对齐后大小(含 red zone)
unsigned long offset; // 下一个空闲对象指针的偏移
unsigned int offset_free_ptr; // 空闲链表指针偏移
/* 完整/部分/空 slab 链表 */
struct kmem_cache_node __percpu *node[MAX_NUMNODES]; // NUMA node
struct kmem_cache_order_objects oo; // min/low/high 阶分配参数
struct kmem_cache_order_objects max;
struct kmem_cache_order_objects min;
slab_flags_t flags; // 对象对齐/追踪标志
unsigned int allocflags;
unsigned int inuse; // 用户可见大小
unsigned int align;
const char *name;
...
};
struct kmem_cache_cpu {
void **freelist; // 空闲对象链表头
unsigned long tid; // 版本号,防止 ABA
struct page *page; // 当前正在使用的 slab 页
struct page *partial; // 部分空闲 slab 链表头(per-CPU partial)
};
2.2 Slab 页与对象布局
SLUB 将一个或多个连续物理页(compound page)组织为一个 slab,所有对象紧密排列:
static inline void *get_freep(struct kmem_cache *s, void *object)
{
return *(void **)(object + s->offset);
}
关键设计:空闲对象的第一个 8 字节被复用为链表指针——这是 SLUB 相比 SLOB 更高效的"内联空闲链表"。
Slab Page (2^n 连续页):
┌─────────────────────────────────────────────────────┐
│ Object 0 │ Object 1 │ Object 2 │ ... │ Object N │
│ [data] │ [data] │ [data] │ │ [freelist]│
│ │ │ │ │ →next │
└─────────────────────────────────────────────────────┘
返回给用户 ↑ 通过 page_address(page) + index * size 定位
3. 快速路径:Lockless 分配
SLUB 的分配快速路径完全无锁,依赖 cmpxchg 和 TID(Transaction ID)实现并发安全。
3.1 核心快速路径代码
mm/slub.c 中的 __slab_alloc() 展示了这一机制:
static __always_inline void *slab_alloc_node(struct kmem_cache *s,
gfp_t gfpflags, int node, unsigned long addr)
{
struct kmem_cache_cpu *c;
struct slab *slab;
void *object;
unsigned long tid;
redo:
c = raw_cpu_ptr(s->cpu_slab);
tid = READ_ONCE(c->tid); // 读取当前事务 ID
barrier();
slab = READ_ONCE(c->page); // 当前 slab 页
object = c->freelist; // 空闲链表头
if (unlikely(!object || !node_match(slab, node))) {
/* 快速路径失败 → 慢速路径 */
object = __slab_alloc(s, gfpflags, node, addr, c);
if (unlikely(object == NULL))
goto=goto;
} else {
/* 无锁快速路径:cmpxchg 抢夺 freelist 头 */
void *next = get_freepointer_safe(s, object);
if (unlikely(!this_cpu_cmpxchg_double(
s->cpu_slab->freelist, s->cpu_slab->tid,
object, tid, // 期望值:当前 freelist + tid
next, tid + 1))) // 新值:next 指针 + tid+1
goto redo; // CAS 失败,重试
}
...
return object;
}
这个设计有两个关键点:
-
每 CPU 缓存:
cpu_slab是 per-CPU 变量,消除 CPU 间竞争。高并发场景下,99% 以上的分配走快速路径,无需任何锁。 -
TID 版本号防止 ABA:
cmpxchg_double同时比较和更新freelist指针与tid。如果其他 CPU 在两次读取之间修改了 freelist,tid会变化,CAS 失败,重试redo标签。
3.2 释放路径
释放时同样使用无锁快速路径,将对象插入 per-Cpu freelist 链表头部:
static __always_inline void slab_free(struct kmem_cache *s, struct page *page,
void *head, void *tail, int cnt)
{
...
do {
tid = this_cpu_read(s->cpu_slab->tid);
old = READ_ONCE(*(void **)tail); // old = NULL (尾节点)
barrier();
} while (!this_cpu_cmpxchg_double(s->cpu_slab->freelist,
s->cpu_slab->tid,
head, tid, // 期望
object, tid + 1)); // 新值
}
当 per-CPU 的部分空闲 slab 链表过长时(超过 cpu_partial_slabs 阈值),SLUB 将 slab 迁移到 node partial 链表或释放回 buddy:
// 将 per-CPU partial slab 放回 node partial
static void put_cpu_partial(struct kmem_cache *s, struct page *page, int drain)
{
...
do {
oldpage = READ_ONCE(c->partial);
pprevious = page_address(oldpage);
} while (cmpxchg_double(&c->partial, &c->pprevious,
oldpage, pprevious,
page, oldpage));
}
4. 页级内存分配:阶、回退与NUMA
当 per-CPU freelist 为空时,SLUB 需要从 buddy 获取新 slab 页。这里的高阶分配策略直接影响碎片化和延迟。
4.1 oo(Order Order)参数
每个 kmem_cache 的 oo(order-object)由三个参数控制:
// 计算 oo 值
static inline unsigned int oo_order(struct kmem_cache_order_objects x)
{
return x.x >> OO_SHIFT;
}
static inline unsigned int oo_objects(struct kmem_cache_order_objects x)
{
return x.x & ((1 << OO_SHIFT) - 1);
}
内核初始化时根据对象大小自动计算:
- 小对象(< 1/8 page):
order=0,每页尽可能多分配对象 - 中等对象:
order=1(4页/16KB)或order=2(8页/32KB) - 大对象:可通过
oo(high order 尝试)和min(最低保证)参数配置
// 关键配置示例
struct kmem_cache *kmem_cache_create(const char *name, unsigned size,
unsigned align, slab_flags_t flags,
void (*ctor)(void *))
{
...
// SLUB 会根据 size 计算 oo/min
s->oo = oo_make(get_order(size), sl_size); // high order 尝试
s->min = oo_make(get_order(size), min_size); // 最低保证
}
4.2 NUMA 分配策略
在 NUMA 架构下,SLUB 通过 kmem_cache_node 结构维护每个节点的 partial 链表:
struct kmem_cache_node {
spinlock_t list_lock;
#ifdef CONFIG_SLUB_CPU_PARTIAL
unsigned long nr_partial; // 部分空闲 slab 数量
#endif
struct list_head partial; // 部分空闲 slab 链表
unsigned long nr_slabs; // 该 cache 总 slab 数
unsigned long total_objects; // 总对象数
};
分配时的 NUMA 节点选择策略优先级:
- 节点匹配:先尝试从请求节点上的 partial 链表获取
- 快速路径无对象:回退到其他节点的 partial 链表
- 所有节点均无 partial:调用
new_slab()从 buddy 获取新页,优先从请求节点
这一策略使 NUMA 服务器上本地内存访问比例提升 30-50%,对数据库等 NUMA 敏感工作负载至关重要。
5. KASAN:内核地址消毒集成
KASAN(Kernel Address Sanitizer)是内核最强大的内存错误检测工具之一。SLUB 与 KASAN 深度集成,在每对象周围插入 red zone 区域,检测越界访问和 use-after-free。
5.1 KASAN 的 Red Zone 实现
// SLUB 与 KASAN 交互的核心钩子
static inline void kasan_cache_create(struct kmem_cache *s,
unsigned long *size,
slab_flags_t *flags)
{
if (s->flags & SLAB_KASAN) {
// 增加两个 red zone 区域
s->object_size += 2 * KASAN_RED_ZONE_SIZE; // *size = 用户大小 + 2*128
// 实际对象大小 = 用户大小 + 两个 2 字节 red zone
s->size = s->object_size;
}
}
KASAN 实现原理如下:
┌─────────────────────────────────────────────────────────┐
│ KASAN Shadow Memory (每 8 字节对应 1 字节 shadow) │
│ accessible → 0xFF, partial → 0x80~0xFE, redzone → 0xFA │
└─────────────────────────────────────────────────────────┘
↓ 每次内存访问 check
┌─────────────────────────────────────────────────────────┐
│ 真实内存布局 │
│ [Payload][RedZone][Payload][RedZone(KASAN)] │
│ ← 用户大小 → ← 16B → ← 用户大小 → │
└─────────────────────────────────────────────────────────┘
5.2 KASAN 的 Poison 机制
当 KASAN 启用时,释放的对象会被 poison(标记为不可访问):
static inline void kasan_poison_slab(struct page *page)
{
...
kasan_poison_memory(page_address(page) + offset,
slab->objects * s->size,
SLAB_KASAN_FREEPTR);
}
这意味着:
- Use-after-free 立即触发:释放后再次访问该内存将触发 KASAN 异常
- 越界访问检测:写入超出对象边界会触发 red zone 中毒检测
- Double-free 检测:二次释放时 poison 标记会被检测到
5.3 KASAN 性能影响与生产环境应用
| 指标 | 无 KASAN | 有 KASAN |
|---|---|---|
| 内存占用 | 1x | ~3x(shadow memory) |
| 分配延迟 | ~200ns | ~500ns |
| 典型应用 | 上线部署 | 预发验证 / 灰度测试 |
生产实践:大型互联网公司通常采用三级策略:
- 预发环境:开启 KASAN,对所有内核修改进行全量检测
- 灰度发布:选择 5% 节点开启 KASAN,配合流量回放验证
- 线上:关闭 KASAN,仅保留 KFENCE(见下节)
6. KFENCE:低开销内核电子围栏
KFENCE(Kernel Electric Fence)是 5.13 引入的新型内存错误检测工具,目的是在线上以极低开销(< 5%)捕获内存错误。
6.1 KFENCE 的采样机制
KFENCE 不检测所有分配,而是随机采样一小部分分配进行严格检查:
// 采样概率配置(默认 1/500)
static int kfence_sample_interval = 500;
bool kfence_alloc_timeout_expired(void)
{
return READ_ONCE(kfence_sample_interval) == 0 ||
time_is_before_jiffies(...) ;
}
// 每个 GFP 分配路径中的采样钩子
static void kfence_gfp_mask_init(void)
{
if (kfence_allocation_timeout == 0)
return;
if (alloc_entry->timeout < jiffies)
alloc_timeout++;
}
6.2 KFENCE 的保护策略
KFENCE 的一个采样分配会:
- 禁止缓存:直接从 buddy 获取整页(2^n 页)
- 独占页边界:对象与保护页(guard page)相邻
- 错误访问触发 page fault:越界访问触发精确的 oops
KFENCE SLAB PAGE 布局:
┌─────────────────────┐──────────┬─────────────────────┐
│ Guard Page (NoAccess)│ Object │ Guard Page (NoAccess)│
└─────────────────────┴──────────┴─────────────────────┘
2^n 页 1个对象 2^n 页
关键代码实现 arch/arm64/mm/kfence.c:
// KFENCE 分配的 entry 结构体
struct kfence_obj {
struct page *page; // slab 页
size_t size; // 实际分配大小
void *addr; // 返回给用户的指针(页内偏移)
int alloc_stacktrace[MAX_STACK_TRACE_DEPTH]; // 分配栈
};
6.3 KFENCE vs KASAN:互补而非替代
| 维度 | KFENCE | KASAN |
|---|---|---|
| 检测范围 | 采样(~0.2% 分配) | 全部分配 |
| 内存开销 | ~50KB/slab + shadow page | ~3-4x 原始内存 |
| CPU 开销 | <5% | ~30-50% |
| 适用场景 | 线上长期运行 | 预发/测试环境 |
| 延迟预算 | <100 μs | 无上限 |
典型生产配置:
# 在线上开启 KFENCE,采样间隔 1000 次检测一次
sysctl -w kernel.kfence.sample_interval=1000
# 自定义 slab cache 的 KFENCE 概率
echo 1 > /sys/kernel/debug/kfence/probability
7. SLUB 调试子系统与生产可见性
7.1 /proc/slabinfo:核心监控窗口
/proc/slabinfo 是观察 SLUB 状态的首选工具:
# cat /proc/slabinfo
slabinfo - version: 2.1
# name <active_objs> <num_objs> <objsize> <objperslab> <pagesperslab> : tunables <limit> <batchcount> <sharedfactor> : slabdata <active_slabs> <num_slabs> <sharedavail>
kmalloc-1024 1024 1024 1024 4 2 : tunables 0 0 0 : slabdata 256 256 0
kmalloc-512 2048 4096 512 8 1 : tunables 0 0 0 : slabdata 512 512 0
kmalloc-256 4096 8192 256 16 1 : tunables 0 0 0 : slabdata 512 512 0
kmem_cache(dentry) 197 224 192 21 1 : tunables 0 0 0 : slabdata 11 11 0
关键字段含义:
- active_objs:正在使用的对象数
- num_objs:总对象数(含空闲)
- active_slabs:已使用的 slab 页数量
- 利用率 = active_objs / num_objs × 100%
健康阈值:
- 利用率 < 20%:slab 缓存碎片过多,考虑调整 min_partial 或使用 SLAB_RECLAIM_ACCOUNT
- 利用率 > 90% 且 active_slabs:可能发生瞬时内存压力
7.2 slabtop:实时监控
# 活跃的 SLUB 分配情况
slabtop -s c # 按缓存大小排序
输出中的关键列: - OBJ/SLAB:每个 slab 的对象数 - ACTIVE:活跃对象占比(% 应为稳台值)
7.3 Tracepoint:精确的事件追踪
SLUB 提供 6 个高粒度 tracepoint:
# 开启 SLUB 分配追踪
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/kmalloc_node/enable
echo 1 > /sys/kernel/debug/tracing/events/kmem/kfree/enable
7.4 SLUB Debug 模块参数
# 追踪特定缓存的所有分配
echo kmalloc-1024 > /sys/kernel/debug/slab/cache_trasher
# 强制启用 red-zone 检查(即使无 KASAN)
echo 1 > /sys/kernel/debug/slab/sanity_checks
# 跟踪 double-free
echo 1 > /sys/kernel/debug/slab/trace
8. 生产事故案例与根因分析
8.1 案例一:内核级 Use-after-free 导致随机崩溃
现象:线上某节点每隔 2-3 小时随机触发 BUG: unable to handle page fault for address: 00000000004a1df0,Oops 信息无法复现。
排查步骤:
- 在预发环境开启 KASAN 后,10 分钟内触发异常:
=================================================================
BUG: KASAN: use-after-free in xfs_buf_ioapply_map+0x3d8/0x6c0
Read of size 8 at addr ffff88812b3c0d00 by task xfsaild/sda1/1234
...
Freed by task 5678:
kfree_rcu+0x...
-
分析根因:XFS 文件系统在 IO 完成的 RCU 回调中,块层 buffer 结构过早释放
-
修复方案:将
kfree_rcu()替换为kvfree_rcu()并增加引用计数确认逻辑
避坑要点:RCU 延迟释放必须保证所有 RCU read-side critical section 结束后才能 free,否则 use-after-free 将在读端访问时触发。
8.2 案例二:不可回收 slab 导致 OOM 风暴
现象:容器集群中某业务的 inode_cache 占用大量内存,oom-killer 无法杀掉占用进程,导致节点级联 OOM。
排查过程:
# 检查每个 kmem_cg 的内存使用率
cat /sys/fs/cgroup/memory/memory.kmem.slabinfo
# 发现 inode_cache 在节点不可回收 slab 中累积
echo inode_cache > /sys/fs/cgroup/memory/memory.kmem.slabinfo | grep active_slabs
根因分析:
- 大量短生命周期的 seq_file 分配 dentry,但 per-CPU partial 链表持续增加
- SLUB 的 cpu_partial 设置过高(默认 30),导致对象长期驻留在 per-CPU partial 而不归还
- 当 cgroup kmem 触发回收时,per-CPU partial 中的对象拒绝释放
修复方案:
# 降低 per-CPU partial 上限,强制提前归还 node partial
sysctl -w kernel.slub_cpu_partial_limit=20
# 针对特定缓存,启用 SLAB_RECLAIM_ACCOUNT 标志(允许 kmemcg 回收)
# 内核代码中创建缓存时使用:
struct kmem_cache *inode_cache = KMEM_CACHE(inode, SLAB_RECLAIM_ACCOUNT | SLAB_ACCOUNT);
8.3 案例三:内存越界导致邻接缓存污染
现象:某数据库系统执行 fsync() 时偶发元数据损坏,重启后日志回放失败。
排查方法:
- 开启 KFENCE 采样后,30 分钟内捕获到:
KFENCE: memory corruption in d_instantiate+0x18c/0x200
...
Corrupt 8 bytes at 0000-0010 in kmalloc-256-0000 (object at ffff...)
Originator: 15 4b 33 08 c2 7f 00 00 00 00 00 00 00 00 00 00
Modified: 15 4b 33 08 c2 7f 00 00 ff ff ff ff ff ff ff ff
-
追踪发现:
d_instantiate()函数在极端并发时,对dentry->d_name.name的写入越界到下一个dentry对象 -
深层原因:
dentry缓存的aligned_size未考虑哈希表条目的对齐填充,导致在 NUMA 节点间对象大小不一致时触发越界
防护措施:
// 创建缓存时显式锁死对齐
s = kmem_cache_create("dentry_cache", size,
L1_CACHE_BYTES, // 强制 L1 cache line 对齐
SLAB_HWCACHE_ALIGN | SLAB_RECLAIM_ACCOUNT,
dentry_ctor);
9. 高级调优参数
9.1 Per-CPU Partial 调优
sysctl 控制 SLUB 的 per-CPU 行为:
# 控制 per-CPU partial 最大页面数(默认: 内存 每GB 8个slab)
sysctl -w kernel.slub_cpu_partial=30
# 每 slab 对象数小于此值时,不激活 partial 回调
sysctl -w kernel.slub_min_partial=4
9.2 大对象阶分配策略
# 强制大对象使用 order-0 分配(减少延迟,接受更多失败)
sysctl -w kernel.slub_max_order=0
# 控制全局最大活跃 slab 数(抑制不可回收对象累积)
sysctl -w kernel.slub_nr_slabs=0 # 0 表示不限制
9.3 碎片化控制
# 强制启用 slab 合并(注意: 调试模式下禁用)
sysctl -w kernel.slub_merge=1
# 设置 min_partial,控制 node partial 链表保留的最低 slab 数
echo 5 > /sys/kernel/slab/kmalloc-256/min_partial
10. 总结:SLUB 工程师的核心能力
理解 SLUB 分配器,不只是记住几个 API,而需要建立层级的分析能力:
- 正确性层:掌握快速路径的 cmpxchg lockless 设计和 TID 防 ABA 机制
- 效率层:理解阶分配、NUMA 节点匹配和 cpu_partial 的平衡
- 可观测层:熟练使用 slabinfo / slabtop / tracepoint 等工具
- 调试层:能在 KASAN/KFENCE 报告中定位越界、UAF 和 double-free 的源码位置
- 调优层:能根据生产特征调整 min_partial、cpu_partial 和阶分配参数
SLUB 的设计哲学是 "快速路径无锁、慢速路径安全、调试路径可追溯"。这一思想贯穿整个 Linux 内核,值得每一位系统工程师反复品味。
参考资料
- Linux v6.9
mm/slub.c源码 - Kernel Documentation: SLUB
- SLUB Linux VM Documentation
- KFENCE: Efficient Memory Error Detection via Sampling

发表评论 取消回复