2. 缺页异常概述

缺页异常(Page Fault)是虚拟内存管理的核心机制。当 CPU 试图访问一个虚拟地址,而该地址对应的物理页框尚未加载到物理内存中,或当前页表项(PTE)的权限检查不通过时,MMU 会触发缺页异常,由内核缺页异常处理程序(do_page_fault)负责解析并修复该异常。

性能视角: 缺页异常是内存子系统中最昂贵的路径之一。一次缺页异常可能涉及磁盘 I/O、内存回收、TLB 刷写等操作,延迟从微秒级到毫秒级不等。理解其分类与触发流程,是进行性能优化的前提。

2.1 缺页异常定义

缺页异常是 x86/ARM 架构下的一种内存异常(Exception),由 MMU 在地址翻译过程中检测到以下情况时触发:

  • 页不存在: 虚拟地址对应的页表项(PTE)的 Present 位为 0。
  • 权限违例: 访问类型(读/写/执行)与 PTE 中的权限位(R/W、NX、User/Supervisor)不匹配。
  • 保留位违规: PTE 中某些保留位被置位,通常表示页表结构损坏。

缺页异常处理完成后,CPU 会重新执行触发异常的指令,此时翻译应能成功完成。

关键指标: 缺页异常率(Page Fault Rate) = 缺页次数 / 总内存访问次数。在性能分析中,通常关注 major fault(主缺页)的占比,因为它直接关联磁盘 I/O 延迟。

2.2 缺页异常分类

Linux 内核将缺页异常分为两大类:硬缺页(Hard Page Fault / Major Fault) 和 软缺页(Soft Page Fault / Minor Fault)。两者的核心区别在于是否需要访问磁盘 I/O。

分类 触发条件 I/O 开销 延迟量级 典型场景
硬缺页(Major Fault) 页不在物理内存中,且不在任何进程的 VMA 中,需要从磁盘 swap 分区或文件映射中读取 有(磁盘 I/O) 毫秒 ~ 数十毫秒 内存不足导致 swap 换入、首次读取文件映射页
软缺页(Minor Fault) 页不在当前进程的页表中,但物理页框已在内存中(如共享内存、COW 写时复制、页面缓存命中) 无(仅内存操作) 微秒级 fork() 后写时复制、共享库加载、匿名页首次访问
优化警示: 硬缺页是性能杀手。在数据库、Web 服务器等内存密集型应用中,应通过 vmstat 的 si(swap in)和 so(swap out)字段监控硬缺页频率。如果 si/so 持续非零,说明物理内存严重不足,需要增加内存或调整 vm.swappiness。

2.3 缺页异常触发流程

缺页异常从 CPU 触发到内核处理完成,经历以下关键阶段。下面用 SVG 流程图展示完整路径:

缺页异常触发与处理流程 缺页异常触发与处理流程 CPU 访问虚拟地址 MMU 地址翻译(TLB + 页表) 检查 PTE Present 位 & 权限位 通过? 正常内存访问 触发缺页异常 do_page_fault() 入口 保存现场,读取 fault 地址 find_vma() 查找 VMA 检查地址是否合法 存在? SIGSEGV 缺页分类处理 handle_mm_fault() 硬缺页 磁盘 I/O 软缺页 仅内存操作 恢复执行触发指令 iret / eret 返回用户态

2.4 缺页异常处理的核心函数调用链

从 CPU 异常向量表到最终完成页框分配,内核的调用路径如下:

// x86-64 架构下的缺页异常处理路径(简化)
// 1. CPU 异常入口
entry_64.S → page_fault → do_page_fault()

// 2. 内核 C 语言处理
do_page_fault(struct pt_regs *regs, unsigned long error_code)
  ├── find_vma(current->mm, address)          // 查找地址所属 VMA
  │   └── 如果未找到 → __do_kernel_fault() → SIGSEGV
  ├── handle_mm_fault(vma, address, flags)    // 核心缺页处理
  │   ├── __handle_mm_fault(vma, address, flags)
  │   │   ├── pgd_offset() / p4d_offset() / pud_offset() / pmd_offset()
  │   │   ├── handle_pte_fault(&pte, ...)
  │   │   │   ├── do_anonymous_page()         // 匿名页缺页(软缺页)
  │   │   │   ├── do_fault()                  // 文件映射缺页
  │   │   │   │   ├── do_read_fault()         // 读缺页
  │   │   │   │   ├── do_cow_fault()          // 写时复制缺页
  │   │   │   │   └── do_shared_fault()       // 共享映射写缺页
  │   │   │   └── do_swap_page()              // swap 换入(硬缺页)
  │   └── mm_account_fault()                  // 统计缺页次数
  └── user_mode(regs) ? 返回用户态 : 返回内核态
性能热点函数: 在缺页异常路径中,handle_pte_fault() 是最大的性能分支点。对于硬缺页,do_swap_page() 和 do_read_fault() 中的 __lock_page_or_retry() 和 wait_on_page_bit() 是主要锁竞争和等待点。对于软缺页,do_anonymous_page() 中的 pte_alloc() 页表分配可能触发内存回收,间接引入延迟。

2.5 缺页异常的性能影响矩阵

不同类型的缺页异常对系统性能的影响差异巨大。下表总结了典型场景下的延迟和资源消耗:

缺页类型 典型延迟 CPU 消耗 锁竞争风险 I/O 等待 优化方向
匿名页软缺页 1~5 µs 低 低(页表锁) 无 预分配、透明大页
文件映射软缺页(缓存命中) 2~10 µs 中 中(页面缓存锁) 无 文件预读、大页
写时复制(COW)软缺页 3~15 µs 中 中(RMAP 锁) 无 KSM、大页 COW 优化
swap 换入硬缺页 1~10 ms 高 高(swap 锁、LRU 锁) 有(磁盘 I/O) 减少 swap、SSD、zswap
文件映射硬缺页(磁盘读取) 2~20 ms 高 中(页面缓存锁) 有(磁盘 I/O) 异步预读、io_uring
性能分析工具: 使用 perf stat -e page-faults,minor-faults,major-faults 快速查看进程的缺页分布。使用 trace-cmd record -e page_fault:* 可以追踪每次缺页异常的详细信息,包括地址、类型和耗时。

2.6 缺页异常与 TLB 的关系

缺页异常处理完成后,内核会更新进程的页表,但 TLB(Translation Lookaside Buffer)中可能仍然缓存了旧的页表项。因此,缺页异常处理程序必须确保 TLB 的一致性:

  • 本地 TLB 刷写: 对于单进程缺页,使用 __flush_tlb_one() 刷写单个虚拟地址对应的 TLB 条目。
  • 全局 TLB 刷写: 对于跨进程的页表修改(如 munmap),使用 flush_tlb_mm() 或 flush_tlb_all()。
  • TLB 缺失与缺页的叠加: 如果 TLB 缺失后页表遍历也失败,则触发缺页异常。缺页处理完成后,TLB 缺失仍然存在,需要重新遍历页表并填充 TLB。
优化陷阱: 频繁的缺页异常会导致 TLB 抖动。每次缺页处理都会刷写至少一个 TLB 条目,如果缺页率过高,TLB 的命中率会急剧下降,形成“缺页 → TLB 刷写 → TLB 缺失 → 页表遍历 → 再次缺页”的恶性循环。这是内存性能优化的核心挑战之一。