title: Linux内核内存管理深度实战:从伙伴系统到SLUB分配器的工程剖析 channel_id: 44 tags: Linux内核,内存管理,伙伴系统,SLUB,性能调优
Linux内核内存管理深度实战:从伙伴系统到SLUB分配器的工程剖析
内核内存管理是整个操作系统的基石。理解伙伴系统如何解决外部碎片、SLUB如何高效服务小内存分配请求,对于系统调优、驱动开发和性能分析至关重要。本文从源码级别剖析两者协作机制,结合真实内核代码(基于 Linux 6.x)讲解核心算法与工程实践。
一、物理内存的组织:从节点到区域到页
Linux内核将物理内存组织为一个多层次的树形结构。理解这个结构是理解后续分配器的前提。
1.1 NUMA 节点与 pglist_data
在NUMA系统中,每个节点由 struct pglist_data(定义在 include/linux/mmzone.h)描述:
typedef struct pglist_data {
struct node_zones node_zones[MAX_NR_ZONES]; // 内存区域
struct zonelist node_zonelists[MAX_ZONELISTS]; // 分配回退列表
int nr_zones; // 该节点区域数
struct page *node_mem_map; // 页描述符数组
unsigned long node_start_pfn; // 起始页帧号
unsigned long node_present_pages; // 总页数
unsigned long node_spanned_pages; // 包含空洞的总页数
...
} pg_data_t;
每个节点包含多个Zone,常见的区域类型包括:
| Zone类型 | 说明 | x86_64典型范围 |
|---|---|---|
| ZONE_DMA | 16MB以下,用于老旧DMA | 0-16MB |
| ZONE_DMA32 | 4GB以下,32位DMA | 16MB-4GB |
| ZONE_NORMAL | 线性映射到内核空间 | 16MB-896MB |
| ZONE_MOVABLE | 可迁移页,减少碎片 | 动态 |
1.2 页帧与 struct page
系统中每个物理页帧都对应一个 struct page 描述符(约64字节)。4GB内存就需要约64MB的 mem_map 数组来存储描述符本身——这是内核内存开销的重要来源。
struct page {
unsigned long flags; // 状态标志(PG_slab, PG_head等)
union {
struct address_space *mapping;
void *virtual; // 内核虚拟地址(若映射)
atomic_t _mapcount;
};
atomic_t _refcount; // 引用计数
...
union {
struct list_head lru; // LRU/Partial列表
structSlabList { ... }; // SLAB链表用
struct { // SLUB用
struct page *next; // 空闲链表指针(union匿名)
int pages; // 复合页阶
int pobjects; // 空闲对象计数
};
};
};
关键细节:在SLUB中,page->next 和 page->pobjects 复用了 lru 和 _mapcount 的存储位置(union机制),因为这些字段在 slab 页面上不会同时使用。
二、伙伴系统(Buddy System):物理页分配的主引擎
2.1 核心思想与数据结构
伙伴系统管理以页(或页组——"阶"为单位进行分配。每个Zone维护11个空闲区域数组(free_area[0..10]),每个数组包含一个按页组大小(2^n页 = 2^n × 4KB)组织的双向链表。
struct free_area {
struct list_head free_list[MIGRATETYPE_TYPES]; // 按迁移类型分链表
unsigned long nr_free; // 该阶空闲页数
};
struct zone {
struct free_area free_area[MAX_ORDER]; // MAX_ORDER = 11
...
};
分配阶n的含义:
- 阶0 = 1页 = 4KB
- 阶2 = 4页 = 16KB
- 阶8 = 256页 = 1MB
- 阶10 = 1024页 = 4MB(最大值)
2.2 分配与分裂
当请求2^n页时,内核首先在 free_area[n] 的空闲链表中查找。若该阶无空闲块,则向上阶(n+1, n+2, ...)查找,找到后反复"分裂":
分配请求:阶0 (1页)
free_area[0]: 空
free_area[1]: [A(2页), B(2页)]
↓ 分配B,分裂B
free_area[0]: [b2(1页)] ← 剩余半块
free_area[1]: [A(2页)]
↓ b1(1页) 给用户
返回 b1
源码中的分裂逻辑(__rmqueue_smallest):
static __always_inline
struct page *__rmqueue_smallest(struct zone *zone, unsigned int order,
int migratetype)
{
unsigned int current_order;
struct free_area *area;
struct page *page;
for (current_order = order; current_order < MAX_ORDER; ++current_order) {
area = &(zone->free_area[current_order]);
page = get_page_from_free_area(area, migratetype);
if (!page)
continue;
expand(zone, page, order, current_order, migratetype, alloc);
set_pcppage_migratetype(page, migratetype);
return page;
}
return NULL;
}
expand 函数执行大块分裂,将多余的低地址部分放回低阶链表。
2.3 释放与合并(Coalescing)
伙伴系统释放的核心在于"伙伴合并"——当两个相邻的同大小空闲块互为"伙伴"时,合并为一个两倍大小的块。
判断伙伴的关键条件(同一阶、物理连续、同属一个Zone、同迁移类型):
static inline bool
page_is_buddy(const struct page *page, const struct page *buddy,
unsigned int order)
{
if (!pfn_valid_within(page_pfn(buddy)))
return false;
if (page_zone_id(page) != page_zone_id(buddy))
return false;
if (page_to_pfn(page) & ((1 << order) - 1)) // 地址对齐检查
return false;
return true;
}
合并过程中有一个精妙设计:通过 page_to_pfn(page) & (1 << order) 的奇偶性计算出"伙伴"页的地址——无需遍历即可O(1)定位伙伴。
2.4 伙伴系统的外部碎片问题
伙伴系统无法解决"外部碎片"问题——即虽然总空闲页很多,但没有足够大的连续物理页。经典场景:一个长期运行的数据库服务器,经历了大量非连续的小块分配释放后,虽然空闲了50%内存,但大多为阶0的孤立页框。
Linux通过以下机制应对:
- 可回收页的页面迁移(
/proc/sys/vm/compact_memory) - 按迁移类型分组(MIGRATE_UNMOVABLE, MIGRATE_MOVABLE, MIGRATE_RECLAIMABLE)
- 主动内存规整(kcompactd 内核线程)
2.5 迁移类型分组:防止碎片的工程智慧
伙伴系统在每个阶中按迁移类型进一步分区:
enum migratetype {
MIGRATE_UNMOVABLE, // 内核对象、页表等(不可移动)
MIGRATE_MOVABLE, // 用户空间页面、可轻松迁移
MIGRATE_RECLAIMABLE, // 缓存页、无引用可回收
MIGRATE_PCPTYPES, // Per-CPU热页缓存
MIGRATE_ISOLATE, // 隔离态
MIGRATE_TYPES
};
核心策略:将不可移动页和可移动页分离。UNMOVABLE页(如 kmalloc 分配的结构体)只占据特定迁移类型的区域,不会散布在MOVABLE区域中阻止后续合并。
三、SLUB分配器:小内存分配的高速通道
3.1 为什么需要 SLAB/SLUB
伙伴系统以页(4KB)为最小分配单位,而内核中大量请求远小于一页(task_struct 约1.7KB,inode 约580B)。多次内核启动阶段可能分配数万个小对象。若每次小分配都消耗一页,物理内存会严重浪费(内部碎片),且频繁的伙伴系统操作带来大量TLB pressure。
SLAB分配器(Solaris首创,Linus引入内核)通过"内核对象缓存"解决这个问题:
- 从伙伴系统预分配整页或连续页
- 将页面切割为固定大小的对象槽位
- 维护空闲对象的快速分配/释放
SLUB是SLAB的简化重写,自2.6.23成为默认分配器。
3.2 SLUB 核心数据结构
struct kmem_cache {
struct kmem_cache_cpu __percpu *cpu_slab; // Per-CPU快速路径
slab_flags_t flags;
unsigned long min_partial; // node partial最小保留数
unsigned int size; // 对象实际大小
unsigned int object_size; // 用户请求的大小
struct reciprocal_value reciprocal_size; // size的倒数
unsigned int offset; // 空闲链表指针偏移
unsigned int cpu_partial; // cpu partial阈值
void (*ctor)(void *); // 对象构造函数
struct kmem_cache_node *node[MAX_NUMNODES]; // NUMA节点级partial列表
struct kmem_cache_order_objects oo; // 最佳order计算
struct kmem_cache_order_objects max;
struct kmem_cache_order_objects min;
...
};
Per-CPU缓存结构(快速路径,无锁):
struct kmem_cache_cpu {
void **freelist; // 空闲对象链表头
unsigned long tid; // 全局事务ID(用于慢路径同步)
struct page *page; // 当前活动的 slab 页面
struct page *partial; // Per-CPU partial slab链表
};
Node级管理结构:
struct kmem_cache_node {
spinlock_t list_lock;
unsigned long nr_partial;
struct list_head partial; // 半空闲slab列表
};
3.3 SLUB 分配流程详解
快速路径(无锁)
static __always_inline void *__slab_alloc(struct kmem_cache *s,
gfp_t gfpflags, unsigned long addr, struct kmem_cache_cpu *c)
{
void *p;
// 1. 尝试从 Per-CPU freelist 直接取出
p = c->freelist;
if (unlikely(!p))
goto new_slab;
c->freelist = get_freepointer(s, p); // 下一个对象地址
return p;
new_slab:
// 2. 当前slab无空闲,尝试从 CPU partial链取一个
if (slab_partial(c)) {
p = c->page->freelist;
c->page = c->partial; // 替换当前active slab
c->freelist = p;
c->partial = c->partial->next;
return p;
}
// 3. 进入慢路径:需要从伙伴系统分配新页面
return ___slab_alloc(s, gfpflags, addr, c);
}
慢路径(加锁分配新slab)
static void *___slab_alloc(struct kmem_cache *s, gfp_t gfpflags,
unsigned long addr, struct kmem_cache_cpu *c)
{
struct page *page;
// 1. 先查Node级partial列表
page = get_partial(s, gfpflags, addr);
if (!page) {
// 2. 从伙伴系统分配新页面
page = new_slab(s, gfpflags, node);
if (!page)
return NULL;
inc_slabs_node(s, page_to_nid(page), page->objects);
}
// 3. 初始化新slab,构建空闲链表,安装到Per-CPU缓存
c->page = page;
c->freelist = page->freelist;
page->freelist = NULL; // 标记为active slab
return __slab_alloc(s, gfpflags, addr, c); // 重试快速路径
}
3.4 SLUB 释放空闲对象
static __always_inline void do_slab_free(struct kmem_cache *s,
void *head, void *tail, unsigned long tid)
{
struct page *head_page = virt_to_head_page(head);
struct kmem_cache_cpu *c;
c = raw_cpu_ptr(s->cpu_slab);
// 快速路径:直接挂回 freelist
set_freepointer(s, object, c->freelist);
c->freelist = object;
c->tid = next_tid(tid);
// 检查是否从 fully-used 变为部分空闲
if (unlikely(slab_was_frozen(head_page))) {
// 页面之前已无空闲,现在变成partial
// 需要调度到特定的释放逻辑
...
}
// 如果Per-CPU partial超过阈值,把partial slab回退到Node级
if (slab_free_exceeds_cpu_partial(s, head_page))
flush_cpu_slab(s);
}
释放逻辑中,当页面从"全满"变"有1个空闲"或从"部分空闲"变"完全空闲"时,有不同的处理路径:
- 从全满 → 部分空闲:页面被添加到Per-CPU partial链表
- 从部分空闲 → 完全空闲:页面返还给Node级partial列表(若Node级也满了则返还给伙伴系统)
3.5 空闲链表与 Red Zone 保护
SLUB将空闲对象本身用作链表存储——空闲内存的起始地址存放"下一个空闲对象"指针(指针侵入技术),零额外内存开销。
当开启 CONFIG_DEBUG_SLUB 时,SLUB在对象前后插入Red Zone marker检测越界写:
┌──────────────────────────────────────────┐
│ RED ZONE │ user object │ RED ZONE │
│ 64 bits │ (requested) │ 64 bits │
└──────────────────────────────────────────┘
检测值为 0x12a8a00010020100(RED_ACTIVE)与 0x99a8a00010020101(RED_INACTIVE),被踩后触发内核告警。
3.6 SLUB vs SLAB vs SLOB
| 特性 | SLAB (legacy) | SLUB (default) | SLOB (tiny) |
|---|---|---|---|
| 复杂度 | 高(多种缓存链表) | 极低 | 极简 |
| 元数据开销 | 高(每slab64B管理数据) | 复用 struct page |
极低 |
| NUMA感知 | 完善(per-node cpucache) | 好(per-node partial) | 无 |
| 调试能力 | 强 | 强 | 弱 |
| 适用场景 | 已废弃 | 通用(默认) | 嵌入式(<64MB RAM) |
SLUB简化策略的核心:将"空闲链表"直接嵌入 struct page,消除了SLAB中独立的 kmem_bufctl_t 数组(每对象4B开销)。
四、实战篇:内存分析与调优
4.1 查看分配器状态
slabtop 实时显示各缓存使用情况:
$ slabtop -o
Active / Total Objects (% used) : 231759 / 232952 (99.5%)
Active / Total Slabs (% used) : 6206 / 6206 (100.0%)
Active / Total Caches (% used) : 88 / 133 (66.2%)
Active / Total Size (% used) : 103.91M / 104.44M (99.5%)
OBJS ACTIVE USE OBJ SIZE SLABS OBJ/SLAB CACHE SIZE NAME
12328 12328 100% 0.19K 587 21 9.39K dentry
26794 26794 100% 0.10K 687 39 5.49K buffer_head
5568 5121 91% 0.63K 232 24 9.28K proc_inode_cache
11214 10914 97% 0.25K 342 33 5.47K vm_area_struct
38106 35689 93% 0.13K 635 60 5.08K radix_tree_node
18490 16303 88% 0.57K 331 56 8.34K inode_cache
各缓存的"OBJ SIZE"正是SLUB按大小分桶的结果。
4.2 /proc/slabinfo 深度分析
$ cat /proc/slabinfo | grep -E "kmalloc-|task_struct"
kmalloc-8k 64 64 8192 4 8 : tunables ...
kmalloc-4k 256 230 4096 8 8 : tunables ...
kmalloc-2k 128 117 2048 16 8 : tunables ...
kmalloc-1k 256 199 1024 32 8 : tunables ...
kmalloc-512 384 352 512 32 8 : tunables ...
kmalloc-256 512 481 256 32 8 : tunables ...
task_struct 494 420 2240 20 18 : tunables ...
字段说明:active_objs / active_slabs / objsize / objs_per_slab / pages_per_slab
高 active_slabs 但低 active_objs/objs_per_slab 比值说明 slab 利用率低,可能存在碎片化问题。
4.3 SLUB 性能调优参数
每个SLUB缓存都有可变参数的 sysfs 接口:
/sys/kernel/slab/<cache_name>/
├── cpu_partial # CPU partial阈值
├── min_partial # node partial最少保留数
├── order # slab阶数
├── objects # 总缓存对象数检测
├── object_size # 对象大小
├── partial # partial slab数
├── poison # 毒化已释放对象 (0/1)
├── red_zone # Red Zone保护 (0/1)
└── store_user # 记录分配/释放调用者 (0/1)
典型调优场景:
# 1. 全局开启SLUB DEBUG(调试内存踩踏)
# 启动参数加 "slub_debug=FZPU"
# 2. 对特定缓存开启用户追踪
echo 1 > /sys/kernel/slab/kmalloc-256/store_user
# 3. 分析非活跃partial slab
slabtop -s c # 按Cache Size排序
4.4 调试实战:追踪内存越界
案例:某驱动出现随机 kmalloc-256 内存损坏。
# 步骤1: 开启 poison 和 red_zone
echo 1 > /sys/kernel/slab/kmalloc-256/poison
echo 1 > /sys/kernel/slab/kmalloc-256/red_zone
# 步骤2: 开启用户追踪记录调用栈
echo 1 > /sys/kernel/slab/kmalloc-256/store_user
# 步骤3: 重启测试用例,等待bug触发
# 步骤4: 从 dmesg 获取调用栈
「BUG: Object kmalloc-256 @ffff88xxxx corrupted」
「 slab_alloc+0x45/0x210 [call trace]」
SLUB的 CONFIG_SLUB_DEBUG 结合 slub_debug=U 还能检测 use-after-free(释放后用0x6B填充对象内容,分配时校验前8字节是否为0x6B)。
4.5 内存碎片排查
# 查看buddy系统当前状态
cat /proc/buddyinfo
Node 0, zone Normal 12 15 8 4 2 1 1 0 1 0 0
# 每列分别为 free_area[0]..[10] 的空闲页数
# 上例中高阶(>=5阶=64页=256KB)空闲块极少,说明存在碎片
# 查看页迁移类型分布
cat /proc/pagetypeinfo
手动触发主动规整:
# 触发内存规整
echo 1 > /proc/sys/vm/compact_memory
# 查看规整结果
grep -i compact /proc/vmstat
五、进阶话题:内存分配的硬核场景
5.1 Per-CPU Hot Pages:伙伴系统的"一级缓存"
在物理页分配路径上,伙伴系统自身还有一层 Per-CPU 热页缓存(pageset),避免每个CPU频繁在zone锁上争抢:
struct per_cpu_pages {
int count; // pcplist中的页数
int high; // 高于此值放回伙伴系统
int batch; // 一次批量移动页数
struct list_head lists[MIGRATYPE_TYPES]; // 头插法的freelist
};
struct zone {
struct per_cpu_pageset __percpu *per_cpu_pageset;
...
};
当代码调用 alloc_pages(GFP_KERNEL) 时,若 order == 0(只请求1页),直接从 Per-CPU pageset 中弹出一页,只有pageset空了才会进入真正的伙伴系统慢路径。这是热路径优化的一大关键。
5.2 Kmalloc 的通用缓存系列
kmalloc(size, flags) 并未创建专属缓存,而是从预建的幂次对齐缓存中选择:
static struct kmem_cache *kmalloc_caches[NR_KMALLOC_TYPES][KMALLOC_SHIFT_HIGH + 1];
关键大小分桶:
| 索引 | 大小 | 实际slab对象大小 |
|---|---|---|
| 6 | 64B | 64B (含元数据对齐) |
| 7 | 128B | 128B |
| 8 | 256B | 256B |
| 9 | 512B | 512B |
| 10 | 1024B | 1K |
| 11 | 2048B | 2K |
| 12 | 4096B | 4K |
| 13 | 8192B | 8K |
请求250B → 实际从 kmalloc-256 取出一个256B槽位(浪费6B内部碎片)。请求3000B → 分配 kmalloc-4k一页(对于4K页这是内部碎片率25%)。
对于超过8KB的请求,kmalloc 直接退化为 alloc_pages + page_address。
5.3 GFP 标志的影响
GFP标志深刻影响伙伴系统和SLUB的行为:
| GFP标志 | 效应 |
|---|---|
__GFP_RECLAIM |
允许kswapd回收页/规整 |
__GFP_HIGH |
允许访问紧急保留池 |
__GFP_IO |
允许发起I/O(写回脏页) |
__GFP_FS |
允许调用文件系统操作 |
__GFP_ZERO |
返回清零内存 |
__GFP_NOFAIL |
永不失败(可能死循环!) |
GFP_NOWAIT |
不回收不阻塞 |
GFP_ATOMIC |
最高优先级,仅从热页池取 |
在硬中断上下文或持有自旋锁时,必须使用 GFP_ATOMIC,因为它不会入睡、不会触发回收。若此时 Per-CPU pageset 已空且伙伴系统无可分配页,GFP_ATOMIC 可能直接返回NULL。
5.4 Kmemleak:检测内存泄漏
CONFIG_DEBUG_KMEMLEAK 可以追踪所有未配对的 kmalloc/kmem_cache_alloc 调用:
# 触发一次全局扫描
echo scan > /sys/kernel/debug/kmemleak
# 查看潜在泄漏
cat /sys/kernel/debug/kmemleak
输出格式:
unreferenced object 0xffff88002f4b7d00 (size 256):
comm "insmod", pid 1234, jiffies 4294937301
backtrace:
[<ffffffff81012345>] create_the_leak+0x15/0x18 [my_module]
[<ffffffff81023456>] my_module_init+0x56/0x78 [my_module]
原理:kmemleak 在每次内存分配时记录调用栈,周期性扫描内存(堆栈、数据段、寄存器)中是否存在指向该对象的引用。未被引报为"潜在泄漏"(可能是误报如全局指针)。
六、总结
Linux内核的内存管理是一个精巧的多层体系:
- 伙伴系统 — 以页为粒度管理物理内存,通过分裂/合并提高效率,按迁移类型分组缓解外部碎片
- Per-CPU Pageset — 热页缓存,消除单页分配时的zone锁竞争
- SLUB分配器 — 构建在伙伴系统之上,提供小对象(B~8KB)的高速分配,Per-CPU freelist实现无锁热路径
- kmalloc 缓存系列 — 幂次分桶的通用缓存机制
理解这四层结构,就掌握了从 kmalloc 到 alloc_pages 完整调用链的工程语义。当面对内存泄漏、碎片问题、分配器性能瓶颈时,/proc/buddyinfo、/proc/slabinfo、slabtop、slub_debug、kmemleak 这些工具能提供足够深入的洞察力。
在云原生场景中,容器内高密部署时每个Pod多出几十MB的slab开销不容忽视;在高性能网络(DPDK/RDMA)场景下,hugepage 绕过伙伴系统直接分配 2MB/1GB 页框的策略又是一条完全不同的工程路径。内存管理——永远是系统工程师绕不开的战场。
参考代码基于 Linux 6.6+,部分结构体字段可能因内核版本不同而有调整。建议读者结合自己的内核源码阅读验证。

发表评论 取消回复