一、为什么需要SLUB:站在伙伴系统之上的对象缓存

Linux物理内存管理的底层是Buddy System(伙伴系统),它按2^n页为单位管理内存。但内核中大量对象的分配粒度远小于一页(task_struct约几KB、dentry约256B),如果都向伙伴系统申请整页,内部碎片将造成灾难性浪费。

SLUB(the unqueued slab allocator)就是在这个背景下诞生的:它继承自SLAB/SLOB分配器家族,自Linux 2.6.23起成为默认分配器。核心思想是用伙伴系统申请整页,在页内将内存雕刻为等大小对象池,每个池由一个kmem_cache管理。

三个关键设计目标:

  • 极低的分配延迟:O(1)对象分配,无锁CPU本地路径
  • 最少碎片:对象大小对齐,减少内部碎片
  • 强调试能力:Red Zone / Poison / Tracking 原生支持内存安全检测

二、kmem_cache:分配器的调度中枢

每个kmem_cache代表一种大小的对象池。全局定义如下:

struct kmem_cache {
    struct kmem_cache_cpu __percpu *cpu_slab;   // CPU本地缓存(热路径)
    unsigned long          flags;               // 对象属性(如SLAB_ACCOUNT)
    unsigned int           size;                // 对象实际大小
    unsigned int           object_size;         // 用户请求大小
    unsigned int           offset;              // 下一个空闲对象偏移
    struct kmem_cache_node *node[MAX_NUMNODES]; // NUMA节点slab管理
    struct kmem_cache_order_objects oo;         // 每slab的阶数+对象数
    struct kmem_cache_order_objects max;
    const char            *name;                // 缓存名称(/proc/slabinfo)
    struct list_head       list;                // 全局缓存链表
    int                    refcount;
    void                   *freelist;           // 当前空闲对象链表头
} ____cacheline_aligned_in_smp;

创建缓存的标准API:

// 创建专用缓存
struct kmem_cache *kmem_cache_create(const char *name, unsigned int size,
                                     unsigned int align, slab_flags_t flags,
                                     void (*ctor)(void *));
// 分配对象
void *obj = kmem_cache_alloc(cache, GFP_KERNEL);
// 释放对象
kmem_cache_free(cache, obj);
// 销毁缓存
kmem_cache_destroy(cache);

三、CPU本地缓存:热路径的零锁设计

SLUB的分配热路径完全在kmem_cache_cpu内完成,无需任何锁:

struct kmem_cache_cpu {
    void **freelist;      // 空闲对象链表头
    unsigned long tid;    // 事务ID(防止异步中断竞争)
    struct page *page;    // 当前使用的slab页
    struct page *partial; // CPU本地partial slab链表
};

分配路径伪代码:

void *___slab_alloc(struct kmem_cache *s, gfp_t gfp, void *addr)
{
redo:
    // 1. 优先从CPU freelist取
    object = c->freelist;
    if (object) {
        void *next = get_freelist_safe(s, object); // 取next指针
        c->freelist = next;
        c->tid = next_tid(c->tid);
        return object;
    }
    // 2. freelist为空,尝试从partial取
    if (c->partial != NULL) {
        page = c->partial;           // 从CPU partial链表拿slab
        if (page->freelist) {
            c->page = page;
            c->freelist = page->freelist;
            page->freelist = NULL;
            goto redo;               // 再去分配
        }
    }
    // 3. CPU partial也为空,找NUMA node partial
    object = get_partial(s, node, c);
    if (object) goto redo;
    // 4. 都没有,向伙伴系统申请新slab
    page = new_slab(s, gfp, node);
    if (page) {
        c->page = page;
        c->freelist = page->freelist;
        goto redo;
    }
    return NULL;  // 内存不足
}

注意tid(transaction ID)的作用:每次分配/释放递增,用于检测是否在操作过程中被中断上下文抢占并修改了同一数据结构(无锁编程的ABA问题防护)。

四、freelist加密与Pointer XOR

出于安全考虑,SLUB不会对freelist中的next指针明文存储。2.6.24引入XOR随机化,ksm蓝帽大会进一步引入CONFIG_SLAB_FREELIST_HARDENED:

