Linux 内核内存管理深度实战:从 Buddy System 到 SLUB 分配器
内存管理是 Linux 内核最核心、最复杂的子系统之一。本文将从底层物理页分配器出发,深入解析 Buddy System、SLUB 分配器、虚拟内存管理、页表机制、内存回收与 OOM Killer 等关键技术,并结合实际调优案例,帮助读者构建完整的内核内存管理知识体系。
一、物理内存架构概览
1.1 物理内存的组织模型
Linux 内核将物理内存组织为一个三层金字塔结构:Node(节点)→ Zone(区域)→ Page(页面)。这种分层设计完美适配了现代计算机的 NUMA 架构和 32/64 位处理器的物理地址空间限制。
三层架构示意:
┌─────────────────────────────────────────────────────┐
│ NUMA Node 0 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ ZONE │ │ ZONE │ │ ZONE │ │
│ │ DMA32 │ │ NORMAL │ │ MOVABLE │ │
│ │ 0-4GB │ │4GB-8GB │ │ 可移动页 │ │
│ │ Page数组 │ │ Page数组 │ │ Page数组 │ │
│ └──────────┘ └──────────┘ └──────────┘ │
├─────────────────────────────────────────────────────┤
│ NUMA Node 1 │
│ ┌──────────┐ ┌──────────┐ │
│ │ ZONE │ │ ZONE │ │
│ │ NORMAL │ │ MOVABLE │ │
│ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────┘
核心数据结构体定义:
// 每个 NUMA 节点对应一个 pglist_data
typedef struct pglist_data {
struct zone node_zones[MAX_NR_ZONES]; // 该节点包含的所有 Zone
struct zonelist node_zonelists[MAX_ZONELISTS]; // 分配后备链表
int nr_zones; // 该节点中 Zone 的数量
struct page *node_mem_map; // 该节点所有物理页的 page 描述符数组
unsigned long node_start_pfn; // 起始物理页帧号
struct bootmem_data *bdata; // 启动内存分配器(过渡用)
unsigned long node_present_pages; // 可用的物理页总数
unsigned long node_spanned_pages; // 包含内存空洞的总页数
} pg_data_t;
// 每个 Zone 的结构体
struct zone {
unsigned long watermark[NR_WMARK]; // 水位线: min/low/high
unsigned long lowmem_reserve[MAX_NR_ZONES]; // 各区域保留页数
struct free_area free_area[MAX_ORDER]; // Buddy System 的各阶空闲链表
// 内存回收相关参数
int temp_priority; // 临时优先级(回收时用)
int prev_priority; // 上一次优先级
// 页 LRU 链表
struct list_head lruvecs[NR_LRU_LISTS]; // LRU list heads
// Per-CPU page cache
struct per_cpu_pageset __percpu *pageset; // 每CPU热页缓存
// 压缩器(zswap/zram)
struct zswap_pool *zswap_pool;
};
在 x86_64 架构中,通常我们看到的 Zone 是:
- ZONE_DMA:0-16MB,用于老式 ISA 设备 DMA(现代系统中基本不再需要)
- ZONE_DMA32:0-4GB,用于 32 位 DMA 设备
- ZONE_NORMAL:直接映射区域(x86_64 上通常覆盖 ~896MB),线性映射到内核虚拟地址空间
- ZONE_MOVABLE:可移动页面区域,用于内存热插拔和碎片整理
1.2 Page 描述符
物理内存被划分为固定大小(通常 4KB)的页帧,每个页帧有一个对应的 struct page 描述符。这个结构体大约 64 字节,是所有内存管理操作的基石:
struct page {
unsigned long flags; // 原子标志位:PG_locked, PG_dirty, PG_lru, PG_active等
union {
struct address_space *mapping; // 文件缓存的文件映射关系
void *s_mem; // slab 中第一个对象
atomic_t compound_mapcount; // compound page 的 mapcount
};
pgoff_t index; // 在映射中的偏移索引
unsigned long private; // 私有数据指针,意义上下文依赖
// 链表节点(用于挂在各种链表上:LRU、Buddy Area 等)
struct {
union { struct list_head lru; struct list_head buddy; };
union { struct list_head slab_list; struct list_head slab_caches; };
};
atomic_t _refcount; // 引用计数,0 表示空闲
atomic_t _mapcount; // 映射计数(同时被多少个进程映射)
#ifdef CONFIG_MEMCG
struct mem_cgroup *mem_cgroup; // 内存控制组
#endif
};
重要标志位说明:
| 标志 | 含义 | 用途 |
|---|---|---|
| PG_locked | 页是否被锁定 | IO 操作期间防止并发 |
| PG_dirty | 页是否被修改 | 回写时检测 |
| PG_lru | 页是否在 LRU 链表 | 回收时遍历 |
| PG_active | 页是否在活跃 LRU | 二次机会算法 |
| PG_slab | 页属于 slab 分配器 | 小对象分配 |
| PG_reserved | 页是否被保留 | 不会被交换出去 |
| PG_reclaim | 页正在被回收 | 正在回写 |
| PG_buddy | 页在 buddy 空闲链表 | 伙伴系统管理 |
| PG_compound | compound page 的一部分 | 复合页 |
| PG_head | compound page 的首页 | 2MB/1GB 大页 |
二、Buddy System(伙伴系统)
2.1 核心思想与数据结构
Buddy System 是物理页分配的核心算法,负责处理以页面(4KB 或其幂次倍数为粒度)为单位的连续物理内存分配请求。它采用分治策略,将空闲内存按 2 的幂次分组,以最小化碎片化。
free_area 结构体:
struct free_area {
struct list_head free_list[MIGRATE_TYPES]; // 不同迁移类型的空闲链表
unsigned long nr_free; // 该阶空闲块总数
};
// 典型的 MAX_ORDER 定义为 11
// 即从 order-0 (4KB) 到 order-10 (4MB,假设 4KB 页)
// 某些系统支持 order-11 (8MB) 甚至 order-12 (16MB)
#define MAX_ORDER 11
2.2 分配流程详解
算法流程图:
分配请求: __alloc_pages(nodemask, order)
│
▼
┌─── get_page_from_freelist() ─────────────────────┐
│ 从空闲链表直接分配: │
│ │
│ 目标阶 = order (如 order=2, 需要 16KB) │
│ │
│ ┌── free_area[order].free_list 非空? ───┐ │
│ │ YES: 直接取出块返回 │ │
│ │ │ │
│ │ NO: 向高阶借块 │ │
│ │ ├── free_area[order+1] 有块? │ │
│ │ │ YES: 劈成两半,一半挂到当前order │ │
│ │ │ 孤独链表,另一半分配出去 │ │
│ │ │ NO: 继续向上查找更高阶 │ │
│ │ │ ... 直到 order-10 │ │
│ │ │ 若全部失败 → 进入慢速路径 │ │
│ └────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
│ 快速路径失败
▼
┌─── __alloc_pages_slowpath() ─────────────────────┐
│ 1. 尝试在更低优先级 Zone 中分配 │
│ 2. 唤醒 kswapd 开始异步回收 │
│ 3. 触发直接内存回收 (direct reclaim) │
│ 4. 触发 compaction 压缩减少碎片 │
│ 5. 最后手段:调用 OOM Killer │
└─────────────────────────────────────────────────┘
关键分配函数逐层解析:
// 核心入口函数
struct page *__alloc_pages(gfp_t gfp_mask, unsigned int order,
int preferred_nid, nodemask_t *nodemask)
{
struct page *page;
// 预分配检查:如果 GFP 不允许 IO/文件系统操作,则检查紧急预留
if (!prepare_alloc_pages(gfp_mask, order, preferred_nid, nodemask, &ac))
return NULL;
// 快速路径:直接空闲链表分配
first_zones_zonelist(ac->zonelist, ac->high_zoneidx, ac->nodemask, &ac->preferred_zone);
page = get_page_from_freelist(alloc_flags, order, alloc_flags, ac);
if (likely(page))
goto out;
// 慢速路径:回收 + 压缩 + 重试
page = __alloc_pages_slowpath(gfp_mask, order, ac);
out:
return page;
}
2.3 伙伴(Buddy)的判定与合并
两个页块是否能合并(称为「伙伴」),依赖于较强的数学性质:
// 伙伴关系定义
// 对于 order-N 的块 X,其伙伴块为:
// buddy = X ^ (1 << N) // N=order
//
// 示例:order-0 的页框号 4,伙伴是 5
// 页框号 4: 二进制 0000 0100
// mask: 0000 0001
// buddy: 0000 0101 = 5
//
// order-1 的块(4,5),伙伴是(6,7)
// buddy mask: 0000 0010
// 4 ^ 2 = 6
//
// 合并条件(三个 simultaneously):
// 1. 两个块大小相同(相同 order)
// 2. 物理地址连续
// 3. 伙伴块的第一个页框号必须是 2^(order+1) 的整数倍
// 即 (page_idx & ((1 << (order+1)) - 1)) == 0
static inline unsigned long __find_buddy_pfn(unsigned long page_pfn, unsigned int order)
{
return page_pfn ^ (1UL << order);
}
static inline bool page_is_buddy(struct page *page, struct page *buddy,
unsigned int order)
{
if (!pfn_valid_within(page_to_pfn(buddy)))
return false;
if (page_zone_id(page) != page_zone_id(buddy))
return false;
if (PageBuddy(buddy) && buddy->private == order)
return true;
return false;
}
2.4 页面迁移类型(Migrate Types)
为了对抗内存碎片化,Buddy System 将每个 order 的空闲链表按照 页面迁移类型 进一步分组:
enum migratetype {
MIGRATE_UNMOVABLE, // 不可移动:内核代码、页表、kmalloc 核心分配
MIGRATE_MOVABLE, // 可移动:用户空间页、文件缓存
MIGRATE_RECLAIMABLE, // 可回收:文件映射缓存(脏页待回写
MIGRATE_PCPTYPES, // Per-CPU 页缓存类型(仅在 PCP 链表)
MIGRATE_HIGHATOMIC, // 紧急预留(GFP_ATOMIC 分配)
MIGRATE_ISOLATE, // 隔离状态(热插拔/迁移中)
MIGRATE_TYPES
};
分配防碎片策略: 分配器记录上一次分配使用的迁移类型。当某个迁移类型的空闲块不足时,可以从其他类型的链表中「借用」,同时通过「反向映射」(fallback steal)机制尽可能地使得同一类型的页面聚集在一起。
2.5 启动过程详解
早期启动阶段,Buddy System 尚未初始化。内核使用 Boot Memory Allocator 作为过渡:
// 启动阶段的内存分配
1. memblock: 最早期物理内存分配器(UEFI/BIOS 传递的内存映射表)
2. bootmem: 基于位图的简单分配器(标记已分配/空闲)
3. Buddy System: 各 Zone 的 free_area 初始化后接管
4. Slab Allocator: 基于 Buddy System 初始化完成
// buddy 初始化关键步骤
void __init free_area_init_nodes(unsigned long *max_zone_pfn)
{
// 1. 初始化各节点和 zone
// 2. 将所有可用物理页标记为 MIGRATE_MOVABLE(特殊区域除外)
// 3. 每个 zone 的 free_area[].free_list 链入对应阶的空闲块
// 4. 设置 watermark 水位线值
}
// 水位线计算
zone->watermark[WMARK_MIN] = managed_pages >> 12; // lowmem_reserve 计算基础
zone->watermark[WMARK_LOW] = managed_pages >> 8; // kswapd 唤醒阈值
zone->watermark[WMARK_HIGH] = managed_pages >> 7; // 充裕线,可直接分配
三、SLUB 分配器
SLUB(Unqueued Slab Allocator)是当前 Linux 默认的小对象分配器,替代了 SLAB 和 SLOB。它是对内核中频繁小对象分配任务(通常小于一个页面,如水描述符、inode、dentry 等)的高效实现。
3.1 SL 分配器概述
三种分配器对比:
| 特性 | SLAB | SLUB | SLOB |
|---|---|---|---|
| 对象队列 | 本地 + 共享(复杂) | 无队列(Per-CPU 缓存) | 无(链表) |
| 扩展性 | 差(多 NUMA 节点下锁竞争) | 优秀(Per-CPU 缓存) | 一般 |
| 碎片控制 | 好(着色) | 简化着色 | 差 |
| 调试支持 | 完善 | KASAN/KFENCE 集成 | 基础 |
| 适用场景 | 通用 | 默认推荐 | 嵌入式(tiny) |
| 现状 | 废弃 | Linux 主选 | 仅 CONFIG_SLOB |
3.2 SLUB 核心结构
// kmem_cache: 每个对象类型一个
struct kmem_cache {
struct kmem_cache_cpu __percpu *cpu_slab; // Per-CPU Slab 缓存
struct kmem_cache_node *node[MAX_NUMNODES]; // 每个 NUMA 节点一个
unsigned int offset; // 下一个空闲对象的偏移量(Redzone 检查)
unsigned int size; // 对象实际大小(含对齐)
unsigned int object_size; // 用户请求的原始大小
// slab 页面属性
slab_flags_t flags; // 对象对齐等标志
unsigned int oo; // max order 编码: min/optimal/max
// 虚拟地址管理
unsigned int align; // 对象对齐要求
const char *name; // 缓存名(/proc/slabinfo 可见)
// 统计与调试
struct list_head list; // 全局缓存链表
struct kobject kobj; // sysfs 属性
};
// Per-CPU 缓存(SLUB 最核心的设计)
struct kmem_cache_cpu {
void **freelist; // 空闲对象链表头
unsigned long tid; // 全局事务 ID(无锁保证)
struct page *page; // 当前正在使用的 slab 页面
struct page *partial; // 部分空闲的 slab 链表(per-CPU partial)
};
// 节点级管理
struct kmem_cache_node {
spinlock_t list_lock; // 保护 partial 链表
unsigned long nr_partial; // 本节点部分空闲 slab 数量
struct list_head partial; // 部分空闲的 slab 页面链表
#ifdef CONFIG_SLUB_DEBUG
unsigned long nr_slabs; // 总 slab 计数
unsigned long total_objects; // 总对象数
struct list_head full; // 已满 slab 链表
#endif
};
3.3 分配路径源码解析
// 快速路径(无锁 Per-CPU 分配)
static __always_inline void *slab_alloc(struct kmem_cache *s, gfp_t gfpflags)
{
struct kmem_cache_cpu *c = raw_cpu_ptr(s->cpu_slab);
struct page *page = c->page;
// Step 1: 从 freelist 取空闲对象
void *object = c->freelist;
if (unlikely(!object)) {
// 无空闲对象,需要从页面分配新对象
return __slab_alloc(s, gfpflags, _RET_IP_);
}
// 取出对象
c->freelist = get_freepointer(s, object);
c->page->inuse++; // 增加已用计数
// 检查是否 slab 已满
if (unlikely(!c->freelist)) {
c->page = NULL; // 当前页用完,下次分配从 partial/new 获取
}
return object;
}
// 慢速路径(需要重新填充 Per-CPU 缓存)
static void *__slab_alloc(struct kmem_cache *s, gfp_t gfpflags, unsigned long addr)
{
struct kmem_cache_cpu *c = raw_cpu_ptr(s->cpu_slab);
struct page *page;
// Step 1: 检查是否有 partial slab(Per-CPU partial 链表)
page = c->partial;
if (!page) {
// Step 2: 从 node partial 链表获取
page = get_partial(s, gfpflags);
if (!page) {
// Step 3: 从 buddy 分配新页,初始化为新 slab
page = new_slab(s, gfpflags);
}
}
// Step 4: 从选中的 slab 取对象
c->page = page;
c->freelist = page->freelist;
page->freelist = NULL; // 取空
page->inuse = page->objects;
return c->freelist;
}
3.4 空闲链表加密与 Redzone
Freelist 指针加密(Hardened Freelist): 为了防止 use-after-free 漏洞利用,SLUB 使用 XOR 和随机值对空闲链表指针进行加密:
// Freelist 指针加解密
static inline void *freelist_ptr(struct kmem_cache *s, void *ptr,
unsigned long ptr_addr)
{
#ifdef CONFIG_SLAB_FREELIST_HARDENED
// 加密方式:ptr_value ^ random ^ swap(ptr_addr)
// 其中 ptr_addr 是 slab 内该对象处的虚拟地址(作为盐值)
// 这确保了攻击者无法通过已知信息预测或伪造指针
return (void *)((unsigned long)ptr ^ s->random ^ ptr_addr);
#else
return ptr;
#endif
}
// Redzone 布局(调试模式)
// ┌──────────────┬──────────┬───────────────┐
// │ 对象内容 │ Redzone │ Next Pointer │
// │ (size) │ (0x...) │ (加密后) │
// └──────────────┴──────────┴───────────────┘
// Redzone 中填充特定值(如 0xbb),发现变化即报告越界
3.5 释放流程解析
// 快速释放路径
static __always_inline void slab_free(struct kmem_cache *s, void *object)
{
struct kmem_cache_cpu *c = raw_cpu_ptr(s->cpu_slab);
struct page *page = virt_to_head_page(object);
unsigned long tid = c->tid;
unsigned long object_addr = (unsigned long)object;
// Step 1: freelist 指针加密
void *next = c->freelist;
set_freepointer(s, object, next); // 内部调用 freelist_ptr 加密
// Step 2: 头入法插入空闲链表
c->freelist = object;
c->page->inuse--;
// Step 3: 判断是否需要触发慢路径
if (unlikely(!c->page->inuse && !c->partial)) {
// 当前 slab 全部空闲,且 partial 链表也空
// 可以考虑将当前 slab 归还节点但不立即释放
c->page = NULL;
} else if (page != c->page && page->inuse == 0) {
// 不同页,且页变空
// 移入 node partial 或归还 buddy
}
c->tid = next_tid(tid); // 递增事务ID(检测并发释放)
}
3.6 kmem_cache 生命周期
// 创建缓存
struct kmem_cache *kmem_cache_create(const char *name, unsigned int size,
unsigned int align, slab_flags_t flags,
void (*ctor)(void *))
{
// 计算对象布局(含元数据、对齐、Redzone)
// 计算每个 slab 可容纳的对象数
// 向 buddy 申请一页作为初始 slab
// 部分预留(可选)
}
// 全局常用缓存(通用大小)
// kmalloc_caches[NR_KMALLOC_TYPES][KMALLOC_SHIFT_HIGH + 1]
// 覆盖 8B 到 8KB,按 2 的幂次对齐提供通用缓存
// 销毁缓存
void kmem_cache_destroy(struct kmem_cache *s)
{
// 释放所有 full 和 partial slab
// 释放 page 回到 buddy
}
3.7 性能调优与监控
// /proc/slabinfo 输出示例
// name <active_objs> <num_objs> <objsize> <objperslab> <pagesperslab>
// kmalloc-256 384 480 256 15 1
// dentry 5120 5120 192 21 1
// inode_cache 3360 3360 648 6 2
// task_struct 200 200 5432 1 8
// SLUB 关键 sysfs 参数
/sys/kernel/slab/<cache>/
├── limit # 每节点 slab 最大数量
├── cpu_partial # Per-CPU partial slab 数量上限
├── min_partial # 节点保留的最小 partial 数
├── order # slab 包含的页数(order)
├── objects # 每个 slab 中的对象数
├── object_size # 每个对象实际分配大小
├── objs_per_slab # slab 中对象最大数目
└── sanity_checks # 启用一致性检查
四、虚拟内存管理
4.1 虚拟地址空间布局
x86_64 Linux 采用 4 级(最新 5 级)页表,进程虚拟地址空间布局如下:
┌─── 0x0000 0000 0000 0000 ──────────────────────────┐
│ 用户空间 (128TB x86_64 48位) │
│ ┌────────────────────────────────────┐ │
│ │ text (代码段) │ │
│ │ data + BSS (数据段) │ │
│ │ ↑ 向下增长 │ │
│ │ Heap (malloc/brk) │ │
│ │ ↓ 向上增长 │ │
│ │ Memory Mapping (mmap) │ │
│ │ ... │ │
│ │ ↑ 栈底 (~stack) │ │
│ │ Stack (向下增长) │ │
│ └────────────────────────────────────┘ │
├─── 0x0000 7FFF FFFF FFFF ──────────────────────────┤
│ 非规范区域 (Never accessible) │
├─── 0xFFFF 8000 0000 0000 ──────────────────────────┤
│ 内核空间 (128TB) │
│ ┌────────────────────────────────────┐ │
│ │ 直接物理映射区 (physmap) │ │
│ │ vmalloc / ioremap 区 │ │
│ │ 内核代码/数据 (kernel text/data) │ │
│ │ fixture (固定映射) │ │
│ └────────────────────────────────────┘ │
└─── 0xFFFF FFFF FFFF FFFF ──────────────────────────┘
4.2 进程地址空间描述符
struct mm_struct {
struct { struct vm_area_struct *mmap; // VMA 链表
struct rb_root mm_rb; // VMA 红黑树(加速查找)
};
unsigned long mmap_base; // mmap 基址
unsigned long task_size; // 代码段最大地址
unsigned long start_code, end_code; // 代码段范围
unsigned long start_data, end_data; // 数据段范围
unsigned long start_brk, brk; // 堆边界
unsigned long start_stack; // 栈起始地址
// 关键参数
unsigned long total_vm; // 总映射页数
unsigned long locked_vm; // 锁定页数(mlock)
unsigned long pinned_vm; // 固定页数
// 页表相关
pgd_t *pgd; // 全局页目录基地址
atomic_t mm_users; // 用户计数
atomic_t mm_count; // 总引用数
// 大页支持
atomic_t hugetlb_usage;
};
4.3 vm_area_struct(VMA)
struct vm_area_struct {
unsigned long vm_start; // 起始虚拟地址
unsigned long vm_end; // 结束虚拟地址(不包含)
struct mm_struct *vm_mm; // 所属 mm_struct
struct vm_area_struct *vm_next; // VMA 链表
// 节点
struct rb_node vm_rb; // 红黑树节点
// 权限与标志
pgprot_t vm_page_prot; // 页面访问权限
unsigned long vm_flags; // VM_READ/WRITE/EXEC/SHARED 等
// 操作回调
const struct vm_operations_struct *vm_ops;
// 映射关系
struct file *vm_file; // 关联的文件(文件映射时)
unsigned long vm_pgoff; // 文件内偏移(以页为单位)
};
// VM 标志位
#define VM_READ 0x00000001 // 可读
#define VM_WRITE 0x00000002 // 可写
#define VM_EXEC 0x00000004 // 可执行
#define VM_SHARED 0x00000008 // 共享映射
#define VM_GROWSDOWN 0x00000100 // 栈向下增长
#define VM_GROWSUP 0x00000200 // 栈向上增长(少见)
#define VM_NOHUGEPAGE 0x00400000 // 禁止透明大页
4.4 缺页异常处理
当 CPU 访问的虚拟地址尚未映射到物理页时,触发缺页异常(Page Fault),内核调用 handle_mm_fault() 处理:
// 缺页异常处理路径(简化版)
vm_fault_t handle_mm_fault(struct vm_area_struct *vma, unsigned long address,
unsigned int flags)
{
// Step 1: 逐级页表查找
pgd = pgd_offset(mm, address);
if (pgd_none(*pgd)) {
// PGD 表项不存在 → 分配新 P4D
// (5 级页表时激活)
pgd = pgd_alloc(mm, pgd, address);
}
p4d = p4d_offset(pgd, address);
if (p4d_none(*p4d)) {
// P4D 表项不存在 → 分配新 PUD
p4d = p4d_alloc(mm, pgd, address);
}
pud = pud_offset(p4d, address);
if (pud_none(*pud)) {
// PUD 表项不存在 → 分配新 PMD
pud = pud_alloc(mm, p4d, address);
}
pmd = pmd_offset(pud, address);
if (pmd_none(*pmd)) {
// PMD 表项不存在 → 分配新 PTE
pmd = pmd_alloc(mm, pud, address);
}
// Step 2: 检查 PTE
pte = pte_offset_map(pmd, address);
if (pte_none(*pte)) {
// PTE 不存在(缺页)→ 根据 VMA 处理
// 文件映射: do_fault() → 从 page cache 查找
// 匿名映射: do_anonymous_page() → 分配零页
// Copy-on-Write: do_wp_page() → 新分配页并拷贝
}
return 0;
}
五、页表机制
5.1 4 级页表结构
x86_64 使用 48 位虚拟地址空间(256TB),采用 4 级页表:
47 39 38 30 29 21 20 12 11 0
┌───────────────┬────────────────┬────────────────┬────────────────┬──────────────┐
│ PGD (Global) │ PUD (Upper) │ PMD (Middle) │ PTE (Table) │ Page Offset │
│ Index 9bit │ Index 9bit │ Index 9bit │ Index 9bit │ 12bit │
└───────────────┴────────────────┴────────────────┴────────────────┴──────────────┘
PGD + PUD + PMD + PTE + Offset = 9+9+9+9+12 = 48 bits
每级 512 项(2^9),每项 8 字节 = 4KB(正好一页)
虚拟地址空间: 2^48 = 256 TB
物理地址空间: 2^40 (传统) ~ 2^52(5级页表)
5.2 TLB 与 ASID
为了加速地址转换,CPU 使用 TLB (Translation Lookaside Buffer) 缓存已转换的页表项:
// TLB 管理关键操作
// 1. TLB 刷新
flush_tlb_mm(mm); // 切换 mm 时刷新整个 TLB
flush_tlb_page(vma, addr); // 刷新单个页面
flush_tlb_range(vma, start, end); // 刷新地址范围
// 2. PCID (Process-Context Identifiers) - Intel 特性
// 允许 TLB 不因进程切换全部失效
// 每个 mm 分配一个 PCID,避免 TLB Shootdown 风暴
// 3. TLB Shootdown (多核同步)
// 当修改 PTE 时需要通知其他 CPU 刷新 TLB
// 使用 IPI (Inter-Processor Interrupt) 广播
void flush_tlb_mm(struct mm_struct *mm)
{
// 标记 INVALID_TLB 状态
// 向所有持有 mm 的 CPU 发送 TLB SHOOTDOWN IPI
// 等待刷新完成
}
5.3 透明大页(Transparent Huge Pages, THP)
THP 是一种自动合并多个 4KB 小页为 2MB 大页的机制,可以减少 TLB miss 和页表访问次数:
// THP 配置路径
/sys/kernel/mm/transparent_hugepage/enabled
└── "always | madvise | never"
// 启用 ways
// always: 总是尝试使用 THP
// madvise: 仅在 madvise(MADV_HUGEPAGE) 区域使用
// never: 完全禁用
// defer + madvise: 使用 MADV_HUGEPAGE 区域 + 普通区域延迟分配
// THP 的核心合并流程
// 1. khugepaged 内核线程周期性扫描匿名页
// 2. 发现连续且可迁移的物理页 → 调用 collapse_huge_page()
// 3. 分配一个 2MB 的复合页(compound page)
// 4. 将 512 个 4KB PTE 替换为 1 个 2MB PMD 项
// 大页相关函数
int do_huge_pmd_anonymous_page(struct vm_fault *vmf)
{
// 为匿名映射分配大页
page = alloc_hugepage_vma(GFP_TRANSHUGE, vma, haddr, HPAGE_PMD_ORDER);
// 设置 PMD 项为 huge 模式
set_pmd_at(mm, haddr, vmf->pmd, huge_pmd);
}
THP 使用建议:
- 大数据处理(Hadoop/Spark):推荐
always模式 - 数据库(PostgreSQL/MySQL):传统建议
never,新版本 Oracle/PG 开始支持 - Redis 等缓存服务:建议
never(只读少写,大页拆分开销大) - 通用虚拟化环境:根据负载观察决定
六、kmalloc 与 vmalloc
6.1 kmalloc
kmalloc() 是内核中最常用的小内存分配函数,分配的内存是物理连续且虚拟连续的:
// kmalloc 底层实现
static __always_inline void *kmalloc(size_t size, gfp_t flags)
{
unsigned int index;
if (size <= KMALLOC_MAX_CACHE) {
// 小对象:使用 kmem_cache 通用缓存
index = kmalloc_index(size);
return __kmalloc_trace(s, kmalloc_caches[ksize_type][index], flags);
}
if (size > KMALLOC_MAX_SIZE) {
// 大小超过 VMALLOC (8KB~64KB) → 失败
return NULL;
}
// 大对象:直接使用 buddy 分配(order 根据 size 向上取整)
return kmalloc_large(size, flags);
}
// GFP 标志总结
// 行动修饰符
GFP_ATOMIC // 不睡眠、不回收(中断上下文、持有自旋锁时使用)
GFP_KERNEL // 默认标志,允许睡眠、可回收、可 IO(进程上下文)
GFP_USER // 分配给用户页,可能被阻塞
GFP_NOIO // 不发起磁盘 IO 的回收(文件系统关键路径)
GFP_NOFS // 不调用文件系统操作的回收
// 区域修饰符
___GFP_DMA // 强制使用 ZONE_DMA 分配
___GFP_DMA32 // 强制使用 ZONE_DMA32 分配
___GFP_HIGHMEM // 高端内存(已废弃,64位不需)
// 回收修饰符
___GFP_RECLAIM // 允许直接回收
___GFP_IO // 允许 IO(如写回脏页)
___GFP_FS // 允许调用文件系统缩缓存
6.2 vmalloc
vmalloc() 分配的内存是虚拟连续但物理不一定连续:
// vmalloc 分配流程
void *vmalloc(unsigned long size)
{
// 1. 分配一段 VMA 区域(vmalloc 区域中的空闲区间)
struct vm_struct *area = get_vm_area(size, VM_ALLOC|VM_UNCERTAIN);
// 2. 调用 alloc_pages() 为每一页分配物理页
for (i = 0; i < area->nr_pages; i++) {
area->pages[i] = alloc_page(GFP_KERNEL);
}
// 3. 建立页表映射
vmap_pages(area); // 逐页映射到连续的虚拟地址
return area->addr;
}
// vmalloc 的代价
// - 非连续物理页 → 增加 TLB miss, DMA 需要 scatter-gather
// - 虚拟地址映射开销(页表写入)
// - 不适用于 DMA(除非配合 DMA_MAP 使用 SWIOTLB)
// 使用场景
// - 内核模块加载(模块代码段可以 > 2MB)
// - 动态数据结构(struct file * 数组)
// - io_remap 时缓存虚拟连续视图
6.3 对比与选择
| 特性 | kmalloc | vmalloc |
|---|---|---|
| 物理连续性 | ✓ 保证 | ✗ 不保证(虚拟连续) |
| 最大可用 | 约 4MB (取决于 order) | 接近 vmalloc 区大小(数百 GB) |
| 分配耗时 | 极快(O(1) 从 freelist) | 较慢(多次 alloc_page + 页表写入) |
| 释放耗时 | O(1) 插入 freelist | O(n) 解除映射 + 释放页 |
| DMA 支持 | ✓ | ✗(需 scatter-gather 或 SWIOTLB) |
| 典型使用 | 水描述符、通用小对象 | 大数组、模块代码、io_uring buffer |
七、内存回收与 OOM Killer
7.1 LRU 链表与二次机会算法
内核使用 LRU (Least Recently Used) 和 二次机会算法 来决定哪些页面应该被回收。传统 Linux 使用 2 个 LRU 链表(活跃/不活跃),Multi-Gen LRU 在 6.1+ 引入了代际模型。
// 传统 LRU 链表(5 条)
enum lru_list {
LRU_INACTIVE_ANONYMOUS, // 不活跃匿名页
LRU_ACTIVE_ANONYMOUS, // 活跃匿名页(2MB THP 可能在此)
LRU_INACTIVE_FILE, // 不活跃文件缓存
LRU_ACTIVE_FILE, // 活跃文件缓存
LRU_UNEVICTABLE, // 不可回收(mlock 等)
NR_LRU_LISTS
};
// 页面老化与晋升规则
// 1. 首次引用 → PG_referenced 置位
// 2. shrink_page_list() 处理不活跃链表时被访问 → 晋升活跃链表
// 3. 访问两次才转活跃(二次机会)
// 4. 被引用三次时:活跃→不活跃(如果未在最近扫描时再次访问)
// Multi-Gen LRU (MGLRU) - Linux 6.1+
// 核心改进:基于页面访问频次分配 TTL(存活时间)
// 每代(Generation)有不同的年龄 cap
// 年轻代 TTL 短但晋升快,老年代 TTL 长但回收优先级高
// 优势:避免「扫描风暴」,开销 O(active_pages) 而非 O(total_pages)
7.2 内存回收流程
// 直接回收 (Direct Reclaim)
// 发生在分配失败且无法从 freelist 直接分配时
// 同步阻塞,分配者等待回收完成
// 异步回收 (Background Reclaim)
// 由 kswapd 内核线程执行
// 当 Zone 空闲页低于 WMARK_LOW 时被唤醒
// 各 Zone 独立回收,使用 target 变量指示目标
// kswapd 工作流
int kswapd(void *p)
{
pg_data_t *pgdat = (pg_data_t *)p;
while (!kthread_should_stop()) {
// Step 1: 检查各水位状态
for (i = 0; i < MAX_NR_ZONES; i++) {
if (zone_watermark_ok(zone, WMARK_LOW)) {
continue; // 不需要回收
}
// Step 2: 确定回收目标(比如 32 个页面)
balance_pgdat(pgdat, order, classzone_idx);
// Step 3: shrink_lruvec() 遍历各 LRU 链表
// → inactive_list_is_low() 判断是否需要平衡
// → shrink_list() 实际回收动作
// → 检查页面类型(匿名/文件),不同策略
}
// Step 4: 重新检查水位
// Step 5: 完成或休眠等待下次唤醒
}
}
7.3 swap 机制
当物理内存不足且没有足够的文件缓存可回收时,内核将匿名页写出到 Swap 设备:
// Swap 核心操作
// 1. Swap Out(页面换出)
// do_try_to_free_pages() → shrink_page_list() → pageout()
// → 分配 swap slot → 写匿名页内容到 swap
// → 标识 PTE 为 swap entry
//
// 2. Swap In(页面换入)
// 进程访问被 swap 的地址 → Page Fault
// → do_swap_page() 读取 swap entry →
// → 从 swap cache 查找(避免重复从磁盘读取)
// → 分配新物理页,从 swap 读入数据
// → 更新 PTE 映射
//
// 3. Swap 关键参数
vm.swappiness = 60 // 内存紧张时 swap 偏好 (0-200)
vm.vfs_cache_pressure = 100 // 回收目录/entry 缓存的倾向
vm.min_free_kbytes = 67584 // Zone MIN 水位线基数
vm.overcommit_memory = 0 // 0:启发式 1:永不过量 2:严格限制
7.4 OOM Killer
当内存极度匮乏(长时间无法回收足够页面以唤醒 kswapd)时,OOM Killer 选择并终止一个或多个进程以释放内存:
// OOM 触发路径
__alloc_pages()
→ __alloc_pages_slowpath()
→ out_of_memory()
→ select_bad_process()
→ oom_kill_process()
// 进程评分机制(oom_badness())
// 计算每个进程的 "badness" 分数(千分比)
// 分数越高,越容易被kill
// 关键因素:
// 1. 进程物理内存使用量(RSS)—— 占主要权重
// 2. 进程优先级(oom_score_adj)—— 用户可调整
// 3. 运行时间 —— 越长偏好不 kill(可能服务重要)
// 4. 是否是特权进程 —— 特权进程 *32% 降分
// 5. 是否存在子进程 —— 杀死父进程子进程一并退出
// oom_score 查看
cat /proc/<pid>/oom_score // 当前评分 (0-1000)
cat /proc/<pid>/oom_score_adj // 手动调整(-1000 ~ 1000)
// -1000 → 永不 kill
// 1000 → 优先 kill
// 预防手段
// 1. 设置 cgroup 内存限制(memory.max + memory.high)
// 2. mlock() 锁定关键内存区域
// 3. 手动 oom_score_adj
// 4. earlyoom/systemd-oomd (用户态更智能的 OOM 守护)
八、NUMA 感知分配
8.1 NUMA 架构影响
NUMA(Non-Uniform Memory Access)架构下,访问本地内存节点的延迟远低于跨节点访问:
// UMA (Uniform) - 所有 CPU 看到相同内存
// NUMA (Non-Uniform) - 每个节点有本地内存
//
// 典型延迟对比:
// 本地访问: ~80ns
// 单跳跨节点: ~140ns
// 双跳跨节点: ~200ns (取决于 QPI/Infinity Fabric)
// 内核策略
// 1. 进程内存尽量分配到其 CPU 所在的 NUMA 节点
// 2. NUMA Balancing: 通过访问计数页面迁移
// 3. 使用 MPOL_BIND/MEM_DEFAULT 等策略
8.2 NUMA Balancing
Linux 的自动 NUMA 页面迁移机制(NCC - NUMA Clarify & Converge):
// 工作原理
// 1. 周期性清除 PTE 中的 Accessed 位
// 2. 一段时间后扫描,检查哪些页面其他节点上的 CPU 在访问
// 3. 将迁移候选页迁移到访问者更近的节点
// 4. 限制迁移速率(numa_balancing_scan_period_min ~ max ms)
// 配置参数
/proc/sys/kernel/numa_balancing
- 0 = 禁用
- 1 = 启用扫描与迁移
/proc/sys/kernel/numa_balancing_scan_period_min // 扫描间隔下限
/proc/sys/kernel/numa_balancing_scan_delay // 进程启动后多久开始
/proc/sys/kernel/numa_balancing_scan_size_mb // 每轮扫描大小
九、CMA (Contiguous Memory Allocator)
某些设备(如 GPU、V4L2 Camera、视频编解码器)需要大块物理连续内存。CMA 设计用于嵌入式/ARM 系统中预留大块连续物理区域:
// CMA 核心思想
// 1. 启动时预留一段物理区域(MIGRATE_CMA 类型标记)
// 2. 未使用时,这些页面可被可移动页回收使用
// 3. 设备请求时,CMA 通过 compaction 腾出连续区域
// 4. 分配:cma_alloc() → alloc_contig_pages_range()
// 碎片整理配合
// 1. compaction 分为:
// a. 前台 compaction(allocation 时同步触发)
// 2. khugepaged 异步 compaction
// 3. 手动触发: /proc/sys/vm/compact_memory
// 4. compact_zone 逻辑
// 扫描 zone 中的每一页
// → 如果是可移动页 → 尝试迁移到 freelist 头部
// → 同时扫描 free_regions 寻找可合并的空闲块
// → 重复直到连续区达到目标大小
十、实战调优案例
10.1 数据库 OLTP 场景
# PostgreSQL / MySQL 内存调优
vm.swappiness = 1 # 极度减少 swap,避免事务延迟抖动
vm.dirty_ratio = 40 # 脏页达到 40% 触发 background writeback
vm.dirty_background_ratio = 10 # IO 刷写阈值
vm.overcommit_memory = 2 # 严格模式(rsyslog/OOM)
vm.overcommit_ratio = 80 # 80% 物理内存可分配
vm.min_free_kbytes = 262144 # 256MB,确保紧急分配池
# THP - 数据库场景通常禁用
echo never > /sys/kernel/mm/transparent_hugepage/enabled
# 原因:大页碎片抖动影响延迟稳定性
# Huge Pages for database
# /etc/sysctl.conf
# vm.nr_hugepages = 1024 # 预分配 2MB*1024=2GB 大页
# 配合 PostgreSQL: shared_buffers = 2GB
10.2 Redis 缓存场景
# Redis 专用优化
vm.overcommit_memory = 1 # 允许 fork() 时过量分配(mem_cow)
vm.swappiness = 0 # 不使用 swap(或设为 1 以防万一)
# THP 必须禁用(fork + THP 拆分延迟严重)
echo never > /sys/kernel/mm/transparent_hugepage/enabled
echo never > /sys/kernel/mm/transparent_hugepage/defrag
# 关闭 NUMA(或设置 interleave)
# numactl --interleave=all redis-server ...
# 或 sysctl: kernel.numa_balancing = 0
# 启用 THP 的降级策略(madvise)
echo madvise > /sys/kernel/mm/transparent_hugepage/enabled
10.3 高性能网络 (DPDK/io_uring)
# DPDK 应用调优
# 1. 预留 1GB 大页(用于 dpdk 内存池)
# GRUB: default_hugepagesz=1G hugepagesz=1G hugepages=4
vm.nr_hugepages = 2048 # 2MB 大页备用
vm.min_free_kbytes = 524288 # 512MB 保留
# io_uring 场景优化
# 启用固定 buffer(IORING_REGISTER_BUFFERS)减少 pin/unpin 开销
# 配置 io_uring 的 SQ/CQ 大小
# 使用 IORING_SETUP_SQPOLL 持续 poll 模式
# 网络栈相关
net.core.rmem_max = 16777216
net.core.wmem_max = 16777216
net.ipv4.tcp_rmem = 4096 87380 16777216
net.ipv4.tcp_wmem = 4096 65536 16777216
十一、调试与观测工具
11.1 perf 与 ftrace
# 1. 追踪 page fault 事件
perf stat -e page-faults,major-faults -p <pid> sleep 10
# major-faults: 缺页导致磁盘 IO 的次数
# page-faults: 所有缺页
# 2. kmalloc/kfree 追踪
echo 1 > /sys/kernel/debug/tracing/events/kmem/kmalloc/enable
echo 1 > /sys/kernel/debug/tracing/events/kmem/kfree/enable
cat /sys/kernel/debug/tracing/trace_pipe
# 3. 回收追踪
echo 1 > /sys/kernel/debug/tracing/events/vmscan/mm_vmscan_kswapd_wake/enable
echo 1 > /sys/kernel/debug/tracing/events/vmscan/mm_vmscan_direct_reclaim_begin/enable
# 4. 内存碎片查看
cat /proc/buddyinfo
# Node 0, zone Normal 238 215 159 91 42 22 12 7 3 1 1
# 含义:order-0: 238个块, order-1: 215个块 ... order-10: 1个块
# 5. 进程内存实用分析
cat /proc/<pid>/maps # 完整 VMA 映射
cat /proc/<pid>/smaps_rollup # 汇总统计
cat /proc/<pid>/status | grep -E 'Vm|Rss'
pmap -x <pid> # 内存映射摘要
11.2 eBPF/BCC 工具
# BCC 工具集
# 1. 检测 slab 对象泄漏
slabratetop # slab 分配速率 TOP
# 2. 页故障统计
faults # 页错误计数
# 3. 回收观察
vmscan # kswapd 和 direct reclaim 统计
# 4. BPF 自定义 Trace
# 使用 bpftrace 自定义 trace
bpftrace -e 'kprobe:__alloc_pages { @alloc[tid] = count(); }'
bpftrace -e 'kprobe:oom_kill_process { printf("OOM kill %d\n", args->p->pid); }'
11.3 KASAN 与 KFENCE
# KASAN (Kernel Address Sanitizer)
# 构建选项: CONFIG_KASAN=y
# 原理:shadow memory 标记每字节分配状态
# 可检测:use-after-free、out-of-bounds、stack/heap overflow
# 开销:约 3x 内存,~70% 性能下降
# 适用:开发/测试环境
# KFENCE (Kernel Electric Fence)
# 构建选项: CONFIG_KFENCE=y
# 原理:采样检测(默认 1/500 概率),使用 guard page
# 开销:极低(~1%)
# 适用:生产环境捕获偶发内存安全错误
# 查看 kfence 统计:
cat /sys/kernel/debug/kfence/stats
十二、总结与面试题
知识体系回顾
Linux 内核内存管理是一个自底向上的多层体系:
- 物理层:Page 描述符 + Zone 分区 + NUMA 节点
- 分配层:Buddy System(物理页) → SLAB/SLUB/SLOB(小对象) → kmalloc/vmalloc(接口层)
- 映射层:MMU + TLB + 页表(4级/5级)+ 缺页异常
- 回收层:kswapd + 直接回收 + LRU/MGLRU + Swap
- 控制层:cgroups v2 memory + oom_score_adj + 系统参数
高频面试题
Q1: Buddy System 如何防止内存碎片?
A: 通过按迁移类型分组(Migratetype)减少不同类型页面间的干扰,配合 CMA 预留大块连续区域,使用 Compaction 定期压缩可移动页面。
Q2: SLUB 为什么比 SLAB 快?
A: SLUB 取消了 SLAB 中的本地队列和全局 parcial 队列,采用 Per-CPU 私有 freelist 实现无锁快速路径,大幅减少跨 CPU 同步开销。
Q3: kmalloc 和 vmalloc 有什么不同?何时使用?
A: kmalloc 保证物理连续且虚拟连续,适合小对象和需要 DMA 的场景;vmalloc 仅保证虚拟连续,适合大数组/模块加载等需大内存但对物理连续性要求不高的场景。
Q4: 请描述 OOM Killer 的评分机制。
A: OOM 评分基于进程物理内存占用(RSS)加权,结合运行时间(越长估值越低)、特权进程降权(32%)、子进程策略;用户可通过 oom_score_adj 微调,-1000 表示永不 kill。
Q5: THP 在什么场景下应该禁用?
A: 对延迟敏感的数据库、Redis 缓存服务等场景需禁用 THP,因为大页拆分和 khugepaged 的 compaction 会导致延迟抖动;MQ 生产者也建议禁用。
参考资源
- 《Understanding the Linux Kernel》第 8-9 章
- 《Professional Linux Kernel Architecture》第 11-13 章
- 内核文档:
Documentation/admin-guide/mm/ - LWN.net 系列文章: "An introduction to the Linux kernel memory management"
- Kernel source:
mm/page_alloc.c,mm/slub.c,mm/vmscan.c

发表评论 取消回复