前言

Linux内核内存管理是操作系统最核心的子系统之一。在用户空间我们有malloc/free这样简洁的接口,但在内核空间,内存分配要面对远为复杂的场景:中断上下文不能睡眠、DMA需要连续物理页、小对象频繁分配释放需要缓存加速、NUMA架构要求就近分配...

本文将深入剖析Linux内核内存分配器的完整架构——从底层的伙伴系统(Buddy System)页面分配器,到上层的SLAB/SLUB/SLOB三大kmalloc分配器,涵盖接口使用、实现原理、调试工具和实际内核模块开发最佳实践。

一、伙伴系统(Buddy System)—— 页面级分配器

1.1 核心思想

伙伴系统是Linux内核内存管理的物理页面分配基础。它将可用内存划分为不同大小等级的空闲区域(free area),每个等级管理大小为2^n个页面的内存块(n=0..10,对应4KB~4MB)。

分配流程:当请求大小为2^k的内存块时,先在order=k的空闲链表中查找。若无,则向上级order=k+1的链表取一个块,对半分裂,一半分配、一半插入order=k链表。

释放流程:释放内存块时检查其伙伴(buddy)是否也在空闲链表中——若是则合并形成更大的块(2^(k+1)),递归向上合并直到最大阶或伙伴已被占用。

1.2 数据结构与实现

每个NUMA节点(pg_data_t)和每个内存区域(zone)维护一组空闲区域:

struct zone {
    struct free_area    free_area[MAX_ORDER];  // 11个阶的空闲链表
    ...
};

struct free_area {
    struct list_head    free_list[MIGRATE_TYPES];  // 按迁移类型分类
    unsigned long       nr_free;                    // 空闲块数量
};

1.3 迁移类型(Page Migration)

伙伴系统按迁移类型组织页面以减少碎片:

  • MIGRATE_UNMOVABLE:不可移动页(内核代码、页表)
  • MIGRATE_MOVABLE:可移动页(用户空间页、磁盘缓存)
  • MIGRATE_RECLAIMABLE:可回收页(不可移动但可释放)
  • MIGRATE_ISOLATE:隔离页(热插拔用)

1.4 Per-CPU页面高速缓存(PCP)

为避免多CPU竞争zone->lock,内核为每个CPU维护per-CPU页面高速缓存(per_cpu_pageset):

  • 热页缓存(hot list):存放1个页面大小的块,分配/释放完全不涉及锁
  • 冷页缓存(cold list):批量缓存多个页面,批量补充/回收

PCP使得单页面分配/释放在大多数情况下只需操作本地缓存,极大提升了多核扩展性。

1.5 分配掩码(GFP Flags)

GFP(Get Free Pages)标志控制页面分配的行为:

标志含义
GFP_KERNEL标准内核分配,可能睡眠(不能用于中断上下文)
GFP_ATOMIC原子分配,不睡眠(中断上下文适用)
GFP_NOIO不发起I/O操作
GFP_NOFS不调用文件系统操作
__GFP_HIGHMEM允许从高端内存分配
__GFP_ZERO返回清零的页面
__GFP_NOWARN分配失败不产生警告
__GFP_RETRY_MAYFAIL允许重试但允许失败
__GFP_NORETRY不重试

二、SLAB分配器 —— 小对象高速缓存

2.1 为什么需要SLAB

伙伴系统以页(4KB)为最小分配单位。内核中大量小对象(task_struct、dentry、inode、file)频繁创建/销毁时,直接使用伙伴系统会产生严重内部碎片(分配4KB只用120字节)。

SLAB分配器在伙伴系统之上构建了一个对象缓存层:

  • 为常用结构预分配内存池(kmem_cache),消除内部碎片
  • 对象复用:释放的对象不立即归还伙伴系统,放入空闲链表
  • 利用硬件缓存:同类型对象聚集,提高缓存命中率
  • 着色机制(slab coloring):偏移 slab 起始地址以分散缓存行

2.2 SLAB的三种布局

布局实施方式
普通布局对象在slab内连续排列,slab管理结构独立分配
Off-slabslab管理结构(kmem_bufctl_t数组)单独分配,slab内仅存对象数据
FSLAB(SLAB内部布局)小对象时管理结构与数据共享同一页

SLAB在大于1/8页大小的缓存下使用普通布局,否则使用off-slab。

2.3 SLAB核心数据结构

