Skip to content

第六十一章:页 fault

元婴中期

涉及内核源码:

林小源亲眼目睹了一次页 fault 的全过程。

事情发生得很突然。他在内景中观察一块 创建的空白大陆时,一道身影从远处飞来——一个进程,正试图访问这块大陆上的某个地址。

"它要踩上去了。"林小源低声说。

"别说话,看。"mm_struct 的声音异常严肃。

那个进程的脚踩上大陆的瞬间,地面没有接住它——脚穿过了表面,踩进了虚无。页表冰层里,对应的 PTE 的 V 位是 0。页面不在内存中。

CPU 发出一声尖锐的警报。整个内景震动了一下。

"page fault。"mm_struct 说。

控制权瞬间切换。那个进程的身影凝固在原地,像被定住了一样。取而代之的是另一个存在——内核的 page fault handler。它没有形体,只是一团冰冷的光,但林小源能感觉到它的意志:快速、精确、不带任何感情。

handler 的第一步是查找 VMA。它拿着 fault address,冲向红黑树的根系。树干上刻着无数地址范围,handler 在其中快速穿梭——左转、右转、左转——只用了几微秒,就找到了对应的 VMA。

"地址合法。"mm_struct 说,"在 VMA 范围内。"

handler 的第二步是权限检查。它对比了访问类型和 VMA 的 flags——进程想写入,VMA 允许读写。

"权限通过。"mm_struct 说。

handler 的第三步是调用

林小源屏住呼吸。他看到 像一只无形的手,从物理内存的深处捞出一个崭新的页面——干净的、全零的页面。它把这个页面安装到页表冰层中,更新 PTE,设好权限位。

整个过程不到两微秒。

handler 的光芒消散了。那个被定住的进程恢复了动作——它的脚这次踩到了实地上。它什么都不知道,什么都没感觉到,只是继续往前走。

"它甚至不知道发生了什么。"林小源喃喃自语。

"对它来说,只是一次普通的内存访问。"mm_struct 说,"但对内核来说——一次完整的 page fault 处理。从异常触发到页面分配到页表更新,全在后台完成。"

"所有 page fault 都这么快吗?"林小源问。

mm_struct 沉默了一会儿。"不。"它的声音变得低沉,"你刚才看到的是 minor fault——次要缺页。页面在内存中,只需要分配物理页、更新页表。一两微秒就完了。"

"还有更慢的?"

mm_struct 没有直接回答。它让林小源看另一个场景——一块曾经存在过、但后来被交换出去的大陆。大陆的表面是暗的,地底下的 PTE 还在,但指向的不是物理页,而是磁盘上的某个位置。

又一个进程踩了上来。page fault 再次触发。handler 再次出现,但这次它的动作不同——它没有从物理内存中捞页面,而是向磁盘发出了一个 IO 请求。

林小源看到了那个请求——一道光线射向远方,消失在黑暗中。然后是等待。漫长的、令人焦躁的等待。

"major fault。"mm_struct 说,"主要缺页。页面不在内存中,需要从磁盘读取。IO 操作——毫秒级的延迟。"

光线终于回来了,带着一个页面。handler 把它安装到页表中,进程恢复执行。但从 page fault 触发到进程恢复,过去了整整十毫秒。

"十毫秒。"林小源倒吸一口冷气,"比 minor fault 慢了一万倍。"

"这就是为什么内核要谨慎地管理页面回收。"mm_struct 说,"一个被错误回收的页面,代价是十毫秒的 IO。如果系统中有大量 major fault——性能会崩塌。"

林小源看着那块重新亮起来的大陆,心中多了一层理解。minor fault 是小磕碰,major fault 是重伤。内核的工作就是尽量避免 major fault——通过合理的页面回收策略、预读算法、working set 保护。

林小源在内景中待了很久,看了很多次 page fault。他开始注意到,page fault 不只是延迟分配的工具——它是一把万能钥匙。

"写时复制。"mm_struct 指着另一块大陆。

那块大陆是 之后共享的——父子进程映射到同一块物理页,但 PTE 标记为只读。当子进程试图写入时,page fault 触发。内核发现这是一个 COW 场景——分配新的物理页,复制内容,更新页表,让子进程写自己的副本。

"文件映射的按需加载。"mm_struct 又指向另一块。

那块大陆映射了一个大文件,但只加载了前几页。后面的页面还在磁盘上。当进程访问后面的地址时,page fault 触发,内核从文件中读取对应的页面。

"swap 换入。"mm_struct 继续,"NUMA 页面迁移。"它停了一下,"page fault 不是错误——它是内存管理的核心机制。几乎所有的内存分配、页面回收、进程隔离,都依赖 page fault 来实现。"

林小源看着眼前这片繁忙的内景。到处都有 page fault 在触发,到处都有 handler 在忙碌。它们不是在处理错误——它们是在维持秩序。

"这下他懂了,轻声说——page fault 不是'出事了',是'该干活了'。"

mm_struct 没有回应。但林小源感觉到,它又在笑。


