上一篇区分了 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
2
3
4
5
6
7
8
9
10
11
12
13
load / store virtual address
-> MMU / TLB
-> page table walk
-> [translation valid?]
yes -> physical access continues
no -> arch fault entry
-> find_vma(mm, address)
-> handle_mm_fault()
-> do_anonymous_page / do_wp_page
do_fault / do_swap_page
hugetlb / userfaultfd / ...
-> update page table
-> return to user, retry instruction

第一段是上一篇的内容: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
missing translation -> consult VMA policy -> materialize or reject

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/vmstatpgfaultpgmajfault 是全局累计。比较时要注意单位和范围。

可观察的工具至少有三种:getrusage 在程序内部读取自身、/proc/<pid>/stat 的相关字段、perf stat 计数事件。后两种不需要修改程序。

一个最小实验:触发并区分 fault

下面这个程序映射一段较大的匿名内存,分阶段访问,并在每个阶段读取 ru_minfltru_majflt 的增量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
#define _GNU_SOURCE
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/resource.h>
#include <unistd.h>

static void snapshot(struct rusage *r) {
if (getrusage(RUSAGE_SELF, r) != 0) {
fprintf(stderr, "getrusage: %s\n", strerror(errno));
exit(1);
}
}

static void report(const char *stage, struct rusage *prev, struct rusage *cur) {
long min_delta = cur->ru_minflt - prev->ru_minflt;
long maj_delta = cur->ru_majflt - prev->ru_majflt;
printf("[%s] minor=+%ld major=+%ld\n", stage, min_delta, maj_delta);
*prev = *cur;
}

int main(void) {
size_t page = (size_t)sysconf(_SC_PAGESIZE);
size_t npages = 1024;
size_t length = page * npages;

char *base = mmap(NULL, length,
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS,
-1, 0);
if (base == MAP_FAILED) {
fprintf(stderr, "mmap: %s\n", strerror(errno));
return 1;
}

struct rusage prev, cur;
snapshot(&prev);

snapshot(&cur);
report("after mmap (no touch)", &prev, &cur);

for (size_t i = 0; i < npages; i++) {
base[i * page] = (char)i;
}
snapshot(&cur);
report("after writing every page once", &prev, &cur);

volatile long sink = 0;
for (size_t i = 0; i < npages; i++) {
sink += base[i * page];
}
snapshot(&cur);
report("after re-reading every page", &prev, &cur);
(void)sink;

munmap(base, length);
snapshot(&cur);
report("after munmap", &prev, &cur);

return 0;
}

编译运行:

1
2
cc -Wall -Wextra -O2 fault_demo.c -o fault_demo
./fault_demo

可选用 perf stat -e page-faults,minor-faults,major-faults ./fault_demo 做交叉验证。

读取实验结果

一台没有内存压力、没有 swap 抖动的普通 Linux 机器上,预期看到类似的结构:

1
2
3
4
[after mmap (no touch)] minor=+0 major=+0
[after writing every page once] minor=+1024 major=+0
[after re-reading every page] minor=+0 major=+0
[after munmap] minor=+0 major=+0

把这几个观察连起来。

第一,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
hardware exception -> kernel handler -> conditional repair -> retry

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

系列导航

参考资料