一、为什么需要虚拟内存

虚拟内存是现代操作系统的基石,它解决了三个核心问题:进程地址空间隔离、物理内存超额使用、以及按需加载。在 Linux 内核中,虚拟内存子系统(Virtual Memory Subsystem)是内存管理的核心模块,负责将进程看到的连续虚拟地址映射到可能离散分布的物理页帧上。

每个进程拥有独立的 4 级(或 5 级)页表树,顶级指针保存在 mm_struct→pgd(Page Global Directory)中。内核通过 CR3 寄存器(x86_64)或 TTBR0/TTBR1(ARM64)在上下文切换时加载新进程的页表根,从而瞬间切换整个地址空间视图。

二、x86_64 四级页表结构详解

x86_64 架构使用 48 位虚拟地址空间(256TB),地址划分为 4 个 9 位索引加一个 12 位页内偏移:

Virtual Address (48-bit):
┌─────────┬─────────┬─────────┬─────────┬──────────┐
│ PGD idx │ PUD idx │ PMD idx │ PTE idx │  Offset  │
│ 9 bits  │ 9 bits  │ 9 bits  │ 9 bits  │ 12 bits  │
└─────────┴─────────┴─────────┴─────────┴──────────┘

Translation Walk:
CR3 → PGD[47:39] → PUD[38:30] → PMD[29:21] → PTE[20:12] → Physical Page Frame + Offset

每一级页表都是一个 4KB 页面,包含 512 个 8 字节条目(PTE)。硬件页表遍历(Page Table Walker)依次解析各级索引,最终在第 4 级 PTE 中找到物理页帧号(PFN)。如果中间任何一级标记为不存在(Present=0),则触发缺页异常(Page Fault)。

Linux 内核用 pgd_t/pud_t/pmd_t/pte_t 类型表示各级页表条目,底层通过 pmd_offset()、pud_offset()、pgd_offset() 等宏计算子表偏移地址。va_to_pa() 风格的遍历在内核调试和分析中被频繁使用。

三、ARM64 页表与 Contiguous Bit 优化

ARM64 架构同样使用 4 级页表(48 位 VA),但引入了独特优化:

  • Contiguous Bit(连续位):当 16 个连续 PTE 映射连续物理页时,硬件可将它们合并为一个 TLB 条目,减少 TLB Miss 率
  • Hardware Update Access/Dirty Bit:硬件自动设置访问位和脏位,减少内核干预开销
  • Top-Byte-Ignore (TBI):地址高 8 位可用于软件标记(如 MTE 内存标签扩展),硬件在翻译时忽略

Linux 内核通过 CONFIG_ARM64_PAN、CONFIG_ARM64_UAO 等配置提供 ARM64 特有的内存访问控制。set_pte_at() 函数是设置 PTE 的标准入口,它会处理架构相关的写屏障和 Context ID 同步。

四、缺页异常(Page Fault)全路径

缺页异常是虚拟内存最核心的事件,分为两大类:

4.1 Major Fault(需 I/O)— 磁盘访问

进程访问的文件映射页面不在页缓存中时,触发 Major Fault:

CPU 触发 #PF → do_page_fault() → __do_page_fault()
  → handle_mm_fault() → handle_pte_fault()
    → do_fault() → do_read_fault() [文件映射非匿名页]
      → filemap_fault() → page_cache_read() → 磁盘 I/O → 页面锁定/解锁

4.2 Minor Fault — 仅页表操作

页面已在页缓存中,只需建立页表映射:

  • 共享文件映射的首次访问(映射已建立但 PTE 未填)
  • 已换出页面的回收(Minor Fault + 延迟加载)
  • Copy-on-Write 触发的页复制

4.3 关键函数调用链

x86_64 路径:
page_fault → do_page_fault (arch/x86/mm/fault.c)
  │ 检查异常类型(写入/执行/用户态)
  │ 搜索 VMA: find_vma() / vma_interval_tree_iterate()
  │ 权限检查: 写只执行段 → SIGSEGV; 写 COW 页 → do_wp_page()
  │
  └→ handle_mm_fault() (mm/memory.c)
      │ __do_fault() → 分配物理页面并读取数据
      │ do_wp_page() → COW 页面复制
      │ do_anonymous_page() → 零页面分配
      │ do_swap_page() → swap slot 换入
      │
      └→ 更新 PTE: set_pte_at() + flush_tlb_page()