c
/*
 * 页 fault 的处理流程:
 *
 * 1. CPU 触发页 fault 异常
 * 2. 内核的页 fault 处理函数被调用
 *    RISC-V: trap_handler() → do_page_fault()
 * 3. 查找 VMA
 *    - 地址不在任何 VMA 中 → SIGSEGV
 *    - 地址在 VMA 中 → 继续处理
 * 4. 权限检查
 *    - 写入只读页面 → SIGSEGV
 *    - 用户态访问内核页面 → SIGSEGV
 * 5. 调用 handle_mm_fault()
 *    - 分配物理页
 *    - 更新页表
 *    - 返回 VM_FAULT_xxx
 * 6. 进程继续执行
 *
 * 页 fault 的类型:
 *   次要缺页 (minor fault) — 页面在内存中,只需要更新页表
 *   主要缺页 (major fault) — 页面不在内存中,需要从磁盘读取
 */

#define VM_FAULT_OOM    0x001
#define VM_FAULT_SIGBUS 0x002
#define VM_FAULT_MAJOR  0x004
#define VM_FAULT_MINOR  0x008

struct vm_area_struct {
    unsigned long vm_start;
    unsigned long vm_end;
    unsigned long vm_flags;
};

int handle_mm_fault(struct vm_area_struct *vma, unsigned long addr) {
    /* 简化的页 fault 处理 */
    printf("  [handle_mm_fault] 处理地址 0x%lx 的页 fault\n", addr);

    /* 分配物理页 */
    printf("  [handle_mm_fault] 分配物理页\n");

    /* 更新页表 */
    printf("  [handle_mm_fault] 更新页表\n");

    return VM_FAULT_MINOR;
}

printf("=== 页 fault — 觉醒的契机 ===\n\n");

/* 模拟进程首次访问 mmap 分配的内存 */
struct vm_area_struct vma = {
    .vm_start = 0x7F000000,
    .vm_end   = 0x7F001000,
    .vm_flags = 0x03,  /* READ | WRITE */
};

unsigned long fault_addr = 0x7F000500;
printf("进程访问地址: 0x%lx\n\n", fault_addr);

/* Step 1: CPU 触发页 fault */
printf("Step 1: CPU 触发页 fault\n");
printf("  PTE.V = 0,页面不在内存中\n\n");

/* Step 2: 查找 VMA */
printf("Step 2: 查找 VMA\n");
if (fault_addr >= vma.vm_start && fault_addr < vma.vm_end) {
    printf("  地址在 VMA [0x%lx, 0x%lx) 中\n",
           vma.vm_start, vma.vm_end);
} else {
    printf("  地址不在任何 VMA 中 → SIGSEGV\n");
    return 1;
}

/* Step 3: 权限检查 */
printf("\nStep 3: 权限检查\n");
printf("  访问类型: 写入\n");
printf("  VMA 权限: RW\n");
printf("  检查通过\n\n");

/* Step 4: 处理页 fault */
printf("Step 4: handle_mm_fault()\n");
int ret = handle_mm_fault(&vma, fault_addr);

if (ret & VM_FAULT_OOM) {
    printf("  内存不足 → OOM\n");
} else if (ret & VM_FAULT_SIGBUS) {
    printf("  总线错误 → SIGBUS\n");
} else {
    printf("  页 fault 处理成功\n");
}

printf("\n--- 页 fault 的类型 ---\n");
printf("次要缺页 (minor fault):\n");
printf("  页面在内存中,只需要更新页表\n");
printf("  例如:首次访问匿名页\n\n");
printf("主要缺页 (major fault):\n");
printf("  页面不在内存中,需要从磁盘读取\n");
printf("  例如:访问被换出的页面\n");
printf("  开销:~10ms(磁盘 I/O)\n\n");

printf("--- 页 fault 的代价 ---\n");
printf("次要缺页: ~1-2 微秒\n");
printf("主要缺页: ~10 毫秒\n");
printf("SIGSEGV:  进程终止\n");

道藏笔记

内核启示

页 fault 的处理流程是一条清晰的流水线:CPU 触发异常,内核先查 VMA 确认地址合法,再做权限检查,然后调用 分配物理页、更新页表,最后进程继续执行。整个过程对进程来说几乎无感。

页 fault 分两种:次要缺页(minor fault)只需更新页表,一两微秒搞定;主要缺页(major fault)要从磁盘读数据,代价是十毫秒——差了一万倍。所以内核会尽量避免 major fault。

页 fault 的应用场景远比你想象的广:延迟分配靠它,写时复制靠它,文件映射的按需加载靠它,swap 换入靠它,NUMA 页面迁移也靠它。几乎所有的内存分配、页面回收、进程隔离,都依赖 page fault 来实现。页 fault 不是错误——是内存管理的"觉醒契机"。


破关试炼

页 fault 之试

访问尚未建立 PTE 的地址时,内核会通过哪一个缺页处理入口继续分配和映射页面?

答对后才能继续滑动和进入下一章。

以修仙之名,悟内核之道