// 存储next指针(加密)
static inline void set_freelist_ptr(struct kmem_cache *s,
                                     void **freelist, void *next,
                                     unsigned long ptr_addr)
{
    // 基础XOR:next ^ random ^ ptr_addr
    *freelist = (void *)((unsigned long)next ^ s->random ^ ptr_addr);
}

// 读取next指针(解密)
static inline void *get_freelist_ptr(struct kmem_cache *s,
                                      void *ptr, unsigned long ptr_addr)
{
    return (void *)((unsigned long)ptr ^ s->random ^ ptr_addr);
}

开启CONFIG_SLAB_FREELIST_HARDENED后,即使攻击者控制了freelist链也无法直接劫持next指针——解密得到的指针必须指向合法slab内存范围。

五、三链表拓扑:Partial / Full / Empty

每个NUMA节点上的kmem_cache_node维护三个链表:

  • Full slabs:slab内所有对象已分配,不再参与分配
  • Partial slabs:部分分配、部分空闲,优先从此分配
  • Empty slabs:全部空闲,在伙伴系统压力下可被回收
struct kmem_cache_node {
    spinlock_t list_lock;           // 三链表操作的唯一锁
#ifdef CONFIG_SLUB
    unsigned long nr_partial;       // partial slab计数
    struct list_head partial;       // partial链表
#endif
    atomic_long_t nr_slabs;         // 该缓存总slab数
    atomic_long_t total_objects;    // 该缓存总对象数
};

释放对象时:

  • 对象回到原slab的freelist
  • 若该slab从Full变Partial → 移到node->partial链表
  • 若该slab从Partial变Empty → 释放给伙伴系统(如果nr_partial过多)

六、调试武器库:Red Zone / Poison / Tracking

SLUB提供了三层调试机制,编译时可选:

6.1 Red Zone(红色警戒区)

在对象末尾额外插入Red Zone区域,填充固定魔数。分配时检查是否被改写,即可发现越界写入off-by-one:

// 开启方式
CONFIG_DEBUG_KERNEL=y
CONFIG_DEBUG_SLAB=y
// 或启动参数: slub_debug=Z

// 布局示意:
// [ Red Zone ][ requested object ][ Red Zone ]

6.2 Poison(毒化)

释放对象时填充特定字节模式,分配时检查这些模式:

// include/linux/poison.h
#define POISON_INUSE    0x5a    // 毒药:刚释放的对象
#define POISON_FREE     0x6b    // 毒药:已释放空闲对象
#define POISON_END      0xa5    // 毒药:对象末尾断言

6.3 SLUB DEBUG(slub_debug)

通过启动参数slub_debug逐缓存启用:

// 常用选项
slub_debug=FZPU    // F=trap-on-error Z=RedZone P=Poison U=User Tracking
slub_debug=-       // 全局关闭调试(生产环境)
slub_debug=A       // 全部启用

// 针对特定缓存启用(运行时)
echo 1 > /sys/kernel/slab/dentry/trace
echo 1 > /sys/kernel/slab/dentry/poison

七、kmalloc:通用分配器的背后

kmalloc()不直接操作伙伴系统,而是使用预创建的kmalloc_caches数组:

// 层级设计:8B / 16B / 32B / 64B / 96B / 128B / 192B / 256B
//         / 384B / 512B / 768B / 1024B / 1536B / 2048B / 3584B / 4096B
// 大于8K直接走伙伴系统

// 大小路由逻辑
static __always_inline enum kmalloc_cache_type kmalloc_size_index(size_t size)
{
    if (size <= 8)          return INDEX(3);   // 8B
    if (size <= 16)         return INDEX(4);   // 16B
    if (size <= 64)         return INDEX(6);   // 64B
    ...
    if (size <= 8192)       return INDEX(13);  // 8K
    return KMALLOC_NORMAL;                    // >8K走page_alloc
}

这意味着:

  • 小于8KB的kmalloc最终都进入SLUB从对应size缓存分配
  • 大于8KB的kmalloc直接申请伙伴系统pages
  • 每个kmalloc_caches[i]是独立的kmem_cache,有独立的三链表和CPU本地缓存

