Page Fault从触发到结束的汇编级旅程 📅 2026/8/5 3:04:00 缺页异常Page Fault是连接“虚拟内存抽象”和“物理硬件操作”最完美的枢纽。从汇编级看它就等于看懂了整个Linux内存管理的“发动机点火瞬间”。从CPU收到一个无效内存访问的那一纳秒开始逐条指令走完整个旅程。全程不涉及复杂C逻辑只盯着寄存器、栈、页表和那几条关键特权指令。第一阶段硬件现场CPU的自动反应当CPU执行一条mov rax, [0x7fff1234]而该虚拟地址的页表项PTE的PresentP位为0时CPU会立即触发#PFPage-Fault Exception编号14。CPU在切入内核前自动且不可中断地完成以下动作这是硬件写死的微码逻辑压栈保存返回现场将当前RIP出错的指令地址、CS代码段选择子、RFLAGS状态寄存器按顺序压入当前RSP指向的内核栈如果是用户态触发CPU会自动切换到IST或TSS指定的内核栈。设置异常信息将引发错误的虚拟地址存入CR2寄存器。这是硬件唯一提供的“线索”。查IDT并跳转根据中断向量号14从IDT中断描述符表中取出对应的中断门描述符加载RIP和CS跳转到内核预先注册的page_fault入口点。关键点此时没有任何内核C代码执行全靠硬件自动完成现场保存和跳转。你的CR2和栈上那5个寄存器值是后续所有分析的起点。第二阶段内核入口汇编桩保存所有寄存器跳转到arch/x86/entry/entry_64.S中的page_fault标号以x86_64为例。这是纯汇编代码负责把CPU自动保存的“最小现场”扩展为完整的“快照”page_fault: /* 1. 切换内核栈如果当前在内核态则跳过 */ cld /* 2. 压栈所有通用寄存器形成 struct pt_regs */ pushq $0xffffffffffffffff /* 伪造错误码占位 */ pushq %rax pushq %rcx pushq %rdx pushq %rsi pushq %rdi pushq %r8 pushq %r9 pushq %r10 pushq %r11 /* ... 还有更多但核心是保存所有上下文 */ /* 3. 将 CR2 中的地址传给C函数 */ movq %cr2, %rdi movq %rsp, %rsi /* 指向 pt_regs 的指针 */ /* 4. 调用C语言异常处理函数 */ call do_page_fault此阶段汇编的核心价值movq %cr2, %rdi—— 这条指令是“破案线索”的移交。你看到pushq和popq的顺序必须清楚栈顶现在就是struct pt_regs的完美镜像。任何C函数返回后这些寄存器将原样恢复。第三阶段C灵魂do_page_fault决定命运进入mm/memory.c中的do_page_fault实际是exc_page_fault这是C语言主导的决策层。跳过复杂的逻辑判断只关注它最终决定如何修复时的汇编级表现。do_page_fault经过层层判断地址是否合法、是否在VMA内、是否可写等假设它决定使用匿名页Anonymous Page的写时复制COW来修复它调用handle_mm_fault→__handle_mm_fault→do_wp_page。在do_wp_page中核心操作是分配一个物理零页并复制内容如果原页有数据。最终它调用set_pte_at用新的物理页帧号PFN和可写、Present权限更新进程页表。第四阶段返回路径汇编级的“重放”修复完成后C函数返回一个值如VM_FAULT_MINOR控制权交还给汇编桩。回到entry_64.S中的error_return路径执行恢复现场并重新执行的指令序列error_return: /* 1. 恢复所有通用寄存器 */ popq %r11 popq %r10 popq %r9 popq %r8 popq %rdi popq %rsi popq %rdx popq %rcx popq %rax addq $8, %rsp /* 跳过伪造的错误码 */ /* 2. 执行 iretq 返回 */ iretqiretq是这条旅程的“大结局”指令它的执行极具戏剧性它从当前RSP指向的栈上依次弹出RIP、CS、RFLAGS、RSP、SS如果发生了特权级切换。注意这个栈上的RIP正是当初触发#PF的那条mov指令的地址因此CPU会重新执行那条曾经失败的mov rax, [0x7fff1234]。现在因为页表已经更新且Present位为1MMU内存管理单元顺利将虚拟地址转为物理地址指令成功执行进程继续运行仿佛什么都没发生过。寄存器全程追踪表脑内检查清单”可以边看边在心里默念下表时刻/指令CR2CR3页表基址RSP关键指令故障触发前旧值当前进程页表用户栈mov rax, [bad_addr]硬件自动压栈后bad_addr新值不变内核栈硬件执行page_fault入口bad_addr不变内核栈压入更多寄存器pushq ...调用do_page_faultbad_addr不变内核栈mov %cr2, %rdiC函数修复页表bad_addr不变页表内容变了内核栈set_pte_atC函数内iretq执行前bad_addr未改不变内核栈已恢复寄存器iretq重新执行成功bad_addr未改不变用户栈mov rax, [bad_addr]这次成功“脑内微操”能在脑子里模拟出边界情况如果do_page_fault发现是内核态访问了非法的vmalloc地址它会调用vmalloc_fault。这个函数会修改当前进程的init_mm页表并必须手动执行invlpg指令刷新TLB。如果忘了这条指令CPU可能仍然用TLB中的旧映射导致修复无效陷入死循环。如果缺页发生在中断上下文比如in_interrupt()为真do_page_fault会直接触发panic因为中断处理程序不允许睡眠等待磁盘I/O比如从swap分区读页。此时汇编桩不会返回而是直接跳转到die打印Oops信息。关于CR2的“只读”属性CR2是只读寄存器只能通过mov %cr2, %rax读取不能直接写入。如果企图用mov %rax, %cr2CPU会触发#GP通用保护异常。内核代码中永远不会尝试修改CR2。“实战检验”如果现在有一个Page Fault导致的内核崩溃打印出如下信息BUG: unable to handle kernel paging request at ffff88807f000000 RIP: 0010:some_function0x14/0x30 CR2: ffff88807f000000能立刻推理出这是因为some_function0x14处的一条mov或test指令试图访问ffff88807f000000。地址高位0xffff8880表明这是内核直接映射区ZONE_NORMAL理论上永远应该存在物理页。因此这极可能是一个空指针解引用NULL被加了偏移或者是Use-After-Free后内存被归还页表项被清空。修复思路是去some_function源码处查看是谁传入了这个非法指针并加上if (!ptr) return -EINVAL的检查。