一、从一次缺页异常说起
当CPU访问一个虚拟地址时,发现该地址对应的页表项不存在或权限不足,硬件会触发缺页异常(Page Fault),CPU陷入内核态,将控制权交给操作系统的缺页异常处理程序。这个看似"异常"的机制,实际上是现代操作系统虚拟内存系统最核心的基石——它实现了按需分配物理内存、共享内存、写时复制、内存映射文件等一系列关键能力。
在Linux内核中,缺页异常处理的代码路径长达数千行,涉及硬件相关层(架构特定处理)、管理层(VMA查找/扩展)和底层(物理页分配与数据读取)。本文从硬件机制出发,深入剖析x86-64架构下的页表结构、缺页异常分类与处理全链路、mmap内存映射机制、写时复制(COW)、透明大页(THP)以及生产环境调优实战。
二、x86-64 四级页表结构
x86-64架构使用四级页表将虚拟地址转换为物理地址:
Virtual Address (48-bit effective):
┌─────────┬─────────┬─────────┬─────────┬──────────┐
│ PML4 │ PDPT │ PD │ PT │ Offset │
│ 9 bits │ 9 bits │ 9 bits │ 9 bits │ 12 bits │
└────┬────┴────┬────┴────┬────┴────┬────┴────┬─────┘
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
PML4表 PDPT表 PD表 PT表 物理页内偏移
(512项) (512项) (512项) (512项) (4KB)
CR3寄存 每个PML4项 每个PDPT项 每个PD项
器指向 指向PDPT 指向PD 指向PT
每一级都有512个条目(9 bits),每个条目8字节,正好是一页(4KB)。每个页表项包含:
- 物理页帧号(PFN):目标页的物理地址
- Present (P) 位:1表示映射存在,0触发Page Fault
- Read/Write (R/W):0表示只读,写操作触发Page Fault
- User/Supervisor (U/S):0表示仅内核可访问,用户态访问触发#PF
- Accessed (A) 位:硬件自动置1,内核用其判断页面活跃度
- Dirty (D) 位:硬件在写入时置1,用于判断是否需要写回
- PCD/PWT:控制页级缓存策略
当MMU遍历四级页表时,任何一级发现Present=0,就会触发Page Fault(中断向量14),错误码压栈包含:bit 0 (P)、bit 1 (W/R)、bit 2 (U/S)、bit 4 (I/D)。
三、缺页异常的三种类型
3.1 次要缺页(Minor Page Fault / Soft Fault)
页面已在内存中(page cache或swap cache),但页表项未建立映射。典型场景:
- 首次访问mmap映射的文件,数据已在page cache但未映射到进程地址空间
- 共享内存重新连接(shmat)后的首次访问
- fork后COW页面的首次写入(页面已分配,只需复制页表项权限)
处理代价极低:只需在内核态查找page cache中的物理页,映射到进程页表,耗时微秒级。
3.2 主要缺页(Major Page Fault / Hard Fault)
页面不在内存中,需要从磁盘(块设备/文件系统)读取。典型场景:
- 首次读取文件内容(mmap或read()导致按需分页)
- 被swap出去的页面再次被访问
- 可执行文件/动态库的代码段首次执行
处理代价高昂:需要发起块I/O请求,进程阻塞等待磁盘读取完成(机械盘ms级,SSD百μs级)。内核通过预读(readahead)和页缓存命中率来缓解。
3.3 无效缺页(Invalid Page Fault / Segmentation Fault)
访问的地址没有任何合法的VMA映射。内核向进程发送SIGSEGV信号,导致段错误。典型场景:
- 空指针解引用 (NULL pointer dereference)
- 栈溢出突破了栈的VMA边界
- 访问已munmap的内存区域
- 越界访问(buffer overflow到未映射地址)
四、do_page_fault 全链路深度剖析
4.1 入口:硬件陷入到内核
硬件自动完成:保存RIP/RSP/错误码到栈 → 切换到内核栈 → 跳转到架构特定入口 page_fault(汇编)。入口代码(arch/x86/entry/entry_64.S)将错误码和故障地址(CR2寄存器)打包,调用C函数 do_page_fault()。
4.2 第一阶段:查找VMA
// arch/x86/mm/fault.c (简化流程)
static noinline void
do_page_fault(struct pt_regs *regs, unsigned long error_code)
{
struct vm_area_struct *vma;
unsigned long address = read_cr2(); // 获取故障虚拟地址
// 1. 在进程地址空间中查找包含该地址的VMA
vma = find_vma(mm, address);
if (!vma || vma->vm_start <= address)
goto bad_area; // 无有效VMA → SIGSEGV
// 2. 权限检查
if (error_code & PF_WRITE) { // 写操作
if (!(vma->vm_flags & VM_WRITE))
goto bad_area; // 写只读区域 → SIGSEGV
}
if (error_code & PF_USER) { // 用户态访问
if (!(vma->vm_flags & VM_READ))
goto bad_area;
}
...
}
find_vma()使用VMA红黑树(mm_rb)进行O(log n)查找,同时利用 mmap_cache(最近访问的VMA缓存)做O(1)的快速路径优化。
4.3 第二阶段:处理缺页(handle_mm_fault)
通过权限检查后,进入通用的 handle_mm_fault(),这是架构无关的缺页处理核心:
// mm/memory.c
vm_fault_t handle_mm_fault(struct vm_area_struct *vma, unsigned long address,
unsigned int flags)
{
// 1. 处理透明大页 (THP)
if (transparent_hugepage_enabled(vma))
return do_huge_pmd_anonymous_page(...); // 尝试分配2MB大页
// 2. 四级页表逐级分配与填充
// pgd → p4d → pud → pmd → pte
return __handle_mm_fault(vma, address, flags);
}
4.4 第三阶段:__handle_mm_fault 页表遍历
static vm_fault_t __handle_mm_fault(struct vm_area_struct *vma, ...)
{
// 逐级遍历页表,按需分配各级页表结构
pgd = pgd_offset(mm, address); // 获取PML4项
p4d = p4d_alloc(mm, pgd, address); // 若不存在则分配
pud = pud_alloc(mm, p4d, address); // PDPT项不存在则分配
pmd = pmd_alloc(mm, pud, address); // PD项不存在则分配
// 最终到达PMD层,根据类型分发
if (flags & FAULT_FLAG_WRITE) {
// 写时复制处理
if (vma_is_anonymous(vma))
return do_wp_page(...); // COW: 复制物理页
}
// 文件映射的缺页
return do_fault(vma, address, pmd, pte, flags);
}
4.5 do_fault:文件-backed缺页的三种细分
static vm_fault_t do_fault(struct vm_area_struct *vma, ...)
{
// 1. do_read_fault: 文件映射的读缺页
// - 先从page cache查找,找到直接映射(minor fault)
// - 未找到则从磁盘读取,可能触发readahead预读
//
// 2. do_cow_fault: 文件映射的写缺页(私有映射 MAP_PRIVATE)
// - 复制物理页 → 允许写入 → 原始页保持干净
// - 这就是写时复制在文件映射中的体现
//
// 3. do_shared_fault: 文件映射的写缺页(共享映射 MAP_SHARED)
// - 写入直接反映到page cache → 由pdflush写回磁盘
}
五、mmap 内存映射机制
5.1 mmap 的两种类型
文件映射(File-backed Mapping):将文件内容直接映射到进程虚拟地址空间,访问文件就像访问内存一样,无需read/write系统调用。内核使用page cache作为中间层,写入通过mmap直接修改page cache,由后台线程写回。
匿名映射(Anonymous Mapping):不对应任何文件,初始全部为零的物理页。用于malloc大内存(>128KB走mmap而非brk)、共享内存等场景。
5.2 VMA(Virtual Memory Area)核心结构
struct vm_area_struct {
unsigned long vm_start; // VMA起始地址
unsigned long vm_end; // VMA结束地址
struct mm_struct *vm_mm; // 所属进程内存描述符
pgprot_t vm_page_prot; // 访问权限
unsigned long vm_flags; // VM_READ/WRITE/EXEC/SHARED等
struct rb_node vm_rb; // 红黑树节点(按地址排序)
struct vm_operations_struct *vm_ops; // 操作函数表
unsigned long vm_pgoff; // 文件内偏移(文件映射时)
struct file *vm_file; // 映射的文件(匿名映射为NULL)
const struct vm_operations_struct *vm_ops;
};
进程的所有VMA通过两种结构管理:红黑树(addr排序,支持O(log n)查找)和链表(支持顺序遍历)。find_vma优先查mmap_cache缓存,命中则O(1)返回。
5.3 mmap 生命周期与缺页驱动的懒加载
mmap调用本身不分配物理页——它只是创建VMA。物理页面的分配完全由缺页异常驱动(demand paging):
用户调用: mmap(NULL, 40960, PROT_READ, MAP_PRIVATE, fd, 0)
↓
内核: sys_mmap() → do_mmap() → mm->mmap() → 创建VMA
↓ (无物理页分配,不读文件)
进程访问 *ptr = 0x7f0000 → #PF(Present=0)
↓
do_page_fault() → handle_mm_fault()
↓
do_read_fault(): 分配物理页 → 读文件4KB到page cache → 映射到页表
↓
返回用户态,指令重新执行(硬件自动重试)
5.4 MAP_PRIVATE vs MAP_SHARED 的缺页差异
| 场景 | 首次读 | 首次写 | 页回收 |
|---|---|---|---|
| MAP_PRIVATE | 映射page cache(minor) | COW:复制页(minor→需分配) | 丢弃(重新从文件读) |
| MAP_SHARED | 映射page cache(minor) | 直接写page cache(minor) | 写回脏页到文件 |
六、写时复制(Copy-on-Write)
6.1 fork 的本质
fork()并不复制父进程的物理内存。内核创建子进程的mm_struct,复制父进程的所有VMA,将父子进程的页表项全部标记为只读(清W位)。父子共享相同的物理页(引用计数+1)。任一方尝试写入时触发COW Page Fault:
进程写操作(#PF, error_code: P=1, W=1)
↓
do_page_fault() → handle_mm_fault()
↓
wp_page_reuse() 或 do_wp_page():
├─ 如果页面引用计数=1(独占):直接恢复写权限(reuse优化)
└─ 如果页面引用计数>1(共享):
1. 分配新的物理页
2. 复制原页面内容到新页面
3. 将新页面映射到写者页表,标记为可写
4. 原页面引用计数-1
6.2 独立映射优化
Linux内核在wp_page_copy()中利用page->_mapcount实现了一个巧妙优化:当引用计数为-1(表示没有其他映射)时,内核直接恢复写权限,避免了不必要的物理页复制。因为如果一个页面只被当前进程映射,"只读"限制就是多余的了。
七、透明大页(Transparent Huge Pages)
7.1 2MB大页对缺页性能的提升
标准4KB页表需要4级遍历(~4次内存访问),2MB大页只需3级遍历(跳过PT级且使用PMD层的Huge Page标志)。收益包括:
- 减少页表遍历次数,TLB命中效率更高(一个TLB条目覆盖2MB而非4KB)
- 减少缺页异常次数(一次大页故障覆盖2MB,相当于512次4KB故障的映射)
- 减少页表内存开销(大页只需要pgd+pud+pmd,不分配pt表)
7.2 MADV_HUGEPAGE 与 khugepaged
内核通过两种机制使用大页:
- 显式:madvise(addr, len, MADV_HUGEPAGE) 建议内核对该区域使用大页
- 隐式:khugepaged内核线程定期扫描进程内存,后台合并符合条件的4KB页为2MB大页
但大页对某些负载有负面影响(内存浪费、碎片化、compaction开销),数据库和虚拟机监控程序常建议关闭THP(echo never > /sys/kernel/mm/transparent_hugepage/enabled)。
八、内核关键函数调用链
CPU #PF中断
→ entry_SYSCALL (entry_64.S: 汇编入口,压错误码)
→ do_page_fault() [arch/x86/mm/fault.c]
→ find_vma() / vma_lookup() — VMA查找(红黑树+缓存)
→ handle_mm_fault() [mm/memory.c]
→ create_huge_pmd() — THP路径
→ __handle_mm_fault()
→ pmd_alloc() / pud_alloc() — 页表结构分配
→ handle_pte_fault()
├── do_anonymous_page() — 匿名页首次访问
│ → alloc_pages() + 零初始化
├── do_fault()
│ → do_read_fault() — 文件读缺页
│ │ → filemap_fault() — 查找page cache
│ ├── do_cow_fault() — COW
│ │ → alloc_page() + copy_page()
│ └── do_shared_fault() — 共享写
└── do_wp_page() — 写时复制
九、生产环境调优实战
9.1 系统级参数
# 页缓存回收倾向:0-100,越低越倾向于回收page cache而非swap
vm.swappiness = 10 # 数据库/缓存场景设为10
vm.swappiness = 60 # 桌面/通用场景默认60
# 脏页写回策略
vm.dirty_ratio = 20 # 单个进程脏页占系统内存的最大比例
vm.dirty_background_ratio = 10 # 后台写回触发阈值
vm.dirty_expire_centisecs = 3000 # 脏页过期时间(30秒)
vm.dirty_writeback_centisecs = 500 # pdflush唤醒间隔(5秒)
# mmap最小映射地址(影响ASLR)
vm.mmap_min_addr = 65536
# overcommit策略:0=启发式 1=总是允许 2=严格检查
vm.overcommit_memory = 0
9.2 进程级诊断工具
# 查看进程缺页统计
$ ps -o min_flt,maj_flt -p $(pidof nginx)
MINFL MAJFL
123456 789 # min=minor fault次数, maj=major fault次数
# /proc/pid/smaps_rollup: 详细内存映射统计
$ cat /proc/$(pidof myapp)/smaps_rollup | grep -E "Rss|Pss|Swap|LazyFree"
# /proc/vmstat: 系统级缺页计数器
$ grep -E "pgfault|pgmajfault|pgfree" /proc/vmstat
pgfault 123456789 # 总缺页次数(含minor)
pgmajfault 12345 # 主要缺页次数(需读盘)
# perf统计缺页事件
$ perf stat -e page-faults,minor-faults,major-faults -p PID
# /proc/pid/pagemap: 查看虚拟地址到物理地址的映射
# 需解析64-bit pagemap entry
9.3 mass预加载:消除启动时Major Fault
应用启动时大量Major Fault导致延迟尖刺?用madvise(MADV_WILLNEED)预加载热数据:
// 提示内核:即将访问此区域,触发异步预读
madvise(addr, length, MADV_WILLNEED);
// 对于私有映射的内存,提示内核:即将写入,提前触发COW避免运行时中断
madvise(addr, length, MADV_HUGEPAGE); // 合并2MB大页减少TLB miss
9.4 perf+ltrace追踪缺页完整路径
# 追踪缺页异常的完整内核栈
perf record -e page-faults -ag -- ./myapp
perf report --sort=comm,dso,symbol
# ftrace 追踪handle_mm_fault延迟
echo handle_mm_fault > /sys/kernel/debug/tracing/set_ftrace_filter
echo function > /sys/kernel/debug/tracing/current_tracer
十、典型场景故障排除
10.1 应用启动缓慢
症状:进程启动需要数秒,ps -o maj_flt持续增长。原因:大量文件映射的首次访问导致Major Fault排队。
解决:启用readahead(madvise MADV_WILLNEED)、预加载关键数据文件、确保link顺序热代码优先。
10.2 周期性卡顿
症状:运行数十分钟后突然卡顿数秒。原因:khugepaged尝试合并大页时触发内存compaction,阻塞缺页路径。
解决:数据库场景关闭THP(madvise MADV_NOHUGEPAGE关键数据区),使用静态大页(hugetlbfs)替代。
10.3 OOM前的异常模式
症状:swap使用稳步上升,Major Fault频率急剧增加。原因:工作集超过物理内存,page回收速率跟不上分配速率。
解决:降低swappiness延长page cache驻留、检查内存泄漏(smaps中持续增长的匿名映射)、限制进程cgroup内存。
10.4 COW风暴(fork-intensive场景)
症状:fork密集导致大量Minor Fault,CPU利用率偏高。原因:大量进程共享同一组物理页,任何写入都触发页面复制。
解决:关键大内存区域使用MAP_SHARED避免COW、vfork替代fork+exec模式、posix_spawn替代fork。
十一、与前序文章的关联
本文讨论的缺页异常正是虚拟内存系统的"最后一公里"——前面姊妹篇《Linux内核内存页回收与Swap机制深度实战》(ID 18005)讨论了物理内存不足时如何选择牺牲页并写回Swap,而本文探讨的正是当数据被换出后如何恢复映射、以及首次访问时如何把文件数据搬进page cache。
结合《Linux内核进程调度器深度实战》(ID 18008)中的进程唤醒机制:当进程从I/O阻塞(等待major fault的磁盘读完成)中被唤醒时,正是通过scheduler的pick_next_task选择一个就绪进程继续执行,I/O-bound进程的vruntime增长较慢从而获得更多CPU时间——这是CFS/EEVDF与缺页机制的协同。
三篇文章构成了内核三大子系统系列的完整闭环:分配(Swap/Buddy) → 映射(mmap/PF) → 调度(CFS/EEVDF)。
十二、总结
缺页异常从表面看是CPU的"异常处理",实质上它是操作系统实现虚拟内存按需分配、共享、写时的核心机制。理解do_page_fault的全链路——从硬件陷入、VMA查找、页表遍历到物理页分配——是诊断内存相关性能问题和段错误的基础。
生产环境中的关键认知:
- Minor Fault频繁是正常的(demand paging设计使然),Major Fault才是性能瓶颈的信号
- THP不是万能的——数据库和高性能网络栈常有反直觉的THP关闭需求
- COW优化(独立映射reuse)是fork性能的关键
- swap配置需要与具体负载特性匹配合,不存在通用最优值
全文以x86-64平台为参考,ARM64/ARM架构的页表结构和异常向量有差异但核心逻辑一致。掌握缺页异常的全链路,是通往Linux内核精深理解的关键一步。

发表评论 取消回复