中断与异常处理
加载出内核后,后续的代码基本就是对内核的设计,首先就是对中断和异常处理的设计。
创建GDT表及其表项
x86架构(IA32 模式 )支持两种存储模式,分段式存储和分页式存储。
分段:先通过分段机制,把逻辑地址(由段选择器和偏移量组成 )转换为线性地址。利用 全局描述符表(GDT)、局部描述符表(LDT )等,依据段选择器找到对应的段描述符,算出线性地址 。这是 x86 内存管理的基础,用于划分内存段、设置访问权限等。
- 分段机制:
- 将线性地址空间转变为多个段(segments)。
- 每个段带有相关的保护机制
- 有多种类型的段:数据、代码、门、tss
- 使用的地址为逻辑地址,即段选择子(指向GDT或LDT中的段描述符)+偏移
分页:可选启用,若启用,线性地址会经分页机制转换为物理地址。通过页目录(Page Directory )、页表(Page Table )等结构,把线性地址按页(如 4KB 页 )拆分,映射到物理内存页,可实现虚拟内存、内存共享、内存保护细化等,还能隐藏物理内存细节,让程序使用连续虚拟地址,实际对应物理内存可离散。
- 分页机制
- 将线性地址转换为物理地址
- 通过虚拟内存机制,用磁盘空间扩展物理内存的容量
- 按需加载等功能
这里主要关注分段式存储中的GDT表。
GDT是x86架构下(尤其是保护模式)用于内存管理和保护的数据结构,本质是一个数组,每个元素叫做段描述符,占八个字节,段描述符控制者个隔断的起始地址、大小和访问属性(当前指令具体访问哪个段则由段寄存器决定),包含:

Seg. Desc.
(通用段描述符)
- 作用:描述最基础的内存段,比如 代码段(Code Segment)、数据段(Data Segment)、栈段(Stack Segment) 。
- 内容:存了段的基地址(Base)、段界限(Limit)、访问权限(如只读 / 可写、特权级) 等。程序里的
CS
(代码段寄存器)、DS
(数据段寄存器)、SS
(栈段寄存器),最终都会通过这类描述符找到实际内存位置。
TSS Desc.
(任务状态段描述符)
- TSS :TSS(Task State Segment)是一块内存区域,存着任务的上下文(比如寄存器值、栈指针、特权级),切换任务时,CPU 会自动从 TSS 恢复 / 保存现场。
TSS Desc.
作用:把 TSS 当普通内存段 “描述” 起来,让 CPU 能找到它的基地址、大小、权限。图里多个 TSS Desc.
,对应多任务场景下不同任务的 TSS。
LDT Desc.
(局部描述符表描述符)
- **LDT :LDT(Local Descriptor Table)是 “局部版 GDT”,一个任务(进程)可以有自己的 LDT,存专属的段描述符(比如用户态程序的私有代码段、数据段 )。
LDT Desc.
作用:把 LDT 本身当一个特殊段,用 LDT Desc.
描述它的位置、大小、权限,让 CPU 能找到并访问这个任务的 LDT。

创建一个GDT表结构体:
1 2 3 4 5 6 7 8 9
| typedef struct _segment_desc_t /*定义GDT表结构体*/ { uint16_t limit15_0; uint16_t base15_0; uint8_t base23_16; uint16_t attr; uint8_t base31_24;
}segment_desc_t;
|
初始化GDT表:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| void segment_desc_set(int selector, uint32_t base, uint32_t limit, uint16_t attr) { segment_desc_t * desc = gdt_table + selector / sizeof(segment_desc_set); desc->limit15_0 = limit & 0xFFFF; desc->attr = attr | (((limit>>16) & 0xF) << 8); desc->base15_0 = base & 0xFFFF; desc->base23_16 = (base >> 16) & 0xFF; desc->base15_0 = (base >> 24) & 0xFF; }
void init_gdt(void) { for(int i =0; i < GDT_TABLE_SIZE; i++) { segment_desc_set(i * sizeof(segment_desc_set), 0, 0, 0); } }
|
分段模型
多段模型(Multi - Segment Model)
把内存拆成多个独立的段,每个段都有自己的基地址、长度、访问权限,不同类型的段分工明确:CS 指向代码段(存程序指令 )、SS 指向栈段(存函数调用的临时数据 )、DS/ES 等指向数据段(存变量 ) 。

平坦模型
他是多段模型的简化版,把整个内存(或线性地址空间)当成一整块连续的区域使用。
只使用了两个段:代码段和数据段。段基是地址均为0,limit大小为4GB(32位系统),这样逻辑地址的偏移量直接对应线性地址,段选择子也就失去实际意义。

逻辑地址转换到线性地址的过程
- 从段寄存器获取段选择子
- 根据选择子在GDT表中获取基地址
- 线性地址=基地址+偏移量,如果无分页机制,则直接为物理地址
CC
重新加载GDT表
在loader_16中有设置过一个GDT表,没有解释每个值的意义,现在了解了GDT表项的含义,在kernel中重新加载GDT表,用到的模式就是平坦模式,只需要初始化两个段,数据段和代码段:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| void init_gdt(void) { for (int i = 0; i < GDT_TABLE_SIZE; i++) { segment_desc_set(i << 3, 0, 0, 0); }
segment_desc_set(KERNEL_SELECTOR_DS, 0x00000000, 0xFFFFFFFF, SEG_P_PRESENT | SEG_DPL0 | SEG_S_NORMAL | SEG_TYPE_DATA | SEG_TYPE_RW | SEG_D | SEG_G);
segment_desc_set(KERNEL_SELECTOR_CS, 0x00000000, 0xFFFFFFFF, SEG_P_PRESENT | SEG_DPL0 | SEG_S_NORMAL | SEG_TYPE_CODE | SEG_TYPE_RW | SEG_D | SEG_G);
lgdt((uint32_t)gdt_table, sizeof(gdt_table));
#define SEG_G (1 << 15) #define SEG_D (1 << 14)
#define SEG_P_PRESENT (1 << 7)
#define SEG_DPL0 (0 << 5) #define SEG_DPL3 (3 << 5)
#define SEG_S_SYSTEM (0 << 4) #define SEG_S_NORMAL (1 << 4)
#define SEG_TYPE_CODE (1 << 3) #define SEG_TYPE_DATA (0 << 3)
#define SEG_TYPE_RW (1 << 1)
|
后面可以在GDT表中增加一些特殊的描述符:门描述符(用于控制程序流程的跳转),包括中断门、陷阱门、任务门。
异常与中断
在程序运行的过程中,有可能会发生各种异常事件,CPU需要跳转到相应的程序对这些事件进行处理。门描述符就是处理异常和中断的中间层。
异常(Exception)
- 定义:由 CPU 内部事件触发的同步事件,与当前执行的指令直接相关。
- 触发原因:
- 错误(Fault):如缺页异常(Page Fault)、除零错误。
- 陷阱(Trap):如系统调用(通过
int
指令主动触发)。
- 终止(Abort):如硬件故障、无法恢复的错误。
- 特点:
- 同步性:与指令执行严格同步,==发生在指令执行期间==。
- 可预测性:通常由程序逻辑或硬件状态引发(如访问非法内存)。
中断(Interrupt)
- 定义:由 CPU 外部设备(如键盘、网卡)或内部定时器触发的异步事件。
- 触发原因:
- 硬件中断:外设(如鼠标、硬盘)通过中断控制器(如 8259A、IOAPIC)向 CPU 发送信号。
- 软件中断:通过特定指令(如 x86 的
INT n
)触发,常用于系统调用。
- 特点:
- 异步性:==与当前指令执行无关==,可能随时发生,由外部事件引起。
- 突发性:由外部设备状态变化引发(如按键按下、数据到达)。
门描述符
门描述符是异常 / 中断处理的中间层,存储在IDT(中断描述符表)中负责:
- 地址映射:将中断向量号映射到具体的处理程序地址。
- 权限控制:限制哪些特权级的代码可以触发该异常 / 中断。
- 上下文切换:自动处理特权级切换(如从用户态到内核态)和堆栈切换。
当 CPU 接收到异常或中断信号时,执行以下步骤:
- 获取向量号:
- 异常:由 CPU 自动生成(如除零错误对应向量号 0)。
- 中断:通过中断控制器(如 IOAPIC)获取向量号(如键盘中断对应 0x21)。
- 查找门描述符:
- 向量号作为索引,从 IDT 中找到对应的门描述符(例如,向量号 0 对应 IDT [0])。
- 验证权限:
- 比较当前特权级(CPL)与门描述符的 DPL(描述符特权级):
- 若
CPL ≤ DPL
,允许访问;否则触发一般保护故障(#GP)。
- 执行处理程序:
- 根据门描述符中的段选择子和偏移量,跳转到处理程序。
- 对于中断门和陷阱门,自动保存当前上下文(如 EFLAGS、CS、EIP)。
中断门描述符与IDT表
IDT表配置类似于GDT表,由一个寄存器指向:IDTR寄存器,内部存放门描述符。
门描述符只存放了基地址和界限信息。

本项目主要实现中断门描述符的功能,其中interrupt gate中segment selector指定了代码段的选择子,offset指定了偏移。即二者结合,指定了各表项对应的异常/中断的处理程序的首地址。

地址生成如下:
- 根据向量号取IDT中的对应表项,
- 从IDT表项取选择子
- 用选择子从GDT表中查找段的首地址,
- 将段首地址+IDT表项中的偏移量,生成处理程序的首地址
- 跳转至首地址运行。
首先与GDT表类似,将IDT表初始化为全零,后续会将具体的异常中断处理程序与IDT表项进行关联。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| # 初始化为全0 void irq_init (void) { for (int i = 0;i < IDE_TABLE_NR; i++) { gate_desc_set(idt_table + i,0, 0, 0); }
lidt((uint32_t)idt_table, sizeof(idt_table)); }
static inline void lidt(uint32_t start, uint32_t size) { struct { uint16_t limit; uint16_t start15_0; uint16_t start31_16; } idt;
idt.start31_16 = start >> 16; idt.start15_0 = start & 0xFFFF; idt.limit = size - 1;
__asm__ __volatile__("lidt %[g]"::[g]"m"(idt)); }
|
初始化并加载成功后,IDT表的首地址与qemu显示的寄存器信息一致:

下面就是为所有异常配置缺省的处理程序,采用平坦模型,所有段的起始地址都为0,通过offset来找到对应处理程序的地址:

发生除零异常:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| void init_main(void) { int a = 3 / 0; for (;;) {} }
static void do_default_handler (const char * message) { for (;;) {} }
void do_handler_unknown (void) { do_default_handler("Unknown exception."); };
void irq_init(void) { for (uint32_t i = 0; i < IDT_TABLE_NR; i++) { gate_desc_set(idt_table + i, KERNEL_SELECTOR_CS, (uint32_t) exception_handler_unknown, GATE_P_PRESENT | GATE_DPL0 | GATE_TYPE_IDT); } lidt((uint32_t)idt_table, sizeof(idt_table)); }
exception_handler_unknown: pusha push %ds push %es push %fs push %gs
call do_handler_unknown
pop %gs pop %fs pop %es pop %ds popa iret
|
解析异常栈信息
这里主要实现具体在哪个位置触发了异常,异常发生时,会有一部分信息被自动压入栈中,包括EFLAGS、CS、EIP和错误码
:

并且在发生异常后我们通过代码也主动保存了一部分信息,最终我们保存的信息如下:

但是可以看出来如果将这些信息全部作为参数传入函数中处理,参数会变得非常长,因此可以看作为结构体,将该结构体的指针也就是ESP寄存器,将结构体指针作为参数传入会更加简洁。
利用宏重用异常处理代码
x86架构保护模式下的异常与中断表定义了非常多的宏,如果针对这些异常分别添加处理函数,就需要复制多次类似的汇编代码编写的处理程序,而这些处理程序的区别只在调用的对应c代码,因此利用gcc工具链中汇编代码中的宏功能,实现类似C语言中的define功能,从而达到重用代码的目的。
如果要定义宏,需要使用.macro和.endm伪指令。其基本示例如下:
.macro 宏的名称 参数0, 参数1….
汇编代码
.endm
其中参数是可选的,也可给参数一个缺省值,例如:
- .macro comm — 定义一个comm宏,不需要参数
- .macro plus1 p, p1 — 定义一个plus1宏,带参数p和p1
- .macro plus1 p p1 — 定义一个plus1宏,带 参数p和p1(用空格分隔)
- .macro reserve_str p1=0 p2 — 定义一个reserve宏,带p1和p2参数,其中p1的缺省值为0
在宏的内部,==可以通过\参数名的方式去引用参数== ,例如:
.macro sum from=0, to=5
.long \from
.if \to-\from
sum "(\from+1)",\to
.endif
.endm
具体实现为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61
| .macro exception_handler name num with_error_code .extern do_handler_\name .global exception_handler_\name exception_handler_\name: // 如果没有错误码,压入一个缺省值 // 这样堆栈就和有错误码的情形一样了 .if \with_error_code == 0 push $0 .endif
// 压入异常号 push $\num
// 保存所有寄存器 pushal push %ds push %es push %fs push %gs
// 调用中断处理函数 push %esp call do_handler_\name add $(1*4), %esp // 丢掉esp
// 恢复保存的寄存器 pop %gs pop %fs pop %es pop %ds popal
// 跳过压入的异常号和错误码 add $(2*4), %esp iret .endm
//调用 exception_handler unknown, -1, 0 exception_handler divider, 0, 0 exception_handler Debug, 1, 0 exception_handler NMI, 2, 0 exception_handler breakpoint, 3, 0 exception_handler overflow, 4, 0 exception_handler bound_range, 5, 0 exception_handler invalid_opcode, 6, 0 exception_handler device_unavailable, 7, 0 exception_handler double_fault, 8, 1 exception_handler invalid_tss, 10, 1 exception_handler segment_not_present, 11, 1 exception_handler stack_segment_fault, 12, 1 exception_handler general_protection, 13, 1 exception_handler page_fault, 14, 1 exception_handler fpu_error, 16, 0 exception_handler alignment_check, 17, 1 exception_handler machine_check, 18, 0 exception_handler smd_exception, 19, 0 exception_handler virtual_exception, 20, 0
// 硬件中断 exception_handler timer, 0x20, 0
|
发生异常后,会根据错误码等跳转到相应的handler函数,并且使CPU暂停执行:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83
| static void do_default_handler (exception_frame_t * frame, const char * message) { for (;;) {hlt();} }
void do_handler_unknown (exception_frame_t * frame) { do_default_handler(frame, "Unknown exception."); }
void do_handler_divider(exception_frame_t * frame) { do_default_handler(frame, "Device Error."); }
void do_handler_Debug(exception_frame_t * frame) { do_default_handler(frame, "Debug Exception"); }
void do_handler_NMI(exception_frame_t * frame) { do_default_handler(frame, "NMI Interrupt."); }
void do_handler_breakpoint(exception_frame_t * frame) { do_default_handler(frame, "Breakpoint."); }
void do_handler_overflow(exception_frame_t * frame) { do_default_handler(frame, "Overflow."); }
void do_handler_bound_range(exception_frame_t * frame) { do_default_handler(frame, "BOUND Range Exceeded."); }
void do_handler_invalid_opcode(exception_frame_t * frame) { do_default_handler(frame, "Invalid Opcode."); }
void do_handler_device_unavailable(exception_frame_t * frame) { do_default_handler(frame, "Device Not Available."); }
void do_handler_double_fault(exception_frame_t * frame) { do_default_handler(frame, "Double Fault."); }
void do_handler_invalid_tss(exception_frame_t * frame) { do_default_handler(frame, "Invalid TSS"); }
void do_handler_segment_not_present(exception_frame_t * frame) { do_default_handler(frame, "Segment Not Present."); }
void do_handler_stack_segment_fault(exception_frame_t * frame) { do_default_handler(frame, "Stack-Segment Fault."); }
void do_handler_general_protection(exception_frame_t * frame) { do_default_handler(frame, "General Protection."); }
void do_handler_page_fault(exception_frame_t * frame) { do_default_handler(frame, "Page Fault."); }
void do_handler_fpu_error(exception_frame_t * frame) { do_default_handler(frame, "X87 FPU Floating Point Error."); }
void do_handler_alignment_check(exception_frame_t * frame) { do_default_handler(frame, "Alignment Check."); }
void do_handler_machine_check(exception_frame_t * frame) { do_default_handler(frame, "Machine Check."); }
void do_handler_smd_exception(exception_frame_t * frame) { do_default_handler(frame, "SIMD Floating Point Exception."); }
void do_handler_virtual_exception(exception_frame_t * frame) { do_default_handler(frame, "Virtualization Exception."); }
|
初始化中断控制器
早期x86使用8259芯片来管理终端,使用两块8259级联来支持15种中断,随着多核处理器的发展,8259被APIC所取代

这里具体的硬件实现不需要关注,只需要关注相应的寄存器的配置。在对寄存器初始化时,需要分配对上述中的master和slave进行初始化。其中master对应的端口起始地址为0x20,slave对应的端口起始地址为0xA0。芯片的手册中给出了初始化流程和相应的寄存器格式说明。

8259的工作模式较为复杂,这里只做了非常简单的配置,不考虑中断嵌套、优先级等问题。具体配置如下:
- 主片:边缘触发,级联、起始中断序号为0x20,IRQ2上有从片,普通全嵌套、非缓冲、非自动结束、8086模式
- 从片:边缘触发,级联、起始中断序号为0x28,连接到主片的IRQ2上,普通全嵌套、非缓冲、非自动结束、8086模式
后续要使用定时器,因此需要做中断的开关函数。
启动定时器并开中断
中断的打开与关闭
中断的打开和关闭受制于两种配置,一个是8259A内部的IMR寄存器,还有一个EFLAGS的IF标志位。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
| void irq_disable_global(void) { cli(); } void irq_enable_global(void) { sti(); }
void irq_enable(int irq_num) { if(irq_num < IRQ_PIC_START) { return; }
irq_num -= IRQ_PIC_START;
if (irq_num < 8) { uint8_t mask = inb(PIC0_IMR) & ~(1 << irq_num); outb(PIC0_IMR, mask); } else { irq_num -= 8; uint8_t mask = inb(PIC1_IMR) & ~(1 << irq_num); outb(PIC1_IMR, mask); }
}
void irq_disable(int irq_num) { if(irq_num < IRQ_PIC_START) { return; }
irq_num -= IRQ_PIC_START;
if (irq_num < 8) { uint8_t mask = inb(PIC0_IMR) | ~(1 << irq_num); outb(PIC0_IMR, mask); } else { uint8_t mask = inb(PIC1_IMR) | ~(1 << irq_num); outb(PIC1_IMR, mask); } }
|
定时器
定时器负责提供精准的时间基准和周期性中断,用于维护系统时间、计算进程执行时间片、实现睡眠等延时函数。x86系统中使用的定时器芯片是8253。
8253是一颗带有3个内部计数器的定时器的芯片,用于为计算机提供相关的定时和计数功能。其中,我们主要关心定时器/计数器0,因其余两个一般用于其它用途。
定时器0可单独计数,其输入的时钟频率为1.193182 MHz。在每个时钟节拍的作用下,进行递减计数。当减至0时,通过8259的IRQ0向CPU发出中断请求,CPU将进入中断服务程序运行。

为实现对8253进行配置,可通过如下端口进行设置。
端口地址 |
名称 |
0x40 |
定时器0数据端口 |
0x41 |
定时器1数据端口 |
0x42 |
定时器2数据端口 |
0x43 |
模式和命令端口 |
具体实现为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
|
uint32_t reload_count = (PIT_OSC_FREQ * OS_TICK_MS) / 1000;
outb(PIT_COMMAND_MODE_PORT, PIT_CHANNEL | PIT_LOAD_LOHI | PIT_MODE3);
outb(PIT_CHANNEL_DATA_PORT, reload_count & 0xFF);
outb(PIT_CHANNEL_DATA_PORT, (reload_count >> 8) & 0xFF);
irq_install(IRQ0_TIMER, (irq_handler_t)exception_handler_time);
irq_enable(IRQ0_TIMER);
|
定时器的中断会在以后一些延时函数、进程调度等地方用到。
以上就是中断与异常处理部分的记录。