前言
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-slab | slab管理结构(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() 的操作步骤:
- 检查
cpu_slab->freelist是否有空闲对象 - 若有,直接pop返回(快速路径,无锁)
- 若无但
cpu_slab->partial有slab,切换到该slab更新freelist - 若无partial slab,去
node->partial获取(加锁) - 若无远程slab,进入慢速路径:
new_slab()从伙伴系统分配页面 - 设置
page->freelist = page_address + s->offset - 返回对象地址,
obj + s->offset指向下一个空闲对象
3.4 SLUB释放路径详解
slab_free() 的操作步骤:
- 若释放对象属于
cpu_slab->page,直接插入 CPU freelist 头部(快速路径) - 若释放对象属于
node的partial slab,s->node操作 + 检查是否需从full变为partial - 若是其他node的slab
n != node,归还给远程node(需加锁) - slab变为空 → 调用
discard_slab()归还伙伴系统
3.5 SLUB与SLAB性能对比
| 维度 | SLAB | SLUB |
|---|---|---|
| 设计复杂度 | 高(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 三大分配器对比
| 特性 | SLAB | SLUB | SLOB |
|---|---|---|---|
| 内存开销 | 中 | 低 | 极低(~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内核内存回收路径:
- kswapd:后台守护线程,扫描各zone内存,低于阈值时异步回收
- Direct Reclaim:分配路径发现内存不足时同步回收(会阻塞调用者)
- 回收手段:文件页
pageout(写回脏页/丢弃干净页)↔ 匿名页swap(写入swap设备) - 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/kzalloc | kmalloc → SLUB kmem_cache → Buddy → PCP |
| 大块无DMA需求(>32KB) | vmalloc/vzalloc | vmalloc → Buddy → 页表映射 |
| 同类型频繁创建销毁 | kmem_cache_alloc/create | kmem_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注册的固定内存接口。

发表评论 取消回复