Linux 内核虚拟内存管理是现代操作系统的核心支柱之一。它通过为每个进程提供独立的虚拟地址空间,实现了进程隔离、内存超配(overcommit)、按需调页(demand paging)和内存映射文件等关键能力。本文将深入剖析 Linux 内核虚拟内存子系统的完整架构,从页表管理、VMA(Virtual Memory Area)区域组织、Page Fault 处理路径,到反向映射(reverse mapping)、KSM 页面合并以及大页(Huge Pages)机制,全面揭示内核如何高效地管理虚拟地址到物理地址的映射。

一、虚拟内存架构总览

Linux 采用多级页表(Multi-level Page Table)实现虚拟地址到物理地址的转换。以 x86_64 架构为例,使用 4 级页表(PGD→PUD→PMD→PTE),虚拟地址空间划分为用户态(0x0000_0000_0000_0000 ~ 0x0000_7FFF_FFFF_FFFF)和内核态(0xFFFF_8000_0000_0000 ~ 0xFFFF_FFFF_FFFF_FFFF)。

// 四级页表地址翻译过程(x86_64, 4KB 页)
// Virtual Address (48-bit effective):
// | PGD index (9) | PUD index (9) | PMD index (9) | PTE index (9) | Offset (12) |
//
// 翻译过程:
// 1. CR3 寄存器 → PGD 基址
// 2. PGD[pgd_index] → PUD 基址
// 3. PUD[pud_index] → PMD 基址
// 4. PMD[pmd_index] → PTE 基址
// 5. PTE[pte_index] → Physical Page Frame
// 6. Page Frame + offset = 物理地址

TLB(Translation Lookaside Buffer)作为页表缓存,存储最近使用的虚拟→物理映射。x86_64 通常有 L1 DTLB(64 项, 4KB 页)、L2 TLB(512 项+)和 STLB(共享 TLB)。当发生 Page Miss(TLB 未命中)时,硬件 Page Table Walker 自动遍历页表填充 TLB。

二、VMA(Virtual Memory Area)管理

2.1 mm_struct 与 VMAs 组织

每个进程的内存管理由 struct mm_struct 描述,其中 VMAs 通过红黑树和双向链表两种结构组织,兼顾范围查询和遍历性能:

// 关键数据结构(简化)
struct mm_struct {
    struct rb_root              mm_rb;          // VMA 红黑树根(按起始地址排序)
    struct vm_area_struct      *mmap;          // VMA 链表头
    atomic_t                    mm_users;       // 用户引用计数
    atomic_t                    mm_count;       // 主引用计数
    unsigned long               start_code;     // 代码段起始
    unsigned long               end_code;       // 代码段结束
    unsigned long               start_data;     // 数据段起始
    unsigned long               end_data;       // 数据段结束
    unsigned long               start_brk;      // 堆起始
    unsigned long               brk;            // 堆当前顶部
    unsigned long               start_stack;    // 栈起始
    unsigned long               rss;            // 驻留页面计数
    unsigned long               total_vm;      // 总虚拟页面数
    pgd_t                      *pgd;            // 全局页目录基址
    rwlock_t                    page_table_lock;// 页表锁
};

struct vm_area_struct {
    unsigned long        vm_start;       // 区域起始地址
    unsigned long        vm_end;         // 区域结束(不包含)
    struct mm_struct    *vm_mm;          // 所属 mm_struct
    pgprot_t            vm_page_prot;    // 访问权限
    unsigned long        vm_flags;       // 标志 (READ/WRITE/EXEC/SHARED)
    struct rb_node       vm_rb;          // 红黑树节点
    const struct vm_operations_struct *vm_ops;  // 操作函数表
    unsigned long        vm_pgoff;       // 文件映射时的页偏移
    struct file         *vm_file;        // 映射的文件(如有)
};

2.2 典型进程内存布局