// kmem_cache:每种对象类型一个
struct kmem_cache {
    struct kmem_cache_node *node[MAX_NUMNODES]; // 每个NUMA节点
    unsigned int object_size;       // 对象大小
    unsigned int offset;            // 空闲对象链表指针偏移
    slab_flags_t flags;             // SLAB标志
    unsigned int size;              // 包含元数据的对象尺寸
    const char *name;               // 缓存名称
    ...
};

struct kmem_cache_node {
    struct list_head slabs_partial;   // 部分分配slab链表
    struct list_head slabs_full;      // 已满slab链表
    struct list_head slabs_free;      // 空闲slab链表
    spinlock_t list_lock;             // 链表保护锁
    unsigned long nr_slabs;           // slab总数
    unsigned long nr_free_objects;    // 空闲对象总数
};

struct slab {
    struct list_head list;           // 挂接到kmem_cache_node
    void *s_mem;                     // slab中第一个对象地址
    unsigned int inuse;              // 已使用对象数
    unsigned int free;               // 第一个空闲对象索引
};

2.4 SLAB生命周期

分配路径:kmem_cache_alloc() → 从当前CPU的kmem_cache_cpu->freelist取对象 → 若本地无,则从kmem_cache_node->slabs_partial补充 → 若无部分slab,则alloc_slab()从伙伴系统分配新页 → 极端情况走cache_alloc_refill()→ 最终到__do_cache_alloc()。

释放路径:kmem_cache_free() → 对象挂回CPU本地freelist → 若本地freelist过大,部分归还给kmem_cache_node → slab全空时归还给伙伴系统。

三、SLUB分配器 —— 现代Linux默认分配器

3.1 SLUB的设计目标

SLAB自1994年引入(SVR4),设计时代CPU核心少、内存小。在NUMA+多核时代暴露了复杂性过高的问题:

  • 每个节点的kmem_cache_node包含3个链表+锁,管理开销大
  • 空闲对象链表维护在每个slab外
  • 调试信息侵入正常代码路径

SLUB(Unqueued Slab Allocator,2007年引入,2.6.23)大幅简化设计:

  • 去掉node的全局链表,每个CPU直接管理本地slab
  • 空闲对象链表用嵌入式链表实现(直接存在空闲对象内存中)
  • 仅保留kmem_cache_cpu(每CPU空闲指针+当前slab)和kmem_cache_node(NUMA远程备用)两层

3.2 SLUB核心结构

struct kmem_cache {
    struct kmem_cache_cpu __percpu *cpu_slab;    // 每CPU的slab
    unsigned long flags;                         // SLUB标志
    unsigned int size;                           // 对象实际大小
    unsigned int object_size;                    // 用户请求大小
    struct kmem_cache_order_objects oo;         // (order << 16) | objects_per_slab
    struct kmem_cache_node *node[MAX_NUMNODES]; // NUMA远程slab
    ...
};

struct kmem_cache_cpu {
    void **freelist;    // 空闲对象链表头
    struct page *page;  // 当前操作的slab页面
    struct page *partial;// 本地CPU的部分分配slab链表(SLUB特有)
};

3.3 SLUB分配路径详解

slab_alloc() 的操作步骤:

  1. 检查 cpu_slab->freelist 是否有空闲对象
  2. 若有,直接pop返回(快速路径,无锁)
  3. 若无但 cpu_slab->partial 有slab,切换到该slab更新freelist
  4. 若无partial slab,去node->partial获取(加锁)
  5. 若无远程slab,进入慢速路径:new_slab()从伙伴系统分配页面
  6. 设置page->freelist = page_address + s->offset
  7. 返回对象地址,obj + s->offset 指向下一个空闲对象

3.4 SLUB释放路径详解

slab_free() 的操作步骤:

  1. 若释放对象属于 cpu_slab->page,直接插入 CPU freelist 头部(快速路径)
  2. 若释放对象属于node的partial slab,s->node操作 + 检查是否需从full变为partial
  3. 若是其他node的slab n != node,归还给远程node(需加锁)
  4. slab变为空 → 调用discard_slab()归还伙伴系统

3.5 SLUB与SLAB性能对比

维度SLABSLUB
设计复杂度高(3层链表+每节点锁)低(2层+仅NUMA远程需锁)
多核扩展性一般(node锁竞争)优(CPU本地Slab无锁快速路径)
内存开销较高(管理结构独立分配)较低(嵌入空闲对象链表)
调试能力内置集成红 poison/red zone/UAF检测
默认使用已废弃(5.17+完全移除)Linux 26+ 唯一默认
NUMA支持完全但复杂简化但高效

四、SLOB分配器 —— 极简嵌入式分配器

4.1 适用场景

SLOB(Simple List Of Blocks)是专门为内存极端受限的嵌入式设备设计的分配器。它可以用CONFIG_SLAB关闭后配置为CONFIG_SLOB。

4.2 核心算法

SLOB使用一个全局链表管理所有空闲块,每个块头部记录自身大小:

struct slob_block {
    slobidx_t units;  // 空闲块的大小(单位为SLOB_UNIT)
    struct slob_block *next;
};
#define SLOB_UNIT (sizeof(slobidx_t))  // 通常2字节

分配:遍历全局链表,找到大小>= requested的块。若块大,先分裂出尾部再用头部。使用首次适应(First Fit)策略。

释放:释放块与相邻空闲块合并(前向+后向),重新插入链表。

4.3 三大分配器对比

特性SLABSLUBSLOB
内存开销中低极低(~2字节/块头部)
分配速度快最快(无锁快速路径)慢(线性链表扫描)
内碎片中中低(按需分裂)
外碎片低低高(无buddy直接管理)
适用场景(已废弃)服务器/桌面/通用嵌入式(内存<128MB)
每CPU特性有有(更强)无(单全局链表)

五、kmalloc/vmalloc/kmem_cache 接口实践

5.1 kmalloc —— 物理连续内存分配

kmalloc分配物理连续+虚拟连续的内存,基于SLUB缓存(或回退到大页分配):

#include <linux/slab.h>

// 基本分配
void *ptr = kmalloc(size, GFP_KERNEL);
void *p = kmalloc_node(size, GFP_KERNEL, numa_node_id());// NUMA感知

// +清零
void *pz = kzalloc(sizeof(struct my_data), GFP_KERNEL);

// 数组
void *array = kmalloc_array(count, sizeof(item), GFP_KERNEL);

// 对齐分配
void *aligned = kmalloc_align(size, align, GFP_KERNEL);

// 释放
kfree(ptr);

kmalloc大小限制:理论上限KMALLOC_MAX_SIZE(64位上通常4MB),超过32KB直接回退到伙伴系统分配(不经过SLUB缓存)。

5.2 vmalloc —— 虚拟连续内存分配

vmalloc分配虚拟连续但物理可以不连续的内存,通过重新映射伙伴系统分配的离散页面实现。

#include <linux/vmalloc.h>

void *vptr = vmalloc(size); // 虚拟连续内存(物理可离散)
void *vz = vzalloc(size);   // +清零

vfree(vptr);

关键区别:

  • kmalloc返回的地址直接映射到物理地址(virt_to_phys()直接可用),设备DMA可用
  • vmalloc分配的内存不能用于DMA(物理不连续),但可用于大块缓冲
  • vmalloc走伙伴系统→映射页表→TLB操作路径,分配开销比kmalloc高1~2个数量级

5.3 kmem_cache —— 自定义对象缓存

对于结构体密集分配的代码,使用kmem_cache预分配缓存池显著降低碎片和分配延迟:

static struct kmem_cache *my_cache;

static int __init my_init(void) {
    // 创建缓存:对象大小/对齐/构造函数
    my_cache = kmem_cache_create(
        "my_struct_cache",          // 缓存名(/proc/slabinfo可见)
        sizeof(struct my_struct),  // 对象大小
        0,                          // 偏移(任意对齐填0)
        SLAB_HWCACHE_ALIGN,        // 缓存行对齐
        NULL                        // 构造函数
    );
    if (!my_cache)
        return -ENOMEM;
    return 0;
}

struct my_struct *alloc_my(void) {
    return kmem_cache_alloc(my_cache, GFP_KERNEL);
}

void free_my(struct my_struct *obj) {
    kmem_cache_free(my_cache, obj);
}

static void __exit my_exit(void) {
    kmem_cache_destroy(my_cache);
}

module_init(my_init);
module_exit(my_exit);
MODULE_LICENSE("GPL");