五、Copy-on-Write(COW)机制深度解析

COW 是 fork() 性能优化的关键。传统 fork 会完整复制父进程页表所映射的所有物理页,而 Linux 采用写时复制:fork 后父子共享同一物理页,只读映射;任何一方尝试写入时触发 #PF,内核才真正复制页面。

5.1 fork 流程

sys_fork() → _do_fork() → copy_process()
  → copy_mm() → dup_mm() → dup_mmap()
    │ 遍历父进程所有 VMA
    │ 对每个 VMA: copy_page_range() → copy_p4d/pud/pmd/pte()
    │ 读页面: 父子均设为只读 + 引用计数 +1
    │ 写时: 父进程写权限也被剥夺 (vm_flags & ~VM_SHARED)
    │
    └→ 最终: 只读 PTE → COW Page Mapping

5.2 COW 触发与处理

进程尝试写入共享只读页面时:

  1. CPU 触发 #PF,进入 do_wp_page()
  2. 检查页面引用计数 page_count() — 若为 1(独占),直接标记为可写并复用该页("reuse" 优化路径,避免复制)
  3. 若引用计数 > 1,执行真正的复制:alloc_page() 分配新页 → copy_user_highpage() 拷贝内容 → set_pte_at() 建立可写映射
  4. 旧页引用计数 -1,若为 0 则释放回伙伴系统

5.3 性能影响

COW 极大地减少了 fork 开销,使得 fork()+exec() 模式成为可能。但大量 COW 操作(如大内存数据库 fork)仍可能产生缺页风暴,此时 vfork() 或 posix_spawn() 是更好的替代选择。

六、内存映射(mmap)完整链路

mmap() 系统调用允许进程将文件、设备或匿名内存映射到其地址空间:

sys_mmap() → vm_mmap_pgoff() → do_mmap_pgoff() → do_mmap()
  │ 参数处理: 确定映射偏移、长度、权限、类型
  │ VMA 查找: get_unmapped_area() → 寻找空闲虚拟地址区域
  │ VMA 创建: vm_area_struct 初始化
  │ 插入红黑树: vma_link() → mm→mm_rb + mm→mmap
  │
  ├─ MAP_PRIVATE: 延迟绑定 → COW 文件映射 (shared file + private mmap)
  ├─ MAP_SHARED: 直接映射页缓存 → 写入反馈到文件
  ├─ MAP_ANONYMOUS: 无文件映射 → 初始零页面
  └─ MAP_HUGETLB: 大页映射 → hugetlbfs 特殊处理

6.1 VMA 数据结构

vm_area_struct 是描述一段连续虚拟地址区域的核心结构:

struct vm_area_struct {
    unsigned long vm_start;      // 区域起始虚拟地址
    unsigned long vm_end;        // 区域结束虚拟地址
    struct mm_struct *vm_mm;     // 所属地址空间
    pgprot_t vm_page_prot;       // 页面访问权限
    unsigned long vm_flags;      // VM_READ/WRITE/EXEC/SHARED 等标志
    struct rb_node vm_rb;        // 红黑树节点
    unsigned long vm_pgoff;      // 文件映射偏移(页面为单位)
    struct file *vm_file;        // 映射文件(匿名映射为 NULL)
    const struct vm_operations_struct *vm_ops;  // 操作函数表
};

VMA 通过红黑树(按地址排序)和双向链表两种结构组织,find_vma() 利用红黑树在 O(log n) 时间内定位目标 VMA。

6.2 Huge Page 与透明大页(THP)

Linux 支持两种大页机制:

  • 静态大页(HugeTLB):启动时预分配,通过 MAP_HUGETLB 显式使用,大小为 2MB 或 1GB
  • 透明大页(THP):内核在运行时自动将连续 4KB 页面合并为 2MB 大页,对程序透明

THP 在 khugepaged 内核线程中工作,扫描可合并区域并调用 collapse_huge_page()。对于数据库、虚拟机等大型工作负载,THP 可减少 30-50% 的 TLB Miss。