// x86_64 Linux 进程典型内存布局(从高地址到低地址):
//
// 0xFFFF_FFFF_FFFF_FFFF ┐
//                       │  内核空间 (128TB)
// 0xFFFF_8000_0000_0000 ┘
// 0x0000_7FFF_FFFF_FFFF ┐
//                       │  栈 (Stack) — 向下增长
//                       │  ↓
//                       │  随机偏移 (ASLR)
//                       │
//                       │  mmap 区域(共享库/文件映射)
//                       │  ↓(Linux 默认栈向 mmap 区增长方向相反)
//                       │
//                       │  堆 (Heap) — brk 向上增长
//                       │  ↑
//                       │  BSS 段
//                       │  Data 段
//                       │  Text 段 (代码段)
// 0x0000_0000_0040_0000 ┘ (典型 ELF 加载地址)

2.3 VMA 查找与合并

内核在每次 VMA 操作时通过 find_vma() 在红黑树中查找包含给定地址的 VMA。如果新映射区域与现有 VMA 相邻且属性相同,内核会自动合并它们,减少 VMA 节点数量:

// VMA 合并判断逻辑
struct vm_area_struct *vma = find_vma(mm, addr);
if (vma && vma->vm_start <= addr + len) {
    // 存在重叠或相邻
    if (vma->vm_flags == flags &&
        vma->vm_pgoff + ((vma->vm_end - vma->vm_start) >> PAGE_SHIFT) == pgoff &&
        !is_merge_constraint_violated()) {
        // 可以合并,扩展现有 VMA
        vma->vm_end = addr + len;
        return vma;
    }
}

三、Page Fault 处理机制

3.1 Page Fault 类型

CPU 在虚拟地址翻译失败时触发 Page Fault(异常 #14),Linux 内核根据具体情况分为三类:

  • Minor Page Fault(次缺页):页面在物理内存中但未建立页表映射(如共享库首次访问、写时复制后)。无需磁盘 I/O,仅需修改页表项。
  • Major Page Fault(主缺页):页面不在物理内存中,需从磁盘(swap/file)加载。涉及 I/O 操作,延迟高(毫秒级)。
  • Invalid/Protection Fault(无效/保护缺页):访问未映射区域(如 NULL 指针解引用)或违反权限(写只读页,触发 COW)。导致 SIGSEGV 或 COW 分裂。

3.2 缺页处理主路径

x86_64 架构下,Page Fault 的处理入口是 do_page_fault()(已整合到 exc_page_fault()),核心调用链如下:

// Page Fault 处理流程(简化)
exc_page_fault (arch/x86/mm/fault.c)
  └── __do_page_fault()
       ├── 1. 获取故障地址 (CR2 寄存器)
       ├── 2. 查找 VMA (find_vma)
       │    ├── VMA 不存在 → SIGSEGV (SEGV_MAPERR)
       │    ├── 权限不匹配 → SIGSEGV (SEGV_ACCERR)
       │    └── VMA 有效 ↓
       ├── 3. handle_mm_fault()
       │    ├── handle_pte_fault()   — PTE 级处理
       │    │    ├── do_anonymous_page() — 匿名页首次访问
       │    │    ├── do_fault()          — 文件映射缺页
       │    │    │    ├── do_read_fault()  — 读文件 (filemap_fault)
       │    │    │    ├── do_cow_fault()   — 写时复制
       │    │    │    └── do_shared_fault()— 共享文件写
       │    │    └── do_swap_page()        — 从 swap 换入
       │    ├── do_huge_pmd_anonymous_page() — 大页匿名映射
       │    └── __handle_mm_fault (PMD/PUD 级)
       └── 返回 (若成功,CPU 自动重试故障指令)

3.3 需求分页(Demand Paging)

Linux 使用延迟分配策略:当进程调用 mmap() 申请大块内存时,内核仅创建 VMA 而不分配物理页面。实际物理页面分配推迟到首次访问时通过 Page Fault 完成:

// do_anonymous_page: 匿名页首次访问处理
static vm_fault_t do_anonymous_page(struct vm_fault *vmf) {
    // 1. 检查是否允许延迟分配(vma->vm_flags & VM_PFNMAP?)
    // 2. 分配一个物理页面 (alloc_zeroed_user_highpage_movable)
    // 3. 建立 PTE 映射 (mk_pte + set_pte_at)
    // 4. 更新 RSS 计数器
    // 5. 返回 FAULT_FLAG_WRITE 以允许后续写入
    
    page = alloc_zeroed_user_highpage_movable(vma, vmf->address);
    if (!page) return VM_FAULT_OOM;  // 内存不足
    
    // 填入页表
    vmf->pte = pte_offset_map_lock(vma->vm_mm, vmf->pmd, vmf->address, &ptl);
    entry = mk_pte(page, vma->vm_page_prot);
    set_pte_at(vma->vm_mm, vmf->address, vmf->pte, entry);
    
    return 0;  // 成功
}

3.4 写时复制(Copy-on-Write)

当 fork() 创建子进程时,Linux 不会立即复制父进程的物理页面。父子共享同一组物理页,但页表项被标记为只读。任何一方尝试写入时触发 COW Fault,内核才真正复制页面:

vm_flags &= ~VM_WRITE)
//
// 2. 任一进程尝试写入共享页 → do_wp_page()
//    - 检查 page->_refcount:
//      - refcount == 1 → 无其他持有者,直接标记可写
//      - refcount > 1 → 需要复制:
//        a. 分配新物理页面
//        b. 复制内容 (copy_user_highpage)
//        c. 建立新 PTE 映射 (可写)
//        d. 旧页面 refcount--

