串口和 panic 可以报告代码主动检测到的错误,但前提是程序执行到了检查位置。除数为零、指令无效、段选择子越界,会让处理器直接转入异常处理。没有异常入口,上一篇建立的输出能力也无从调用。

本篇安装 IDT,把异常现场整理成 C 结构,打印向量、错误码和寄存器。一个受控 INT3 探针验证返回路径,除零、非法指令和一般保护异常分别验证故障路径。范围限定在 32 位保护模式、同一特权级、固定 QEMU 教学配置;设备 IRQ 尚未启用。

IDT 决定异常从哪里进入

处理器通过异常向量查找中断描述符表 IDT。向量 0 对应除法错误 #DE,3 对应断点 #BP,6 对应无效操作码 #UD,13 对应一般保护异常 #GP。IDT 中的门描述符给出目标代码段和入口地址。

1
2
3
4
5
6
7
8
9
10
11
指令触发异常

CPU 按向量查 IDT,检查门与目标代码段

CPU 保存返回现场,转入对应汇编 stub

stub 补齐向量与错误码,公共入口保存寄存器

trap_dispatch(frame)
├─ 受控 INT3:返回公共入口,恢复现场,IRETD
└─ 其他异常:打印现场,panic,HLT

32 位中断门占 8 字节。入口地址拆成低、高两个 16 位字段,中间还有代码段选择子、保留字节和属性字节。IDTR 则是独立的寄存器,保存表的基址和界限;lidt 从内存读取这两个值。

kernel/exceptions.c 用紧凑布局描述硬件格式:

1
2
3
4
5
6
7
8
9
struct idt_gate {
uint16_t low, selector;
uint8_t zero, flags;
uint16_t high;
} __attribute__((packed));
struct idt_pointer {
uint16_t limit;
uint32_t base;
} __attribute__((packed));

初始化遍历前 32 个向量,填入各自的汇编入口:

1
2
3
4
5
6
for (unsigned i = 0; i < 32; ++i) {
uint32_t address = (uint32_t)exception_stubs[i];
idt[i] = (struct idt_gate){address, 0x08, 0, 0x8e, address >> 16};
}
idtr = (struct idt_pointer){sizeof(idt) - 1, (uint32_t)idt};
__asm__ volatile("lidt %0" : : "m"(idtr) : "memory");

这里的 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
2
3
4
5
6
7
8
9
10
11
高地址
┌──────────────────────┐
│ EFLAGS │
├──────────────────────┤
│ CS │
├──────────────────────┤
│ EIP │
├──────────────────────┤
│ error code(若存在) │ ← CPU 交付异常后的 ESP
└──────────────────────┘
低地址

同级入口没有旧 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
2
3
4
5
6
7
8
9
%macro ISR 1
global isr%1
isr%1:
%if %1 != 8 && %1 != 10 && %1 != 11 && %1 != 12 && %1 != 13 && %1 != 14 && %1 != 17
push dword 0
%endif
push dword %1
jmp exception_common
%endmacro

