缺页异常:一次访问如何进入内核
上一篇区分了 VMA 与页表:VMA 是地址区间策略,页表是 CPU 当前可消费的翻译记录。当 VMA 合法、PTE 却不满足这次访问时,硬件会把异常抛回内核,由内核的缺页处理路径接管。这一篇把这条路径走一遍。
核心问题可以压成一句话:
page fault 不是错误的同义词,它是 Linux VM 延迟兑现承诺的主要入口。
问题从哪里来
“缺页异常”这个翻译容易引误解。它在英文里是 page fault,词义中性,本意只是“缺一次翻译”。大量正常程序每秒会产生上百次甚至数千次 page fault,进程跑得很好,没有任何错误。
mmap 完成后第一次访问、fork 后子进程写共享只读页、读一个尚未在 page cache 的文件、被回收过的匿名页再次访问、栈在合法范围内增长,都会触发 page fault。这些 fault 走完之后,程序继续执行下一条指令,用户态察觉不到任何异常。
只有少数情况会让 page fault 变成可见错误:访问完全不属于任何 VMA 的地址、对只读 VMA 写入、对不可执行区段取指令、内核回收阶段无法找到合适页面来满足需求。这些情况要么导致信号送回用户态,要么进入 OOM 兜底路径。
把“缺页异常”和“访问失败”划等号,会把 Linux VM 大量正常机制误读成异常路径。把它理解成“访问触发的延迟兑现协议”,后续 fault 分类、minor/major 统计、SIGSEGV/SIGBUS 的边界就都顺理成章。
最小模型
一次用户态访问的完整路径:
1 | |
第一段是上一篇的内容:MMU 查 TLB,TLB miss 时走 page table walk。这一段路径上没有内核 fault handler 参与。
第二段是本篇的主线。页表 walk 找不到合法翻译或权限不满足时,硬件把控制权交给架构相关的 fault 入口,比如 x86 的 do_page_fault、arm64 的 do_mem_abort。这层做最少的事:从硬件寄存器拿到出错地址、错误码、访问类型,然后调进通用 mm 入口。
通用入口在 mm/memory.c::handle_mm_fault()。它先确认地址覆盖的 VMA 是否存在、是否允许这次访问,再根据 PTE 当前状态分派到具体 fault 路径:匿名页、文件页、COW、swap、huge page、userfaultfd 等。
具体路径完成后,PTE 被更新,控制权返回用户态,CPU 重新执行触发 fault 的那条指令。这一次 TLB miss 后页表 walk 能成功,访问继续。
整条路径里有几个分界值得记下:MMU 决定是否需要进入内核、find_vma 决定地址是否合法、handle_mm_fault 决定走哪条 fault 路径、具体 fault 路径决定是分配页、复制页、读文件、换入、扩栈,还是把异常变成信号。
fault 的几种结局
把 fault 按结局列一遍,能避免把 page fault 当成单一事件:
| fault 触发条件 | VMA 状态 | 内核动作 | 用户态可见结果 |
|---|---|---|---|
| 匿名 VMA 首次访问 | 合法可写 | do_anonymous_page 分配清零页 |
继续执行 |
| COW 共享页写入 | 合法可写 | do_wp_page 复制页面 |
继续执行 |
| 文件 VMA 未在内存 | 合法 | do_fault 通过 page cache 取页 |
继续执行 |
| 匿名页在 swap | 合法 | do_swap_page 从 backing store 读回 |
继续执行 |
| 栈附近的访问 | 邻接可扩展栈 VMA | 扩展栈 VMA | 继续执行 |
| 越界访问 | 不属于任何 VMA | 不修复,发信号 | SIGSEGV |
| 只读区段写入 | 合法但权限不允许 | 不修复,发信号 | SIGSEGV |
| 不可执行区段取指令 | 合法但权限不允许 | 不修复,发信号 | SIGSEGV |
| 文件映射超出文件尺寸 | 合法但 backing 不可读 | 发信号 | SIGBUS |
| 分配/回收失败兜底 | 合法 | 进入 OOM 路径 | 可能被杀 |
第一组结局都是正常路径的一部分,用户态察觉不到任何异常。第二组才是“异常”。区分这两组,是判断一次性能问题该往哪一层定位的关键。
官方文档中关于栈扩展和越界访问的说法很克制:用户态访问 VMA 之外的地址是非法的,除非该地址毗邻一个可扩展的栈 VMA 并能把它扩进来。这条规则解释了为什么递归过深的程序往往不是被一次大跳跃打死,而是栈在合法增长几次之后撞到限制才报错。
模式提炼:缺翻译先查策略,再决定兑现还是拒绝
1 | |
page fault 不是一个动作,是一个分支决策。CPU 报告的是“这里翻译不了”,内核把它翻译成“这次访问是否被策略允许,如果允许应该怎样兑现”。
| 输入 | 决策点 | 输出 |
|---|---|---|
| 出错地址 | find_vma | 是否合法 |
| VMA 权限 + 错误码 | 权限检查 | 是否允许 |
| PTE 状态 | 分派 | 走哪条 fault 路径 |
| 分配/I/O 是否成功 | 资源检查 | 修复或失败上抛 |
这个结构在其他系统也常见:缓存未命中后查权限和元数据再决定是 lazy load 还是返回错误,事务管理器收到一个未知 key 时先看是否属于事务范围再决定填补或拒绝。
minor 和 major 的区分边界
getrusage(2) 把 fault 计数分两列。man-pages 当前版本给出的定义很具体,不要扩张解读:
| 字段 | man 原文 | 内核语义 |
|---|---|---|
ru_minflt |
the number of page faults serviced without any I/O activity | 通过从待重用页面列表里回收一个页帧服务掉的 fault |
ru_majflt |
the number of page faults serviced that required I/O activity | 服务这次 fault 期间发起了 I/O |
这里要谨慎几件事。
第一,把 minor 简化成“没有读盘”、major 简化成“读盘了”不算错,但少了细节。minor 的关键不是“没读盘”,而是内核能从已经准备好的页面池里直接挑一个填上。这个池子在 Linux 实现里既包括 buddy 维护的空闲页,也可能包括 page cache 中已经存在、可以被复用的页面。从 backing store 取页才会被记成 major。
第二,统计口径会随版本演化。某种 fault 是否计入 major,要看当前内核源码里 count_vm_event(PGMAJFAULT) 这类调用的位置。不要在文章里写“某种 fault 一定是 major”这种全称断言。
第三,进程级和系统级计数器不完全等价。getrusage 看到的是单个进程或线程的累计;/proc/vmstat 的 pgfault、pgmajfault 是全局累计。比较时要注意单位和范围。
可观察的工具至少有三种:getrusage 在程序内部读取自身、/proc/<pid>/stat 的相关字段、perf stat 计数事件。后两种不需要修改程序。
一个最小实验:触发并区分 fault
下面这个程序映射一段较大的匿名内存,分阶段访问,并在每个阶段读取 ru_minflt 和 ru_majflt 的增量。
1 | |
编译运行:
1 | |
可选用 perf stat -e page-faults,minor-faults,major-faults ./fault_demo 做交叉验证。
读取实验结果
一台没有内存压力、没有 swap 抖动的普通 Linux 机器上,预期看到类似的结构:
1 | |
把这几个观察连起来。
第一,mmap 只是建立 VMA。它本身不分配物理页、不读盘,因此第一段 fault 增量为 0。
第二,逐页写入产生 1024 次 minor fault。每一页都是匿名 VMA 中尚未兑现的虚拟页,首次访问触发 do_anonymous_page,从空闲页池中取出一页清零后填进 PTE。整个过程没有发起 I/O,因此全部计入 minor。
第三,重新读所有页面没有产生 fault。这一阶段 PTE 已经 present,TLB 命中或硬件 walk 直接得到 PFN,内核不参与,统计也不变化。
第四,munmap 释放映射本身不计 fault。
把场景换一下,效果会差别明显:
- 把匿名映射换成对一个未在 page cache 的大文件的
mmap,第一次顺序读会出现 major fault,因为内核要从底层 backing store 把页面读上来。 - 在制造内存压力或启用 swap 的环境下重跑相同实验,部分页面可能在循环过程中被回收,再访问时变成 swap-in,进入 major 统计。
- 用
fork()后让子进程写入大量页面,会观察到 COW 触发的额外 minor fault。子进程并没有新分配 VMA,而是在写入瞬间复制了页面。
这一篇先把基本结构立住,COW、文件映射、swap 各自的细节留到后续单独成篇。
模式提炼:fault 是一种受控异常
1 | |
page fault 在硬件层是异常,在软件层是一个有合同的修复点。CPU 报告异常,内核检查 VMA 和 PTE,按规则决定能不能修复,能修复就更新页表并让指令重试。不能修复才把它变成信号或终结进程。
很多系统都有类似结构:CPU 的浮点异常、Java 的 implicit null check、JIT 的 trap-based deoptimization,都是把不经常发生的事件交给慢路径处理,保留快路径的简洁。
错误 fault 怎样变成信号
并不是所有 fault 都能被修复。下面这两类大致对应两种信号:
| 错误来源 | 典型情形 | 信号 |
|---|---|---|
| 地址不属于任何 VMA、权限不允许 | 野指针、栈溢出、空指针解引用、写只读段 | SIGSEGV |
| 文件映射在文件之外,或硬件级别访问错误 | mmap 超出文件大小后访问、某些机器异常 |
SIGBUS |
判断流程大致是这样:fault handler 调 find_vma 找不到覆盖该地址的 VMA,且地址不属于可扩展栈范围,就准备发 SIGSEGV;找到 VMA 但权限位不允许,也会准备发 SIGSEGV;找到 VMA 且权限允许,但底层 backing 出错,往往走 SIGBUS。
实际信号生成路径比这要复杂,涉及 force_sig_fault、架构相关错误码、信号处理器是否安装。这一篇不展开。重点是把“fault 是机制”和“segfault 是机制的一种失败结果”分清。
第二个常被混淆的点:用户态拿到 SIGSEGV 之后,并不是“内核刚刚发生了一次 page fault”,而是“内核在 fault 路径上判定无法修复”。两者只在因果上相关,不要把 segfault 当成 fault 的同义词。
源码锚点
| 入口 | 读它的目的 |
|---|---|
mm/memory.c |
handle_mm_fault()、__handle_mm_fault() |
mm/memory.c |
do_anonymous_page()、do_wp_page()、do_swap_page()、do_fault() |
mm/mmap.c |
find_vma()、VMA 查找的锁路径 |
| 架构相关 | x86 arch/x86/mm/fault.c、arm64 arch/arm64/mm/fault.c |
include/linux/mm.h |
vm_fault 结构与 vm_fault_reason 标志 |
读源码时不要从架构入口一头扎进去。从 handle_mm_fault() 反向看更省力:先理解通用入口的分支,再回头看架构入口提供了哪些信息(出错地址、错误码、是写访问还是读访问、是否取指令)。
研究生迁移表
| Linux fault 概念 | 一般系统设计 |
|---|---|
| TLB miss | 一级缓存未命中 |
| page table walk | 一级查不到时的二级查询 |
| handle_mm_fault | 慢路径入口 |
| find_vma | 元数据/策略层查询 |
| do_anonymous_page | lazy allocation |
| do_wp_page | copy-on-write materialization |
| do_swap_page | 外部 backing 兜底 |
| SIGSEGV / SIGBUS | 慢路径无法修复时的失败上抛 |
这套模式在数据库 buffer pool、对象存储客户端缓存、JIT 编译器的 trap 处理里都能找到对应。重要的不是名字,而是“快路径 + 慢路径 + 慢路径里的细分修复 + 修复失败时的上抛”这一套四段结构。
常见误解
第一个误解是把所有 page fault 当作性能损失。绝大多数 minor fault 在微秒量级以内,是 lazy allocation 的代价。真正值得警惕的是 major fault 速率,以及把同一组页面反复换入换出的 thrash。
第二个误解是把 major fault 等同于磁盘 I/O。当前内核里 major 的判定取决于服务这次 fault 是否发起 I/O。具体哪条路径会被记成 major,应回到当前源码确认。
第三个误解是把 SIGSEGV 当成 page fault 本身。SIGSEGV 是 fault handler 判定无法修复后发送的信号,并不是异常本身。理解“fault 是机制,segfault 是机制的一种结果”有助于读 kernel log 和 core dump。
第四个误解是认为内核线程也会按用户态规则触发 page fault。内核线程通常没有自己的用户态地址空间,它执行的地址来源不同。内核态错误访问的路径和用户态 fault 不一样,不要混读。
第五个误解是认为 fault 路径很短。从硬件入口到具体修复函数,至少要穿过架构入口、通用入口、VMA 查找、分派函数、具体 fault 路径几层。理解这一层结构,才能读懂为什么 fault 的延迟分布不是均匀的。
练习
第一,按文中代码跑 fault_demo,记录每个阶段的 minor 与 major 增量。把 MAP_PRIVATE | MAP_ANONYMOUS 加上 MAP_POPULATE,再跑一次,观察首次访问阶段的 minor fault 是否消失。
第二,把映射大小调到 64MB,把每页写入改成 memset(base, 0, length),对比逐页写和整段写两种循环方式下的 minor fault 数量是否一致。
第三,把匿名映射换成对一个新创建的大文件的 MAP_SHARED 映射,先顺序读再随机读,比较 minor 和 major 的分布。先用 posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED) 或 echo 3 > /proc/sys/vm/drop_caches 让 page cache 不预热(注意后者需要 root)。
第四,用 perf stat -e page-faults,minor-faults,major-faults ./fault_demo 跑同样的实验,比较 perf 计数和 getrusage 读到的值。如有差异,回看 perf event 的定义。
第五,写一个程序在已映射区间之外读一个字节,安装 SIGSEGV handler 把 siginfo_t 中的 si_addr 打出来,确认它就是出错地址。再把访问目标换成只读 VMA 的写入,比较 si_code。
系列导航
- 上一篇:页表:CPU 能读懂的翻译结构
- 本文:缺页异常:一次访问如何进入内核
- 下一篇:匿名页和 COW:
fork为什么便宜,写入为什么变贵