5.4 内核开发最佳实践

  • 优先kmalloc:物理连续内存需求场景(DMA、硬件交互)
  • 大块用vmalloc:8KB以上大块分配且无DMA需求
  • 高频结构体用kmem_cache:task_struct、dentry、网络连接等
  • 中断上下文用GFP_ATOMIC:若上下文可睡眠则用GFP_KERNEL
  • NUMA感知:kmalloc_node()/kmem_cache_alloc_node()优先从指定节点分配
  • 避免重复分配:考虑slab中的CPU本地缓存可复用,短期对象优先从缓存获取
  • 不要增删slab调试后忘记释放:kmem_cache_destroy()确保完全无引用才释放

六、内存碎片与反碎片机制

6.1 内存碎片的类型

  • 内部碎片:分配出去的内存中未使用的部分(如分配4KB只用120字节)
  • 外部碎片:空闲内存不连续,无法分配大块连续区域

6.2 反碎片伙伴分配

解决外部碎片的关键技术——按迁移类型分组(前文所述):当可移动页和不可移动页分离后,在内存回收时可迁移可移动页以腾出连续空间,伙伴系统的伙伴合并机制可以将小碎片合并成连续大块。

6.3 虚拟可回收内存(VM reclaim)

Linux内核内存回收路径:

  1. kswapd:后台守护线程,扫描各zone内存,低于阈值时异步回收
  2. Direct Reclaim:分配路径发现内存不足时同步回收(会阻塞调用者)
  3. 回收手段:文件页pageout(写回脏页/丢弃干净页)↔ 匿名页swap(写入swap设备)
  4. OOM Killer:systemd-oomd或内核OOM在所有回收失败时强制终止进程

七、调试工具与诊断

7.1 /proc/buddyinfo

查看伙伴系统各阶空闲区域数量:

$ cat /proc/buddyinfo
Node 0, zone   Normal  12  45  23  12   4   1   0   0   0   0   0

第i列代表2^i个连续空闲页面的块数。从左到右,空闲大块越来越少则碎片越严重。

7.2 /proc/slabinfo

查看SLUB/SLAB缓存状态(CONFIG_SLUB_DEBUG):

$ sudo cat /proc/slabinfo | head -20
slabinfo - version: 2.1
# name     <active_objs> <num_objs> <objsize> <objperslab> <pagesperslab> : ...
kmalloc-1k      128  128   1024    4    1 : ...
dentry          952 1024     192   21    1 : ...
inode_cache     864  864    632    6    1 : ...
task_struct     42   42    7360    1    3 : ...

关键指标:active_objs / num_objs利用率高,objsize对象大小,objperslab每slab对象数。

7.3 slabtop 与 slabinfo

实时监控slab缓存(sudo slabtop -o)和详细诊断(sudo slabinfo -r kmalloc-1k)。

7.4 SLUB Debugtools

// 启用 poison 调试(检测UAF)
sudo echo 1 > /sys/kernel/slab/kmalloc-256/poison

// 启用 red zone(检测缓冲区溢出)
sudo echo 1 > /sys/kernel/slab/kmalloc-256/red_zone

// 启用 store_user(检测跨缓存越界拷贝)
sudo echo 1 > /sys/kernel/slab/kmalloc-256/store_user

// 查看错误跟踪
sudo cat /sys/kernel/slab/kmalloc-256/alloc_traces
sudo cat /sys/kernel/slab/kmalloc-256/free_traces

7.5 内核模块崩溃分析

内存分配异常时,kernel oops和BUG()会打印调用栈。使用addr2line将地址转为源码行号:

$ llvm-addr2line -e vmlinux 0xffffffffc0001234
/home/user/mymodule/mymodule.c:42

八、总结:选型指南

需求推荐接口分配器链路
通用小对象(<32KB)kmalloc/kzallockmalloc → SLUB kmem_cache → Buddy → PCP
大块无DMA需求(>32KB)vmalloc/vzallocvmalloc → Buddy → 页表映射
同类型频繁创建销毁kmem_cache_alloc/createkmem_cache 专属缓存 → SLUB
NUMA敏感分配kmalloc_node(vmalloc_node)指定node的SLUB node → Buddy
嵌入式低内存kmalloc(SLOB)SLOB全局链表

Linux内存分配器经过30年持续演进:SLOB满足嵌入式最小化需求,SLAB曾是32位时代的通用方案,SLUB则在64位多核NUMA时代成为最优解。理解各层的职责边界和性能特征,是内核开发的基础功。

下一步我们可以探索内存回收路径(kswapd/OOM)、kmemcg内存cgroup控制、以及新兴的pin_user_pages与IO_uring注册的固定内存接口。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部