从零编写操作系统 11 - 分页与页故障:修改映射后重试同一条指令
物理页分配器已经能交出一页空闲 RAM,但它不决定一条访存指令最终访问哪一页。
本篇建立 32 位两级页表,开启分页,再让同一个虚拟地址先后访问两个物理页。
随后触发两次真正的页故障:一次读取未映射页,一次向只读页写入;处理函数修复映射,返回后重试原指令。
实验沿用单 CPU、64 MiB RAM 的 qemu32 模拟机,所有代码运行在 ring 0。
低 64 MiB 保持恒等映射,分页开启前后继续使用原 C 栈。
页目录和页表共占用 18 个物理页,演示结束后仍然保留;用于读写的两个数据页则归还分配器。
一次访存经过哪几种地址
在此前的平坦段模型中,代码段和数据段基址都是 0。
指令使用的偏移经过分段后得到同值的线性地址,分页关闭时,再按此前建立的地址环境访问物理内存。
开启分页后,线性地址需要经过页表翻译;本文把这一层输入称为虚拟地址。
物理页分配器返回的 uint32_t 仍然表示物理地址。
把它转换为 C 指针,得到的却是一次线性地址访问。
只有相关地址已经建立适当映射,这种直接转换才有效;分页不会自动给所有分配结果补上映射。
本篇刻意保留低地址恒等映射,使物理页地址 p 同时可以通过虚拟地址 p 访问。
另选 0x40000000 作为实验虚拟地址,它不在低 64 MiB 窗口内,可以独立更换目标页。
因此同一个物理页会有低地址和高地址两个访问别名。
32 位地址按 10、10、12 位拆分
本实验采用非 PAE、4 KiB 页面,关闭 PSE 大页。
Intel SDM Volume 3A §4.3给出了这种分页结构:一级页目录,二级页表,最后得到页框地址。
每一级都包含 1024 个四字节表项,恰好占一页。
1 | |
页内偏移占 12 位,可以选择一页中的 4096 个字节。
一个页表覆盖 1024 × 4 KiB = 4 MiB,整个页目录最多描述 4 GiB 线性地址空间。
这不要求机器安装 4 GiB RAM;未使用的目录项和页表项可以保持不在场。
对地址 0x40000000,目录索引为 256,页表索引和页内偏移都为 0。
本篇把独立实验页表接到 directory[256],之后只修改它的第零项。
低地址窗口则使用目录项 0 到 15,正好覆盖 64 MiB。
PDE 是 page-directory entry,保存下一层页表的物理基址和属性;PTE 是 page-table entry,保存目标页框的物理基址和属性。
CR3 保存的是页目录的物理基址,不能随意写入一个仅对软件有效的虚拟指针。
软件读写 PDE/PTE 时仍要通过线性地址访问表页,CPU 遍历页表时使用其中的物理地址,两种访问方式不能混淆。
表项低位控制在场和读写权限
页面按 4096 字节对齐,地址低 12 位为零,表项可以用这些位置存储属性。
本篇只主动设置 PRESENT=1 和 WRITE=2,没有设置 U/S 位。
低地址映射的 PDE 和 PTE 都是 P=1、R/W=1、U/S=0。
| 位 | 名称 | 当前用途 |
|---|---|---|
| 0 | P | 为 1 时该级表项在场 |
| 1 | R/W | 为 1 时允许写,为 0 时只读约束需要结合 WP |
| 2 | U/S | 保持 0,映射仅供 supervisor 访问 |
权限需要同时考虑 PDE 和 PTE,不能只看最后一级。
例如把某个 PTE 设为可写,并不能绕过上级 PDE 的只读约束。
Intel SDM §4.6.1还规定了 CR0.WP 对 supervisor 写入的影响:WP=1 时,内核写操作也必须遵守只读分页权限。
这正是后面写保护实验必须开启 WP 的原因。
如果只开启 PG,仍让 supervisor 写入绕过只读约束,就不能用 ring 0 写入验证预期的保护故障。
CPU 还可能更新 Accessed、Dirty 等状态位,因此检查脚本按掩码核对地址与 P/RW/US,不要求整个表项永远等于初始化数值。
从分配器取得 18 个表页
低 64 MiB 的映射需要 16 个页表,再加一个页目录,共 17 页。
高地址实验另需一个页表,总计 18 页,也就是 72 KiB。
其中低地址结构本身就需要 68 KiB,不能把它们全部塞进仍受 64 KiB 运行区上限约束的静态 BSS。
build_tables 直接调用第 10 篇的物理页分配器。
页目录取第一张页,低地址页表取接下来的 16 张页,最后一张页作为实验页表。
每张页分配后显式清零,因为 page_alloc 不保证返回零填充的内容。
1 | |
局部数组只有 72 字节,用来记录已经取得的页面,便于中途失败时逐一归还。
这段初始化运行在 PG=0 时;失败后尚未安装新表树,因此可以释放这些页。
回滚分支经过源码检查,当前记录没有注入页表分配耗尽,不能把它写成已经完成的低内存客体实验。
低地址表项按物理页号直接填入:
1 | |
清零还使其余目录项和实验页表项都不在场。
映射覆盖低 64 MiB,不表示这片范围内的所有地址都成为可分配 RAM。
保留区、VGA 和地址空洞仍由 E820 及分配器规则约束,演示不会因为它们有映射就主动遍历读写。
开启 PG 后,代码和栈仍在原地址
置 CR0.PG 的那条指令执行完成后,后续取指和访存都要满足分页规则。
正在执行的代码、原栈、异常入口、控制台数据以及页表的软件访问地址必须提前可达。
低地址恒等映射同时覆盖了这些对象,避免在开关分页时另做一次栈迁移。
源码先关闭 CR4 的 PSE、PAE 和 PGE,再加载 CR3。
随后读取 CR0,按位加入 PG 与 WP,保留已经开启的 PE。
核心写入如下:
1 | |
两次保存 ESP 的位置分别在设置控制寄存器之前和之后。
客体断言两个值相同;GDB 还验证这个值位于链接脚本声明的原栈范围内。
本篇正常镜像记录为 CR3=0x10a000、ESP=0x1096f0,开启分页没有改变栈地址。
这些地址属于本篇冻结版本,新增代码导致的链接变化会影响数值。
本篇 __kernel_end=0x109750,初始空闲数是 16086,比上一篇快照少一页。
比较页面收支应使用本次启动保存的 paging_initial_pages,不能套用上一篇的 16087。
同一虚拟地址先读 A,再读 B
启用分页后,演示分配两个不同物理页 A、B,通过低地址恒等别名分别写入 0x12345678 和 0x87654321。
实验虚拟地址始终为 0x40000000,先映射到 A,再改为 B。
两次读取必须得到对应的不同值,才能证明高地址访问确实经过了页表。
修改 PTE 后还要使旧地址翻译失效。
Intel SDM §4.10.4.1规定的 INVLPG 以线性地址为操作数;本篇传入实验虚拟页地址,不是 PTE 自身的存储地址。
否则内存里的表项虽然更新,处理器仍可能使用缓存中的旧映射。
1 | |
所有实验映射变化都通过 test_map,包括清除映射和放宽只读权限。
重新加载 CR3 也涉及地址翻译失效,但本篇只改一个虚拟页,用 INVLPG 就能表达需要刷新的范围。
当前只有一个 CPU;以后若多个 CPU 同时使用这套页表,还需要处理其他 CPU 的翻译缓存。
错误码 0 与 3 分别表示什么
页故障通过异常向量 14 进入,CPU 自动压入错误码。
公共汇编入口对该向量不再补一个零,仍沿用第 07 篇的 68 字节同特权级现场布局。
错误码记录这次访问为什么失败,与 PTE 属性位的含义不同。
| 错误码位 | 为 0 | 为 1 |
|---|---|---|
| P | 不在场导致故障 | 保护条件违反 |
| W/R | 读访问 | 写访问 |
| U/S | supervisor 访问 | user 访问 |
清空实验 PTE 后,由 ring 0 读取,预期得到 error=0。
把 B 映射为 present、readonly,再由 ring 0 写入,且 WP=1,预期得到 error=3,即 P 与 W/R 同时为 1。
这里的 3 不是“把 PTE 的 PRESENT 和 WRITE 原样打印出来”。
Intel SDM §4.7 与异常 14 的说明还定义了其他错误条件。
本篇没有构造保留位错误;在固定的 PSE=0 非 PAE 模式下,不能任意设置一个高位就声称完成了 RSVD 实验。
原始错误码始终完整打印,恢复逻辑则只接受预定的精确值。
先保存 CR2,再输出诊断
CR2 保存导致页故障的线性地址,保存的 EIP 指向故障指令。
两者用途不同:CR2 用于定位哪一个地址无法访问,EIP 用于定位哪条指令发起访问。
打印函数如果又产生页故障,CR2 会被后一次故障覆盖,因此分发函数先读取 CR2,再调用页故障处理函数。
1 | |
这次保存发生在进入 C 分发函数之后、任何诊断输出之前。
它依赖此前已经映射好的异常入口、栈和数据;不能把它理解为任意嵌套故障下都能恢复的完整机制。
当前实验也不在处理函数中临时分配页面,待修复的 B 页已经预先取得。
只修复精确匹配的实验故障
读写探针使用独立汇编标签,让错误指令的位置可以准确比较:
1 | |
处理函数首先要求 CR2 等于实验地址,CS 等于内核代码段选择子 8。
读故障还要求阶段为 1、错误码为 0、EIP 等于读标签,且当前 PTE 恰好为零。
写故障要求阶段为 2、错误码为 3、EIP 等于写标签,且 PTE 低三位只有 PRESENT。
条件全部满足后,函数把实验页重新映射到预先保存的 repair_page,允许读写并执行 INVLPG。
随后清除 expected_fault,增加故障计数,返回公共汇编出口。
它没有更改现场中的 EIP,也没有按指令长度向后跳过失败访问。
iretd 恢复现场后,CPU 重新执行原来的 mov。
读探针这次读到 B 的 0x87654321;写探针这次完成写入,B 的低地址别名读回 0xfeedface。
如果处理函数只打印信息就返回,又不修复映射,原指令会再次触发同样的故障。
GDB 检查在处理函数入口核对错误码、地址、EIP、阶段和表项。
修复返回后,再在对应汇编标签设置硬件断点,确认实际执行重新到达保存的同一个 EIP。
正常镜像的两处地址分别为 0x102cd0 与 0x102cd6,不能用故障变体 ELF 的地址替代。
解除映射后释放数据页,活动页表继续保留
演示结束先清零实验 PTE 并失效翻译,再释放 A 和 B。
这样已经撤销的高地址映射不会继续通过旧缓存访问被分配器重新交出的页面。
低地址恒等窗口仍保留,所以释放物理页并不等于删除它的所有虚拟别名;后续调用者仍须遵守所有权规则。
页目录、16 个低地址页表和一个高地址页表继续供 CPU 使用,不能随数据页释放。
最终空闲数应为 16086 - 18 = 16068。
GDB 除了检查计数,还检查占用位图恰好有 18 个置位,并确认它们包含当前目录和全部活动表页。
高地址只读实验只限制这一个别名。
同一物理 B 页仍有低地址 RW 别名,低地址窗口也包含内核 text 和页表,因此不能宣称实现了内核代码只读或安全隔离。
非 PAE 表项没有 NX 位,本篇不具备 W^X,也没有用户态、进程地址空间或通用虚拟内存接口。
未预期的页故障必须停机
独立的 mbr-pagefault.img 在正常演示完成后,再次调用读取探针。
此时高地址 PTE 已清零,恢复阶段也已经清除,即使故障地址相同,也不会重新进入白名单恢复路径。
处理函数打印 CR2、原始错误码和完整同级现场,然后调用 panic。
故障检查先用 GDB 确认 panic_halt 位置确实是 HLT 指令。
另外启动一次不在该指令前暂停的客体,再由 QEMU monitor 读取 HLT=1,确认处理器实际进入停止状态。
正常镜像和故障镜像分别保存截图,故障画面不会覆盖正常演示结果。
下载与复现
下载第 11 篇完整源码,解压目录前缀为 os-day-11。
源码冻结提交为 851ec46333fdc377201598bf93a6bb6cdf3d8aec,实现位于 examples/build-an-os。
继续使用第 01 篇已经准备好的 localhost/build-an-os:day01 镜像,无需为本篇重建工具链。
1 | |
两条检查命令顺序执行,脚本共享 GDB 的 1234 端口,不应同时运行。
check-paging 核对控制寄存器、16384 个低地址 PTE、故障与重试、页面收支和真实停机;正常路径还继续运行到 keyboard_ready。
make check 继续执行既有引导、装载、C 入口、控制台、异常、时钟、键盘和物理页回归。
本篇 docs/day11-evidence.md 记录的正常输出包括:
1 | |
完整回归及源码附件独立解压后的累计检查均退出 0,实验范围限于指定 QEMU、单 CPU、64 MiB 的配置。
串口、GDB 和停止状态产物保存在 build/paging-*-serial.txt、build/paging-*-gdb.txt 与 build/paging-halted-registers.txt。
两张画面来自 QEMU 显示设备;它们辅助观察结果,寄存器与页表断言承担对应的状态检查。
练习
- 计算
0x40001004的目录索引、页表索引和页内偏移,再说明当前表树访问该地址为什么会故障。 - 给实验页表的第二项建立映射,验证两个相邻虚拟页可以映射到不相邻物理页,并分别执行失效操作。
- 在单独实验变体中保持 WP=1,把高地址 PDE 改为只读,预测只改 PTE 为可写能否恢复写访问;保留未预期故障的停机路径。
上一篇:10 - 物理页分配。
下一篇:12 - 内核堆,在页面分配与映射的基础上处理按字节申请内存的需求。