// do_wp_page 核心逻辑
static vm_fault_t do_wp_page(struct vm_fault *vmf) {
    page = vmf->page;
    if (page_count(page) == 1) {
        // 独占页面,直接升级写权限
        entry = pte_mkwrite(ppo_mkdirty(vmf->orig_pte));
        set_pte_at_notify(mm, vmf->address, vmf->pte, entry);
        return 0;
    }
    // 多持有者 → 复制新页面
    new_page = alloc_page_vma(GFP_HIGHUSER_MOVABLE, vma, vmf->address);
    copy_user_highpage(new_page, page, vmf->address, vma);
    __SetPageUptodate(new_page);
    set_pte_at_notify(mm, vmf->address, vmf->pte,
                      mk_pte(new_page, vma->vm_page_prot));
    return 0;
}

四、反向映射(Reverse Mapping, RMAP)

4.1 为什么需要 RMAP

页表(PTE)是正向映射:虚拟地址→物理地址。但当内核需要回收或换出一个物理页面时,必须知道哪些进程的页表项指向它,才能全部失效(unmap)并更新。这就是反向映射的作用。

4.2 匿名页 RMAP(anon_vma)

匿名页通过 anon_vma 链表维护所有映射到该页的 VMA 实例:

// 匿名页 RMAP 结构 (简化)
struct anon_vma {
    struct anon_vma *root;       // 根 anon_vma(fork 时合并)
    atomic_t         refcount;   // 引用计数
    unsigneddegree  degree;     // VMA 嵌套层级
    struct rb_root   rb_root;    // interval tree(区间树,用于高效查找)
};

struct anon_vma_chain {
    struct vm_area_struct *vma;          // 指向的 VMA
    struct anon_vma      *anon_vma;      // 所属的 anon_vma
    struct list_head      same_vma;      // 链入 VMA 的 anon_vma_chain 列表
    struct list_head      rb;            // 按区间排序的红黑树节点
};

// 遍历同页面所有映射项
static void unmap_anon_vma(struct page *page) {
    struct anon_vma *anon_vma = page_anon_vma(page);
    struct anon_vma_chain *avc;
    pgoff_t pgoff = page->index;
    
    // 遍历所有 avc,对每个 VMA 调用 teardown
    anon_vma_interval_tree_foreach(avc, &anon_vma->rb_root, pgoff, pgoff) {
        struct mm_struct *mm = avc->vma->vm_mm;
        unsigned long address = vma_address(page, avc->vma);
        // 取消映射
        pte_get_and_clear_full(mm, address, pte, 0);
    }
}

4.3 文件页 RMAP(address_space)

文件映射(file-backed)页面的反向映射通过 address_space 的优先搜索树(priority search tree, PST)实现。由于文件页可能被数千个进程共享(如共享库的代码段),PST 支持高效的多索引查询:

