引言
在 Linux 内核中,物理内存管理是一个多层次的体系:Buddy 系统负责管理物理页面的分配与回收,而 SLAB 分配器则在 Buddy 系统之上,负责内核对象的精细分配与缓存。SLAB 分配器是高性能子系统的基石——从网络 socket 到文件系统 inode,从进程描述符 task_struct 到 VMA 结构体,几乎每个核心对象都通过 SLAB 分配。本文将深入剖析 Linux 内核内存管理体系,从 Buddy System 的物理页管理到 SLAB/SLUB/SLOB 三种分配器的架构差异,再到实战中的碎片调优、NUMA 感知分配、以及现代内核 6.x 的演进。
一、Buddy System:物理页管理的核心算法
1.1 设计哲学
内存碎片是动态分配器的天敌。Buddy System 采用按 2 的幂次方连续页面分组的方式,将物理内存划分为 10 个阶(order)的链表: order-0 (1页)、order-1 (2页)... order-9 (512页)。分配时从最小的 sufficiently large order 中取,回收时与"伙伴"合并为更大的块,从而实现了 O(log n) 的分配与高效的碎片合并。
1.2 伙伴算法核心机制
- 分配流程: free_area → 从请求 order 向上查找 → 若找到则从 free_list 摘除 → 若 order 大于请求值则将剩余部分逐级分裂为更小块
- 回收恢复: (addr ^ (1ULL << order)) 定位伙伴 → 验证伙伴是否在 free_list → 合并为更高 order 的块或回到 free_list
- 水位线控制: WMARK_MIN / WMARK_LOW / WMARK_HIGH 三级水位控制 swapd/kswapd 的唤醒与直接回收触发
1.3 页面迁移与碎片类型
Buddy System 将页面按可移动性分为 5 种迁移类型: MOVABLE、RECLAIMABLE、UNMOVATBLE、HWCMA、ISOLATE。外部碎片场景下的 页面迁移(Page Migration) 是解决碎片的关键: 通过在 compaction 线程中移动 MOVABLE 页面,将分散的空闲页面合并成高阶连续块。
1.4 GFP 标志位解析
GFP_KERNEL 是最常用的标志,允许直接回收和 I/O 唤醒。GFP_ATOMIC 禁止中断上下文中的阻塞与回收。GFP_NOWAIT 禁止回收但不允许阻塞。Zero 标志 (GFP_ZERO) 要求清零返回内存。理解标志位对避免死锁和递归分配至关重要。
二、SLAB/SLUB/SLOB 分配器架构全景
2.1 为什么需要 SLAB
Buddy System 以页(通常 4KB)为粒度分配,但内核对象往往很小(task_struct ~8KB、inode ~600B、dentry ~200B)。直接逐页分配小对象会造成严重的内部碎片。SLAB 分配器在页面内部划分出"缓存",消除内部碎片并加速对象的分配与释放。
2.2 SLAB 原始设计(Solaris 风格)
- kmem_cache: 缓存对象描述符,管理构造函数/析构函数、着色偏移、SLABS 链表
- SLAB 物理布局: 满/空/部分三个双向链表,每个页面链接描述符 (slab、slab_t)
- 对象缓存: kmem_cache_alloc 直接从 free 链表中摘取一个对象,kmem_cache_free 将对象归还到 free 表
- 构造函数优化: ctor/dtor 在分配和释放时完成初始化/清理,避免重复的零值清理
- 缺陷:复杂的元数据结构(每个 SLAB 的描述符链表)占用空间较大,多 NUMA 节点场景下扩展性差
2.3 SLUB:现代默认分配器
- 设计目标: 简化多核扩展性、消除 per-CPU 管理开销、为虚拟化与 NUMA 优化
- 关键特性:
- 无 SLAB 描述符页——对象直接嵌入 page→freelist 链
- per-CPU 部分链表(page→freelist_cpu)减少锁竞争
- Red-Zone 前后置保护检测越界写
- Freelist 随机化(CONFIG_SLAB_FREELIST_RANDOM)防御溢出攻击
- 高速路径: kmem_cache_alloc → page→freelist (无锁),kmem_cache_free → 插入 page→freelist
2.4 SLOB:资源受限场景
- 适用场景: 嵌入式无 MMU、极小内存
- 算法: 最坏匹配法 (best-fit),将小块放进大空洞——简单但分配 O(n)
- 取舍:代码简单但碎片严重、性能差,不适合通用服务器
2.5 三种分配器对比
| 特性 | SLAB | SLUB | SLOB |
|---|---|---|---|
| 默认场景 | 历史默认 | 现代通用 | 嵌入式极简 |
| 扩展性 | 一般 | 最强 | 弱 |
| 元数据开销 | 高(per-slab 描述符) | 低(page→freelist) | 极低 |
| 多 NUMA | 差(复杂管理) | 良好(per-node) | 无支持 |
| 碎片控制 | 一般 | 良好(合并同大小缓存) | 差 |
三、kmalloc:通用分配接口深度解析
3.1 size-class 映射表
kmalloc 不是直接分配请求大小,而是向上取整到最近的 kmalloc_size_class: 32B, 64B, 96B, 128B, 192B, 256B, 512B, 1024B... 例如 kmalloc(200) 实际分配 256B 缓存对象。尺寸超过 8KB 时直接走 Buddy System 页面分配。
3.2 高速缓存路径
kmalloc 的高速路径非常简单: 查表确定 request_size 对应的 kmem_cache 指针 → 调用 kmem_cache_alloc → 走 SLUB 的 page→freelist 无锁路径。释放时 kmem_cache_free 将对象插回。只要 size class 命中且部分链表非空,整个过程仅需几条指令。
3.3 vmalloc vs kmalloc 的本质区别
- kmalloc: 物理连续(Buddy 保证),适合 DMA、设备调用、小对象
- vmalloc: 仅虚拟连续(映射多个非连续物理页),适合仅内核态访问的大内存
- vzalloc/vmalloc_long: flag 变体,vmalloc 开销更高(需要建立页表映射)
经验:超过一页大小的单纯数据缓冲建议 vmalloc,DMA 网络缓冲区用 kmalloc 或 dma_alloc_coherent
四、缓存着色(Cache Coloring)与硬件缓存对齐
4.1 着色问题来源
现代 CPU 的 L1 缓存采用直接映射或组相联结构。同一 SLAB 中连续的对象若大小相同,它们的低 cache-line 地址相同,可能导致缓存行 "冲突" —— 对象 A 和对象 B 竞争同一缓存行,来回颠簸(cache thrashing)。
4.2 着色解决方案
- SLAB 着色偏移: kmem_cache 在创建时随机选择一个 colour 值(cache_line_size 的倍数),每个 SLAB 的实际可用空间起始位置不同
- 效果: 不同 SLAB 中的相同对象偏移不同 L1 缓存组,减少冲突
- 指标: slab_info.colour_next = (colour_next + cache_line_size) % colour_off
4.3 数据结构 flag
- SLAB_HWCACHE_ALIGN: 强制 cache_line_size 对齐,适合频繁读结构
- SLAB_ACCOUNT: 归属 cgroup memory.stat 统计
- SLAB_TYPESAFE_BY_RCU: 允许 RCU 读者重分配,适合链表节点
五、内核内存碎片与调试工具链
5.1 外部碎片:/proc/buddyinfo 与 /proc/pagetypeinfo
/proc/buddyinfo 输出各个 order 区域在各 NUMA 节点的空闲页块数。若 order≥3 的块非常少,外部碎片严重。/proc/pagetypeinfo 增加按迁移类型区分页面分布,配合 compact_memory 手动触发页面压缩。
5.2 内部碎片与 cache 对象占用
- /proc/slabinfo: 核心调试接口。字段包括 kmem_cache_name、active_objs、num_objs、objsize、objperslab、pagesperslab
- 计算内部碎片: wasted = pagesperslab × 4096 - active_objs × objsize
- slabtop: 实时 TUI 展示对象分配与释放速率,按内存在高排
5.3 Kernel Address Sanitizer (KASAN) 与 SLAB 检测
- KASAN: 红区加 shadow memory (1:8 比例),检测越界与 UAF。开启后 SLAB 对象需要额外 redzone
- KASAN-TAGS (MTE): ARM MTE 的 LKASAN-HW 模式,tag-based 检测精度更高
- debug SLAB: CONFIG_DEBUG_SLAB 开启 Poison 填充 (0xA5 释放标记、0x6B 分配填充)、Red-Zone、Freelist 随机化
5.4 kmemleak:内存泄漏检测
CONFIG_DEBUG_KMEMLEAK 开启后,kmemleak 通过 scan_thread 定期扫描内存: kmemleak_alloc() / kmemleak_free() 钩子跟踪可疑性指针。触发 echo scan > /sys/kernel/debug/kmemleak 手动扫描,cat /sys/kernel/debug/kmemleak 查看疑似泄漏的调用栈。
5.5 SLUB debug 实践案例
# 1. 追踪特定缓存的分配
echo <slab_cache> > /sys/kernel/debug/slab/trace # SLUB debug trace
# 2. 强制释放 per-cpu partial 缓存到 node partial
echo 1 > /sys/kernel/debug/slab/<cache>/flush_cpu_partial
# 3. 使用 ftrace 监控分配栈
echo 'kmem_cache_alloc kmem_cache_free' > /sys/kernel/debug/tracing/set_ftrace_filter
echo function > /sys/kernel/debug/tracing/current_tracer
echo 1 > /sys/kernel/debug/tracing/tracing_on
# 4. 使用 drgn 实时分析 Python 接口
$ drgn -k
>>> for slab in for_each_slab_cache(prog):
... print(slab.name.string_().decode(), slab.object_size.value_(), slab.oo.x.value_())
六、NUMA 感知内存分配
6.1 架构背景
多路服务器中,CPU 访问本地内存延迟低,跨节点访问延迟 1.2~1.6 倍。传统 SLAB 使用 per-node 链表,SLUB 改进为 per-CPU + per-node partial 双层结构。
6.2 NUMA 分配策略
- NO policy (GFP_KERNEL): 从当前 CPU 所在节点的 partial 分配 → 退回其他节点
- MPOL_BIND: 限定固定节点列表
- MPOL_PREFERRED: 首选某节点,失败再向其他节点 fallback
- MPOL_INTERLEAVED: 轮询各节点,优化大对象带宽
6.3 NUMA Balancing 自主动页迁移
kernel.numa_balancing 启用后,内核通过周期性扫描将页面迁移到访问它的进程所在节点。/proc/<pid>/numa_maps 可视化进程地址空间在每个节点的分布。对于数据库、DPDK 等 NUMA 敏感场景,应通过 libnuma + numactl --cpunodebind 显式绑定。
6.4 交错分配示例
# 查看当前 SLUB per-node 占用
$ cat /proc/slabinfo | grep -E "^(kmalloc|task_struct|dentry|inode)"
$ numastat -c slab # per-node slab 内存统计
# 强制将某缓存降级回收
$ echo 1 > /sys/kernel/slab/<cache>/shrink
七、实战:从零创建自定义 SLAB Cache
7.1 为什么需要自定义缓存
当驱动或子系统需要频繁分配同一结构体时(如字符设备私有数据、网络连接),使用 kmalloc 存在两个问题: (1) size-class 向上取整浪费内存 (2) 无法定义构造函数完成一次性初始化。自定义缓存通过预分配和构造函数解决。
7.2 完整可编译驱动模块示例
// demo_cache.c - Linux 6.x 内核模块:自定义 SLAB Cache 实战
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/slab.h>
#include <linux/fs.h>
#include <linux/uaccess.h>
#define CACHE_NAME "demo_ring_buffer"
#define CACHE_PAGES_ORDER 0 // 每个 SLAB 1 页
#define POISON_PATTERN 0xA5A5A5A5
struct my_ring_entry {
u32 id;
u32 data_len;
u8 data[]; // 柔性数组——现代 SLAB 最佳实践
};
static struct kmem_cache *my_cache;
static DEFINE_SPIN_LOCK(entry_lock);
// 构造函数:每次分配时调用,模拟复杂初始化
static void my_ctor(void *p) {
struct my_ring_entry *e = p;
e->id = 0;
e->data_len = 0;
memset(e->data, 0, 256);
}
// 分配一个 ring entry(含柔性数组)
struct my_ring_entry *alloc_entry(u32 data_len) {
// 缓存中存储 id+data_len,柔性数组由 kmalloc 固定部分承载
// 对于变长需求,使用 kvmalloc() 或独立分配 data buffer
struct my_ring_entry *e = kmem_cache_alloc(my_cache, GFP_KERNEL);
if (!e)
return NULL;
e->data_len = data_len;
e->id = get_random_u32();
return e;
}
static int __init demo_init(void) {
// struct_size() 是安全的变长结构大小计算宏
size_t entry_size = struct_size((struct my_ring_entry *) 0, data, 256);
my_cache = kmem_cache_create(CACHE_NAME, entry_size,
cache_line_size(), // 硬件缓存对齐
SLAB_HWCACHE_ALIGN | SLAB_ACCOUNT,
my_ctor);
if (!my_cache)
return -ENOMEM;
// 预分配 32 个对象到 partial 链表
kmem_cache_shrink(my_cache);
pr_info("demo_cache: created %s (size=%zu, align=%d)\n",
CACHE_NAME, entry_size, cache_line_size());
return 0;
}
static void __exit demo_exit(void) {
kmem_cache_destroy(my_cache); // 无条件释放全部 SLAB
pr_info("demo_cache: destroyed\n");
}
module_init(demo_init);
module_exit(demo_exit);
MODULE_LICENSE("GPL");
# Makefile
# obj-m += demo_cache.o
# KDIR ?= /lib/modules/$(shell uname -r)/build
# make -C KDIR M=$(PWD) modules
7.3 柔性数组与 SLAB
Linux 6.x 大量采用柔性数组(如 struct tcp_sock { ... u8 sysctl_tcp[]; })。SLAB 通过记录 obj_size 准确分配,构造函数仅在对象首次加入缓存时调用,释放仅标记为 free——满足高性能要求。
八、性能调优参数与现代演进
8.1 vm/sysctl 调优参数
- vm.min_free_kbytes: 最低空闲内存(建议系统内存的 1%。过低直接回收频繁、过高浪费)
- vm.vfs_cache_pressure: inode/dentry 缓存回收优先级(默认 100,加大更积极回收)
- compact_memory: 触发全局碎片压缩
- slab_max_order: SLUB 最大 allocation order
- slab_reclaim_mode: SLABALLOW_RECLAIM vs SLAB_NORECLAIM
8.2 实战监控脚本
#!/bin/bash
# mem_frag_check.sh: 监控系统碎片状态
echo "=== Buddy 碎片 ==="
awk 'NR>2 {for(i=5;i<=NF;i++) printf "Order%d: %d ", i-5, $i; print ""}' /proc/buddyinfo
echo "=== Top5 高占用缓存 ==="
awk 'NR>2 {alloc=$3*$5; if(alloc>1048576) printf "%s: %.1fMB (%d objs)\n", $1, alloc/1048576, $3}' /proc/slabinfo | sort -t: -k2 -rn | head -5
echo "=== NUMA 本地/远程比率 ==="
numastat -c slab | head -20
8.3 内核 6.x 演进
- mm: SLUB Preemption: SLUB 每次分配可抢占,提升虚拟化实时性
- memcg: Tiering API: cgroup v2 memory.tiering 支持自动多级回收
- mm: MGLRU (Multi-Gen LRU): 替代传统 Active/Inactive 双链表,更高效内存回收
- group memory controller: memory.max_usage_in_bytes 永久追踪历史最高内存峰值
- kmemleak: offloading: 将扫描移至 workqueue,避免 kthread 阻塞
- SLAB_VIRTUAL: SLUB 6.7+ 虚拟内存浪费修正——消除页对齐但对象结束后的尾部碎片
8.4 Rust 生态与 Buddy Refcount (AREf)
Rust-for-Linux 引入的 Aref 类型是引用计量的 RAII 包装,配合 KmemCache<T> 可安全地让 Rust 分配器使用 SLAB 内核缓存。这将彻底消除 C 中手动 kmem_cache_free 忘记调用导致的泄漏。
九、常见陷阱与最佳实践
- 陷阱 1: kmalloc 尺寸超过 2 页仍用 kmalloc — 应改用 kvmalloc(),自动降级到 vmalloc
- 陷阱 2: 中断上下文使用 GFP_KERNEL — 导致死锁,应 GFP_ATOMIC 或 pre_alloc 缓冲区
- 陷阱 3: 频繁分配释放相同结构体不使用缓存 — size_class 不连续导致内存浪费
- 陷阱 4: 缓存创建后长期不销毁 — 驱动未调用 kmem_cache_destroy,导致模块痕迹残留 slabinfo
- 最佳实践 1: 调试期开启 CONFIG_DEBUG_SLAB + KASAN,生产后关闭
- 最佳实践 2: NUMA 多节点场景,进程绑定正确 CPU+Mem 节点 numa_run_on_node
- 最佳实践 3: 监控
/proc/slabinfo增长异常,怀疑模块泄漏时用 drgn 查看 freelist 链表 - 最佳实践 4: 大对象连续使用 vmalloc,启用 CONFIG_HIGHMEM 时注意 DMA 32 区域溢出
十、总结
SLAB 分配器是 Linux 内核内存管理的核心子系统,理解它的架构对编写高性能驱动和调优系统至关重要。从 Buddy 系统的物理页管理,到 SLUB 的高效可扩展设计,再到 NUMA 拓扑感知分配,每个层次都蕴含着工程智慧。现代内核 6.x 持续在碎片压缩、内存泄漏检测、cgroup 统计、以及 Rust 分配器生态上推进演进。建议读者从/proc/buddyinfo → /proc/slabinfo → slabtop → drgn 这条工具链入手,逐步深入。
参考资料:
- kernel Documentation/mm/ — 官方内存管理文档
- Bovet & Cesati《Understanding the Linux Kernel》— 经典内核教材 SLAB 章节
- lwn.net — SLUB/MGLRU 设计原理解析系列文章
- drgn — Python-based 内核调试器实战工具
- Brendan Gregg Linux Performance — perf/ftrace/eBPF 内存追踪

发表评论 取消回复