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_compoundcompound page 的一部分复合页
PG_headcompound 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 分配器概述

三种分配器对比:

特性SLABSLUBSLOB
对象队列本地 + 共享(复杂)无队列(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 对比与选择

特性kmallocvmalloc
物理连续性✓ 保证✗ 不保证(虚拟连续)
最大可用约 4MB (取决于 order)接近 vmalloc 区大小(数百 GB)
分配耗时极快(O(1) 从 freelist)较慢(多次 alloc_page + 页表写入)
释放耗时O(1) 插入 freelistO(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 内核内存管理是一个自底向上的多层体系:

  1. 物理层:Page 描述符 + Zone 分区 + NUMA 节点
  2. 分配层:Buddy System(物理页) → SLAB/SLUB/SLOB(小对象) → kmalloc/vmalloc(接口层)
  3. 映射层:MMU + TLB + 页表(4级/5级)+ 缺页异常
  4. 回收层:kswapd + 直接回收 + LRU/MGLRU + Swap
  5. 控制层: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
点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部