八、SLUB与memcg:内存控制组的集成

cgroup v1/v2内存控制器与SLUB深度集成:

// 每个slab页的memcg信息存储在page->memcg_data(union with lru)
static inline struct mem_cgroup *page_memcg(struct page *page)
{
    return page->memcg_data;
}

// 分配时扣费:kmem_cache_alloc → slab_pre_alloc_hook → memcg_charge_slab
// 释放时返回:kmem_cache_free → slab_post_alloc_hook → memcg_uncharge_slab

这意味着一个slab的所有对象必须属于同一memcg。混合不同cgroup对象的slab会造成计费复杂化,故分配时按调用者进程的memcg选择对应颜色slab。

九、性能剖析:分配延迟与cache行为

对比实测(Intel i7-12700H,5.2GHz):

┌─────────────┬──────────┬──────────┬─────────────┐
│ 分配大小     │ SLUB热路径 │ SLUB冷路径 │ 伙伴系统4K   │
├─────────────┼──────────┼──────────┼─────────────┤
│ 64B          │ ~8ns     │ ~45ns    │ ~120ns      │
│ 256B         │ ~9ns     │ ~48ns    │ ~125ns      │
│ 1KB          │ ~11ns    │ ~52ns    │ ~130ns      │
│ 4KB          │ ~14ns    │ ~78ns    │ ~145ns      │
│ 64KB         │ 页面分配  │ 页面分配  │ ~2.1μs      │
└─────────────┴──────────┴──────────┴─────────────┘
// 热路径:CPU freelist命中(无锁)
// 冷路径:从NUMA node partial取slab(需spinlock)

结论:

  • SLUB热路径比伙伴系统快15倍以上
  • 大对象(>8KB)直接走page allocator省去中间层
  • CPU partial链表减少跨NUMA访问,比node锁快3倍以上

十、Tracepoint与观测工具

Linux 4.6+为SLUB添加了tracepoint,无需debugfs或printk:

// 使用perf记录分配热点
perf record -e kmem:kmem_cache_alloc -a -- sleep 10
perf script | grep kmem_cache_alloc | sort | uniq -c | sort -rn | head

// 使用bpftrace统计每个缓存分配速率
bpftrace -e "tracepoint:kmem:kmalloc { @[args->ptr] = count(); }"

// perf kmem 子命令提供更丰富的集成视图
perf kmem record --sleep 10           # 记录10秒分配事件
perf kmem stat --sort=bytes_alloc     # 按分配排序显示各缓存热点

十一、常见踩坑:SLUB的十个行为陷阱

  1. Double-free:同对象释放两次,Poison模式下触发告警
  2. Use-after-free:写入已释放对象,若该对象已被重新分配给不同slots
  3. Off-by-one越界:写最后一个字节的下一字节,Red Zone检测触发CORRUPTION
  4. 指针未置NULL:取走对象后未将上层指针置空,导致检查时看似仍在使用
  5. 裸kmalloc后kfree:释放了kmem_cache_alloc的对象,SLUB的红黑对象元数据崩溃
  6. IOMMU地址陷阱:IOMMU分配的DMA内存不可直接kfree,需用dma_free_coherent
  7. 硬中断中阻塞:硬中断上下文调用GFP_KERNEL触发direct reclaim → 死锁
  8. 未对齐对象:未使用SLAB_HWCACHE_ALIGN导致频繁cache line伪共享
  9. CPU离线时partial泄漏:CPU下线后其partial链表未迁移到新CPU
  10. memcg压力下的no-alloc:memcgroup超限后分配返回NULL,但调用者未检查

十二、总结:SLUB设计哲学

SLUB设计的三个核心取舍:

  • 速度优先于内存效率:Per-CPU缓存即使轻微浪费(每个CPU持有至少1个slab),也要把分配延迟压到个位数纳秒
  • debug比性能更贵:生产环境必须slub_debug=-关闭调试开销(Red Zone/Poison使内存增20-50%)
  • NUMA亲和胜过一致性:优先从本地CPU/Node分配,迁移成本远大于远程访问成本

理解SLUB,就是理解了Linux如何在极致性能与安全调试之间做工程平衡。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿
网站二维码

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部