引言
内存管理是 Linux 内核最核心的子系统之一。无论是用户态的 malloc() 背后的 brk()/mmap(),还是内核态的 kmalloc()/vmalloc(),亦或页面回收、OOM killer、KSM 去重、CMA 连续内存分配——整个系统的性能、稳定性和响应速度都与内存管理紧密相关。而且更现实的是:驱动开发、网络栈优化、容器密度调优,几乎每一个方向都离不开对内存管理的理解。
本文将从物理内存的 NUMA 节点模型出发,深入伙伴系统(Buddy System)的分配与合并算法、Slab 缓存层的设计与实现(SLAB/SLUB/SLOB)、虚拟内存分配(vmalloc)与物理连续分配(kmalloc)的本质区别、内存碎片与反碎片机制、CMA 连续内存分配器、以及 struct page 的管理与内存热插拔支持。目标是让你在遇到内存相关性能问题或内核 panic 时,不仅能定位根因,更能从源码层面理解解决方案的原理。
一、物理内存模型:NUMA 与内存区域
1.1 从 UMA 到 NUMA
Linux 内核早期假设所有内存对 CPU 的访问延迟一致——UMA(Uniform Memory Access)。但现代多路服务器普遍采用 NUMA(Non-Uniform Memory Access)架构,CPU 访问本地节点的 DDR 内存远快于跨节点访问。内核用 pg_data_t 描述每个 NUMA 节点:
typedef struct pglist_data {
struct zone node_zones[MAX_NR_ZONES]; // 内存区域数组
struct zonelist node_zonelists[MAX_ZONELISTS]; // 分配回退列表
int nr_zones; // 节点中区域的数量
struct page *node_mem_map; // 节点内所有 page 数组
unsigned long node_start_pfn; // 起始物理页号
unsigned long node_present_pages; // 实际存在页数
unsigned long node_spanned_pages; // 跨度页数(含空洞)
int node_id; // NUMA 节点 ID
// ...
} pg_data_t;
每个 NUMA 节点被划分为多个"内存区域"(Zone),不同区域的用途截然不同。
1.2 Zone 类型与边界
| Zone 类型 | 典型范围 | 用途 |
|---|---|---|
ZONE_DMA |
0-16MB | ISA DMA 设备(已很少使用) |
ZONE_DMA32 |
16MB-4GB | 32-bit DMA 设备 |
ZONE_NORMAL |
4GB-上限 | 内核直接映射区(线性映射) |
ZONE_HIGHMEM |
>896MB(32位) | 高端内存(64位已废弃) |
ZONE_MOVABLE |
用户配置 | 可迁移页面区(用于内存热插拔) |
64位系统中,ZONE_HIGHMEM 已经消失——64位地址空间足够直接将全部物理内存线性映射到内核虚拟地址空间。
1.3 水位线机制(Watermark)
每个 Zone 维护三条水位线,决定页面回收的触发时机:
- WMARK_MIN:紧急水位,低于此值分配器进入直接同步回收(direct reclaim)
- WMARK_LOW:低水位,后台 kswapd 唤醒
- WMARK_HIGH:高水位,kswapd 停止回收
struct zone {
unsigned long _watermark[NR_WMARK]; // [MIN, LOW, HIGH]
unsigned long watermark_boost; // 短时突发内存提升
// ...
};
水位线由 vm.min_free_kbytes 参数调节,过大会浪费内存,过小容易触发 OOM。
二、伙伴系统(Buddy System):物理页分配的基石
2.1 伙伴系统核心思想
伙伴系统是物理页面分配的核心算法。它将空闲页面按 2 的幂次分组(order-0 到 order-10,对应 1 页到 1024 页即 4KB 到 4MB),分配时按最小满足原则拆分,释放时合并相邻的"伙伴"块。
struct zone {
struct free_area free_area[MAX_ORDER]; // 11 个空闲区域
// ...
};
struct free_area {
struct free_list free_list[MIGRATE_TYPES]; // 多个迁移类型队列
unsigned long nr_free; // 空闲块总数
};
free_list[MIGRATE_TYPES] 是"反碎片"的关键——按页面迁移能力分为多个队列:
MIGRATE_UNMOVABLE:内核代码、页表等不可移动页面MIGRATE_MOVABLE:用户空间页面、可移动匿名页MIGRATE_RECLAIMABLE:缓存页面(可回收但不可迁移)MIGRATE_HIGHATOMIC:高原子分配专用池MIGRATE_ISOLATE:热插拔用(已隔离页面)
2.2 分配流程:alloc_pages()
分配路径:
alloc_pages()
→ alloc_pages_current()
→ __alloc_pages_nodemask()
→ get_page_from_freelist() // 快速路径:直接分配
→ __alloc_pages_slowpath() // 慢速路径
→ wake_all_kswapds() // 唤醒后台回收
→ __alloc_pages_direct_reclaim() // 同步回收
→ __alloc_pages_may_oom() // 尝试 OOM
→ out_of_memory() // OOM killer
快速路径(fast path)直接从伙伴系统的空闲列表取出页面;慢速路径则涉及内存回收、压缩等复杂操作。
2.3 伙伴合并算法
释放页面时,内核尝试将当前空闲块(order-N)与其"伙伴"合并:
/*
* 伙伴的 "buddy_pfn" 计算方式:
* if (pfn & (1 << order)) → buddy_pfn = pfn - (1 << order)
* else → buddy_pfn = pfn + (1 << order)
*/
unsigned long __find_buddy_pfn(unsigned long page_pfn, unsigned int order)
{
return page_pfn ^ (1UL << order);
}
合并持续进行:order-N 空闲块成功与伙伴合并后,升到 order-(N+1),继续尝试合并,直到达到最高阶或不存在可合并的伙伴。
2.4 伙伴系统的问题
- 外部碎片:长期运行后页面分散,无法满足高阶连续物理页请求
- 分配开销:连续大块内存分配(如 DMA 缓冲区)可能失败
- 需要额外层:频繁小对象分配直接走伙伴系统极其低效 → Slab 层应运而生
- 内存浪费:40 字节的
task_struct不足一页,却要占整个页帧 - 碎片严重:反复分配释放小对象导致伙伴系统内部碎片化
- 物理连续:返回的内存在物理上连续,可直接用于 DMA
- 大小限制:通常上限为
2 * PAGE_SIZE(约 8KB),大分配走vmalloc() - GFP flags:控制分配行为,GFP_KERNEL 可睡眠,GFP_ATOMIC 不可睡眠(中断上下文用)
- 完全免除伙伴系统开销(批量分配)
- 可启用 debug 选项(red zoning、poisoning)
SLAB_HWCACHE_ALIGN对齐到 L1 cache line(避免伪共享)- 支持
kmem_cache_shrink()主动回收 partial 页 - TLB thrashing:访问分散的物理页时 TLB 频繁刷新
- DMA 不友好:硬件 DMA 需要物理连续或 scatter-gather(IOMMU/SWIOTLB 可缓解)
- 分配开销高:需要逐个建立页表映射(mprotect 式操作)
- 内部碎片(Internal Fragmentation):分配的块比请求的大(为了满足对齐/最小分配大小)
- 外部碎片(External Fragmentation):足够的空闲内存分散在不连续的区域中
- UNMOVABLE:内核代码区、持久页表、
vmalloc区域 → 基本不被移动 - MOVABLE:用户匿名页、缓冲区、缓存 →
compaction时移动 - RECLAIMABLE:共享库映射、缓冲区 → 可以释放后再读回
madvise模式:应用显式声明需要 THPalways模式:全局启用(可能造成内存浪费)- 触发手动规整:
echo 1 > /proc/sys/vm/compact_memory - 检查 MIGRATE_MOVABLE 是否太少(特定驱动让 page unmovable?)
- 考虑使用 CMA 满足大块需求
- 用户态:
valgrind/jemallocprofiling - 内核 kmalloc/kmem_cache:
kmemleakdebugfs - 伙伴系统层面:
cat /proc/buddyinfo+slabtop监控趋势 - page 级别:
page_owner(CONFIG_PAGE_OWNER) 追踪每个 alloc_pages 的调用栈 - 伙伴系统:物理页框的分配与合并引擎,是最底层原语
- Slab/SLUB:在小对象场景下避免伙伴系统的内部碎片,提供 O(1) 分配
- vmalloc:用页表映射实现虚拟连续 / 物理分散,适合大块非 DMA 内存
- CMA:以"借用-归还"策略在移动设备上保障连续物理内存供给
- struct page:贯穿所有子系统的元数据基石,支撑大页、迁移、热插拔
三、Slab 分配器:小对象分配的利器
3.1 设计动机
内核中频繁分配的小对象(如 task_struct、inode、dentry、file 等)通过伙伴系统分配有两个致命缺陷:
Slab 分配器在伙伴系统之上建立对象缓存层:预先分配一个或多个页(Slab),将其划分为固定大小的槽位(slot),直接从缓存中分配/回收对象。
3.2 三种 Slab 实现
Linux 内核提供了三种 Slab 实现,当前默认使用 SLUB:
| 特性 | SLAB | SLUB | SLOB |
|---|---|---|---|
| 出处 | Solaris 移植 | SLAB 简化版 | 嵌入式专用 |
| 元数据开销 | 高(每页独立管理) | 最低(利用 page->freelist) | 最少 |
| 可-debug | 良好 | 优秀(CONFIG_DEBUG_KMEMLEAK) | 无 |
| 调试支持 | full | 完整 | 无 |
| 默认启用 | 是(旧版本) | 是(现代内核) | 仅嵌入式 |
| NUMA 感知 | 强 | 强 | 无 |
3.3 SLUB 核心数据结构
SLUB 的最大创新是利用 struct page 自身存储元数据,避免额外的管理开销:
struct kmem_cache {
struct kmem_cache_cpu __percpu *cpu_slab; // CPU 本地 Slab
struct kmem_cache_node *node[MAX_NUMANODES]; // 节点级缓存
unsigned int object_size; // 裸对象大小
unsigned int size; // 对齐后大小
unsigned int offset; // freefield 偏移
struct kmem_cache_order_objects oo; // min/low/high order
slab_flags_t flags; // SLAB flags
char name[KMALLOC_CNAME_LEN]; // 缓存名称
// ...
};
struct kmem_cache_cpu {
void **freelist; // 空闲对象链表头
unsigned long tid; // 唯一事务 ID(防中断重入)
struct page *page; // 当前 CPU 本地页
struct page *partial; // 本地 partial 页链表
};
3.4 kmalloc 家族
kmalloc() 是内核最常用的动态分配接口,底层基于大小分级的通用 Slab 缓存:
// size <= 8B → kmalloc-8
// size <= 16B → kmalloc-16
// size <= 32B → kmalloc-32
// ...
// size <= 8K → kmalloc-8192
// size > 8K → vmalloc()(不保证物理连续)
核心特点:
GFP flags 常用组合:
| 标志 | 含义 |
|---|---|
| GFP_KERNEL | 标准分配,可睡眠,可触发 I/O 回收 |
| GFP_ATOMIC | 原子分配,不可睡眠,使用紧急保留页 |
| GFP_NOIO | 允许睡眠但不触发 I/O 回收 |
| GFP_NOFS | 允许睡眠但不触发文件系统操作 |
| GFP_DMA | 从 ZONE_DMA 分配 |
| GFP_ZERO | 分配后清零 |
| GFP_ACCOUNT | 计入内存 cgroup 统计 |
3.5 kmem_cache:专用缓存
对于频繁分配固定大小的结构体,建议创建专用缓存:
static struct kmem_cache *my_obj_cache;
// 模块初始化时创建
my_obj_cache = kmem_cache_create("my_obj", sizeof(struct my_obj), 0,
SLAB_HWCACHE_ALIGN, NULL);
struct my_obj *p = kmem_cache_alloc(my_obj_cache, GFP_KERNEL);
kmem_cache_free(my_obj_cache, p);
// 模块退出时销毁
kmem_cache_destroy(my_obj_cache);
专用缓存的好处包括:
3.6 kmemleak:内存泄漏检测
kmem_cache_alloc 分配的缓存对象泄露无法被常规工具检测。内核的 kmemleak 子系统通过标记 + 定期扫描的方式追踪所有分配,报告未引用的孤立对象:
# 查看 kmemleak 报告
echo scan > /sys/kernel/debug/kmemleak
cat /sys/kernel/debug/kmemleak
四、vmalloc:虚拟连续的内存分配
4.1 vmalloc 与 kmalloc 的本质区别
当需要分配较大块的连续虚拟地址内存但不需要物理连续时,使用 vmalloc():
| 属性 | kmalloc | vmalloc |
|---|---|---|
| 物理连续性 | 是 | 否 |
| 映射方式 | 直接映射区(linear mapping) | 非连续映射区(vmalloc area) |
| 分配上限 | ~8KB(MAX_ORDER 决定) | 很大(受限于 vmalloc 空间) |
| 分配延迟 | 低(直接空闲链表) | 高(需修改页表) |
| 使用场景 | DMA、低功耗循环 | 模块加载、大段 I/O 缓冲 |
| TLB 效率 | 高(连续,少 TLB 条目) | 低(碎片化,多 TLB 条目) |
4.2 vmalloc 的内部实现
vmalloc() 的工作方式:
vmalloc(size)
→ __vmalloc_node_range()
1. 在 vmalloc 区域分配连续虚拟地址
2. 调用 alloc_pages() 分配足够的物理页(可能分散)
3. 修改内核页表建立虚拟→物理映射
// 内核定义的 vmalloc 区间(x86_64)
#define VMALLOC_START 0xffffc90000000000
#define VMALLOC_END 0xffffe8ffffffffff
// 大小约 32TB,足够使用
4.3 性能注意事项
由于 vmalloc 分配的页面物理上分散,有以下性能代价:
在需要大量非连续内存做 DMA 的场景,应使用 dma_alloc_coherent() + IOMMU 或 scatter-list 机制。
五、内存碎片与反碎片机制
5.1 碎片的分类
5.2 伙伴系统的反碎片:迁移类型
如 2.1 节所述,伙伴系统按页面迁移能力将空闲页分为不同队列(free_list[MIGRATE_TYPES]),使得日后移动页面合并碎片时知道哪些区域可以回收。
迁移类型:
5.3 内存规整(Memory Compaction)
当伙伴系统无法提供期望阶次的连续页时,内核的 compaction 机制扫描所有 UNMOVABLE 页面,将其中的 MOVABLE 页面迁移到别处,整理出大块空闲区域:
// compaction 核心路径
compact_zone()
→ isolate_movable_pages() // 选出可移动页面
→ migrate_pages() // 迁移到目标区域
→ release_pages() // 释放旧页面
可以通过 /proc/sys/vm/compact_memory 触发整机规整。
5.4 Transparent Huge Pages (THP)
THP 是二阶策略:分配大页(2MB/1GB)减少 TLB miss 和 page table 深度:
# 查看 THP 状态
cat /sys/kernel/mm/transparent_hugepage/enabled
# 统计大页命中
grep -i huge /proc/meminfo
六、CMA:连续内存分配器
6.1 CMA 设计理念
嵌入式/移动设备上的 GPU、VPU、Camera 等硬件需要大块连续物理内存做 DMA。由于伙伴系统的长期运行后产生外部碎片,纯伙伴系统可能无法满足需求。CMA(Contiguous Memory Allocator)采用"借一还一"策略:
预先在系统启动时预留一片连续区域,正常时期允许普通 MOVABLE 页面使用这片区域(提高内存利用率),需要 DMA 硬件时将这些 MOVABLE 页面迁移走,归还给硬件。
6.2 CMA 使用方式
通过 DTS(设备树)定义 CMA 区域:
reserved-memory {
linux,cma {
compatible = "shared-dma-pool";
reusable;
reg = <0x0 0x80000000 0x0 0x10000000>; // 256MB@0x80000000
linux,cma-default;
};
};
内核启动参数:
cma=256M@0-4G // 在 0-4G 段预留 256MB CMA
6.3 CMA 工作流程
┌─────────────────────────────────┐ 设备需要连续内存
│ CMA 预留区域 (256MB) │
├──────────────┬──────────────────┤
│ MOVABLE页(被 │ MOVABLE页(被 │ ← 普通使用时空闲内存
│ 进程占用) │ 内核缓存占用) │ ← 被"借用"
├──────────────┴──────────────────�│
│ migration → free │ ← 设备请求时迁移全部页
└─────────────────────────────────┘
│ return to device driver │ ← 设备独占使用
6.4 CMA 与其他机制的互补
| 机制 | 解决问题 | 适用场景 |
|---|---|---|
| 伙伴系统 | 小页分配 | 通用 4KB 页分配 |
| Slab | 小对象分配 | 频繁分配固定大小结构 |
| CMA | 大块连续物理内存 | GPU/VPU/Camera DMA |
| IOMMU | 设备视角"连续" | 需要 IOMMU 支持的设备 |
七、struct page:每页的元数据
7.1 核心作用
内核为系统中每一帧物理页维护一个 struct page 实例。对于 64GB 内存的服务器,大约需要 1600 万个 struct page(约 640MB 元数据开销!)。
struct page {
unsigned long flags; // 页面状态(dirty/active/locked)
union {
struct address_space *mapping; // 文件页→地址空间
void *s_mem; // Slab 页首个对象
atomic_t compound_mapcount; // 复合页映射计数
};
struct {
union {
pgoff_t index; // 在映射中的偏移
void *freelist; // SLUB 空闲链表头
};
union {
unsigned long counters;
struct {
unsigned inuse:16; // Slab 使用计数
unsigned objects:15;
unsigned frozen:1;
};
};
};
// ... 省略大量 union 成员
};
7.2 复合页(Compound Pages)
超出 4KB 的大页(2MB THP / 1GB HugeTLB)在内存子系统中以"复合页"形式管理:
// 判断是否为复合页
static inline bool PageHead(struct page *page)
{
return test_bit(PG_head, &page->flags);
}
// 获取复合页的 order
unsigned int compound_order(struct page *page)
{
if (!PageHead(page))
return 0;
return page[1].compound_order;
}
compound_head() 用于从任意成员页返回"头页"——引用计数的权威来源。
7.3 内存热插拔
当物理内存被热添加/移除时,内存子系统通过以下步骤处理:
void online_pages(unsigned long pfn, unsigned long nr_pages)
{
// 1. 将页面从 buddy 的 offlined 状态 → P_online
// 2. 设置 zone 水位线
// 3. 刷新per-cpu pageset
// 4. 初始化 kswapd
}
void offline_pages(unsigned long pfn, unsigned long nr_pages)
{
// 1. 标记 zone 不可分配
// 2. 迁移所有页面到 ZONE_MOVABLE
// 3. 从伙伴系统移除该区域页
}
八、性能调优与调试
8.1 /proc 与 /sys 关键参数
# 内存概览
cat /proc/meminfo
cat /proc/buddyinfo # 每个 zone 的伙伴系统 order-N 空闲块数
cat /proc/pagetypeinfo # 按迁移类型的详细页面分布
cat /proc/slabinfo # 所有 kmem_cache 的分配统计
cat /proc/vmallocinfo # 所有 vmalloc 分配区域
# 调优参数
sysctl -w vm.min_free_kbytes=65536 # 提升最小空闲(防直接回收)
sysctl -w vm.swappiness=10 # 减少 swap 倾向
sysctl -w vm.dirty_ratio=40 # 脏页比例上限
sysctl -w vm.dirty_background_ratio=10 # 后台刷盘触发点
sysctl -w vm.vfs_cache_pressure=50 # inode/dentry 缓存回收倾向
sysctl -w vm.zone_reclaim_mode=0 # NUMA 本地回收模式
8.2 Slab 信息查看
# 查看所有 slab 缓存
cat /proc/slabinfo
# slabtop 实时监控(类似 top)
slabtop
# 查看特定缓存详情
cat /sys/kernel/slab/kmalloc-64/objects # 总对象数
cat /sys/kernel/slab/kmalloc-64/order # 每 slab 的阶数
8.3 伙伴系统碎片诊断
Buddyinfo 的输出解读:
cat /proc/buddyinfo
# Node 0, zone Normal 10 5 3 2 1 1 0 0 0 0 0
# order: 0 1 2 3 4 5 6 7 8 9 10
如果高 order(如 order-9/10)空闲块为 0,说明严重外部碎片化。此时可以:
8.4 perf 监控内存子系统
# 监控 page fault 事件
perf stat -e page-faults,minor-faults,major-faults -a
# 监控 TLB miss
perf stat -e dTLB-load-misses,dTLB-store-misses -a
# 监控直接回收事件
perf stat -e vmscan:mm_vmscan_direct_reclaim_begin -a
# 追踪 kmalloc
perf probe --add 'kmalloc'
perf record -e probe:kmalloc -ag
九、常见问题 FAQ
Q1:为什么 kmalloc 不能超过 ~8KB?
A:伙伴系统默认最大连续物理内存为 MAX_ORDER-1 个页(通常 order-10,即 4MB)。但 kmalloc 使用的通用 Slab 缓存最大 size 为 KMALLOC_MAX_SIZE(2 * PAGE_SIZE = 8KB)。更大的分配应使用 vmalloc() 或专用大缓存。
注意:KMALLOC_MAX_SIZE 可通过 CONFIG_MAX_ORDER 调高,极端配置下可达 64MB(但这会消耗伙伴系统的大部分内存用于元数据)。
Q2:GFP_KERNEL 与 GFP_ATOMIC 的使用场景?
A:在中断处理程序、softirq、spinlock 持有期间,必须使用 GFP_ATOMIC(不可睡眠)。其他上下文使用 GFP_KERNEL。使用 GFP_KERNEL 时可以被分配器挂起(sleep),等待内存回收完成再分配。
Q3:vmalloc 能否用于 DMA?
A:通常不能。大多数 DMA 设备需要物理连续的内存(除非设备支持 scatter-gather 或 IOMMU)。在 IOMMU 系统上,可用 dma_alloc_coherent() + IOMMU 映射让设备看到连续的"IO 虚拟地址",但底层物理页可以是分散的。
Q4:如何确认某个内存泄露?
A:分层排查:
Q5:slab 缓存为什么要区分 per-cpu 和 node 两级?
A:per-cpu slab(kmem_cache_cpu->freelist)消除同一 CPU 的竞争,是最快路径;当当前 CPU slab 耗尽时,从 node 级 partial 列表补充;当 node 也无可用 slab 时,向伙伴系统请求新页。这种分级设计兼顾了性能与内存利用率。
十、总结
Linux 内核内存管理是一个精心设计的分层系统:
在实际工程实践中,理解这些层次的设计意图和相互作用,比死记 API 更重要。当你的系统出现内存碎片、分配延迟抖动或 OOM 问题时,只要从伙伴系统状态(/proc/buddyinfo)、Slab 使用(slabtop)、vmalloc 用量(/proc/vmallocinfo)三个维度入手,往往能在几分钟内定位问题根源。
延伸阅读:
- Mel Gorman, Understanding the Linux Virtual Memory Manager(经典书籍)
- /Documentation/mm/ — 内核文档
-
mm/page_alloc.c、mm/slub.c、mm/vmalloc.c— 源码三件套
- LWN.net Memory Management 系列文章(持续跟进新特性)

发表评论 取消回复