一、为什么需要虚拟内存
虚拟内存是现代操作系统的基石,它解决了三个核心问题:进程地址空间隔离、物理内存超额使用、以及按需加载。在 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 触发与处理
进程尝试写入共享只读页面时:
- CPU 触发 #PF,进入
do_wp_page() - 检查页面引用计数
page_count()— 若为 1(独占),直接标记为可写并复用该页("reuse" 优化路径,避免复制) - 若引用计数 > 1,执行真正的复制:
alloc_page()分配新页 →copy_user_highpage()拷贝内容 →set_pte_at()建立可写映射 - 旧页引用计数 -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.swappiness | 1-10(数据库),0(不 swap) | 控制 kernel swap 倾向 |
vm.dirty_ratio | 10-40% | 脏页占系统总内存比例上限 |
vm.dirty_background_ratio | 5-10% | pdflush/writeback 开始刷盘比例 |
vm.overcommit_memory | 0/1/2 | 内存分配策略 |
vm.nr_hugepages | 按应用需求 | 预留大页数 |
| THP 模式 | madvise 或 always | 大页分配策略 |
虚拟内存子系统是 Linux 内核中最复杂、最核心的模块之一。从硬件页表遍历到 THP 自动合并,从 COW 优化到 MGLRU 回收机制,理解这些细节对于系统性能调优、内核开发和故障排查都至关重要。建议在内核源码 mm/ 目录下阅读 memory.c、mmap.c、fault.c 等核心文件,结合实际硬件平台深入实践。

发表评论 取消回复