引言
内存管理是 Linux 内核最核心、最复杂的子系统之一。它直接决定了系统在多核 NUMA 服务器、嵌入式设备和云计算场景下的性能上限。本文将从源码级别逐层剖析 Linux 内存管理的完整架构:物理页框分配器(Buddy System)、对象缓存分配器(Slab/SLUB)、虚拟内存管理器(VMA/mmap),并配合生产级故障排查与性能调优案例。
一、物理页框与 struct page
Linux 将物理内存建模为 NUMA Node → Zone → Page Frame 三级结构。每个物理页框对应一个 struct page 描述符,系统维护一个全局 mem_map 数组。
// 每个物理页的管理结构(简化)
struct page {
unsigned long flags; // 脏页/锁定/引用等标志
atomic_t _refcount; // 引用计数
atomic_t _mapcount; // 映射计数(多少进程在使用)
unsigned long private; // 类型私有数据
struct address_space *mapping; // 关联的地址空间
pgoff_t index; // 在 mapping 中的偏移
struct list_head lru; // LRU 链表节点
};
在 64 位系统中,x86_64 通常定义 4KB 页,struct page 约占 64 字节。1TB 物理内存约对应 2.5 亿个 page 结构,仅 mem_map 就需约 1.5GB 内存。这也是为什么内核引入了 稀疏内存模型(SPARSEMEM) 和 vmemmap 动态映射来优化。
二、伙伴系统(Buddy System)
2.1 二进制伙伴算法原理
Buddy System 是 Linux 物理页分配的核心算法。它将可用内存划分为 11 个 free_area 链表,管理 order 0~10 的连续页块(2^n 个页面):
//mm/page_alloc.c
struct free_area {
struct list_head free_list[MIGRATE_TYPES];
unsigned long nr_free;
};
// order=0: 1页(4KB), order=1: 2页(8KB), ..., order=10: 1022页(4MB)
分配流程:从目标 order 开始向上查找合适大小的块;若找到上层大块,则分裂(expand())至目标 order,一半放入空闲链表,另一半交给调用者。
释放流程:释放页块时检查其伙伴(buddy)是否也在空闲链表中。若伙伴空闲则合并(__free_one_page)为更大的块,递归向上合并。
2.2 页面迁移类型(Migrate Types)
为提高抗碎片化能力,内核将页面按可移动性划分为多种迁移类型分池管理:
| 迁移类型 | 用途 | 可移动性 |
|---|---|---|
| MIGRATE_UNMOVABLE | 内核对象、内核栈 | 不可移动 |
| MIGRATE_MOVABLE | 用户态页面、缓存 | 可移动 |
| MIGRATE_RECLAIMABLE | 可回收的内核缓存 | 不可直接移动,但有回收路径 |
| MIGRATE_ISOLATE | 热插拔/离线专用 | 完全隔离 |
2017 年内核引入的 Anti-Fragmentation 机制 确保不可移动的对象不会占用可移动页池,这对 THP(透明大页)的合并成功与否至关重要。
2.3 ZONE 分区策略
x86_64 通常设三个 ZONE:
- ZONE_DMA32(0~4GB):兼容 32 位 DMA 设备
- ZONE_NORMAL(线性映射区):直接映射内核代码和数据
- ZONE_MOVABLE:可移动页面池,支持内存热插拔
分配时优先使用 NORMAL 区,仅在 DMA 请求时分配 DMA 区,回退策略(fallback)通过 zone 的 zonelist 实现。
三、per-cpu 页面缓存
NUMA 系统中 Buddy 的锁竞争是性能瓶颈。内核在每个 CPU 上维护了 Hot/Cold 两个 per-cpu 缓存:
// 从 per-cpu 缓存分配单页(order=0)
static struct page *get_pcp_page(zone, order, gfp) {
// 优先从 pcp->lists[MIGRATE_PCPTYPES] 取出
// 不足时从 Buddy 批量补充(batch=64 页)
}
Hot页直接用于分配,Cold页作为储备。当 Hot 页不足且 Cold 页超过阈值时,将 Cold 页归还给 Buddy。这种设计大幅减少了 zone->lock 自旋锁的争用。
四、Slab / SLUB / SLOB 分配器
4.1 设计动机
内核态分配通常需要小于整页的内存(如 task_struct 约 7KB)。直接从 Buddy 分配 4KB 页再分裂有大量内碎片。Slab 分配器将一页或数页分割成固定大小的对象缓存,每个对象即为一个内核结构。
4.2 SLUB 核心数据结构
SLUB 是当前默认的 Slab 分配器,核心结构为 kmem_cache:
struct kmem_cache {
struct kmem_cache_cpu __percpu *cpu_slab; // per-cpu 活跃 slab
unsigned long flags;
unsigned int size; // 对象大小(含对齐)
unsigned int object_size; // 请求的原始大小
unsigned int offset; // 空闲链表指针偏移
struct kmem_cache_node *node[MAX_NUMNODES]; // NUMA 远端 slab
};
每个 slab 页面内含若干相同大小的对象,通过 free list(内嵌指针链接)管理可用对象。SLUB 的创新在于取消了单独的 slab 管理页,将所有元数据嵌入到 struct page 的 union 中(仅在 slab 使用时生效)。
4.3 kmalloc 三步分配
kmalloc(size, flags) 是内核最常见的内存分配接口,其实现为三步查找:
- Size 到 Cache 映射:kmalloc 维护 13 个固定尺寸的通用缓存池(8B~8KB),size 向上对齐到最近缓存。若 size > 8KB,转到 vmalloc。
- Allocator 选择:优先从
kmem_cache_cpu->freelist分配热路径(per-cpu partial hit → node partial → new page from buddy)。 - NUMA 感知:从请求 CPU 的本地 node 分配,本地无可用对象时再访问远端 node。
若 size 超过两个页面(8KB on 4KB page),kmalloc 转调 kmalloc_large() 走 Buddy 直接分配,并标记为 __GFP_COMP compound page。
五、虚拟地址空间与 mmap
5.1 进程地址空间结构
每个进程的 48 位虚拟地址空间(配置 5 级页表时为 57 位)由多个 struct vm_area_struct(VMA)组成,按地址排序组织在红黑树中:
struct mm_struct {
pgd_t *pgd; // 页全局目录
struct rb_root mm_rb; // VMA 红黑树根
struct vm_area_struct *mmap; // VMA 链表头
unsigned long start_code, end_code;
unsigned long start_brk, brk; // 堆边界
unsigned long mmap_base; // mmap 起始地址
// ...19 个字段
};
5.2 mmap 分配路径
用户态 mmap 通过 ELF loader / malloc (大块) / 直接 syscall 触发。内核使用 bottom-up 策略 在 mmap_base 以下向上扫描空闲 gap(通过 VMA 红黑树),或通过 top-down 策略(ASLR 启用时)从栈顶向下扫描。找到合适 gap 后创建新 VMA 并入树。
5.3 缺页中断(Page Fault)
物理页不在内存时触发缺页,缺页路径决定了 mmap 的按需调页特性:
// arch/x86/mm/fault.c:do_page_fault()
1. 查找 VMA:find_vma(mm, address) —— O(log n) 红黑树查找
2. 权限校验:VMA 有对应 r/w/x 权限
3. 匿名页分配:do_anonymous_page() — 分配零页写入
4. 文件页加载:do_fault() — 从磁盘/PageCache 调入
5. COW 分裂:do_wp_page() — fork 后写时复制
对于 mmap 映射的文件,内核首先检查 Page Cache(radix_tree_lookup);若已在 cache 则直接映射,否则启动预读(page_cache_sync_readahead)将磁盘数据加载进内存。
六、页面回收与交换
6.1 LRU 链表
内核将页面按活跃度维护在 4 条 LRU 链表上:active_file / inactive_file / active_anon / inactive_anon。页面首次加入时放在 inactive 尾部,被访问时 mark_page_accessed() 提升至 active。回收时扫描 inactive 最久未访问的页面予以驱逐。
6.2 页面回收(KSwapd + Direct Reclaim)
KSwapd 作为后台守护线程,get_scan_count() 计算每条 LRU 的扫描窗口:匿名页扫描权重更高(因回收代价更大,需要写 swap)。当空闲页低于 wmark_low 时唤醒 kswapd,低至 wmark_min 时分配路径同步 direct reclaim。
Direct Reclaim 在内存分配时发生,会阻塞调用者直到回收足够页,对用户态延迟影响巨大("direct reclaim stall"是生产常见性能问题)。
6.3 KSM 与 Zswap
KSM(Kernel Samepage Merging) 通过红黑树扫描用户态页面(标记为 MADV_MERGEABLE),发现相同内容的页后合并为一页(COW 共享),为 KVM 虚拟机节省 30%~50% 内存。
Zswap 压缩被驱逐的匿名页,存入内存中的压缩缓存池(默认 zbud 分配器,压缩比通常 2:1),减少 swap I/O。
七、NUMA 内存策略
NUMA 系统中远端内存访问延迟可达本地 2~3 倍。内核通过以下机制优化:
- 本地优先分配:alloc_pages_node() 从请求 CPU 所属 node 开始
- Zone Reclaim:本地 node 不足时触发回收再回退远端
- MPOL_BIND/INTERLEAVE:用户态 set_mempolicy() 显式绑定
- AutoNUMA Balancing:内核 3.13+ 将访问频次最高的页面迁移到本地(扫描开销 per-thread 10ms/页)
八、实战:内核驱动内存分配示例
// 字符设备驱动的完整内存分配流程
#include
#include
#include
struct my_dev {
void *kbuf; // kmalloc 分配(物理连续)
void *vbuf; // vmalloc 分配(虚拟连续)
struct page **pages; // 用户态 mmap 用
};
static int dev_mmap(struct file *filp, struct vm_area_struct *vma)
{
unsigned long offset = vma->vm_pgoff << PAGE xss=removed>private_data;
/* 方法1:remap_pfn_range 物理映射 */
// io_remap_pfn_range(vma, vma->vm_start,
// virt_to_phys(dev->kbuf) >> PAGE_SHIFT, size, vma->vm_page_prot);
/* 方法2:fault 钩子(推荐,支持 COW 和按需调页) */
vma->vm_ops = &dev_vm_ops;
return 0;
}
static vm_fault_t dev_fault(struct vm_fault *vmf)
{
struct my_dev *dev = vmf->vma->vm_private_data;
vmf->page = vmalloc_to_page(dev->vm.buf + (vmf->pgoff << PAGE>page);
return 0;
}
// 分配与使用的典型模式
static long dev_ioctl(struct file *f, unsigned int cmd, unsigned long arg)
{
struct my_dev *dev = f->private_data;
switch (cmd) {
case MY_ALLOC:
// 小对象(< 8KB>kbuf = kzalloc(SZ_4KM, GFP_KERNEL);
if (!dev->kbuf) return -ENOMEM;
// 大对象(可能 > 4MB): vmalloc(虚拟连续,不保证物理连续)
dev->vbuf = vzalloc(SZ_8MB);
if (!dev->vbuf) { kfree(dev->kbuf); return -ENOMEM; }
// 用户态 mmap 映射准备
dev->pages = kcalloc(n_pages, sizeof(struct page *), GFP_KERNEL);
for (i = 0; i < n>pages[i] = alloc_page(GFP_KERNEL | __GFP_ZERO);
break;
}
return 0;
}
九、调试与性能分析
9.1 从 /proc 与 /sys 获取状态
# 查看伙伴系统分布(每个 order 的连续空闲页数量)
$ cat /proc/buddyinfo
Node 0, zone Normal 120 580 892 432 156 48 32 8 2 1 0
# 查看 slab 对象缓存分布(slabtop / /proc/slabinfo)
$ slabtop -o | head -20
OBJS ACTIVE USE OBJ SIZE SLABS OBJ/SLAB CACHE SIZE NAME
12600 12600 100% 0.49K 150 84 24K kmalloc-512
8400 8400 100% 0.19K 100 84 16K kmalloc-192
# 查看 NUMA 节点内存分布
$ numastat -c
Node MemTotal MemFree HugePages
node0 251884 86523 32
node1 252087 198401 64
# 查看页面回收统计
$ cat /proc/vmstat | grep -E "pswpin|pswpout|kswapd|compact"
9.2 perf 事件探查
# 监控 Buddy 分配失败(order >= 3 失败说明内存碎片化)
perf record -e 'buddy:buddy_alloc_failed' -a sleep 10
# 追踪 kmalloc/kfree 调用栈
trace-cmd start -e 'kmem:kmalloc' -e 'kmem:kfree'
trace-cmd show | head -50
# 统计 TLB 缺失率
perf stat -e iTLB-load-misses,dTLB-load-misses ./target_process
9.3 生产诊断案例
案例一:THP 分配失败导致数据库性能下降
现象:随机延迟尖峰,/proc/buddyinfo 显示 order≥9 计数长期为 0。原因:内核长期运行后内存碎片化,瞬时无足够 2M 连续页面。解决:通过 compact_memory 触发内存规整;或启用 1GB 大页静态分配(hugepagesz=1G),彻底绕过 Buddy order 5(2M)限制。
案例二:Direct Reclaim 导致 Redis 尾延迟飙升
现象:P99 延迟 99% 分位超过 50ms。原因:Redis 大 value 分配触发 Direct Reclaim,kswapd 来不及回收。解决:禁用 THP(echo never > /sys/kernel/mm/transparent_hugepage/enabled),调低 vm.vfs_cache_pressure,或使用 jemalloc 替代 glibc malloc 优化小对象路径。
案例三:NUMA 跨节点访问拖慢高并发服务
现象:80% 内存访问来自远端 node,IPC(每秒指令数)下降 30%。原因:numactl --interleave=all 未设置,线程和内存默认都在首次调度的 node。解决:绑定线程至本地 node,开启 AutoNUMA 并确保扫描周期合适(numabalancing_scan_delay_ms=1000)。
十、生产级最佳实践(8 条)
- 小对象分配优先 kmalloc(< 8KB> —— 三选一按场景匹配
- GFP 标志严格区分:GFP_NOWAIT(中断上下文不可睡眠)、GFP_NOFS(文件系统持有锁时防递归)、GFP_DMA32(硬件 32 位地址限制)
- 每 CPU 频繁数据使用 per_cpu() 结合 get_cpu()/put_cpu() —— 避免 false sharing 与 NUMA 跨节点访问
- 巨型分配(>4MB)考虑 CMA(Contiguous Memory Allocator) —— 预分空闲帧保障多媒体/DMA 需求
- NUMA 场景启用 AutoNUMA,配合 set_mempolicy() 显式绑定敏感线程 —— 减少远端访问延迟
- kswapd 调优:swappiness=0(非 swap 环境)、脏页比率 dirty_ratio=40 —— 减少 direct reclaim 概率
- 监控 /proc/buddyinfo 与 /proc/slabinfo:order≥7 持续为 0 立刻触发 compact_memory —— 防 THP 失败
- 频繁创建销毁同类型对象务必用 kmem_cache_create() —— slab 缓存局部性比 kmalloc 高 30%,加/减锁代价不可忽略
结语
从 Buddy System 的抗碎片化设计,到 SLUB 的 CPU 热路径优化,再到 VMA 红黑树的 O(log n) 缺页处理,Linux 内存管理是一组精密配合的子系统。理解这些机制,不仅能写出内存高效的内核驱动,更能在生产环境中快速定位内存泄漏、THP 与碎片化问题。下一篇文章我们将深入 Linux 内核的 进程调度器 CFS/EAS,探索多核负载均衡与实时性保障机制。

发表评论 取消回复