引言

在 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 三种分配器对比

特性SLABSLUBSLOB
默认场景历史默认现代通用嵌入式极简
扩展性一般最强弱
元数据开销高(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 内存追踪

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部