这七个向量是当前教学目标采用的经典错误码集合,并非现代 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
2
3
4
5
struct trap_frame {
uint32_t gs, fs, es, ds;
uint32_t edi, esi, ebp, pushad_esp, ebx, edx, ecx, eax;
uint32_t vector, error, eip, cs, eflags;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
frame 指向最低地址;每格 4 字节

偏移 字段 来源
0 gs 汇编
4 fs 汇编
8 es 汇编
12 ds 汇编
16 edi PUSHAD
20 esi PUSHAD
24 ebp PUSHAD
28 pushad_esp PUSHAD 执行前的 ESP
32 ebx PUSHAD
36 edx PUSHAD
40 ecx PUSHAD
44 eax PUSHAD
48 vector 汇编
52 error CPU 或软件补零
56 eip CPU
60 cs CPU
64 eflags CPU
68 帧结束 无旧 SS/ESP

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
2
3
4
5
6
7
cld
mov ebx, esp
and esp, -16
sub esp, 12
push ebx
call trap_dispatch
mov esp, ebx

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
2
3
popad
add esp, 8
iretd

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 并恢复其调用者现场。

INT3 返回后在 probe_restored 暂停的 VGA 现场

这张图截取的是 GDB 在 probe_restored 的暂停点。正常内核随后继续执行上一篇的控制台换行、滚屏和格式化自检,最终输出 CONSOLE OK;图中的异常信息不是正常启动的最终停机画面。

三种真实 fault 与保存的指令地址

除法错误由汇编构造,避免 C 的除零未定义行为影响实验。EDX:EAX 设为 1,ECX 设为零,故障发生在 div ecx

1
2
3
4
5
6
7
divide_zero:
xor edx, edx
mov eax, 1
xor ecx, ecx
divide_fault:
div ecx
ret

无效操作码实验执行 ud2。这条指令专门用于产生 #UD,不用依赖随意选择的字节碰巧无效。两者都是 fault,保存的 EIP 指向故障指令本身。若不修复除数就从 #DE 返回,处理器会再次执行同一个 div

一般保护实验把 0x18 装入 AX,再尝试写入 DS。当前 GDT 没有对应的有效项,因此 mov ds, ax 触发 #GP

1
2
3
4
5
general_protection:
mov ax, 0x18
gp_fault:
mov ds, ax
ret

这次 CPU 确实压入错误码 0x18,可以检验“有硬件错误码”的入口。它指向 GDT 索引 3,错误码低位表示该例不是 IDT 引用、也不是外部事件。

int 13 不能代替这个实验。软件 INT n 不会因为向量恰好属于某个带错误码的异常,就自动压入该异常的 CPU 错误码。直接执行它会违反当前 isr13 的栈布局假设。

冻结源码的实测结果如下,EIP 与 ELF 中的对应汇编标签逐项比较:

触发指令 向量 error 保存的 EIP 处理结果
int3 3 0 0x100de2probe_resume 返回并继续自检
div ecx 0 0 0x100df4divide_fault panic
ud2 6 0 0x100df7ud_fault panic
mov ds, ax 13 0x18 0x100dfegp_fault panic

串口使用 %x 输出错误码,因此 GP 日志中的 error=18 是十六进制。三个 fault 均不修改 EIP,不尝试统一加上某个指令长度。这些地址属于当前编译结果;后续代码改变后地址可能变化,检查脚本按标签比较。

一般保护异常报告:向量 13,错误码 18,随后 panic

三个故障变体分别启动。每个变体先完成受控 INT3 返回测试,再触发自己的 fault,避免第一个 panic 阻止后面的实验执行。

复现与验证边界

本篇源码提交为 1737b2d320dcb333fd70d2b2720c36c897f4b8e5,提供完整源码附件。解压后进入 os-day-07/examples/build-an-os/,沿用第 01 篇已经建立的 localhost/build-an-os:day01 工具链容器。

1
2
3
4
unzip os-day-07-source.zip
cd os-day-07/examples/build-an-os
podman run --rm -v "$PWD:/work" -w /work localhost/build-an-os:day01 make check-exceptions
podman run --rm -it -v "$PWD:/work" -w /work localhost/build-an-os:day01 make run

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
podman run --rm -v "$PWD:/work" -w /work localhost/build-an-os:day01 make screenshot

产物是 build/day07-console.png。累计回归使用 make check,还包含前文的装载、内存信息、C ABI、运行库和控制台检查。详细运行记录随附件保存在 docs/day07-evidence.md

异常处理依赖有效的 IDT、代码段和可写栈。交付异常时再出错,部分异常组合会升级为双重故障;如果双重故障仍无法交付,可能表现为复位。当前同栈入口不能保证从损坏栈中恢复,安装 #DF 表项也没有消除这个限制。

异常输出同样依赖尚可使用的串口、VGA 与有限格式化路径。本篇没有加入堆分配或复杂锁;故障破坏了输出设施时,仍需借助 GDB 和模拟器日志定位。

练习与参考资料

  1. exception_common 的实际压栈顺序推导全部偏移,解释为何 pushad_esp == frame + 48
  2. 对照 INT3div ecx 保存的 EIP,说明为何前者可以直接返回,后者保留零除数时不能继续正常执行。
  3. 在临时实验副本中删除无错误码入口的补零,推导哪个 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 - 时钟中断