Linux内核内存管理:从物理页到虚拟空间的深度实战
Linux内核的内存管理子系统是整个操作系统最复杂、最精妙的模块之一。它不仅要管理物理内存的分配与回收,还要为上层提供高效、安全的抽象接口。本文将从底层硬件机制出发,深入剖析 Buddy 分配器、SLAB/SLUB 分配器、vmalloc 机制和虚拟内存映射四大核心子系统,并结合实际内核代码与性能调优案例,帮助开发者构建完整的内存管理知识体系。
一、物理内存架构与节点-区域-页三级模型
1.1 NUMA架构与内存节点
现代多核服务器普遍采用 NUMA(Non-Uniform Memory Access)架构,不同 CPU 访问不同内存区域的速度不一致。Linux 内核用 pg_data_t 结构描述每个内存节点:
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; // 指向节点的页描述符数组
unsigned long node_start_pfn; // 节点起始页帧号
unsigned long node_present_pages; // 物理页总数
unsigned long node_spanned_pages; // 跨度总页数(含空洞)
int node_id; // 节点ID
// ... 其他字段
} pg_data_t;
在 x86_64 架构中,典型的区域划分为:
- ZONE_DMA:0-16MB,留给 ISA 设备 DMA 操作
- ZONE_DMA32:16MB-4GB,支持 32 位 DMA 设备
- ZONE_NORMAL:直接映射到内核虚拟地址空间(896MB)
- ZONE_HIGHMEM:高端内存(32 位系统,64 位系统已不再需要)
- ZONE_MOVABLE:可移动页区域,支持内存热插拔
1.2 页描述符 struct page
内核为系统中的每一物理页维护一个 struct page 描述符。在 4KB 页大小的系统上,16GB 内存对应约 400 万个页描述符,每个约 64 字节,总计约 256MB 元数据开销。
struct page {
unsigned long flags; // 状态标志(PG_locked, PG_dirty, PG_lru等)
atomic_t _refcount; // 引用计数
atomic_t _mapcount; // 映射计数(被多少个页表引用)
union {
struct { // LRU 链表节点
struct list_head lru;
struct address_space *mapping;
pgoff_t index;
};
struct { // Slab 分配器使用
struct slab_cache *slab_cache; // 指向所属的 kmem_cache
void *freelist; // 空闲对象链表
};
};
unsigned long private; // 私有数据指针
// ... 更多 union 成员
};
_refcount 为 0 表示页空闲,大于 0 表示被引用。内核通过 page_ref_count() 和 get_page()/put_page() 来管理引用。
二、Buddy System:物理页分配的核心算法
2.1 伙伴系统的基本原理
Buddy(伙伴)系统是 Linux 物理页分配的核心算法。它将所有空闲页按 2 的幂次(order)分组管理,每个 order-N 的链表包含大小为 2^N 个连续物理页的空闲块。
// 简化的伙伴系统分配逻辑
struct free_area {
struct list_head free_list[MIGRATE_TYPES]; // 按迁移类型分链表
unsigned long nr_free; // 空闲块总数
};
// 每个 zone 维护 11 个 order(0-10)
struct zone {
struct free_area free_area[MAX_ORDER]; // MAX_ORDER = 11
// order 0: 1页(4KB), order 1: 2页(8KB), ..., order 10: 1024页(4MB)
};
分配过程:当请求 order-N 的页块时,系统在 free_area[N] 的链表中查找。若为空,则向上级 order-(N+1) 取一个块,将其一分为二,一半分配给请求者,另一半降入 order-N 的空闲链表。
释放过程:释放 order-N 块时,检查其伙伴块(相邻且同样大小的块)是否空闲。若空闲,则合并为 order-(N+1) 的块,递归向上检测是否可以继续合并。
2.2 伙伴块的计算
给定页帧号 pfn 和 order n,伙伴的页帧号为:
buddy_pfn = pfn ^ (1 << order)
这个 XOR 操作翻转第 order 位,正好得到伙伴块的起始地址。例如 pfn=0x30, order=2 时,伙伴为 pfn=0x34(因为 0x30 ^ 0x04 = 0x34)。
2.3 页迁移类型与碎片缓解
Linux 2.6.24 引入了页迁移类型(Page Migration Types)来缓解外部碎片:
- MIGRATE_UNMOVABLE:内核对象、页缓存等不可移动的页
- MIGRATE_MOVABLE:用户空间页、可移动页缓存
- MIGRATE_RECLAIMABLE:可回收的 slab 对象
- MIGRATE_ISOLATE:离线页,用于热插拔
- MIGRATE_CMA:连续内存分配器,用于大段连续物理内存
将不可移动页与可移动页分离开,可以有效减少碎片化。当某个区域的碎片严重时,内核可以通过内存规整(Memory Compaction)将可移动页迁移走,腾出连续空间。
2.4 实战:查看伙伴系统状态
$ cat /proc/buddyinfo
Node 0, zone DMA 1 0 1 0 2 1 1 0 1 1 3
Node 0, zone DMA32 645 410 245 98 52 27 12 6 3 2 2852
Node 0, zone Normal 1587 2048 1536 1024 768 512 256 128 64 32 8192
$ cat /proc/pagetypeinfo # 更详细的迁移类型信息
这告诉我们 Normal 区域有大量 order-10(4MB)空闲块,分配连续物理内存成功率高。
三、SLAB/SLUB分配器:内核对象的高效分配
3.1 为什么需要Slab?
Buddy System 最小分配单位是页(通常 4KB),而内核中大量对象大小只有几百字节甚至更小(如 struct inode 约 700B,struct dentry 约 192B)。如果每分配一个 inode 就请求一页,内存利用率只有 17%。频繁的小对象分配还会产生大量内部碎片和初始化/销毁开销。
Slab 分配器(Jeff Bonwick, 1994, Solaris)的核心思想是:对象缓存 + 预初始化 + 着色对齐。
3.2 SLAB 三态模型
每个 slab 是一个或多个连续物理页,被等分为 N 个对象槽位。三个关键链表:
- slabs_full:所有槽位已分配
- slabs_partial:部分槽位空闲(快速分配来源)
- slabs_empty:全部空闲(可被释放回 Buddy System)
分配优先级:partial > empty > 新建 slab。释放对象时,若 slab 从 full 变为 partial,则移入 partial 链表。
3.3 SLUB:Linux 默认分配器
SLUB(Unqueued Slab)从 2.6.23 起成为 Linux 默认分配器,简化了 SLAB 的多队列设计:
// SLUB 核心结构:每个 CPU 维护一个活跃 slab
struct kmem_cache_cpu {
void **freelist; // 指向下一个空闲对象的指针
struct page *page; // 当前 CPU 正在使用的 slab 页
struct page *partial; // CPU 本地部分空闲 slab 链表
};
// kmem_cache:一种对象类型的"工厂"
struct kmem_cache {
struct kmem_cache_cpu *cpu_slab; // 每 CPU 活跃 slab
unsigned long flags;
unsigned int size; // 对象大小
unsigned int object_size; // 实际对象大小(不含元数据)
unsigned int offset; // 空闲指针偏移
struct kmem_cache_node *node[MAX_NUMNODES]; // 每节点 slab 管理
const char *name; // 缓存名称(如"dentry_cache")
// ...
};
SLUB 的 CPU-local freelist 设计使得大多数分配操作无需加锁——CPU 从自己的 freelist 中弹出空闲对象,O(1) 时间复杂度。只有当本地 slab 耗尽时,才需要从 node 级别的 partial 链表获取。
3.4 Slab 着色(Slab Coloring)
Slab 着色通过在 slab 起始位置添加随机偏移量(color offset),使得不同 slab 中相同位置的对象落入不同的缓存行。这减少了 CPU 缓存行争用,提升 L1/L2 缓存命中率。
// 着色偏移计算
unsigned int colour = cachep->colour; // 可用颜色总数
unsigned int colour_off = cachep->colour_off; // 颜色步长(通常 cache line 大小)
current_slab_offset += colour_off; // 新 slab 偏移递增
if (current_slab_offset >= colour * colour_off)
current_slab_offset = 0; // 循环至 0
3.5 SLOB:嵌入式系统的极简方案
SLOB(Simple List Of Blocks)适用于极资源受限的嵌入式系统。它基于首次适应(First-Fit)算法维护全局空闲链表,几乎无元数据开销,但分配效率低(O(n)),仅支持 order-0(单页)分配,不支持 CPU 热插拔和 NUMA。
3.6 实战:SlabTop 与 slab 调优
# 查看 slab 缓存使用
$ slabtop -o
Active / Total Objects (% used) : 3847K / 4123K (93.3%)
Active / Total Slabs (% used) : 98532 / 98532 (100.0%)
Active / Total Caches (% used) : 165 / 257 (64.2%)
OBJS ACTIVE USE OBJ SIZE SLABS OBJ/SLAB CACHE SIZE NAME
12544 12015 95% 0.65K 598 21 19136K dentry
10224 9876 96% 0.19K 96 112 3072K kmalloc-192
7680 7421 96% 1.00K 480 16 15360K ext4_inode_cache
# 手动回收 slab 缓存(谨慎使用)
$ echo 3 > /proc/sys/vm/drop_caches # 释放 pagecache + slab
# 查看具体缓存详情
$ cat /proc/slabinfo | head -5
dentry 38784 38784 192 21 1 : tunables 0 0 0
四、vmalloc 与内核虚拟地址空间
4.1 虚拟地址空间布局(x86_64)
在 64 位 Linux 上,内核虚拟地址空间布局如下:
ffffffffffffffff +---------------------+
| vmalloc 区域 | (虚拟连续,物理不一定连续)
| (约 32TB) |
ffffffff80000000 +---------------------+
| 内核代码/数据/直接映射 | ZONE_NORMAL 直接映射
| (约 64TB) |
c000000000000000 +---------------------+ (x86_64 直接映射起始)
| 用户空间 |
| (128TB) |
0000000000000000 +---------------------+
ZONE_NORMAL 中的所有物理页直接映射到内核虚拟地址空间(物理地址 + 固定偏移),而 HIGHMEM 区域(32 位系统)或超大物理内存(64 位)通过 vmalloc 动态映射。
4.2 vmalloc 机制
vmalloc() 分配大小任意、虚拟地址连续但物理上不一定连续的内存。实现原理:
- 从 Buddy System 分配若干单页(order-0),这些页可能物理不连续
- 在 vmalloc 区域中找到一段连续的虚拟地址区间
- 修改内核页表,将这些虚拟地址映射到已分配的物理页
- 返回虚拟地址
// vmalloc 的核心流程(简化)
void *vmalloc(unsigned long size)
{
struct vm_struct *area;
area = get_vm_area(size, VM_ALLOC); // 分配虚拟地址区间
area->addr = __vmalloc_area(area, size); // 分配物理页并建页表
return area->addr;
}
4.3 vmalloc vs kmalloc
| 特性 | kmalloc | vmalloc |
|---|---|---|
| 物理连续性 | 物理连续(从直接映射区分配) | 物理不连续(逐页分配) |
| 大小限制 | ≤ 4MB(实际 ≤ 通常几百KB) | 几乎无限制(受 vmalloc 区域大小约束) |
| 性能 | 极快(仅线性映射转换) | 慢(需修改页表,可能触发 TLB flush) |
| 适用场景 | DMA 操作、驱动需要物理连续 | 大内存分配、仅需虚拟连续 |
| 分配 GFP | GFP_KERNEL / GFP_ATOMIC | GFP_KERNEL |
| 释放 | kfree() | vfree() |
4.4 vmap/vunmap:临时物理到虚拟映射
当需要将一组已分配的物理页临时映射为连续虚拟地址时(如拼接分散的 DMA scatter-gather 列表),使用 vmap():
void *vmap(struct page **pages, unsigned int pages, unsigned long flags, prot);
// 使用完必须调用 vunmap(addr) 释放
vmap 的实现会分配一个 vm_struct 并在红黑树中注册,避免与其他 vmalloc 区域冲突。
五、内存映射与缺页处理
5.1 虚拟内存区域(VMA)
每个进程的地址空间由若干 VMA(struct vm_area_struct)描述,每个 VMA 代表一段具有相同权限和后端的虚拟地址区间:
struct vm_area_struct {
unsigned long vm_start; // VMA 起始虚拟地址
unsigned long vm_end; // VMA 结束地址(不包含)
struct mm_struct *vm_mm; // 指向所属进程的 mm_struct
struct vm_file *vm_file; // 关联的文件(文件映射时)
unsigned long vm_pgoff; // 文件内偏移(页为单位)
pgprot_t vm_page_prot; // 访问权限(R/W/X)
// 操作函数表
const struct vm_operations_struct *vm_ops;
};
VMA 组织为红黑树 + 双向链表,既支持 O(log n) 地址查找,也支持线性遍历。
5.2 mmap 系统调用流程
mmap() 的完整流程涉及文件系统和内存管理的协同:
用户调用 mmap(addr, length, prot, flags, fd, offset)
↓
do_mmap_pgoff() // 核心入口
↓
get_unmapped_area() // 找到合适长度的空闲虚拟地址区间
↓
mmap_region() // 创建 VMA
↓
vma_merge() // 尝试与相邻 VMA 合并
↓
文件映射 → file->mmap(vma) → 建立 address_space 关联
匿名映射 → 不建立文件关联,留待缺页时分配物理页
5.3 缺页中断(Page Fault)处理
当 CPU 访问未建立映射的虚拟地址时触发 Page Fault,进入 handle_mm_fault():
int handle_mm_fault(struct vm_area_struct *vma, unsigned long addr, unsigned int flags)
{
// 1. 检查 PMD 是否存在(大页拆分时需特殊处理)
// 2. 调用 handle_pte_fault()
// → do_fault():需要读磁盘(文件映射)
// → do_read_fault():文件读取(Page Cache)
// → do_cow_fault():写时复制
// → do_shared_fault():共享可写(多个进程映射同一文件)
// → do_anonymous_page():匿名映射(用户堆/mmap)
};
写时复制(Copy-on-Write, COW)原理
fork() 时,子进程复制父进程的所有 VMA,但物理页共享(引用计数+1),且两个进程的页表项均标记为只读。当任一进程尝试写该页时触发 COW 缺页:
static int do_cow_fault(struct vm_fault *vmf)
{
// 1. 分配新物理页
new_page = alloc_page_vma(GFP_HIGHUSER_MOVABLE, vma, vmf->address);
// 2. 从父进程复制数据
copy_user_highpage(new_page, vmf->page, vmf->address, vma);
// 3. 建立新映射(可写)
vmf->pte = mk_pte(new_page, vma->vm_page_prot);
set_pte_at(vma->vm_mm, vmf->address, vmf->pte, vmf->pte);
return VM_FAULT_NOPAGE;
}
COW 是现代操作系统 fork 高效实现的基础,使得 fork 仅复制页表,而物理页延迟到真正写入时再复制。
六、大页(Huge Pages)与透明大页
6.1 Huge Pages 减少 TLB 压力
标准页大小 4KB,2MB 大页(HugeTLB)或 1GB 巨型页(Giant Pages)可以将 TLB 覆盖率提升 512 倍。进程通过 mmap(MAP_HUGETLB) 或 hugetlbfs 使用大页。
// 分配 2MB 大页(hugetlbfs 方式)
int fd = open("/dev/hugepages/myfile", O_CREAT | O_RDWR, 0755);
void *addr = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
// 显式大页分配 API
void *mmap_huge(void *addr, size_t length) {
return mmap(addr, length, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_HUGETLB, -1, 0);
}
6.2 透明大页(Transparent Huge Pages, THP)
THP 由内核 kthread(khugepaged)自动将相邻可合并的标准页升级为 2MB 大页,对应用透明。配置方式:
$ cat /sys/kernel/mm/transparent_hugepage/enabled
always madvise [never]
# 对数据库负载建议关闭 THP(避免延迟尖刺)
$ echo never > /sys/kernel/mm/transparent_hugepage/enabled
七、性能调优与实战案例
7.1 OOM Killer 分级策略
当系统内存耗尽时,OOM Killer 根据 oom_score 选择牺牲进程:
- 计算公式:基于物理内存使用量 + CPU 时间 + 子进程 + oom_score_adj
- 管理员调节:
/proc/<pid>/oom_score_adj(-1000 到 1000) - -1000 表示永不选中(如 systemd),1000 表示优先选中
- cgroup v2 替代方案:通过
memory.max限制整体内存,避免触发 OOM Killer
7.2 内存碎片化诊断
# 检查碎片化程度
$ cat /proc/buddyinfo
$ cat /proc/pagetypeinfo
# 触发内存规整(手动)
$ echo 1 > /proc/sys/vm/compact_memory
# 查看当前碎片化指数
$ cat /sys/kernel/debug/extfrag/extfrag_index # 需 debugfs
# CMA 预留大页检查
$ cat /proc/meminfo | grep -i cma
CmaTotal: 512000 kB
CmaFree: 498000 kB
7.3 SLAB 调试与内存泄漏检测
内核提供多种 slab 调试工具:
- slab_debug:编译时开启 CONFIG_DEBUG_SLAB,检测越界写、释放后使用(UAF)
- kmemleak:CONFIG_DEBUG_KMEMLEAK,运行时扫描检测内存泄漏
- KASAN:CONFIG_KASAN,检测 out-of-bounds 和 use-after-free(runtime error detector)
// kmemleak 报告示例
$ cat /sys/kernel/debug/kmemleak
unreferenced object 0xffff88803f4a0000 (size 256):
comm "my_module", pid 1234, jiffies 4294937493
backtrace:
[<ffffffff81234567>] my_module_init+0x47/0x100
[<ffffffff81002abc>] do_one_initcall+0x3c/0x1b0
[<ffffffff81145678>] kernel_init+0x88/0x1c0
八、总结与前沿发展
Linux 内存管理子系统经过三十余年持续演进,已形成一套兼顾效率与安全的层次化架构:
- 物理层:Buddy System 管理伙伴分配 + 页迁移类型 + CMA 连续内存预留
- 对象层:SLUB 分配器基于 per-CPU freelist 实现无锁快速分配
- 虚拟层:vmalloc 提供虚拟连续映射 + 缺页中断按需分配
- 映射层:VMA 树 + 多级页表 + COW + THP 大页优化
当前的前沿方向包括:
- folio(5.16+):与 compound page 统一,简化页缓存管理
- Multi-Gen LRU(6.1+):替代传统 Active/Inactive 双链表算法,减少页面回收开销
- DAMON(Data Access MONitor):基于采样的内存访问模式监控,支撑自适应页面回收和 NUMA 绑定
- 内存热插拔与 virtio-mem:支持动态调整虚拟机内存拓扑
- Rust for Linux:驱动内存管理的安全性革新
深入理解 Linux 内存管理,对于编写高性能内核模块、优化数据库存储引擎、调优容器化应用都至关重要。希望本文能为读者建立从硬件页帧到虚拟地址空间的完整知识图谱。

发表评论 取消回复