在 x86_64 架构下,#PF(Page Fault)处理函数的汇编层面,处理错误码主要分两个核心部分:保存现场时将它作为参数传递,以及恢复现场时在iretq之前正确地跳过它。此外,错误码本身包含了触发异常的关键信息。
第一部分:进入处理函数——保存错误码并传递
当 CPU 触发#PF时,它会自动完成以下操作:
压栈:将错误码压入当前栈中。此时栈顶布局为:
[rsp] → 错误码 (Error Code) [rsp+8] → RIP [rsp+16] → CS [rsp+24] → RFLAGS [rsp+32] → RSP (如果发生了特权级切换) [rsp+40] → SS (如果发生了特权级切换)
跳转:通过 IDT(中断描述符表)中的
#PF门描述符,跳转到内核注册的处理函数入口。
在汇编入口处,需要保存通用寄存器,并将错误码作为参数,传递给后续的 C 语言处理函数。
; 汇编入口:page_fault_handler_entry page_fault_handler_entry: ; 1. 保存所有通用寄存器,以便在 C 函数中恢复 push rax push rbx push rcx push rdx push rsi push rdi push rbp push r8 push r9 push r10 push r11 push r12 push r13 push r14 push r15 ; 2. 保存当前栈指针到 rbp,方便后续定位 mov rbp, rsp ; 3. 将错误码作为第一个参数传递给 C 函数 ; 注意:错误码此时位于 [rbp + 15*8] 处 ; (因为我们已经压入了 15 个 8 字节的通用寄存器) mov rdi, [rbp + 15*8] ; rdi = Error Code ; 4. 将出错的虚拟地址(CR2)作为第二个参数 mov rsi, cr2 ; rsi = Faulting Address push rsi ; 保存 CR2 到栈上(可选) ; 5. 调用 C 语言编写的核心处理函数 call do_page_fault ; void do_page_fault(unsigned long error_code, unsigned long address) ; 6. C 函数返回后,恢复之前保存的寄存器 ; 注意:此时 rsp 需要恢复到保存通用寄存器之后的位置 mov rsp, rbp pop r15 ; ... (按相反顺序弹出所有寄存器) pop rax ; 7. 跳过错误码并返回 add rsp, 8 iretq
第二部分:do_page_faultC 函数对错误码的解析
C 函数do_page_fault会收到错误码,并解析其位字段以决定处理方式:
| 错误码 Bit | 含义 | 处理逻辑 |
|---|---|---|
| Bit 0 (P) | 0 = 页不存在,1 = 权限违规 | 若为 0,分配物理页并建立映射;若为 1,检查权限。 |
| Bit 1 (W) | 0 = 读操作,1 = 写操作 | 确定访问类型,用于 COW(写时复制)或只读页错误。 |
| Bit 2 (U) | 0 = 内核态,1 = 用户态 | 决定是内核错误(panic)还是用户态错误(SIGSEGV)。 |
| Bit 3 (RSVD) | 1 = 使用了保留位 | 通常意味着页表损坏,直接触发panic。 |
| Bit 4 (I/D) | 1 = 指令获取 | 若为 1,表示 CPU 尝试从此地址执行指令,需特殊处理。 |
第三部分:返回前正确处理错误码
在汇编中,关键在于add rsp, 8指令的位置。它必须紧跟在iretq之前执行:
为什么是
add rsp, 8?因为错误码是 CPU 在进入处理程序时压入的,它不属于iretq的正常返回栈帧。iretq期望栈顶是RIP,如果不跳过,iretq会尝试将错误码当作RIP使用,导致系统崩溃。
错误码的常见陷阱
栈偏移计算错误:在多级栈帧中,如果你在通用寄存器压栈和
call指令之间操作了栈,那么错误码在栈上的偏移量会发生改变。忘记处理
CR2:在 64 位模式下,出错的地址存放在CR2寄存器中。在调用do_page_fault之前,必须将CR2的值也作为参数传递。