操作系统学习19:缺页异常 #PF 与按需分页
-
- 一、为什么第 19 课学 #PF?
- 1. #PF是什么
- [2. 当前能力链](#2. 当前能力链)
- [二、#PF 时 CPU 做了什么?](#PF 时 CPU 做了什么?)
-
- [2.1 CPU 触发 #PF 的条件:](#PF 的条件:)
-
- [1. 页不存在(P=0)](#1. 页不存在(P=0))
- [2. 权限违规(P=1)](#2. 权限违规(P=1))
- [3. 保留位错误](#3. 保留位错误)
- [2.2 错误码各位](#2.2 错误码各位)
- [2.3 恢复执行的关键点](#2.3 恢复执行的关键点)
- [2.4 #PF 的硬件流程](#PF 的硬件流程)
- [2.5 OS 在 #PF 中能做什么?](#PF 中能做什么?)
- 三、本课实现结构
- 四、源码要点
-
- [4.1 注册 `register_interrupt_handler`](#4.1 注册
register_interrupt_handler) - [4.2 汇编桩与错误码](#4.2 汇编桩与错误码)
- [4.3 `page_fault_handler`](#4.3
page_fault_handler) - [4.4 按需映射](#4.4 按需映射)
- [4.5 VGA 卷屏](#4.5 VGA 卷屏)
- [4.1 注册 `register_interrupt_handler`](#4.1 注册
- 五、构建与运行
- 六、建议实验
- 七、小结

承接 lesson17 VMM / lesson18 kmalloc。
本课目标:挂上 向量 14(#PF) ,在访问未映射页时不再三重故障重启;并演示最简单的 demand paging(按需映射)。
lesson17 开分页后,访问页表里不存在的地址会直接崩溃。真正的 OS 靠缺页异常决定:是报错杀掉进程,还是临时分配一页让指令重试------这正是「虚拟内存」里「虚」字落地的地方。
一、为什么第 19 课学 #PF?
1. #PF是什么
#PF 是 CPU 在访问某个线性地时发现该地址对应的页表项无效或权限不符时触发的异常。
它是虚拟内存系统的核心机制:
- 不是所有内存都必须提前分配
- 访问时再分配(按需分页)才是现代 OS 的常态

虚拟内存的目标是让进程看到一个"巨大的、连续的、干净的"地址空间,但物理内存有限且碎片化。如果没有 #PF:
- 所有虚拟地址必须提前映射 → 浪费内存
- 程序访问未映射地址 → 直接三重故障重启
无法实现:
- 按需分页
- 写时复制
- 匿名映射
- 用户态栈自动增长
- 内存映射文件
所以 #PF 是虚拟内存的"调度中心":
CPU 发现问题 → 抛异常 → OS 决定如何处理。
2. 当前能力链
text
PMM → VMM → kmalloc → ★ #PF/按需分页 → 用户态 / 每进程页表
16 17 18 19 20+
| 备选主题 | 为何本课优先 #PF |
|---|---|
| 用户态 + 系统调用 | 跨度大(TSS、Ring3、门),建议缺页先稳 |
| 每进程页表 | 依赖异常路径与更完整的地址空间策略 |
| #PF | 直接补齐 VMM 承诺,一课能跑通「故障→处理→恢复」 |
二、#PF 时 CPU 做了什么?
2.1 CPU 触发 #PF 的条件:
1. 页不存在(P=0)
- 页表项的 Present 位为 0
- 最常见:按需分页、未分配、未加载
2. 权限违规(P=1)
- 写入只读页(W/R=1)
- 用户态访问内核页(U/S=1)
- 执行 NX(No-Execute)页(在 x86-64)
3. 保留位错误
- 页表项中某些保留位被错误设置(严重错误)
- 访问线性地址时,MMU 查页表失败或权限不够 → 触发 异常向量 14:
- 把故障线性地址写入 CR2
- 压栈:错误码、EIP、CS、EFLAGS(同特权级)
- 跳到 IDT14 指向的处理程序
2.2 错误码各位
| 位 | 名称 | 意义 |
|---|---|---|
| 0 | P | 0=页不存在;1=页存在但权限/保留位错误 |
| 1 | W/R | 0=读;1=写 |
| 2 | U/S | 0=内核态;1=用户态 |
| 3 | RSVD | 页表保留位被错误使用 |
| 4 | I/D | 0=数据访问;1=指令取指(仅在某些 CPU) |
本课按需映射只处理 P=0(not-present) 的情况。
2.3 恢复执行的关键点
处理程序若补好了映射,必须:
- 弹出错误码(
ADD ESP, 4) IRET回到故障 EIP
CPU 会 重试 那条引发缺页的指令------所以 *p = MAGIC 看起来像「写一次」,中间其实经历了一次完整异常往返。
2.4 #PF 的硬件流程
- MMU 查页表失败或权限不符
- CPU 将故障线性地址写入 CR2
- CPU 生成错误码(Error Code)
- CPU 按异常规则压栈:
- EIP
- CS
- EFLAGS
- 错误码
- 跳转到 IDT14 指定的处理程序
- OS 处理后执行 IRET
- CPU 重试导致异常的那条指令
这就是为什么按需分页能"无缝恢复"。
2.5 OS 在 #PF 中能做什么?
缺页异常是 OS 的"万能入口",可以做:
- 按需分配匿名页
- 加载文件页(mmap)
- 栈自动增长
- 写时复制(COW)
- 交换页(swap in/out)
- 检测非法访问 → 杀掉进程(segfault)
本文即实现匿名页按需分配(demand-zero page)。
三、本课实现结构
| 文件 | 职责 |
|---|---|
page_fault.s |
isr_page_fault:保存寄存器、读 CR2、调 C、IRET |
page_fault.c |
打印诊断;可选 pmm_alloc + vmm_map |
utilities.s |
导出 init_idt、read_cr2 |
main.c |
对 0x40000000 未映射写入,验证按需恢复 |
text
*p = MAGIC (p 未映射)
→ #PF
→ isr_page_fault
→ page_fault_handler
├─ 打印 CR2 / 错误码
├─ pmm_alloc_page
├─ vmm_map_page(virt, phys)
└─ return
→ IRET 重试 MOV
→ 写入成功
四、源码要点
4.1 注册 register_interrupt_handler
pf_init 里先 init_idt(),再把向量 14 指到汇编入口:
c
init_idt();
register_interrupt_handler(PF_VECTOR, (uint32_t)isr_page_fault);
实现位于 utilities.s:按 cdecl 取 vector / handler,向内存中的 idt[vector] 写入一条 32 位中断门(每项 8 字节)。
asm
; void register_interrupt_handler(uint32_t vector, uint32_t handler);
; cdecl: [ESP+4]=vector, [ESP+8]=handler
register_interrupt_handler:
PUSH EAX
PUSH EBX
MOV EBX, [ESP + 12] ; vector(PUSH 了 2 个寄存器后偏移 +8)
MOV EAX, [ESP + 16] ; handler
LEA EBX, [idt + EBX * 8] ; 指向 idt[vector]
MOV WORD [EBX + 0], AX ; Offset 15..0
MOV WORD [EBX + 2], SELECTOR_CODE
MOV BYTE [EBX + 4], 0
MOV BYTE [EBX + 5], 0x8E ; P=1, DPL=0, 32-bit interrupt gate
SHR EAX, 16
MOV WORD [EBX + 6], AX ; Offset 31..16
POP EBX
POP EAX
RET
调用瞬间栈上是 [ESP]=返回地址, [ESP+4]=vector, [ESP+8]=handler;函数开头又 PUSH EAX/EBX,所以取参要用 [ESP+12] / [ESP+16]。
LEA EBX, [idt + EBX*8]:每项 8 字节,算出该向量在 IDT 表中的地址。随后按 Intel 中断门布局填表:
| 偏移 | 写入 | 含义 |
|---|---|---|
| +0 | handler 低 16 位 |
Offset 15...0 |
| +2 | SELECTOR_CODE |
内核代码段选择子 |
| +4 | 0 |
保留 |
| +5 | 0x8E |
P=1,DPL=0,类型=中断门 |
| +6 | handler 高 16 位 |
Offset 31...16 |
只改表项不够:CPU 认的是 IDTR。init_idt 内部会 LIDT [idt_ptr],所以必须先加载 IDT,再改写向量 14;本课用单独的 init_idt(),不走 init_keyboard(避免顺带 STI 开中断)。
4.2 汇编桩与错误码
asm
isr_page_fault:
PUSHAD
MOV EAX, [ESP+32] ; error_code
MOV ECX, CR2
PUSH ECX ; 参数 cr2
PUSH EAX ; 参数 error_code
CALL page_fault_handler
ADD ESP, 8
POPAD
ADD ESP, 4 ; 丢掉 error_code(IRET 不要它)
IRET
栈布局(PUSHAD 之后):[ESP+0..31] 保存的通用寄存器,[ESP+32] 为 CPU 压入的错误码,再往上是 EIP/CS/EFLAGS。
cdecl 从右往左压参,故先压 cr2、再压 error_code,对应 C 侧 page_fault_handler(error_code, cr2)。
因为 CPU 在进入异常时压了一个错误码,这里不能漏掉 ADD ESP, 4,否则会导致 IRET 把错误码当成 EIP,立刻再次异常。
4.3 page_fault_handler
由 isr_page_fault 以 cdecl 调用,签名为:
c
void page_fault_handler(uint32_t error_code, uint32_t cr2);
| 参数 | 来源 | 作用 |
|---|---|---|
error_code |
CPU 压栈,桩里从 [ESP+32] 取出 |
区分 not-present / 权限错误,以及读/写、内核/用户 |
cr2 |
桩里 MOV ECX, CR2 |
故障线性地址(按需映射时对齐到页) |
处理流程:
- 更新统计:
fault_count++,记下last_cr2/last_error - 打印
#PF #N、CR2、以及解码后的错误码(not-present|prot、read|write、kernel|user) - 可恢复路径 :
demand_enabled且P=0(页不存在)→ 见下节按需映射,return后由 IRET 重试故障指令 - 不可恢复路径 :权限错误、未开 demand、或 alloc/map 失败 → 打印后
io_hlt死循环,避免再次缺页导致三重故障重启
成功返回与停机的分界很明确:只有「开了按需 + not-present + 映射成功」才会回到故障 EIP;其余一律停机保平安。
4.4 按需映射
c
if (pf.demand_enabled && (error_code & PF_ERR_PRESENT) == 0) {
phys = pmm_alloc_page();
vmm_map_page(cr2 & PAGE_MASK, (uint32_t)phys, PRESENT|WRITE);
page_zero(phys);
return; /* 重试 */
}
/* 否则打印后 io_hlt,避免三重故障 */
cr2 & PAGE_MASK 把故障地址对齐到页起点再映射;page_zero 清掉脏页,避免读到旧物理页残留数据。
pf_set_demand(0) 时,未映射访问会停在「PF: unhandled, halt」,而不会整机重启------比 lesson17 时友好得多。
4.5 VGA 卷屏
本课日志较长(PMM 分区、VMM、HEAP、#PF 演示),超过 VGA 文本模式的 25 行 。
旧版 print_char 在 Y >= 25 时把光标卡在最后一行继续写 ,后几行会互相覆盖,尾部看起来像乱码(例如 _one. PMM free pages...)。
本课在 utilities.s 增加了 scroll_up:换行越界时把显存第 1~24 行上移一行并清空最后一行,光标停在第 25 行。
因此演示末尾的 after write / faults= / Done 可以完整看到;更早的 PMM 明细会滚出屏幕顶部,属正常现象。
五、构建与运行
bat
cd lesson19
make
make install
make run
预期输出(要点)
text
Lesson19: Page Fault (#PF) + Demand Paging
=== VMM Init ===
VMM: paging ON ...
=== PF Init ===
PF: IDT[14] ready demand=OFF
before: 0x40000000 -> 0x00000000 (unmapped)
=== Demand Fault Demo ===
PF: demand ON
writing via unmapped virt...
#PF #1 CR2=0x40000000
err=0x00000002 [not-present write kernel]
PF: demand-map 0x40000000 -> 0x........ OK (retry)
after write: value=0x4C313950 OK
after: 0x40000000 -> 0x........
faults=1 demand_mapped=1
错误码 0x2 = 写 + 页不存在(bit1=W,bit0=P 清零),与演示一致。
实际运行截图:

六、建议实验
pf_set_demand(0)再访问未映射页,确认停机而非重启- 对已 map 的只读页做写(需改 PTE 去掉 WRITE)观察
prot类错误 - 在处理函数里打印
fault_count,连续碰多个未映射页看按需次数 - 对照 Intel SDM Vol.3 的 Interrupt 14---Page-Fault Exception
七、小结
- 挂上 #PF,缺页不再等于整机复位
- 理解 CR2 + 错误码 + IRET 重试
- 实现最小 demand paging:缺页时现分配、现映射
本文开源地址: