第三幕:中断处理——当 CPU 求救时
绪
当 CPU 遇到无法自行处理的情况时——比如除零、非法指令、缺页——它会暂停当前执行流,查 IDT,然后跳转到对应的中断服务例程(ISR)。我们的任务,就是写出这些 ISR。
但在此之前,我们需要搞清楚一件事:哪些异常会附带错误码?
错误码的有无
x86-64 定义了 32 种 CPU 异常。其中只有少数几个会在栈上压入一个错误码,其余则不会。这很重要——因为 ISR 的函数签名必须与 CPU 实际压栈的内容匹配。如果我们在不需要错误码的 ISR 中多弹了一个值,整个栈就会错位,iretq 返回时就会跳到错误地址。
需要错误码的异常:
- #8 Double Fault
- #10 Invalid TSS
- #11 Segment Not Present
- #12 Stack-Segment Fault
- #13 General Protection Fault
- #14 Page Fault
- #17 Alignment Check
- #21 Control Protection Exception
- #29 VMM Communication Exception
- #30 Security Exception
其余 22 个异常不压入错误码。
GCC 的 interrupt 属性
在裸机环境下编写 ISR,我们面临一个棘手的问题:编译器不知道这是 ISR,它可能会在函数入口处压入额外的寄存器、修改栈帧布局。幸运的是,GCC 提供了 __attribute__((interrupt)) 来解决这个问题。
使用这个属性后,GCC 会:
- 保存所有被调用者保存的寄存器
- 使用
iretq(而不是普通的ret)来返回 - 正确理解中断栈帧的布局
我们把 ISR 接受的参数定义为一个结构体:
struct interrupt_frame {
uint64_t rip;
uint64_t cs;
uint64_t rflags;
uint64_t rsp;
uint64_t ss;
} __attribute__((packed));当中断发生时,CPU 会自动压栈:SS、RSP、RFLAGS、CS、RIP(按这个顺序)。如果有错误码,CPU 会在这之后额外压入错误码。GCC 的 interrupt 属性能正确处理这种差异。
宏生成 ISR
32 个 ISR 如果用重复代码一个个写,不仅冗长而且容易出错。我们使用两个宏来批量生成:
#define ISR_NO_ERR(vector, name) \
__attribute__((interrupt)) void isr_##vector( \
struct interrupt_frame *frame) { \
panic_handler(frame, vector, 0, name); \
}
#define ISR_ERR(vector, name) \
__attribute__((interrupt)) void isr_##vector(struct interrupt_frame *frame, \
uint64_t error_code) { \
panic_handler(frame, vector, error_code, name); \
}## 是 C 预处理器的拼接运算符,ISR_NO_ERR(0, "Divide by Zero") 展开后会生成名为 isr_0 的函数。不带错误码的 ISR 只接受 interrupt_frame 指针,带错误码的则多一个 error_code 参数。
Panic 处理——最后的告别
当异常发生时,我们的内核还不具备恢复能力。目前能做的最有用的事,就是尽可能详尽地记录"案发现场",然后停机。
void panic_handler(struct interrupt_frame *frame, uint64_t int_no,
uint64_t err_code, const char *msg) {
fb_print_str("\n==================================================\n");
fb_print_str("KERNEL PANIC: ");
fb_print_str(exception_names[int_no]);
fb_print_str("\n==================================================\n");
fb_print_str("Vector: "); fb_print_hex(int_no);
fb_print_str("\nError Code:"); fb_print_hex(err_code);
fb_print_str("\n\n");
fb_print_str("RIP: "); fb_print_hex(frame->rip);
fb_print_str("\nCS: "); fb_print_hex(frame->cs);
fb_print_str("\nRFLAGS: "); fb_print_hex(frame->rflags);
fb_print_str("\nRSP: "); fb_print_hex(frame->rsp);
fb_print_str("\nSS: "); fb_print_hex(frame->ss);
fb_print_str("\n\n");
if (int_no == 14) {
uint64_t cr2;
__asm__ volatile("mov %%cr2, %0" : "=r"(cr2));
fb_print_str("CR2 (Fault Address): ");
fb_print_hex(cr2);
fb_print_str("\n");
}
fb_print_str("\nSystem Halted.\n");
while (1) {
__asm__ volatile("cli; hlt");
}
}为什么要特别处理 Page Fault?
缺页异常(#14)是内存管理的核心。CR2 寄存器保存了导致缺页的虚拟地址——这是调试内存问题的关键线索。将来的虚拟内存管理(VMM)中,我们会利用这个信息来判断:是非法访问、还是需要按需分配页面、还是 Copy-on-Write 触发。
这个 panic 输出虽然简陋,但在没有调试器的裸机环境下,它是我们唯一的"法医"——RIP 告诉我们代码死在哪里,错误码告诉我们死因,CR2 告诉我们(如果是缺页)是和哪块内存过不去。
完整的异常名称表
我们还需要一个异常名称对照表,让 panic 输出对人类友好:
static const char *exception_names[32] = {
"Division By Zero", "Debug", "Non Maskable Interrupt",
"Breakpoint", "Into Detected Overflow",
"Out of Bounds", "Invalid Opcode",
"No Coprocessor", "Double Fault",
"Coprocessor Segment Overrun", "Bad TSS",
"Segment Not Present", "Stack Fault",
"General Protection Fault", "Page Fault",
"Unknown Interrupt", "Coprocessor Fault",
"Alignment Check", "Machine Check",
"SIMD Floating-Point Exception", "Virtualization Exception",
"Control Protection Exception",
// 22-27 Reserved
"Hypervisor Injection Exception",
"VMM Communication Exception",
"Security Exception",
"Reserved"
};小结
至此,我们的内核拥有了完整的异常处理能力。虽然目前只能以 panic 停机收场,但这已经比"默默崩溃"强太多了。从此以后,每一次除零、每一次越界访问、每一次缺页,CPU 都会在屏幕上留下一份"遗书"后再安然停机。
这也是我们调试未来的 VMM、进程管理、系统调用的最基础保障——在你能修复一个 bug 之前,你得先看见它。
