当前位置: 首页 > news >正文

从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 做了什么?

  1. 读取当前 IDTR 寄存器
  2. 找到中断号对应的 IDT 表项 idt_base + (int_no * 8)
  3. 跳转到描述符指定的中断处理函数地址

✅ 面试叙述建议

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->gsframe->eaxframe->numframe->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;

发生异常:

  1. CPU 触发中断号 0(除 0)
  2. 从 IDT[0] 查到:handler = exception_handler_divider
  3. 汇编执行宏生成的中断入口,压入寄存器、error_code、num
  4. call do_handler_divider(frame)
  5. 打印错误信息,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)原本也是 015 → 重映射后变成 3247定时器、键盘、网卡

✅ 二、异常中断(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 ~ 31CPU 内部IDT[0~31]除 0,页错,非法操作等
IRQ32 ~ 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 的中断处理函数实现调度、响应或日志等逻辑。

如何给面试官讲解中断和异常

讲清楚你设计的中断与异常机制,是面试官判断你是否真正理解操作系统保护模式、硬件协作、中断向量与栈机制的核心依据。


✅ 面试时讲中断/异常系统的目标

你要让面试官听完之后知道:

  1. 你能正确 初始化 IDT配置中断门
  2. 你能处理 CPU 异常 + IRQ 外部中断
  3. 你理解 中断/异常发生的堆栈切换和返回(iret)
  4. 你支持 用户态/内核态的隔离与安全

✅ 讲解结构

① 总体思路概括

在此操作系统中,我自定义了中断/异常处理机制,不依赖 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 控制逻辑

http://www.dtcms.com/a/266891.html

相关文章:

  • 02每日简报20250704
  • Spring Boot + 本地部署大模型实现:安全性与可靠性保障
  • 高档宠物食品对宠物的健康益处有哪些?
  • MySQL/MariaDB数据库主从复制之基于二进制日志的方式
  • 如何查看自己电脑的显卡信息?
  • 力扣hot100题(1)
  • C++26 下一代C++标准
  • 通用人工智能三大方向系统梳理
  • 学习者的Python项目灵感
  • 【python实用小脚本-128】基于 Python 的 Hacker News 爬虫工具:自动化抓取新闻数据
  • [数据结构]详解红黑树
  • 小架构step系列04:springboot提供的依赖
  • mobaxterm终端sqlplus乱码问题解决
  • 使用循环抵消算法求解最小费用流问题
  • opencv的颜色通道问题 rgb bgr
  • 智绅科技:以科技为翼,构建养老安全守护网
  • Vue中对象赋值问题:对象引用被保留,仅部分属性被覆盖
  • 八股学习(三)---MySQL
  • 高流量发布会,保障支付系统稳定运行感想
  • Flink-05学习 接上节,将FlinkJedisPoolConfig 从Kafka写入Redis
  • 关于python
  • Javaweb - 10.2 Servlet
  • 【51单片机倒计时选位最右侧2位显示秒钟后最左侧1位显示8两秒后复位初始状态2个外部中断组合按键功能】2022-7-5
  • 数据库位函数:原理、应用与性能优化
  • Nuxt 3 面试题合集(中高级)
  • 在 C++ 中,判断 `std::string` 是否为空字符串
  • 【贪心】P2660 zzc 种田
  • Rust 中的返回类型
  • 指数分布的Python计算与分析
  • 微服务架构下的抉择:Consul vs. Eureka,服务发现该如何选型?