缺页中断不是"出错",是内核最忙的一条正常路径
前两篇我们把地址翻译这条路走完了:地址是假的、按页翻译、查多级页表、TLB 兜底。中间反复出现一个词------缺页中断(page fault):CPU 拿着虚拟地址查页表,翻不出来,就停下当前指令、陷入内核。
但前两篇都把它当黑箱:要么段错误崩掉,要么内核"现场补一页"。补一页这三个字,藏了一整套内核里最高频的逻辑。这篇就把这个黑箱打开------内核接手之后,一步步判断了什么、按什么顺序、最后怎么补上那一页,或者怎么决定杀掉你。
先纠一个名字带来的误会。fault 听着像"出错",但你程序里绝大多数缺页是完全正常 的:malloc 完第一次写、程序第一次执行到某段代码、读一个 mmap 进来的文件------全靠缺页才把内存真正落地。缺页是内核最忙的正常路径之一,不是异常。真正"出错"的那种(段错误)只是这棵决策树最末端的一个分支。
本文所有数字真跑。环境是一台 Linux(aarch64,内核 6.12,4KB 页)。每段都给了命令,复制能复现。函数名都是 Linux 内核里真实的符号,你可以拿去 grep 源码。
一、内核接手的第一件事:这地址你到底有没有资格碰
CPU 翻页表失败、陷入内核,最先跑起来的是体系结构相关的入口函数------在 arm64 上叫 do_page_fault(x86 上是 do_user_addr_fault,干的事一样)。它手里有两样东西:出错的那个虚拟地址 ,和当时是想读、想写、还是想执行。
内核的第一个问题不是"去哪儿弄这页内存",而是:这个地址,这个进程到底有没有资格碰?
判断的依据,就是上一篇看过的那张 /proc/self/maps。进程合法拥有的每一段地址区间,内核内部用一个叫 VMA (virtual memory area,虚拟内存区域)的东西记着------maps 里的每一行就是一个 VMA,记着这段区间的起止地址、权限(读/写/执行)、以及这段内存是什么 (匿名内存?还是映射了磁盘上哪个文件?)。do_page_fault 拿着出错地址,调用 find_vma 去这些区间里查:这个地址落在哪个 VMA 里?
为什么要有 VMA,页表不够吗? 不够,因为这俩记的不是一回事。回想上一篇:你
malloc了 1GB,页表只长了 8KB------多级页表只为你真碰过的页建条目 。可那 1GB 里大部分页还没碰、页表里压根没有,它们却是合法的 :你随时去写,内核就该补页,而不是报错。内核凭什么知道"这片还没碰的地址是合法的"?光查页表查不到(没建条目),靠的就是 VMA------malloc那一刻内核记了一个 VMA"这段归你、可读写",但一个物理页都没分。所以页表记的是"已经兑现的翻译",VMA 记的是"承诺过的资格"。缺页处理第一步的安检,查的正是这份"承诺清单",它才能区分"合法但还没碰"和"非法瞎访问"------这两种在页表里长得一模一样(都查不到)。
css
出错地址 ──► find_vma 在进程所有 VMA 里查它落在哪个区间
/proc/self/maps(每行 = 一个 VMA,末尾那几位是权限)
aaaaca4d0000-aaaaca4d1000 r-xp ← 代码段:可读可执行,不可写
aaaaca4f0000-aaaaca4f1000 rw-p ← 数据段:可读可写
aaaaf432c000-aaaaf434d000 rw-p [heap] ← 堆
ffffb6760000-ffffb68fc000 r-xp libc ← 库代码
...
ffff... rw-p [stack] ← 栈
查的结果分两种:根本没有哪个 VMA 罩住它 / 有,但权限不符
这一步直接岔出这棵树最容易理解的两个"失败"分支。
分支①:没有任何 VMA 罩住这个地址 → 这地址你从没申请过 → 段错误。
随手编个地址去读,最干脆:
c
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <string.h>
#include <unistd.h>
// 信号处理器里不能用 printf(它不是 async-signal-safe),用 write 直接写
void handler(int sig, siginfo_t *si, void *ctx){
char buf[128];
int n = snprintf(buf, sizeof buf,
"收到 SIGSEGV: 内核查遍所有 VMA 都没找到地址 %p, 判定非法访问\n", si->si_addr);
write(1, buf, n);
_exit(139);
}
int main(void){
setvbuf(stdout, NULL, _IONBF, 0); // 关缓冲,保证崩前的打印能出来
struct sigaction sa;
memset(&sa, 0, sizeof sa);
sa.sa_sigaction = handler;
sa.sa_flags = SA_SIGINFO;
sigaction(SIGSEGV, &sa, NULL);
int *wild = (int*)0x1234; // 一个从没映射过的地址
printf("准备读 %p ...\n", (void*)wild);
volatile int x = *wild; // 缺页 -> find_vma 查无此 VMA -> SIGSEGV
printf("不该到这 %d\n", x);
return 0;
}
bash
gcc -O0 segv.c -o segv && ./segv; echo "退出码=$?"
跑起来:
yaml
准备读 0x1234 ...
收到 SIGSEGV: 内核查遍所有 VMA 都没找到地址 0x1234, 判定非法访问
退出码=139
find_vma 查遍所有 VMA,没有一个区间罩得住 0x1234,内核的结论是"你从没申请过这块地方",给进程发 SIGSEGV------就是我们熟悉的段错误(退出码 139 = 128 + 信号 11)。注意 si_addr:内核把到底是哪个地址出的事精确告诉了你,这正是它第一步查的东西。
分支②:有 VMA,但你干的事它不允许 → 保护错误。
这个更微妙:地址是合法的,但你的操作越权了。比如对一段只读内存动手写:
c
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>
void handler(int sig, siginfo_t *si, void *ctx){
char b[160];
int n = snprintf(b, sizeof b,
"收到 SIGSEGV: 地址 %p 在 VMA 里(合法),但那段是只读的,写它=越权 -> 保护错误\n",
si->si_addr);
write(1, b, n);
_exit(139);
}
int main(void){
setvbuf(stdout, NULL, _IONBF, 0);
struct sigaction sa;
memset(&sa, 0, sizeof sa);
sa.sa_sigaction = handler;
sa.sa_flags = SA_SIGINFO;
sigaction(SIGSEGV, &sa, NULL);
long page = sysconf(_SC_PAGESIZE);
char *p = mmap(NULL, page, PROT_READ, // 只读映射
MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
printf("读它没问题: 第一个字节=%d\n", p[0]); // 读 -> OK
printf("现在去写它 %p ...\n", (void*)p);
p[0] = 1; // 写 -> 越权 -> 保护错误
return 0;
}
bash
gcc -O0 prot.c -o prot && ./prot; echo "退出码=$?"
makefile
读它没问题: 第一个字节=0
现在去写它 0xffffabd19000 ...
收到 SIGSEGV: 地址 0xffffabd19000 在 VMA 里(合法),但那段是只读的,写它=越权 -> 保护错误
退出码=139
这次地址有 VMA 罩着(读它毫无问题),但那个 VMA 的权限是只读。内核拿"你想干什么"(写)去对 VMA 的权限(只读)一比,不符,照样 SIGSEGV。这就是为什么改一个字符串常量、往代码段写东西会崩------不是地址不存在,是权限这一关没过。
到这里能看出内核的做事顺序了:它先安检,再办事 。第一步跟"去哪儿弄这页内存"毫无关系,纯粹在问"你有没有资格碰这里、能不能这么碰"。两道关------有没有 VMA、权限符不符------任何一道没过,直接 SIGSEGV,连后面都不用走。我们平时眼里"一种"的段错误,在内核这儿其实是两个不同的失败分支。
安检过了,才进入真正有意思的部分。
二、安检通过:那这页内存,到底该从哪儿来
地址合法、操作也合规,内核才开始办正事。这一步进入和体系结构无关的核心函数 handle_mm_fault,它再往下走到 handle_pte_fault------名字里的 pte 就是上一篇那个第 4 级页表里、真正记录页框号的表项。
handle_pte_fault 要决定的是:这一页内容该从哪儿来? 答案不止一种,它根据"这个 VMA 是什么类型、页表项当前是什么状态"分出几个分支,每个分支由一个专门的函数处理:
scss
handle_pte_fault 看 VMA 类型 + 页表项状态,分派给不同处理函数:
┌─ 匿名内存(malloc/栈/堆),页表项是空的
│ → do_anonymous_page:要么给共享零页(只读),要么清零一张新物理页
│
├─ 文件映射(mmap了文件 / 程序代码段),页不在内存
│ → do_fault / filemap_fault:去文件(磁盘或page cache)把这页读进来
│
├─ 页被换出去过(swap),页表项记着它在交换区的位置
│ → do_swap_page:从磁盘交换区把它换回内存
│
└─ 页在内存、但只读,而你要写(典型=fork后的共享页)
→ do_wp_page:写时复制,复制一份新页给你写
这四个分支,正好对应你程序里几乎所有"正常缺页"的来源。下面逐个用真实数据砸实------重点看一个贯穿全篇的区分:minor fault (次缺页,没碰磁盘)和 major fault(主缺页,真去磁盘转了一趟)。这两个数 Linux 一直在替你数,下面先说清楚怎么自己把它们看出来------后面每一节的数字都是这么读出来的,不是我空口说的。
这些缺页数,你怎么自己看到
正文后面每一节都会甩出一个缺页数(16384、32、4096......),它们都来自同一个地方:Linux 为每个进程实时累计着 minor / major fault 的次数。看它有两条路,由浅入深:
最省事------不用改代码:/usr/bin/time -v。 在任何程序前面挂上它跑,结束后它把整个进程的缺页数打出来:
bash
/usr/bin/time -v ./prog
yaml
Minor (reclaiming a frame) page faults: 16468
Major (requiring I/O) page faults: 0
Maximum resident set size (kbytes): 66728
(注意是 /usr/bin/time -v,不是 shell 内置的 time------得写全路径。装 perf 的话,perf stat -e minor-faults,major-faults ./prog 也行。)
最精确------程序里读 getrusage: 上面那个法子数的是"整个进程从头到尾"的缺页,掺了启动、库加载的开销(所以是 16468 不是齐整的 16384)。想精确测"某一段代码 触发了多少缺页",就在那段代码前后各读一次 getrusage,相减:
c
#include <sys/resource.h>
struct rusage r0, r1;
getrusage(RUSAGE_SELF, &r0); // 测量区间------前
/* ... 想测的那段代码 ... */
getrusage(RUSAGE_SELF, &r1); // 测量区间------后
printf("这段代码触发: minor=%ld major=%ld\n",
r1.ru_minflt - r0.ru_minflt, // 没碰磁盘的缺页
r1.ru_majflt - r0.ru_majflt); // 真去磁盘的缺页
正文后面所有数字,用的都是这个"前后相减取增量"的办法------所以它们干净,正好等于那段循环碰过的页数。(还有第三条路,连 C 都不用写:/proc/<pid>/stat 这个文件的第 10、12 个字段就是该进程的 minflt / majflt,cat 出来就能看。)
下面进入四个分支,每个都用这套办法把数读给你看。
三、匿名页:内核就地造一页,不碰磁盘
malloc 来的内存、栈、堆,都是匿名内存 ------它不对应磁盘上任何文件,内容凭空产生。第一次访问它触发缺页,走 do_anonymous_page。
这里内核分读和写两种情况,真正的区别不是缺页次数 ,而是缺页后内核有没有分配真实物理内存。下面的代码把两种都测,用 RSS(进程真正占用的物理内存)来揭示差别:
c
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <sys/resource.h>
#include <unistd.h>
int main(void){
long page = sysconf(_SC_PAGESIZE);
size_t N = 64L*1024*1024; // 64MB = 16384 个 4KB 页
struct rusage r0, r1;
// ---- 第一次只"读" ----
char *z = mmap(NULL, N, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
madvise(z, N, MADV_NOHUGEPAGE);
long rss_before = r0.ru_maxrss;
getrusage(RUSAGE_SELF, &r0); rss_before = r0.ru_maxrss;
long sum = 0;
for (size_t i = 0; i < N; i += page) sum += z[i];
getrusage(RUSAGE_SELF, &r1);
printf("匿名页只读: minor=%ld major=%ld RSS增长=%ld kB 内容和=%ld\n",
r1.ru_minflt-r0.ru_minflt, r1.ru_majflt-r0.ru_majflt,
r1.ru_maxrss - rss_before, sum);
// ---- 另开一块,第一次"写" ----
char *p = mmap(NULL, N, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
madvise(p, N, MADV_NOHUGEPAGE);
getrusage(RUSAGE_SELF, &r0); rss_before = r0.ru_maxrss;
for (size_t i = 0; i < N; i += page) p[i] = 1;
getrusage(RUSAGE_SELF, &r1);
printf("匿名页写 : minor=%ld major=%ld RSS增长=%ld kB\n",
r1.ru_minflt-r0.ru_minflt, r1.ru_majflt-r0.ru_majflt,
r1.ru_maxrss - rss_before);
printf("(64MB = %ld 页 = %ld kB)\n", (long)(N/page), (long)(N/1024));
return 0;
}
bash
gcc -O0 anon.c -o anon && ./anon
ini
匿名页只读: minor=16384 major=0 RSS增长=0 kB 内容和=0
匿名页写 : minor=16384 major=0 RSS增长=65400 kB
(64MB = 16384 页 = 65536 kB)
读和写的缺页次数 都是 16384------每个页第一次碰都要陷内核一趟,逃不掉。真正的差别在 RSS:只读 RSS 增长 0 ,内核没分配任何物理页,而是让所有只读的页表项都指向一张全局共享的只读"零页"(内容保证全 0,所以 sum=0)。写 RSS 增长 65400kB ≈ 64MB,每一页都分了真物理页。
内核的逻辑是:你只是读、而且匿名内存保证是 0,那就让所有人共享同一张零页,不浪费物理内存;等你哪天真写,再给你一张独属于你的新页。
四、文件页:这页在磁盘上,得真去取------major fault 登场
如果你 mmap 的是一个文件(或者程序代码段------它本质就是可执行文件被映射进来的),那这页的内容在磁盘上 。第一次访问、内容还没进内存,走 do_fault / filemap_fault:内核得真去磁盘(或者文件在 page cache 里的副本)把这页读进来。
读磁盘是缺页里最贵的操作,所以它单独算一类------major fault。下面的代码造一个 64MB 文件、清掉内存缓存逼内核真读盘,并关掉预读(否则内核顺手读一大片,把大多数缺页变成 minor):
c
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/resource.h>
#include <fcntl.h>
#include <unistd.h>
int main(void){
long page = sysconf(_SC_PAGESIZE);
size_t N = 64L*1024*1024;
struct rusage r0, r1;
// 造一个 64MB 文件(每字节填 7,方便校验)
int fd = open("/tmp/big.bin", O_RDWR|O_CREAT|O_TRUNC, 0644);
char *tmp = malloc(N); memset(tmp, 7, N);
write(fd, tmp, N); free(tmp); fsync(fd);
// 把文件内容从内存缓存里清掉,逼内核真去磁盘读
sync();
int dc = open("/proc/sys/vm/drop_caches", O_WRONLY);
if (dc >= 0) { write(dc, "3", 1); close(dc); } // 需要 root 权限
char *p = mmap(NULL, N, PROT_READ, MAP_PRIVATE, fd, 0);
madvise(p, N, MADV_RANDOM); // 关预读,一页一页来
long sum = 0;
getrusage(RUSAGE_SELF, &r0);
for (size_t i = 0; i < N; i += page) sum += p[i];
getrusage(RUSAGE_SELF, &r1);
printf("文件页 64MB 逐页读: minor=%ld major=%ld (共 %ld 页, 每页值=%ld)\n",
r1.ru_minflt-r0.ru_minflt, r1.ru_majflt-r0.ru_majflt,
(long)(N/page), sum/(long)(N/page));
return 0;
}
bash
gcc -O0 filepage.c -o filepage && ./filepage
ini
文件页 64MB 逐页读: minor=0 major=16384 (共 16384 页, 每页值=7)
把它和第三节那行并排放,是这篇最干净的一组对照:
ini
同样 16384 个页,第一次访问:
匿名页(malloc)写: minor=16384 major=0 ← 内核就地造,不碰磁盘
文件页(mmap文件)读: minor=0 major=16384 ← 每页都得去磁盘取
差别全在 handle_pte_fault 那一步分派给了谁:
do_anonymous_page(凭空造) vs filemap_fault(去磁盘取)
同样是"第一次碰 16384 个页、触发 16384 次缺页",一边 major=0、一边 major=16384,差了一整个数量级的代价。你的代码完全一样(都是循环逐页读),区别全在内核第二步------handle_pte_fault 判断"这页从哪来"时,匿名内存走了"凭空造一页",文件页走了"去磁盘取一页"。
这就是为什么 minor 和 major 要分开数:minor fault 是微秒级(内核内存里捣鼓一下),major fault 是毫秒级(等磁盘)。差三个数量级。你平时感觉"程序卡了一下",很多时候就是一串 major fault------内核在替你等磁盘 I/O。调性能时如果看到 major fault 居高不下,基本就是内存不够、数据反复从磁盘进出的信号。
五、写时复制:fork 之后那个谜,到这儿彻底闭环
还剩第四个分支 do_wp_page,wp = write protect。它处理的情况是:页在内存里、好好的,但被标成了只读,而你要写它。
最典型的来源就是 fork。上上篇我们见过 fork 的"同址两值"之谜,当时只说了结论(写时复制),现在能从缺页这一侧把它讲透了。
fork 出子进程时,内核没有真复制父进程的内存,而是让父子的页表指向同一批物理页、并把这些页全标成只读 。于是父子读这些页都正常(读不违反只读);可一旦有谁去写 ,写只读页 → 触发缺页 → 走 do_wp_page:内核这才复制一份新物理页给写的那一方,把它的页表项改指到新页、权限恢复成可写,再让那条写指令重来。
每一个被写到的页,触发一次 COW 缺页。父进程先写满 16MB(4096 页),fork 后让子进程逐页改写:
c
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <sys/resource.h>
#include <sys/wait.h>
#include <unistd.h>
int main(void){
setvbuf(stdout, NULL, _IONBF, 0);
long page = sysconf(_SC_PAGESIZE);
size_t N = 16L*1024*1024; // 16MB = 4096 页
struct rusage r0, r1;
char *p = malloc(N);
for (size_t i = 0; i < N; i += page) p[i] = 1; // 父:先写满,页已落地
pid_t pid = fork(); // fork:页被标只读、父子共享
if (pid == 0) {
getrusage(RUSAGE_SELF, &r0);
for (size_t i = 0; i < N; i += page) p[i] = 2; // 子:逐页写 -> 每页一次 COW 缺页
getrusage(RUSAGE_SELF, &r1);
printf("[子] 写时复制触发的缺页 = %ld (共 %ld 页)\n",
r1.ru_minflt-r0.ru_minflt, (long)(N/page));
_exit(0);
}
wait(NULL);
return 0;
}
bash
gcc -O0 cow.c -o cow && ./cow
scss
[子] 写时复制触发的缺页 = 4096 (共 4096 页)
4096 个页,子进程写一遍,正好 4096 次缺页------一页一次,每一次就是 do_wp_page 现场复制了一张新页。
css
fork 后(共享,只读) 子写第 k 页 → do_wp_page
父 ─┐ 父 ─► 物理页A (原值)
├─► 物理页A (只读,共享)
子 ─┘ 子 ─► 物理页B (复制出来的新页,可写)
↑ 这一次 COW 缺页干的事
读都不缺页;谁写谁触发一次缺页,分一张私有页给它
上上篇那个"同址两值"到这里彻底闭环:父子虚拟地址一直没变,变的是子进程那次写触发了 do_wp_page,把它的页表项从共享只读页,掉包成了一张私有的新页。
还有第五种没细讲:页被换出到磁盘交换区 (swap)过,页表项里记着它在交换区的位置,再次访问触发缺页走
do_swap_page,把它从磁盘换回内存------这也是一次 major fault。swap 本身够再写一篇,这里只在决策树上给它留个位置。
六、补完那一页,最后一步:回到出错的指令重来
不管走的是哪个分支------清零造页、从磁盘取、复制新页------内核做完之后都干同一件事:把这一页的映射填进页表,然后让 CPU 回到当初出错的那条指令,重新执行一次。
这次再查页表,映射有了,翻译通过,指令顺利跑完。整个缺页处理对你的程序是透明 的:你那行 p[i] = 1 不知道自己中途陷进过内核、内核忙活了一通、又被放回来重跑了一遍。它只是"执行了一次赋值"。
把整条路径连起来:
scss
p[i] = 1 (CPU 查页表失败,陷入内核)
│
▼
do_page_fault ── find_vma ──┬─ 没有 VMA ───────────► SIGSEGV(段错误)
(第一步:安检) └─ 有 VMA,但权限不符 ──► SIGSEGV(保护错误)
│ 合法
▼
handle_mm_fault → handle_pte_fault ──┬─ do_anonymous_page 匿名页:清零造一页 (minor)
(第二步:这页从哪来) ├─ filemap_fault 文件页:去磁盘取 (major)
│ ├─ do_swap_page 换出过:从swap换回 (major)
│ └─ do_wp_page 只读被写:复制一页 (COW)
▼
填好页表项 ──► 回到 p[i]=1 这条指令重新执行 ──► 这次翻译通过,跑完
把这篇收一下,其实就是一棵两层的决策树:
- 第一层是安检 :
do_page_fault拿出错地址调find_vma,问"这地址你有没有资格碰"。没有 VMA(地址不存在)或权限不符(越权操作),就是你熟悉的两种段错误。这一步跟"弄内存"毫无关系,纯粹在确认资格。 - 第二层是"这页从哪来" :
handle_pte_fault按 VMA 类型和页表项状态分派------匿名页凭空清零造(do_anonymous_page,minor)、文件页去磁盘取(filemap_fault,major)、换出过的从交换区换回(do_swap_page,major)、只读被写的复制一份(do_wp_page,COW)。你程序里几乎所有"正常"的内存落地,都是这一层在干活。 - 最后填好页表、回到出错指令重跑,整个过程对你的代码完全透明。
这也把前面几篇连成了一个完整的故事:你打印出来的地址是假的(虚拟地址)→ 访问它要查多级页表、TLB 兜底 → 翻不出来就缺页、陷入内核 → 内核走这棵决策树,安检 + 补页 → 回到原指令重跑、这次翻译通过。从 &x 那一串数字,到内存条上真实的一格,中间这条路,到这篇算是走完了。
还欠一篇:物理内存真的不够用时,内核怎么挑"最不常用"的页踢到磁盘交换区、腾地方给新的缺页(swap 与页面回收)------也就是本篇
do_swap_page那个分支的反面。那是另一棵树,下次再走。