从0写自己的操作系统(3)x86操作系统的中断和异常处理
创建GDT表
段界限
基地址
特殊的位(属性)
GDT是一个配置表,里面存储了有关存储访问相关的配置信息,也是整个x86-32位最核心的配置数据。在进入保护模式后,所以有关内存访问操作,都需要经过GDT,GDT中每项称为段描述符( Segment descriptors)。其具体格式如下:
/*** GDT描述符*/
typedef struct _segment_desc_t {uint16_t limit15_0;uint16_t base15_0;uint8_t base23_16;uint16_t attr;uint8_t base31_24;
}segment_desc_t;
一个表项的大小是32字节
/*** 设置段描述符*/
void segment_desc_set(int selector, uint32_t base, uint32_t limit, uint16_t attr) {segment_desc_t * desc = gdt_table + (selector >> 3);// 如果界限比较长,将长度单位换成4KBif (limit > 0xfffff) {attr |= 0x8000;limit /= 0x1000;}desc->limit15_0 = limit & 0xffff;desc->base15_0 = base & 0xffff;desc->base23_16 = (base >> 16) & 0xff;desc->attr = attr | (((limit >> 16) & 0xf) << 8);desc->base31_24 = (base >> 24) & 0xff;
}
GDT表作用
中断(外部)
进入 32 位保护模式后,中断和异常机制必须重新配置,否则 CPU 收到中断或异常时将无法正确响应,通常会导致 Triple Fault(重启)或系统死机。
在保护模式下,CPU 不再使用实模式的中断向量表(0:0 开始的 0x400 字节),而是使用 IDT(中断描述符表),所以必须重新构建并加载 IDT,配置各个中断/异常的处理函数入口。
/*** 设置中断门描述符(interrupt gate)*/
void gate_desc_set(gate_desc_t * desc, uint16_t selector, uint32_t offset, uint16_t attr) {desc->offset15_0 = offset & 0xffff;desc->selector = selector;desc->attr = attr;desc->offset31_16 = (offset >> 16) & 0xffff;
}
在进入保护模式后,我初始化了 IDT 表,准备了 128 个表项。每个表项通过 gate_desc_set() 函数构造中断门,指向对应的异常处理函数地址。默认的处理程序为 exception_handler_unknown,但我为常见的 CPU 异常(如除0、页错误、非法指令等)注册了专门的处理函数。构建完成后,我通过 lidt 指令(内联汇编)将 IDT 加载进 IDTR 寄存器,使 CPU 可以在中断或异常发生时跳转到我的处理器入口。最后,我初始化了 PIC 控制器,重新映射 IRQ,确保中断不会和 CPU 异常冲突。
IDTR(Interrupt Descriptor Table Register)是什么
IDTR(Interrupt Descriptor Table Register)是中断描述符表寄存器,用于告诉 CPU:中断表(IDT)在哪里、有多大。
当中断或异常发生时,CPU 就会根据 IDTR 中保存的信息,在 IDT 中找到对应的入口跳转过去。
✅ IDTR 的结构
IDTR 是一个 6 字节(48 位) 的寄存器,包含:
c
复制编辑
struct {uint16_t limit; // IDT 的大小(单位:字节)减 1uint32_t base; // IDT 的基地址
} __attribute__((packed));
你在 C 中看到的结构大概是这样定义的(你项目中也有):
c
复制编辑
struct {uint16_t limit; // 表长(最多支持 256 个 * 8 = 2048 字节)uint32_t base; // 表的起始地址
} __attribute__((packed)) idt_info;
✅ 怎么设置 IDTR?
你需要使用 lidt
(Load IDT)指令:
c
复制编辑
lidt(&idt_info); // 加载 IDT 表地址和长度到 IDTR 寄存器
你代码里封装的是:
lidt((uint32_t)idt_table, sizeof(idt_table));
这个操作告诉 CPU:
“我的 IDT 表从这里开始,这么大,有 128 项,你之后收到任何中断,都从这个表里查地址。”
✅ 当中断发生时,CPU 做了什么?
- 读取当前 IDTR 寄存器
- 找到中断号对应的 IDT 表项
idt_base + (int_no * 8)
- 跳转到描述符指定的中断处理函数地址
✅ 面试叙述建议
IDTR 是 CPU 内部的一个寄存器,保存中断描述符表(IDT)的地址和大小。当中断或异常发生时,CPU 会根据 IDTR 中保存的 IDT 位置去查找对应的中断处理函数入口地址。我们通过 lidt 指令设置 IDTR,一般是在进入保护模式后立即执行。没有正确设置 IDTR,中断一触发系统就会崩溃或者重启(Triple Fault)。
✅ 补充小贴士
- 实模式使用 0x0000 的中断向量表(IVT)
- 保护模式必须用 IDT + IDTR
- 如果你忘记设置 IDTR,开中断(
sti
)后立马崩!
捕获异常
在进入 32 位保护模式后,必须重新配置中断系统。实模式的中断向量表失效,CPU 改用 IDT 机制来管理中断与异常。我需要构造一张新的 IDT 表,并通过 lidt 加载到 IDTR,确保 CPU 在发生异常(如除 0)或外部中断(如定时器)时能跳转到我定义的 handler。对于 PIC,我还需通过端口重映射 IRQ 避免与异常冲突,最终使用 sti 打开中断。整个过程是操作系统初始化的关键步骤,关乎系统稳定性和调试能力。
进入中断寄存器的保存
如何保存其他寄存器
iret 会将中断/异常进入时保存的 EIP、CS、EFLAGS(还有必要时的 SS 和 ESP)弹栈恢复到 CPU 寄存器中,其他的寄存器需要我们自己压栈出栈 保存
压栈 出栈 顺序相反
如何在C函数中获得这些寄存器参数?
如何定义这个结构体呢?
/*** 中断发生时相应的栈结构,暂时为无特权级发生的情况*/
typedef struct _exception_frame_t {// 结合压栈的过程,以及pusha指令的实际压入过程int gs, fs, es, ds;int edi, esi, ebp, esp, ebx, edx, ecx, eax;int num;int error_code;int eip, cs, eflags;int esp3, ss3;
}exception_frame_t;
然后将gs压入esp中
在 start.s 或 isr.S 中使用 push、pusha 这些指令把寄存器压入栈后,为什么可以直接 call 一个 C 函数,并让它自动用 exception_frame_t* frame 解析这些值?
因为你压栈的顺序 精确匹配了 exception_frame_t 的字段布局,所以在 C 函数中,只要接收一个 exception_frame_t* 指针,它就能直接按字段顺序解析栈上的内容。
我在汇编中断入口函数中,通过 pusha 和手动 push 指令,把 CPU 寄存器、段寄存器、异常号、错误码等内容按照结构体字段顺序压入栈。这样在 C 中只要定义一个匹配的 exception_frame_t 结构体,就可以直接通过指针访问中断上下文信息。这种技巧允许我用 C 来编写中断处理逻辑,简化调试和维护。
如何使用宏复用异常处理代码,以及 exception_handler_\name 为什么能自动处理结构体?
项目中的 start.S
文件里,已经非常典型地用 GAS 宏(.macro
)机制 封装了异常处理逻辑,实现了高复用,非常专业。我们来深入讲清楚:
✅ 宏定义作用回顾(来自你代码)
.macro exception_handler name num with_error_code...
exception_handler_\name:.if \with_error_code == 0push $0.endifpush $\num...call do_handler_\name...iret
.endm
这个宏自动生成一套完整的中断处理入口,包括:
- 是否压入 error_code
- 压入中断号(num)
- 保存/恢复寄存器(pushal/popal)
- 调用 C 函数处理器
- 最后用
iret
返回
每个中断号的处理函数只需要一句:
asm
复制编辑
exception_handler page_fault, 14, 1
就自动生成:
asm
复制编辑
.global exception_handler_page_fault
exception_handler_page_fault:push $14 ; 中断号... ; error_code、pushal、push段寄存器call do_handler_page_fault...iret
✅ 它为什么能匹配 C 的结构体?
这是因为你在汇编里构造的栈布局(寄存器压入顺序)与 C 端定义的 exception_frame_t
成员顺序严格一致:
typedef struct _exception_frame_t {int gs, fs, es, ds;int edi, esi, ebp, esp, ebx, edx, ecx, eax;int num;int error_code;int eip, cs, eflags;int esp3, ss3;
} exception_frame_t;
只要你在汇编里 按顺序 push,然后调用 call handler
前执行:
asm
复制编辑
push %esp ; 传入当前栈指针 → C 端接收为 exception_frame_t* frame
call do_handler_\name
就能在 C 中解析出 frame->gs
、frame->eax
、frame->num
、frame->eip
等。
✅ 好处总结(面试重点)
避免手写大量重复中断入口函数保证所有中断压栈一致性,防止出错能统一对接 C 的结构体,方便调试error_code 有无自动处理,逻辑一致与 irq_install() 和 IDT 注册机制高度契合
✅ 面试叙述建议
为了避免重复书写多个中断入口函数,我使用 GAS 宏定义(.macro)封装了中断门的处理模板。每个异常通过 exception_handler name, num, with_error_code 自动生成中断入口函数,包括 error_code 判断、寄存器压栈、C 函数调用和 iret 返回。这样我在汇编中构造了与 exception_frame_t 完全匹配的栈结构,使得 C 函数可以直接访问中断上下文信息,大大提升了中断处理的灵活性和可维护性。
iret的作用
iret
(或 iretd
) 的作用就是让 CPU 从中断或异常中“恢复执行”,返回到中断/异常发生前的位置继续执行。
iret 会将中断/异常进入时保存的 EIP、CS、EFLAGS(还有必要时的 SS 和 ESP)弹栈恢复到 CPU 寄存器中,实现真正意义上的返回。
🧠 iret
执行流程图
假设这是中断发生时,CPU 自动压栈的内容(保护模式下):
text
复制编辑
栈结构(从高到低):
┌────────────┐ ← ESP before iret
│ SS (if 从低特权级返回) │ ← 特权级变化才会压
│ ESP │
│ EFLAGS │
│ CS │
│ EIP │ ← 中断发生时下一条指令地址
└────────────┘
当执行:
asm
复制编辑
iret ; 或 32 位下是 iretd
时,CPU 会按顺序弹出:
css
复制编辑
→ EIP ← 跳转目标(异常/中断发生前的下一条指令地址)
→ CS ← 段选择子(恢复原执行上下文)
→ EFLAGS ← 恢复标志寄存器
[→ ESP, SS ← 如果从高特权级切换回来(如中断进入用户态)]
✅ 场景:异常返回(你问的)
举例:
c
复制编辑
int a = 1 / 0; // 除 0 异常 → ISR
异常处理函数执行完后执行 iret
:
asm
复制编辑
iret → 自动返回到 `/` 语句之后
此时恢复的就是:
EIP
: 除法之后的地址CS
: 原本执行代码的段EFLAGS
: 原中断标志位等
✅ 面试时你可以这样说:
当 CPU 响应中断或异常时,会自动把 EIP、CS 和 EFLAGS 压栈,如果是从用户态进入内核,还会压栈 SS 和 ESP。处理完成后,通过执行 iret 指令,CPU 会依次恢复这些寄存器,从而准确返回到中断或异常发生的位置,继续原来的程序执行。
🔍 延伸(bonus)
返回场景 | 栈中恢复内容 | 是否切换栈 |
---|---|---|
内核 → 内核中断返回 | EIP, CS, EFLAGS | 否 |
用户态 → 内核中断返回 | EIP, CS, EFLAGS, ESP, SS | 是 |
使用 syscall → iret | 也依赖中断门 DPL = 3,iret 返回用户态 | 是 |
异常/中断整体流程
irq_install(num, handler) 会把指定的 中断处理函数地址 填入 IDT 表 中的 中断门描述符,而 CPU 收到中断时,会根据 IDT → 跳转到你在汇编中生成的 exception_handler_xxx,最后 call 到你注册的 C 处理函数。
✅ 整体联动流程图(由外到内)
↓ 外设中断 / CPU 异常↓[ CPU 收到中断 N ]↓[ CPU 查 IDTR → 定位 IDT[N] ]↓[ IDT[N] 中断门 → offset = handler ]↓
[ 汇编中的 exception_handler_xxx 执行(宏展开) ]↓push error_code? + push num + pushal ...↓push espcall do_handler_xxx (C 函数)↓
[ 你在 irq_install() 注册的处理函数 被执行 ]
✅ 联动关键点详解
① irq_install()
:注册处理函数
在 irq.c
中定义:
c
复制编辑
int irq_install(int irq_num, irq_handler_t handler) {if (irq_num >= IDT_TABLE_NR) return -1;gate_desc_set(idt_table + irq_num, KERNEL_SELECTOR_CS, (uint32_t) handler,GATE_P_PRESENT | GATE_DPL0 | GATE_TYPE_IDT);return 0;
}
- 把 handler 地址写入
idt_table[irq_num]
- handler 就是宏生成的汇编
exception_handler_xxx
② 中断门结构 gate_desc_t
c
复制编辑
typedef struct {uint16_t offset_low;uint16_t selector;uint8_t zero;uint8_t type_attr;uint16_t offset_high;
} gate_desc_t;
这一结构由 gate_desc_set()
设置。
③ 宏定义自动生成 handler 汇编函数
exception_handler general_protection, 13, 1
→ 生成汇编函数:exception_handler_general_protection:push $error_codepush $13pushapush段寄存器push %espcall do_handler_general_protection...iret
④ do_handler_xxx 函数就是你项目中写的 C 函数
如 irq.c
:
void do_handler_general_protection(exception_frame_t *frame) {log_printf("GP Exception! CS=%x, EIP=%x\n", frame->cs, frame->eip);...
}
✅ 举个真实例子:除 0 异常
假设程序执行:
c
复制编辑
int a = 1 / 0;
发生异常:
- CPU 触发中断号
0
(除 0) - 从 IDT[0] 查到:handler =
exception_handler_divider
- 汇编执行宏生成的中断入口,压入寄存器、error_code、num
call do_handler_divider(frame)
- 打印错误信息,
iret
返回
✅ 面试叙述模板(推荐说法)
在我的中断系统中,irq_install(n, handler) 会将中断处理函数地址写入 IDT 的第 n 项。中断发生时,CPU 根据 IDTR 定位 IDT 表,从第 n 项中读取跳转地址,进入宏定义生成的中断汇编入口函数。在那里我构造了与 exception_frame_t 对应的栈结构,并调用 do_handler_xxx(frame) 进行中断处理,最后通过 iret 返回到中断前的位置,实现中断流程闭环。
中断(外部)
定时器
硬件 → PIC → CPU → 处理函数
IRQ0 定时器 → 映射中断号 0x20 → 触发 IDT[32] → do_handler_timer()
IRQ1 键盘 → 映射中断号 0x21 → 触发 IDT[33] → do_handler_kbd()
IRQ14 硬盘 → 映射中断号 0x2E → 触发 IDT[46] → do_handler_ide()
一、异常(Exception)和中断(Interrupt)是两类不同来源
类型 | 来源 | 中断号范围 | 举例 |
---|---|---|---|
异常 | CPU 内部触发 | 0~31 | 除 0、页错误、GPF等 |
中断 | 外设触发(IRQ) | 原本也是 0 | 定时器、键盘、网卡 |
✅ 二、异常中断(0~31)怎么来的?
这些是 CPU 硬编码好的,不是由 PIC 发出的,也不走 IRQ 总线,而是:
- 当执行指令过程中发生非法情况
- 比如:
- 除以 0 → #DE(中断号 0)
- 页错误 → #PF(中断号 14)
- 通用保护错误 → #GP(中断号 13)
此时,CPU 自动触发中断号 0~31,去查 IDT[0~31],跳转到对应异常处理器。
c
复制编辑
irq_install(0, exception_handler_divider); // 除0
irq_install(14, exception_handler_page_fault); // 页错误
这些你注册在 IDT[0~31] 的函数,是给 CPU 异常准备的。
✅ 三、IRQ(硬件中断)为什么要重映射到 32~47?
原始 IRQ0~IRQ15 的中断号是 0x08~0x0F(主片)、0x70~0x77(从片),但这会和上面的异常号冲突!
💥 比如:
- IRQ0(定时器)默认映射到中断号 8 → 与 Double Fault 冲突 → Triple Fault 崩溃
所以必须在 init_pic()
中重映射:
c
复制编辑
主 PIC 起始中断号 = 0x20 → IRQ0 ~ IRQ7 = 32 ~ 39
从 PIC 起始中断号 = 0x28 → IRQ8 ~ IRQ15 = 40 ~ 47
这就是你设置的:
c
复制编辑
outb(PIC0_ICW2, 0x20); // IRQ0 映射为中断号 0x20
outb(PIC1_ICW2, 0x28); // IRQ8 映射为中断号 0x28
✅ 四、触发来源总结
类型 | 中断号范围 | 谁触发的? | IDT 索引 | 用来干啥 |
---|---|---|---|---|
异常 | 0 ~ 31 | CPU 内部 | IDT[0~31] | 除 0,页错,非法操作等 |
IRQ | 32 ~ 47 | 外设(经 PIC) | IDT[32~47] | 定时器、键盘、IDE等 |
✅ 五、怎么判断中断来源?
你可以在通用中断入口 c_entry(exception_frame_t *frame)
中根据 frame->num
判断:
c
复制编辑
if (frame->num < 32) {// 是异常
} else if (frame->num >= 32 && frame->num < 48) {// 是 IRQ
}
✅ 面试回答建议(标准模板)
异常由 CPU 内部触发,固定占用中断号 0~31,比如除 0、页错误、非法指令等;而硬件中断通过 PIC 发出,原始中断号也在 0~15,但为了避免冲突,我在 init_pic() 中将它们重映射到 32~47。这样在 IDT 表中,异常和 IRQ 各自占据不同范围,可以同时注册、同时处理。中断触发后,CPU 会根据中断号查 IDT 表项,跳转到我的汇编入口函数,最终调用 C 的中断处理函数实现调度、响应或日志等逻辑。
如何给面试官讲解中断和异常
讲清楚你设计的中断与异常机制,是面试官判断你是否真正理解操作系统保护模式、硬件协作、中断向量与栈机制的核心依据。
✅ 面试时讲中断/异常系统的目标
你要让面试官听完之后知道:
- 你能正确 初始化 IDT 和 配置中断门
- 你能处理 CPU 异常 + IRQ 外部中断
- 你理解 中断/异常发生的堆栈切换和返回(iret)
- 你支持 用户态/内核态的隔离与安全
✅ 讲解结构
① 总体思路概括
在此操作系统中,我自定义了中断/异常处理机制,不依赖 BIOS 或 GRUB 提供的中断表。进入保护模式后,我会构建自己的 IDT 表,并使用 lidt 加载到 IDTR 中。整个中断系统支持 0~255 个向量,其中前 32 个保留处理 CPU 异常,后面用于硬件中断和系统调用。
② 此项目如何构建 IDT 表
预先构建了一个 128 项的 IDT 表,每项 8 字节。中断门的设置通过封装的 gate_desc_set() 函数完成,指定:
handler 的地址(如
do_handler_page_fault
)段选择子为
KERNEL_SELECTOR_CS
类型字段为
0x8E
表示中断门、DPL=0、P=1初始化时我会将所有项先指向默认处理器,再通过
irq_install()
为特定中断设置实际的处理函数。
③ PIC 初始化和 IRQ 映射
在初始化阶段使用编程 I/O 操作重新配置了 8259A PIC,把 IRQ0IRQ15 从默认的 0x080x0F 映射到 0x20~0x2F,避免和 CPU 异常冲突。之后我为定时器(IRQ0)和键盘(IRQ1)等设置了具体的中断处理函数,并支持中断屏蔽、EOI、开关控制等操作。
④ 异常处理函数设计(通用框架)
所有中断处理函数都使用统一的 exception_frame_t 结构作为参数,可以查看当时的寄存器和错误码。我为每个异常设置了具体说明,比如页错误会通过 CR2 获取地址、段错误会解析 error code。这些信息通过 log 输出,方便调试。
⑤ 中断返回:iret & 栈切换机制
当中断触发时,CPU 会自动压栈 EIP、CS、EFLAGS,如果从用户态进入内核还会额外压 SS 和 ESP。中断处理函数结束后我使用 iret 指令将这些值依次恢复,确保系统能够准确返回到中断前的执行位置。这也支持从内核态正确返回到用户态。
⑥ 用户态中断支持(进阶可选)
支持设置用户态 DPL=3 的中断门,比如 syscall 入口。这样用户程序可以通过 int 0x80 等方式触发系统调用,由内核处理再通过 iret 返回。
✅ 面试官常见追问 & 应对
追问 | 建议回应方式 |
---|---|
为什么中断门要用 KERNEL_SELECTOR_CS ? | 因为中断发生时会远跳转,需要进入代码段。 |
异常如何返回? | 通过 iret 恢复 EIP/CS/EFLAGS,返回中断前位置 |
如何保证用户态不能直接触发内核中断? | 通过 DPL 限制中断门访问权限,只允许 CPL ≤ DPL |
你怎么处理页错误? | 我会读取 CR2 查地址,通过 error_code 分析读写/用户态错误等 |
你中断中是否支持嵌套?是否有重入保护? | 我支持手动屏蔽中断,防止栈破坏,也有全局 cli/sti 控制逻辑 |