Linux 内核内存管理深度实战:从伙伴系统到 Slab 分配器的完全工程指南
Linux 内核内存管理是操作系统最底层的基石之一,从物理页面的分配与回收,到虚拟地址空间的映射与管理,再到内核态高效的 Slab/Slob/Slub 分配器设计,每一层都蕴含着精妙的工程智慧。本文将带你深入 Linux 内存管理的核心子系统,从原理到生产实践,构建完整的知识体系。
一、物理内存管理:伙伴系统(Buddy System)
1.1 物理页面(Page Frame)与内存节点
Linux 将物理内存组织为 NUMA 节点 → Zone → Page Frame 的三层结构:
struct pglist_data (每个NUMA节点)
├── struct zone ZONE_DMA / ZONE_NORMAL / ZONE_HIGHMEM
│ ├── free_area[MAX_ORDER] // 伙伴系统的空闲块链表
│ ├── pageset per_cpu // per-CPU 热页缓存
│ └── watermark[WMARK_MIN/LOW/HIGH] // 水位线
└── node_mem_map // 全局 page 数组
每个物理页面对应一个 struct page 结构体(约 64 字节),它在 mem_map 数组中按页帧号(PFN)索引,管理着页面引用计数、映射指针、lru 链表指针等核心元数据。
1.2 伙伴系统核心算法
伙伴系统解决的是 外部碎片 问题,将空闲页面按 2^n 大小分组管理:
// 分配 2^order 个空闲页面
unsigned long __get_free_pages(gfp_t gfp_mask, unsigned int order);
// 核心分配流程(简化版)
alloc_pages()
└── __alloc_pages_nodemask()
├── alloc_pages_slowpath() // 慢路径:回收/压缩
│ └── __alloc_pages_direct_reclaim()
│ └── try_to_free_pages() // 扫描 LRU 链表
└── get_page_from_freelist() // 快速路径:从 freelist 取
└── __rmqueue()
└── rmqueue_bulk() // 从 free_area 链表移除块
伙伴系统的名称来源于:当一个空闲块被等分为两半时,这两半被称为"伙伴"(buddy)。释放时,系统会检查其伙伴是否空闲,若空闲则合并(coalesce)成更大的块,递归向上合并直到无法合并或达到最大阶。
1.3 页面分配器(Page Allocator)与 GFP 标志
GFP(Get Free Pages)标志控制页面分配的行为:
| 类别 | 标志 | 含义 |
|---|---|---|
| 区域修饰符 | __GFP_DMA | DMA 区域内存(物理地址<16MB> |
| 区域修饰符 | __GFP_HIGHMEM | 高内存区域(32位系统 >896MB) |
| 区域修饰符 | __GFP_MOVABLE | 可移动页面 |
| 行为修饰符 | __GFP_WAIT | 允许等待(可睡眠) |
| 行为修饰符 | __GFP_HIGH | 高优先级,可访问紧急预留 |
| 行为修饰符 | __GFP_IO | 允许启动 I/O 回收 |
| 行为修饰符 | __GFP_FS | 允许调用文件系统回收 |
| 行为修饰符 | __GFP_ZERO | 分配后清零页面 |
| 类型 | GFP_KERNEL | 内核标准分配(可睡眠) |
| 类型 | GFP_ATOMIC | 原子分配(不可睡眠) |
| 类型 | GFP_NOWAIT | 绝不等待,失败直接返回 |
1.4 水位线机制与内存回收触发
伙伴系统通过三条水位线控制内存紧张度:
水位线层级:
FREE_PAGES_HIGH ─┐ // 充裕水位,kswapd 停止回收
│
FREE_PAGES_LOW ─┤ // 警戒水位,唤醒 kswapd 开始回收
│
FREE_PAGES_MIN ─┘ // 紧急水位,同步回收 + OOM 风险
触发链:
page allocation < LOW>
二、Slab 分配器:内核对象的高效缓存
2.1 Slab 分配器架构演进
Slab 分配器解决的是内部碎片和对象分配初始化开销两大问题。Linux 内核目前支持三种 Slab 分配器实现:
- Slab:最早的实现,Linus Torvalds 设计
- Slob:针对嵌入式设备的极简实现
- Slub:默认分配器,调试支持强,性能最优
Slab 的核心思想是对象缓存:将频繁分配/释放的对象保持初始化状态,避免重复执行构造函数。典型的 Slab 缓存大小从 32 字节到 8KB 不等。
2.2 Slab 缓存结构
struct kmem_cache
├── slab_cache 缓存本身(元数据)
├── slab_partial // 部分空闲 slab 链表
├── slab_full // 满 slab 链表
├── slab_free // 完全空闲 slab 链表(Slub)
├── object_size // 单个对象大小
├── size // 包含元数据的对象大小
├── offset // 下一个空闲对象的指针偏移
├── oo // 最佳 max/min order / objects-per-slab
└── name // 缓存名称(/proc/slabinfo 中可见)
struct slab(Slub 结构)
├── freelist // 空闲对象链表
├── inuse // 已使用对象计数
├── objects // 总对象数
└── node[0] // 所属 node 的 slab 列表节点
2.3 Slab 分配与释放流程
// 创建 Slab 缓存
struct kmem_cache *kmem_cache_create(const char *name, size_t size,
unsigned long flags, void (*ctor)(void *));
// 从缓存分配对象
void *kmem_cache_alloc(struct kmem_cache *cachep, gfp_t flags);
// 释放对象回缓存
void kmem_cache_free(struct kmem_cache *cachep, void *objp);
// 通用分配(小对象走 Slab,大对象走 Page Allocator)
void *kmalloc(size_t size, gfp_t flags);
void kfree(const void *objp);
void *vmalloc(size_t size); // 虚拟地址连续、物理不连续
void vfree(const void *addr);
2.4 Slab 与伙伴系统的接口
Slab 分配器在内部消费伙伴系统的页面:每个 Slab 通常占用连续的 2^order 个物理页面。当 Slab 分配对象需要新的物理内存时,调用 alloc_pages();当 Slab 中所有对象都被释放时,将页面归还给伙伴系统。
三、虚拟内存管理:地址空间与页表
3.1 进程地址空间(mm_struct)
每个进程拥有独立的虚拟地址空间(64 位系统下为 128TB 用户态 + 128TB 内核态):
struct mm_struct
├── mmap // VMA 红黑链表(vm_area_struct)
├── mm_rb // VMA 红黑树根
├── pgd // 顶级页表指针(PGD)
├── start_code/end_code // 代码段范围
├── start_data/end_data // 数据段范围
├── start_brk/brk // 堆区域
├── start_stack // 栈顶
├── arg_start/arg_end // 命令行参数
├── env_start/env_end // 环境变量
├── map_count // VMA 数量
├── total_vm // 总映射页数
├── locked_vm / pinned_vm // 锁定/钉住的页数
└── rss_stat // 驻留页面统计
3.2 虚拟内存区域(VMA)结构
struct vm_area_struct
├── vm_start // VMA 起始地址
├── vm_end // VMA 结束地址(不包含)
├── vm_flags // VM_READ/WRITE/EXEC/SHARED 等
├── vm_page_prot // 页面保护位
├── vm_next/vm_prev // VMA 链表
├── vm_rb // VMA 红黑树节点
├── vm_file // 映射的文件(文件映射时非空)
├── vm_pgoff // 文件偏移(以页为单位)
└── vm_ops // 缺页处理等操作函数集
3.3 多级页表实现
Linux 5.x 使用五级页表(57 位虚拟地址,256TB):
// x86_64 五级页表翻译流程
CR3 ──────┐
├── PGD (Page Global Directory) 9 bits
├── P4D (Page 4th Directory) 9 bits
├── PUD (Page Upper Directory) 9 bits
├── PMD (Page Middle Directory) 9 bits
├── PTE (Page Table Entry) 9 bits
└── Offset 12 bits
// TLB 翻译缓存效果:
// 1 次 TLB 命中 = 1 次内存访问
// 1 次 TLB miss(5级)= 6 次内存访问(页表遍历)+ 1 次目标访问
3.4 缺页异常处理流程
do_page_fault(struct pt_regs *regs, unsigned long error_code)
├── 查找 VMA:find_vma(mm, address)
│ ├── VMA 不存在 → SIGSEGV(段错误)
│ ├── VMA 存在但权限不足 → SIGSEGV
│ └── VMA 存在且权限合法 → 继续处理
├── handle_mm_fault()
│ ├── VM fault(匿名映射)
│ │ └── do_anonymous_page():分配物理页面并建立映射
│ ├── File-backed fault(文件映射)
│ │ └── do_fault()
│ │ ├── do_read_fault():文件预读 + 页面缓存查找
│ │ ├── do_cow_fault():写时复制
│ │ └── do_shared_fault():共享写映射
│ └── p4d_alloc/pud_alloc/pmd_alloc:页表项不存在时动态创建
└── 返回用户态,重新执行触发缺页的指令
四、页面回收与交换:KSwapD 与 LRU 算法
4.1 双重 LRU 链表机制
Linux 使用 Active/Inactive 双重 LRU 链表来更精确地区分页面热度:
每个 Zone 的 LRU 链表(LRU_BASE):
├── LRU_ACTIVE_ANON // 活跃匿名页(最近访问过)
├── LRU_INACTIVE_ANON // 非活跃匿名页(候选回收)
├── LRU_ACTIVE_FILE // 活跃文件页(最近访问过)
├── LRU_INACTIVE_FILE // 非活跃文件页(候选回收)
├── LRU_UNEVICTABLE // 不可回收页(mlocked/device映射)
└── 辅助:zone->lruvec(per-zone的 LRU 管理上下文)
页面热度变化:
访问 → mark_page_accessed() → 从 Inactive 移到 Active
时间衰减 → shrink_lruvec() → 从 Active 移到 Inactive
回收 → shrink_page_list() → 写出磁盘 / 丢弃 → 释放
4.2 页面老化算法(Refault Distance Tracking)
Linux 内核 5.6+ 引入了 Workingset 跟踪机制,通过记录页面从 Inactive 被回收后在不久后又被访问的情况(refault),动态调整匿名页和文件页之间的回收比例,防止一次性扫描流将工作集页面挤出内存。
4.3 Swap 机制
Swap 将匿名页面写入磁盘空间(分区/文件),释放出的物理页面可重新分配:
Swap 核心数据结构:
struct swap_info_struct
├── flags // SWP_USED / SWP_WRITEOK
├── swap_file // 指向文件或分区
├── bdev // 块设备上下文
├── swap_map // 到 page slot 的映射数组
├── prio // 优先级(优先使用高优先级 swap)
├── pages // 总可用 slot 数
├── max // 最大 slot 数
└── inuse_pages // 已使用 slot 数
Swap 读写路径:
swap_out(): 脏匿名页 → pageout() → writepage() → 写入磁盘 → 释放 page
swap_in(): 缺页异常 → do_swap_page() → swapin_readahead() → 从磁盘读回
五、内存碎片整理与反碎片化
5.1 碎片类型与成因
伙伴系统的分配策略虽然解决了部分外部碎片问题,但长时间运行后仍会产生不可移动页面造成的空洞。Linux 通过 页面迁移类型分组(pageblock migration type)来对抗碎片:
| 迁移类型 | 可分配性 | 典型用户 |
|---|---|---|
| MIGRATE_UNMOVABLE | 不可移动 | 内核数据结构、页表、内核栈 |
| MIGRATE_MOVABLE | 可移动 | 用户空间匿名页、文件缓存 |
| MIGRATE_RECLAIMABLE | 不可移动但可回收 | inode cache、dentry cache |
| MIGRATE_PCP | per-CPU 热页缓存 | 临时高频分配 |
| MIGRATE_ISOLATE | 完全隔离 | CMA 区域 |
5.2 内存压缩(Memory Compaction)
当碎片导致无法满足连续的大块分配请求时,kcompactd 内核线程被唤醒,将可移动页面迁移到一起,创造出更大的连续物理块:
compaction 流程:
kcompactd_run() → kcompactd()
└── compact_zone_order()
├── isolate_migratepages() // 扫描并隔离可移动页面
├── isolate_freepages() // 扫描并隔离空闲目标区域
├── migrate_pages() // 执行页面迁移(建立反向映射更新)
└── putback_movable_pages() // 释放失败时回滚
// 触发时机:
alloc_pages() 失败 → direct compaction → failed && order >= pageblock_order → wake kcompactd
六、CMA:连续内存分配器
Contiguous Memory Allocator (CMA) 为需要连续大块物理内存的设备驱动(如 GPU、视频编解码器)提供支持:
CMA 工作流程:
1. 启动时保留一块物理连续区域(mem=@ 或设备树配置)
2. 该区域标记为 MIGRATE_CMA,空闲时可按 MIGRATE_MOVABLE 被使用
3. 设备驱动请求时(dma_alloc_coalesce),将已分配的 movable 页面迁移走
4. 释放页面时归还给伙伴系统而非 CMA 区域
// 核心 API:
struct page *dma_alloc_coherent(struct device *dev, size_t size,
dma_addr_t *dma_handle, gfp_t flag);
void dma_free_coherent(struct device *dev, size_t size,
void *cpu_addr, dma_addr_t dma_handle);
七、大规模内存调优实践
7.1 NUMA 感知分配策略
在多 NUMA 节点服务器上,内存分配的性能取决于分配发生在本地节点还是远程节点(远程访问延迟通常是本地的 2-3 倍):
// NUMA 策略配置(/sys/devices/system/node/)
// per-node 页面分配策略
/sys/devices/system/node/node*/meminfo
/sys/devices/system/node/node*/numastat
// 查看进程 NUMA 内存分布
numastat -p $PID
numactl --hardware
numactl --interleave=all ./app // 跨节点交错分配
numactl --cpunodebind=0 --membind=0 ./app // 绑定本地节点
// 内核 NUMA balancing(Linux 3.8+)
/proc/sys/kernel/numa_balancing = 1
// 自动扫描进程内存,将页面迁移到访问 CPU 所在节点
7.2 HugePage 与透明大页
大页(2MB/1GB)通过减少 TLB miss 和页表遍历开销来提升性能:
HugePage(静态大页):
预留方式:vm.nr_hugepages = 2048
挂载:mount -t hugetlbfs ...
使用:mmap(MAP_HUGETLB)
透明大页(Transparent HugePages,THP):
/sys/kernel/mm/transparent_hugepage/enabled = madvise/always
khugepaged 内核线程:扫描匿名区域并合并为 2MB 大页
数据库场景建议关闭(redis 推荐 never)
巨型页(1GB 页):
需在内核启动参数配置:hugepagesz=1G hugepages=4
适用:DPDK 驱动的网卡用户态驱动、大型数据库缓冲池
7.3 内存 OOM 杀手调优
当系统内存耗尽且无法通过回收释放时,OOM killer 选择一个进程杀死以换取系统生存:
// OOM 评分机制
// 每个进程的 OOM 评分计算在 oom_badness() 中:
// score = total_vm * 1000 / totalram_pages * oom_score_adj_range (0~1000)
// 评分最高的进程被选中杀死
// 调整进程被 OOM 选中的概率
echo -1000 > /proc/$PID/oom_score_adj // 永不选中(关键服务)
echo -1000 > /proc/$PID/oom_adj // 旧接口
echo 100 > /proc/$PID/oom_score_adj // 更容易被选中
// 控制 OOM 行为
/proc/sys/vm/overcommit_memory = 0 // 启发式过量分配
/proc/sys/vm/overcommit_memory = 1 // 总是允许(风险高)
/proc/sys/vm/overcommit_memory = 2 // 严格不允许过量分配
/proc/sys/vm/panic_on_oom = 2 // OOM 时触发 kernel panic
八、调试与性能监控工具
生产环境中排查内存问题需要系统化的工具链:
| 工具/接口 | 用途 |
|---|---|
| /proc/meminfo | 内核内存使用概况 |
| /proc/buddyinfo | 伙伴系统各阶空闲块数量 |
| /proc/slabinfo | Slab 缓存详细使用情况 |
| /proc/$PID/smaps | 进程详细 VMA 内存映射 |
| /proc/pagetypeinfo | 按迁移类型分布的内存使用情况 |
| free -h | 快速查看用户态可见内存 |
| vmstat -s | 内存统计信息 |
| drop_caches | 清理页面缓存(3=全部,1=dentry+inode) |
| perf stat -e dtlb_load_misses | TLB miss 监控 |
| bpftrace -e 'kprobe:oom_kill_process { ... }' | OOM 触发追踪 |
| slabtop | Slab 实时使用监控(类似 top) |
| numastat | NUMA 节点访问分布 |
8.1 内存泄漏检测工具链
// slabinfo 追踪:增长不降的缓存即疑似泄漏
watch -n 1 'cat /proc/slabinfo | sort -k3 -n -r | head -20'
// ftrace + kprobe 追踪 kmalloc/kfree 不匹配
echo 'p:kmalloc_Entry kmalloc size=$arg2' > /sys/kernel/debug/tracing/kprobe_events
echo 'r:kmalloc_Exit kmalloc retval=$retval' > /sys/kernel/debug/tracing/kprobe_events
// eBPF 实时内存追踪(bcc-tools)
# /usr/share/bcc/tools/memleak -p $PID
# /usr/share/bcc/tools/kmemleak
# /usr/share/bcc/tools/shmsnoop -p $PID
九、最新发展趋势
Linux 内存管理子系统的持续演进:
- MGLRU(Multi-Gen LRU):Linux 6.1+ 引入,通过多代跟踪(最多 4 代)替代 Active/Inactive 二元链表,大幅提升扫描效率和 refault 保护
- Damon(Data Access Monitor):基于区域的精确内存访问监控,为 MGLRU 提供运行时热度数据
- FOLIO(复合页):Linux 5.16+ 引入,统一 head page 和 tail page 管理,减少 slab/kmem 开销
- Memory Cgroup v2:容器内存限制的统一控制器,支持 recursive protection(pod→container 级联限制)
- CXL 2.0/3.0 内存扩展:CXL.mem 协议让 CPU 使用非本地 DRAM/PMEM 的共享内存池,NUMA 距离更长但容量更大
- PKey(Protection Keys):硬件支持的页面权限控制,允许用户态进程无 sys call 切换内存访问权限
十、总结
Linux 内核内存管理是一个精密协作的子系统:伙伴系统管理物理页面,Slab 分配器加速频繁对象分配,页表和多级寻址实现虚拟地址空间隔离,LRU 算法和 kswapd 维护页面热度,CMA 满足特殊硬件需求,NUMA 和大页优化满足企业级服务器性能需求。
掌握这些机制不仅是理解 Linux 内核的关键,更是性能调优、驱动开发、容器平台优化、生产排障的共同基础。在 AI 计算、大数据处理、高频交易等低延迟场景中,对内存管理精度的把控往往能带来数量级的性能差异。

发表评论 取消回复