从零编写操作系统 07 - 异常入口:CPU 出错后把哪些信息交给内核
串口和 panic 可以报告代码主动检测到的错误,但前提是程序执行到了检查位置。除数为零、指令无效、段选择子越界,会让处理器直接转入异常处理。没有异常入口,上一篇建立的输出能力也无从调用。
本篇安装 IDT,把异常现场整理成 C 结构,打印向量、错误码和寄存器。一个受控 INT3 探针验证返回路径,除零、非法指令和一般保护异常分别验证故障路径。范围限定在 32 位保护模式、同一特权级、固定 QEMU 教学配置;设备 IRQ 尚未启用。
IDT 决定异常从哪里进入
处理器通过异常向量查找中断描述符表 IDT。向量 0 对应除法错误 #DE,3 对应断点 #BP,6 对应无效操作码 #UD,13 对应一般保护异常 #GP。IDT 中的门描述符给出目标代码段和入口地址。
1 | |
32 位中断门占 8 字节。入口地址拆成低、高两个 16 位字段,中间还有代码段选择子、保留字节和属性字节。IDTR 则是独立的寄存器,保存表的基址和界限;lidt 从内存读取这两个值。
kernel/exceptions.c 用紧凑布局描述硬件格式:
1 | |
初始化遍历前 32 个向量,填入各自的汇编入口:
1 | |
这里的 0x08 指向已有 GDT 的内核代码段。它不是任意内核都适用的常量:如果 GDT 布局改变,门里的选择子也必须对应改变。
0x8e 表示存在位为 1、DPL 为 0、类型为 32 位中断门。通过中断门进入时,CPU 清除实时 IF,因此处理函数暂时不会接收可屏蔽外部中断。门不会自动清除 DF,也不能阻止处理函数自身再次触发异常。
数组有 256 项,共 2048 字节,IDTR 的界限取最后一个有效字节偏移,所以是 2047。第 32 到 255 项保留为不在场状态。此处没有 PIC/PIT 配置、IRQ handler 或 EOI;CLI 也不妨碍同步 CPU 异常发生。门格式和进入时的标志处理见 Intel SDM Vol.3A §6.11–6.12。
同一特权级下,CPU 只保存返回所需的现场
当前异常前后均在 Ring 0,CPU 在现有栈上依次保存 EFLAGS、CS 和 EIP。部分异常还会压入错误码。每项占一个 4 字节栈槽,压栈后 ESP 指向低地址端。
1 | |
同级入口没有旧 SS、旧 ESP 两项。发生特权级切换时,硬件保存内容会扩展,不能直接把本篇的结构用于用户态入口。CPU 也没有在这里自动保存 EAX、EBX 等通用寄存器,那是汇编入口的责任。Intel SDM Vol.3A §6.12.1、Figure 6-4 给出了同级与跨级入口的区别。
错误码是异常的附加信息,与向量分开。错误码的值为零,不代表这个栈槽不存在。入口必须根据该异常的硬件规则决定是否补槽,不能读取数值后再猜布局。
汇编入口统一错误码与寄存器布局
无硬件错误码的入口压入一个软件零,然后压向量;有硬件错误码的入口只压向量。这样进入公共处理代码时,栈顶统一为 vector, error, eip, cs, eflags。
kernel/exceptions.asm 用 NASM 宏生成入口:
1 | |
这七个向量是当前教学目标采用的经典错误码集合,并非现代 x86 全集。现代扩展例如 CET 的 #CP 也有错误码,迁移目标时必须重新审核入口分类。安装 0 到 31 的入口,只表示这些表项存在,不能据此声称所有异常均已运行验证。
公共入口首先执行 pushad,保存八个通用寄存器槽,再保存 DS、ES、FS、GS。pushad 的实际压入顺序为 EAX、ECX、EDX、EBX、原 ESP、EBP、ESI、EDI,所以从最终低地址读取时顺序反过来。
段寄存器选择子只有 16 位。实现先清零 EAX,再把选择子放入 AX,以完整 32 位值压栈,避免依赖段寄存器直接压栈时的高位内容。EAX 在此前已经由 pushad 保存,可以安全充当临时寄存器。
最终 struct trap_frame 与汇编布局逐项对应:
1 | |
1 | |
pushad_esp 指向已经压入向量和错误码的栈顶,即 frame + 48。它不是异常发生前的 ESP。当前实测 frame 位于 0x105a24,该字段为 0x105a54,两者相差 48 字节。Intel 指令手册的 PUSHAD 条目 将其定义为执行 PUSHAD 之前的 ESP。
C 文件同时用 _Static_assert 检查总大小 68、vector 偏移 48、eflags 偏移 64。静态检查能够发现结构布局改变;真正的压栈顺序仍需运行时读取验证。
调用 C 前处理数据段、DF 和栈对齐
普通 C 调用通常由另一段遵守 ABI 的代码发起。异常可以打断任意指令,入口不能假定此时 DF 恰好为零,也不能假定硬件压栈后已经满足编译器的栈对齐要求。
保存段寄存器后,公共入口把 DS、ES、FS、GS 装为内核数据段 0x10,再执行 cld。原来的 DF 仍保存在 frame 的 EFLAGS 中,清除的是处理函数运行时使用的标志。
1 | |
EBX 保留原始 frame 指针。对齐后的 ESP 用于临时 C 调用栈,减 12 再压入一个 4 字节参数,使 call 前 ESP 是 16 的倍数。call 又压入返回地址,因此 C 函数第一条指令处的 ESP 模 16 应为 12。
EBX 按当前 C ABI 由被调用者保留,所以函数返回后能够恢复原始 ESP。这个原始指针不能从已经向下对齐的值简单反推,否则异常恰好发生在不同栈位置时就会恢复错位。
探针故意在 int3 前执行 std,让 DF=1。实测进入 C 时 DF=0、IF=0,而 frame 中 EFLAGS 的 DF 仍为 1;C 入口 ESP 为 0x105a0c,余数正是 12。这样既检查调用条件,也检查原标志是否被保留。
恢复路径最终执行 IRETD
当 trap_dispatch 返回,汇编逆序恢复 GS、FS、ES、DS,再执行 popad。随后移除统一头中的向量与错误码,让 ESP 再次指向 CPU 保存的 EIP:
1 | |
IRETD 恢复 EIP、CS、EFLAGS。它不会自动弹出错误码,所以 add esp, 8 必须同时移除向量和错误码两个槽。用普通 ret 无法恢复这组硬件现场;少移除一格也会把错误码误当成返回地址。Intel SDM Vol.3A §6.13 说明了中断返回及错误码清理要求。
本实现只允许一个确定的探针返回:vector == 3,并且保存的 EIP 等于 probe_resume。分派函数还断言错误码、CS、DF 和各寄存器的预设值,随后增加 breakpoint_count 并返回;其他入口全部走 panic。
INT3 产生陷阱,保存的 EIP 已经指向下一条指令,即 probe_resume。代码不修改 EIP。恢复后探针立即执行 pushfd; pushad,GDB 在 probe_restored 读取这份快照,与异常 frame 对照全部通用寄存器、ESP 和 EFLAGS。DF 也恢复为 1,然后探针自己执行 cld 并恢复其调用者现场。
这张图截取的是 GDB 在 probe_restored 的暂停点。正常内核随后继续执行上一篇的控制台换行、滚屏和格式化自检,最终输出 CONSOLE OK;图中的异常信息不是正常启动的最终停机画面。
三种真实 fault 与保存的指令地址
除法错误由汇编构造,避免 C 的除零未定义行为影响实验。EDX:EAX 设为 1,ECX 设为零,故障发生在 div ecx:
1 | |
无效操作码实验执行 ud2。这条指令专门用于产生 #UD,不用依赖随意选择的字节碰巧无效。两者都是 fault,保存的 EIP 指向故障指令本身。若不修复除数就从 #DE 返回,处理器会再次执行同一个 div。
一般保护实验把 0x18 装入 AX,再尝试写入 DS。当前 GDT 没有对应的有效项,因此 mov ds, ax 触发 #GP:
1 | |
这次 CPU 确实压入错误码 0x18,可以检验“有硬件错误码”的入口。它指向 GDT 索引 3,错误码低位表示该例不是 IDT 引用、也不是外部事件。
int 13 不能代替这个实验。软件 INT n 不会因为向量恰好属于某个带错误码的异常,就自动压入该异常的 CPU 错误码。直接执行它会违反当前 isr13 的栈布局假设。
冻结源码的实测结果如下,EIP 与 ELF 中的对应汇编标签逐项比较:
| 触发指令 | 向量 | error | 保存的 EIP | 处理结果 |
|---|---|---|---|---|
int3 |
3 | 0 | 0x100de2,probe_resume |
返回并继续自检 |
div ecx |
0 | 0 | 0x100df4,divide_fault |
panic |
ud2 |
6 | 0 | 0x100df7,ud_fault |
panic |
mov ds, ax |
13 | 0x18 |
0x100dfe,gp_fault |
panic |
串口使用 %x 输出错误码,因此 GP 日志中的 error=18 是十六进制。三个 fault 均不修改 EIP,不尝试统一加上某个指令长度。这些地址属于当前编译结果;后续代码改变后地址可能变化,检查脚本按标签比较。
三个故障变体分别启动。每个变体先完成受控 INT3 返回测试,再触发自己的 fault,避免第一个 panic 阻止后面的实验执行。
复现与验证边界
本篇源码提交为 1737b2d320dcb333fd70d2b2720c36c897f4b8e5,提供完整源码附件。解压后进入 os-day-07/examples/build-an-os/,沿用第 01 篇已经建立的 localhost/build-an-os:day01 工具链容器。
1 | |
make check-exceptions 构建正常、divide、ud2、gp 四种镜像,串行使用 GDB 端口 1234。目标固定为 pc-i440fx-7.2、TCG、qemu32、单核 64 MiB 和 SeaBIOS;这些结果属于该模拟环境,不代表实际硬件覆盖。
GDB 阶段检查 IDTR 界限、256 项门属性、68 字节 frame、C 入口栈对齐和 DF,并在 IRETD 后验证恢复结果。日志保存在 build/gdb-exception-{normal,divide,ud2,gp}.txt。
脚本还对四个镜像分别执行不设断点的启动,通过 QEMU monitor 检查 HLT=1。正常镜像必须含 CONSOLE OK 且不含 PANIC;三个故障镜像必须含对应向量、错误码和 unhandled CPU exception。因此停机证据包括实际 HLT 状态,未仅以“断在 HLT 指令前”替代执行验证。
正常最终控制台画面可单独生成:
1 | |
产物是 build/day07-console.png。累计回归使用 make check,还包含前文的装载、内存信息、C ABI、运行库和控制台检查。详细运行记录随附件保存在 docs/day07-evidence.md。
异常处理依赖有效的 IDT、代码段和可写栈。交付异常时再出错,部分异常组合会升级为双重故障;如果双重故障仍无法交付,可能表现为复位。当前同栈入口不能保证从损坏栈中恢复,安装 #DF 表项也没有消除这个限制。
异常输出同样依赖尚可使用的串口、VGA 与有限格式化路径。本篇没有加入堆分配或复杂锁;故障破坏了输出设施时,仍需借助 GDB 和模拟器日志定位。
练习与参考资料
- 按
exception_common的实际压栈顺序推导全部偏移,解释为何pushad_esp == frame + 48。 - 对照
INT3与div ecx保存的 EIP,说明为何前者可以直接返回,后者保留零除数时不能继续正常执行。 - 在临时实验副本中删除无错误码入口的补零,推导哪个 frame 字段会先错位,再用 GDB 验证;不要让该变更进入正常源码。
规范依据为 Intel SDM Vol.3A,253668-078US,December 2022 的 Table 6-1、§6.11–6.13 和各异常条目;PUSHAD、CLD、UD2 的指令行为见 Intel SDM Vol.2。
上一篇:06 - 输出与故障现场。下一篇:08 - 时钟中断。