五、Swap 与页面回收

5.1 LRU 页面回收算法

Linux 使用双链表 LRU(Least Recently Used)实现页面回收。每个内存区域(zone)维护 Active/Inactive 两个文件页和匿名页链表,共四个 LRU 链表:

5.2 Swap Slab 与换入换出

当页面需要被换出到 swap 时,内核通过 swap_entry_t 编码 swap 设备 ID 和页面偏移,填入 PTE。再次访问时触发 Page Fault,内核识别 PTE 类型后从 swap 读入:

六、大页(Huge Pages / THP)

6.1 透明大页(Transparent Huge Pages)

Linux 内核支持 2MB 甚至 1GB 的大页(x86_64),以减少 TLB 未命中和页表开销。THP(Transparent Huge Pages)允许内核自动将普通页面合并为大页:

6.2 hugetlb 静态大页

静态大页通过启动参数 hugepages= 预留内存(必须连续物理池),适用于 DPDK/数据库等场景:

七、KSM(Kernel Same-page Merging)

Kernel Same-page Merging 允许内核扫描已注册的红黑树中的页面,识别重复页面并合并:

八、内存压力与直接回收(Direct Reclaim)

当进程自身在分配内存时遇到 low watermark,会触发直接回收(Direct Reclaim):

九、mmap 与零拷贝内存映射

9.1 mmap 系统调用

mmap() 将文件或设备映射到进程地址空间,允许进程通过内存读写操作与文件交互:

9.2 MAP_POPULATE 与 MADVISE

对于希望避免 Minor Fault 的场景,可使用 MAP_POPULATE 预分配物理页面,或通过 madvise() 提供访问模式提示:

十、性能监控与调优

10.1 Page Fault 监控工具

10.2 减少 Major Fault 的最佳实践

  • 增加 file cache 热页驻留:调整 vm.min_free_kbytes、vm.swappiness(降低值避免换出缓存页)
  • 预读优化:使用 MAP_POPULATE 或 posix_fadvise() 提前加载大文件
  • 大页加速 TLB 密集场景:数据库/Redis 等建议使用 THP 或 hugetlb
  • <numactl 绑核:避免跨 NUMA 节点内存访问,numactl --cpunodebind=0 --membind=0 ./app
  • 禁用 swap 对延迟敏感场景:vm.swappiness=1 或 mlockall(MCL_CURRENT | MCL_FUTURE)

10.3 TLB Shootdown 注意事项

当一个进程 munmap() 或修改页表权限时,其他 CPU 上可能缓存了旧的 TLB 条目。此时内核发送 IPI(Inter-Processor Interrupt)请求这些 CPU 执行 TLB 失效,这被称为 TLB Shootdown:

十一、实战案例:高并发内存池的 Page Fault 优化

某高并发交易系统在使用 glibc malloc 时遇到启动延迟高的问题,分析发现原因是大量 Minor Fault:

十二、总结

Linux 内核虚拟内存管理是整个操作系统的核心子系统。它通过多级页表、VMA 组织、需求分页、写时复制、反向映射、LRU 回收、透明大页等机制,在安全性、隔离性和性能之间取得了精妙的平衡。

关键要点总结:

  1. 延迟分配:mmap 只创建 VMA,物理页面在首次访问(Page Fault)时分配
  2. COW 优化 fork:父子进程共享页面,写入时才复制
  3. RMAP 支持回收:通过 anon_vma(匿名页)和 address_space(文件页)快速找到所有持有者
  4. LRU 链表分级:Active/Inactive × File/Anonymous 四链表,回收时优先丢弃文件页缓存
  5. THP 大页加速:khugepaged 自动合并 4KB → 2MB 大页,减少 TLB Miss
  6. 性能调优指标:Major Fault 是关键指标,反映 cache 命中率;减少 Major Fault 就是要增加文件缓存驻留

深入理解虚拟内存机制,是诊断系统性能问题(抖动、OOM、TLB Miss)和开发高性能程序(内存池、预读、大页利用)的基础。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论