你这段宏写得很棒,是在实现 异常/中断统一入口处理宏,非常适合操作系统内核!👏
下面我来详细讲解这段汇编宏 exception_handler
的核心逻辑、各部分的作用,帮你真正搞清楚它为什么这么写、哪些寄存器被保存/恢复、为什么 iret
是必须的👇
✅ 这段宏的作用是什么?
用一个宏模板,快速生成多个中断/异常处理入口函数(例如除零异常、页错误、非法指令等),并统一保存现场、调用 C 函数、恢复现场。
🧩 宏定义结构说明:
.macro exception_handler name num with_err_code
参数 | 含义 |
---|---|
name | 异常名,比如 divide , page_fault |
num | 异常号,比如 0 , 14 |
with_err_code | 是否有 error code(1 = 有,0 = 没有) |
✅ 每个 handler 生成一个这样的函数名:
.global exception_handler_divide
exception_handler_divide:
这是你要挂接到 IDT(中断描述符表)里的真正入口。
🔒 现场保存部分解释:
🔹 pusha
pusha
- 保存通用寄存器
eax
,ecx
,edx
,ebx
,esp
,ebp
,esi
,edi
- 顺序固定:依次压入 8 个通用寄存器的值
💡 保存当前运行上下文
🔹 error code 填充逻辑(有些异常没有 error code)
.if \with_err_code == 0
push $0
.endif
- 如果该异常没有 error code,就人为 push 一个 0
- 这样后面处理函数就可以统一读取“异常号 + error code”
🔹 push $\num
push $\num
- 把异常号也压栈,这样你可以在 C 函数中知道是哪个异常
🔹 再次保存段寄存器等
pushal ; GCC 的命名,等同于 pusha
push %ds
push %es
push %fs
push %gs
这些段寄存器不会被 pusha
保存,但在某些平台(特别是 32 位 x86)中断会切换段寄存器,为了保险,手动也保存。
🔹 保存当前 esp
,调用 C 函数处理异常
push %esp
call do_handler_\name
pop %esp
你传的是 %esp
—— 也就是“栈顶指针”,这是整个中断现场的栈帧指针,C 函数 do_handler_xxx()
就可以这样使用:
void do_handler_divide(uint32_t *esp) {
regs_t *regs = (regs_t *)esp; // 栈帧结构体
}
🔁 恢复现场部分
pop %gs
pop %fs
pop %es
pop %ds
popal
依次恢复段寄存器 + 通用寄存器。
🔄 跳过异常号和错误码
add $(2*4), %esp ; 跳过 num + err_code(2个DWORD)
你压了 2 个额外的值:异常号、错误码(即使没 error code,我们也补了 0),所以要把栈顶“抬”回去,恢复成
iret
要求的格式。
✅ 最重要的收尾:iret
iret
❗ 你不能用
ret
,必须用iret
!
指令 | 功能 |
---|---|
ret | 仅恢复 eip (返回地址) |
iret | 恢复 eip 、cs 、eflags ,如果权限级变化,还会恢复 esp 、ss (即用户态 → 内核态切换时) |
所以只有 iret
才能正确跳回中断前的位置,同时恢复执行上下文。
📊 总结流程图(简要)
[中断进入]
↓
push 自动保存(eip, cs, eflags, esp, ss...)
↓
exception_handler_xxx:
pusha ; 保存通用寄存器
push $errcode ; 手动填补
push $num ; 异常号
push 段寄存器
push %esp ; 传给 C 函数
call do_handler_xxx
pop %esp
pop 段寄存器
pop 通用寄存器
add $8, %esp ; 跳过异常号+错误码
iret
🧠 面试总结金句:
中断/异常发生时,CPU 会自动保存最小上下文(eip/cs/eflags),必要时还有 esp/ss。为统一处理,我们额外保存所有通用寄存器和段寄存器,并通过
iret
恢复完整上下文,这正是中断处理函数宏的核心逻辑。
esp
、eflags
、cs
、eip
、error code
这些寄存器啥时候一起出现?为啥有时你看不到它们?
这确实让很多人疑惑,其实这背后是 中断/异常触发后,CPU 自动入栈机制的结果!
✅ 一句话总结:
当中断(包括硬件中断、异常、系统调用等)发生时,CPU 会自动把当前执行上
下文的一些关键寄存器压入栈中,包括EIP
、CS
、EFLAGS
,在某些情况下还包括ESP
、SS
、Error Code
。
📌 什么时候会看到这些寄存器?
✅ 发生中断/异常/陷阱(trap)的时候
比如:
- 硬件中断(键盘、时钟等)
- 异常(除零、页错误、非法指令)
- 系统调用(int 0x80 / syscall)
- Ring3 → Ring0 的权限级别切换(比如用户态 → 内核态)
🔧 CPU 自动压栈机制(中断/异常时)
🔺 普通中断(CPL 没变,比如 Ring 0 → Ring 0)
CPU 自动压栈内容(从栈顶开始):
eflags
cs
eip
[error code] ← 仅某些异常有
🔻 权限级别切换(比如 Ring3 → Ring0)
CPU 自动多压两个寄存器(从用户态换内核栈):
ss ← 用户态栈段选择子
esp ← 用户态栈指针
eflags
cs
eip
[error code]
💡 这时候 CPU 会使用 TSS.esp0 和 ss0 自动切换到内核态栈!
✅ error code 是哪来的?
某些特定异常会自动压入一个 error code
,它告诉你异常类型的具体错误原因,比如:
异常 | 错误码? | 示例 |
---|---|---|
页错误(Page Fault) | ✅ 有 | 访问了不存在的页 |
段不存在(Segment Not Present) | ✅ 有 | 无效段访问 |
除零异常(#DE) | ❌ 无 | 不会压 error code |
一般保护异常(#GP) | ✅ 有 | 访问非法段 |
💡 举个例子:页错误异常触发时
触发 #PF
,CPU 会自动压入以下内容(从高地址到低地址):
用户态:
+----------+
| SS | ← 只有从用户态切内核态才有
| ESP |
+----------+
| EFLAGS |
| CS |
| EIP |
| ERROR CODE |
+----------+ ← 当前 ESP(内核栈)
你在中断处理函数中可以直接读取这些值,或者通过一个中断栈帧结构体访问它们。
✅ 为什么你能看到它们?
-
如果你是在 OS 实验(如 xv6、Linux、裸机)中看到它们,是因为:
中断/异常发生,CPU 自动压入了它们 → 然后你打印或调试栈,就能看到!
-
比如中断处理函数这样定义:
void page_fault_handler(struct interrupt_frame *frame) {
printf("EIP = %x\n", frame->eip);
printf("CS = %x\n", frame->cs);
printf("ESP = %x\n", frame->esp); // 如果有
printf("Err = %x\n", frame->error_code);
}
✅ 中断门(Interrupt Gate)会自动设置这些:
中断描述符表(IDT)中定义中断门时,CPU知道该切哪种栈,是否跨特权级,并自动决定压哪些寄存器。
🎯 总结一句话:
当发生中断或异常时,CPU 会根据是否跨权限、是否需要错误码,自动将当前执行状态(如 eip、cs、eflags、esp、ss、error code)压入栈,供内核态处理后恢复上下文。这是中断/异常机制的底层核心。