七、页表同步与 TLB 管理

7.1 TLB Shootdown

当内核修改页表(如 munmap()、mprotect()、页面换出)后,其他 CPU 核心的 TLB 可能仍保留旧的映射。这需要 IPI(处理器间中断)机制强制刷新所有核心的 TLB:

#include <asm/tlbflush.h>

// 单页面刷新
flush_tlb_page(vma, addr);

// 全地址空间刷新
flush_tlb_mm(mm);

// 批量刷新(延迟模式,合并多次刷新为一次 TLB invalidation)
arch_enter_lazy_mmu_mode();
... // 批量修改多个 PTE
arch_leave_lazy_mmu_mode();
flush_tlb_mm_range(mm, start, end, stride);

7.2 PCID/ASID 优化

PCID(Process-Context Identifier)是 Intel 引入的 TLB 标记机制。不同 PCID 的 TLB 条目互不干扰,进程切换时无需完全刷新 TLB。内核配置CONFIG_X86_PCID后即可启用:

// Linux 使用 12 位 PCID(4096 个标识符)
// 每次加载 CR3 时设置 PCID 字段
// INVPCID 指令可针对特定 PCID 进行局部刷新

八、内存映射的实战陷阱与调试

8.1 Bus Error(总线错误)

对文件映射区域执行超过文件末尾的写入会触发 SIGBUS,因为文件空洞不支持

8.2 内存泄漏检测

// 查看进程 VMA 列表
cat /proc/<PID>/maps

// 查看内存统计
cat /proc/<PID>/smaps | grep -E "^(Rss|Private|Shared|Swap)"

// 检查页表内存占用
cat /proc/meminfo | grep PageTables

8.3 性能诊断

使用 perf 和 valgrind 分析缺页行为:

# 统计缺页计数(minor vs major)
perf stat -e page-faults,minor-faults,major-faults ./program

# 使用 strace 跟踪 mmap/munmap 调用
strace -e trace=mmap,munmap,mprotect ./program

# eBPF 跟踪缺页延迟
funclatency-bpfcc 'handle_mm_fault'

九、Linux 虚拟内存子系统的最新演进

Linux 6.x 内核在 VM 子系统方面的重要更新:

  • Multi-Generational LRU (MGLRU):替代传统 Active/Inactive 链表,通过页面世代和访问频率更精确地回收冷页面,减少不必要的 swap 抖动
  • Damon(Data Access Monitor):提供轻量级内存访问采样,可与 THP 和大页策略配合实现自适应页面合并
  • Folio 页面:Linux 5.16 引入复合页面的抽象,减少 struct page 遍历开销,提升大页(THP)管理效率
  • Maple Tree:用于 VMA 管理的红黑树替代数据结构,使用 B树 变体提升并发 VMA 查找性能
  • 用户faultfd(userfaultfd):允许用户态处理缺页异常,支持虚拟机热迁移和自定义内存后端

十、总结:虚拟内存调优指南

针对生产环境的关键调优参数:

参数建议用途
vm.swappiness1-10(数据库),0(不 swap)控制 kernel swap 倾向
vm.dirty_ratio10-40%脏页占系统总内存比例上限
vm.dirty_background_ratio5-10%pdflush/writeback 开始刷盘比例
vm.overcommit_memory0/1/2内存分配策略
vm.nr_hugepages按应用需求预留大页数
THP 模式madvise 或 always大页分配策略

虚拟内存子系统是 Linux 内核中最复杂、最核心的模块之一。从硬件页表遍历到 THP 自动合并,从 COW 优化到 MGLRU 回收机制,理解这些细节对于系统性能调优、内核开发和故障排查都至关重要。建议在内核源码 mm/ 目录下阅读 memory.c、mmap.c、fault.c 等核心文件,结合实际硬件平台深入实践。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿
网站二维码

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部
/* 跳过导航链接 (无障碍) */ position: absolute; top: -100px; left: 15px; z-index: 99999; padding: 8px 16px; background: #007bff; color: #fff; font-size: 14px; border-radius: 0 0 4px 4px; text-decoration: none; transition: top 0.2s; } top: 0; outline: 3px solid #0056b3; }