早期计算机没有中断机制。CPU 想知道键盘是否有输入,只能反复检查键盘控制器的状态位,这叫轮询(polling)。轮询的代价是巨大的:CPU 把时间浪费在无意义的检查上,无法做其他事情。中断机制的发明解决了这个问题,让硬件主动通知 CPU,CPU 只在需要时才响应。
打个比方:你在做作业时,突然电话响了。你需要:
- 暂停当前的作业
- 记住做到哪一步(保存上下文)
- 接听电话(处理中断)
- 记录电话内容(处理中断请求)
- 恢复做作业(恢复上下文)
CPU 也是如此。中断机制让 CPU 能够:
- 响应外部硬件设备的通知(键盘、鼠标、网卡等)
- 处理内部的异常情况(除零、内存访问错误等)
- 实现定时任务和时间片调度
- 在用户态和内核态之间切换
中断描述符表(IDT)
CPU 收到中断信号后,需要知道该执行什么代码。IDT 是一个表格,将中断向量号(0-255)映射到中断处理程序的地址。就像一个”电话簿”,CPU 查询 IDT 就知道该拨打哪个处理程序的”电话”。
IDT 包含 256 个描述符,每个描述符 8 字节。x86 保护模式下,IDT 替代了实模式的中断向量表(IVT)。
IDT 描述符结构(8 字节):
| 字节 | 0 - 1 | 2 - 3 | 4 | 5 | 6 - 7 |
|---|---|---|---|---|---|
| 字段 | offset_low (16 bit) | selector (16 bit) | reserved (8 bit) | type_attr (8 bit) | offset_high (16 bit) |
关键字段说明:
offset_low/offset_high:中断处理程序的地址(32 位)selector:代码段选择子,通常指向内核代码段type_attr:类型和属性,包括:- Present 位:描述符是否有效
- DPL(Descriptor Privilege Level):特权级(0=内核,3=用户)
- Type:中断门(type 字段 = 0xE)或陷阱门(type 字段 = 0xF)
对应的数据结构定义如下:
typedef struct idt_entry{ uint16_t offset_low; // 处理程序地址低16位 uint16_t selector; // 代码段选择子 uint8_t reserved; // 保留,必须为0 uint8_t type_attr; // 类型和属性 uint16_t offset_high; // 处理程序地址高16位} __attribute__((packed)) idt_entry_t;
typedef struct idt_ptr{ uint16_t limit; // IDT大小-1 uint32_t base; // IDT基地址} __attribute__((packed)) idt_ptr_t;中断帧(Interrupt Frame)
IDT 告诉 CPU 该调用哪个处理函数,但在调用之前,CPU 需要保存当前执行状态,这就是中断帧的作用。中断发生时,CPU 自动保存部分寄存器(EIP、CS、EFLAGS),但需要保存所有通用寄存器才能安全地执行 C 代码。中断帧定义了栈上的数据布局,确保 C 函数能够访问所有必要的信息。
当中断发生时,栈上的数据布局如下:
CPU 自动压栈(从高地址到低地址):
- SS(用户态中断时压入)
- ESP(用户态中断时压入)
- EFLAGS
- CS
- EIP
- Error Code(某些异常可选压入)
汇编存根压栈(从高地址到低地址):
- EAX
- ECX
- EDX
- EBX
- ESP(dummy)
- EBP
- ESI
- EDI
- DS
- ES
- FS
- GS
- int_no
- err_code
关键点:
- 对于某些异常(如 #DF、#TS、#NP、#SS、#GP、#PF),CPU 会自动压入错误码
- 汇编存根需要为没有错误码的异常压入一个占位符(0)
- 汇编存根使用
pusha保存所有通用寄存器 - 最后压入段寄存器以确保 C 代码使用正确的数据段
对应的数据结构:
typedef struct interrupt_frame{ uint32_t gs, fs, es, ds; // 段寄存器 uint32_t edi, esi, ebp; // 通用寄存器 uint32_t esp_dummy; // pusha 保存的 esp uint32_t ebx, edx, ecx, eax; // 通用寄存器 uint32_t int_no; // 中断号 uint32_t err_code; // 错误码 uint32_t eip, cs, eflags; // CPU 保存 uint32_t useresp, ss; // 用户态栈(可选)} interrupt_frame_t;8259A 可编程中断控制器(PIC)
x86 CPU 只有两条中断引脚(INTR 和 NMI),但系统有很多硬件设备需要产生中断。PIC 负责管理多个中断源,仲裁优先级,并向 CPU 发送中断信号。就像公司的前台,负责接听所有来电并转接到正确的部门。
PC 系统使用两个级联的 8259A PIC:
初始化序列(ICW1-ICW4):
- ICW1:发送初始化命令,告诉 PIC 开始初始化
- ICW2:设置中断向量偏移(主 PIC = 0x20,从 PIC = 0x28)
- ICW3:配置主从级联关系
- ICW4:设置工作模式(8086 模式)
EOI(End of Interrupt): 处理完中断后,必须向 PIC 发送 EOI 信号,告诉它可以接受下一个中断。如果是从 PIC 的中断(IRQ8-15),需要同时向主 PIC 和从 PIC 发送 EOI。
关键接口:
pic_init(master_offset, slave_offset):初始化 PICpic_send_eoi(irq):发送中断结束信号
CPU 异常
硬件中断来自外部设备,而 CPU 异常来自处理器内部。两者的处理机制相似,但触发方式不同。CPU 在执行过程中可能遇到错误条件(如除零、页错误),异常处理程序可以优雅地处理这些情况或终止有问题的程序。
x86 定义了 32 个异常向量(0-31):
| 向量 | 名称 | 类型 | 说明 |
|---|---|---|---|
| 0 | #DE | Fault | 除零错误 |
| 6 | #UD | Fault | 无效操作码 |
| 8 | #DF | Abort | 双重错误 |
| 13 | #GP | Fault | 通用保护错误 |
| 14 | #PF | Fault | 页错误 |
异常类型:
- Fault:可修复,返回到触发指令重新执行
- Trap:返回到下一条指令继续执行
- Abort:严重错误,无法恢复,通常需要停止系统
错误码: 某些异常(如 #GP、#PF)会提供错误码,帮助诊断问题。例如,#PF 的错误码包含引起页错误的内存访问类型。
可编程间隔定时器(PIT)
时钟中断是整个中断系统中最频繁、最基础的中断源,也是多任务调度的时间基准。8254 PIT 是 PC 上最基本的定时器硬件,为操作系统提供周期性的时钟信号。
8254 PIT 有三个通道:
- 通道 0:系统定时器,连接到 IRQ0
- 通道 1:内存刷新(现代系统很少使用)
- 通道 2:PC 扬声器
PIT 的输入时钟频率固定为 1193180 Hz(PC 主板晶振频率),输出频率由分频值决定:输出频率 = 1193180 / 分频值。例如要实现 50 Hz 的定时中断,分频值 = 1193180 / 50 = 23863。
工作模式:
- 模式 3:方波发生器,最适合定期中断
- 访问模式:先读写低字节,后读写高字节
初始化定时器的核心步骤是:向 PIT 命令端口写入模式配置(通道 0、模式 3、双字节访问),再向通道 0 数据端口依次写入分频值的低字节和高字节。完整实现见下文「定时器驱动」一节。
代码实现
文件结构
boot
mbr.S
loader.S
kernel
include
types.h
vga.h
io.h
ports.h
gdt.h
interrupt.h
timer.h
interrupt
interrupt.c
interrupt.S
timer.c
mm
gdt.c
gdt.S
lib
string.c
drivers
vga.c
kernel.c
link.ld
Makefile
中断处理流程
IDT 初始化
void idt_init(void){ // 初始化IDT指针 idt_ptr.limit = sizeof(idt_entry_t) * IDT_ENTRIES - 1; idt_ptr.base = (uint32_t)&idt;
// 清空IDT表 for (int i = 0; i < IDT_ENTRIES; i++) { idt_set_gate(i, 0, 0, 0); }
// 注册 CPU 异常处理程序(向量 0-31) SET_ISR(0); // #DE 除零 SET_ISR(13); // #GP 通用保护 SET_ISR(14); // #PF 页错误 // ... 其他异常
// 注册硬件中断处理程序(向量 32-47) SET_IRQ(0); // 定时器 (向量32) SET_IRQ(1); // 键盘 (向量33) // ... 其他IRQ
// 初始化PIC pic_init(PIC1_VECTOR_OFFSET, PIC2_VECTOR_OFFSET);
// 加载IDT到CPU __asm__ volatile("lidt %0" : : "m"(idt_ptr));
vga_printf("[IDT] Initialized with %d entries.\n", IDT_ENTRIES);}解析:
SET_ISR(n)宏设置 CPU 异常处理程序(向量 0-31)SET_IRQ(n)宏设置硬件中断处理程序(向量 32-47)lidt指令将 IDT 基地址和大小加载到 CPU 的 IDTR 寄存器
中断汇编存根
; 无错误码的CPU异常%macro ISR_NOERR 1global isr%1isr%1: push dword 0 ; 错误码占位(因为CPU不会压入) push dword %1 ; 中断号 jmp isr_common_stub%endmacro
; 带错误码的CPU异常%macro ISR_ERR 1global isr%1isr%1: push dword %1 ; 中断号(CPU已自动压入错误码) jmp isr_common_stub%endmacro
; 通用ISR处理入口isr_common_stub: pusha ; 保存所有通用寄存器 push ds push es push fs push gs
mov ax, KERNEL_DS ; 加载内核数据段 mov ds, ax mov es, ax mov fs, ax mov gs, ax
push esp ; 传递栈指针(指向中断帧) call isr_handler ; 调用C处理函数 add esp, 4 ; 弹出参数
pop gs pop fs pop es pop ds popa add esp, 8 ; 清除错误码和中断号 iret ; 中断返回解析:
pusha保存所有通用寄存器(EAX、ECX、EDX、EBX、ESP、EBP、ESI、EDI)- 段寄存器必须单独保存和恢复
KERNEL_DS确保处理程序使用内核数据段iret指令恢复 EIP、CS 和 EFLAGS,并可能恢复用户态的 SS 和 ESP
中断分发器
void isr_handler(interrupt_frame_t *frame){ // 设置中断上下文标志 in_interrupt_context = TRUE;
// 调用注册的处理程序(如果有) if (interrupt_handlers[frame->int_no] != 0) { interrupt_handler_t handler = interrupt_handlers[frame->int_no]; handler(frame); } else { // 如果没有注册处理程序,打印异常信息 if (frame->int_no < 32) { vga_printf("\nUnhandled Exception: %s (0x%x)\n", exception_names[frame->int_no], frame->int_no); vga_printf("Error Code: 0x%x\n", frame->err_code); vga_printf("EIP: 0x%x, CS: 0x%x, EFLAGS: 0x%x\n", frame->eip, frame->cs, frame->eflags);
// 对于严重异常,停止系统 if (frame->int_no == 8 || frame->int_no == 13 || frame->int_no == 14) { vga_printf("System halted due to critical exception.\n"); __asm__ volatile("cli; hlt"); } } }
// 清除中断上下文标志 in_interrupt_context = FALSE;}解析:
interrupt_handlers数组存储每个向量的处理程序- 对于未处理的 CPU 异常,打印详细信息并可能停止系统
cli; hlt禁用中断并停止 CPU,防止系统继续执行
PIC 初始化
void pic_init(uint8_t offset_vector_master, uint8_t offset_vector_slave){ // 保存当前中断屏蔽状态 uint8_t mask1 = inb(PIC1_DATA); uint8_t mask2 = inb(PIC2_DATA);
// ICW1: 开始初始化 outb(PIC1_CMD, PIC_ICW1_INIT | PIC_ICW1_ICW4); io_wait(); outb(PIC2_CMD, PIC_ICW1_INIT | PIC_ICW1_ICW4); io_wait();
// ICW2: 设置中断向量偏移 outb(PIC1_DATA, offset_vector_master); // 主 PIC: 0x20 outb(PIC2_DATA, offset_vector_slave); // 从 PIC: 0x28
// ICW3: 设置主从级联关系 outb(PIC1_DATA, PIC_MASTER_ICW3_IRQ2); // IRQ2 连接从 PIC outb(PIC2_DATA, PIC_SLAVE_ICW3_ID); // 从 PIC ID = 2
// ICW4: 设置8086模式 outb(PIC1_DATA, PIC_ICW4_8086_MODE); outb(PIC2_DATA, PIC_ICW4_8086_MODE);
// 恢复原有屏蔽位 outb(PIC1_DATA, mask1); outb(PIC2_DATA, mask2);
vga_printf("[PIC] Initialized: master offset=0x%x, slave offset=0x%x\n", offset_vector_master, offset_vector_slave);}解析:
io_wait()确保命令被 PIC 接受(硬件时序要求)- ICW3 的主 PIC 值 0x04 表示 IRQ2 连接从 PIC
- ICW3 的从 PIC 值 0x02 表示从 PIC 连接到主 PIC 的 IRQ2
- 最后恢复中断屏蔽状态,避免意外启用所有中断
定时器驱动
void init_timer(uint32_t frequency){ // 注册定时器中断处理程序 register_interrupt_handler(IRQ0_INT_NUM, &timer_callback);
// 计算分频值(加 frequency/2 进行四舍五入) if (frequency == 0) frequency = 1; uint32_t divisor = (CLOCK_TICK_RATE + frequency / 2) / frequency;
// 发送命令字节 outb(PIT_CMD, PIT_CHANNEL0 | PIT_MODE3 | PIT_ACCESS_BOTH | PIT_BCD);
// 发送分频值(先低字节,后高字节) uint8_t l = (uint8_t)(divisor & 0xFF); uint8_t h = (uint8_t)((divisor >> 8) & 0xFF); outb(PIT_CH0, l); outb(PIT_CH0, h);}
static void timer_callback(interrupt_frame_t *regs){ tick++; vga_printf("tick=%d\n", tick);}解析:
PIT_MODE3表示方波模式,适合定期中断PIT_ACCESS_BOTH表示先读写低字节,后读写高字节- 分频值计算中的四舍五入可以减少频率误差
- 每次定时器中断都会调用
timer_callback,增加 tick 计数
运行与验证
编译运行
cd 07.kernel-idtmake all # 编译make run # 运行make clean # 清理预期输出
Started in 16-bit real mode (BIOS)Now in 32-bit protected mode (direct video)Now Enable PageHello, kernel world!Value: 1234, Hex: 0x4d2, Char: A, String: VGA printf OK!--- GDT Dump ---GDT Base: 0x..., Limit: 0x...[GDT] Initialized.[IDT] Initialized with 256 entries.[PIC] Initialized: master offset=0x20, slave offset=0x28tick=1tick=2tick=3...定时器中断每秒触发 50 次,屏幕持续输出 tick 计数。
测试异常处理
要测试异常处理,可以在 kernel.c 中添加以下代码:
// 测试除零异常void test_divide_error() { int a = 10; int b = 0; int c = a / b; // 这会触发 #DE 异常}
// 测试页错误void test_page_fault() { int *ptr = (int *)0x10000000; // 未映射的地址 *ptr = 42; // 这会触发 #PF 异常}踩坑记录
-
中断后系统崩溃:
- 原因:寄存器保存/恢复不正确,段选择子错误
- 解决方案:检查
pusha/popa和段寄存器保存是否正确,确保 IDT 描述符的段选择子指向正确的代码段
-
定时器不工作:
- 原因:PIC 未正确初始化或未发送 EOI
- 解决方案:确保
pic_init被调用,并且irq_handler中调用了pic_send_eoi
-
双重错误(Double Fault):
- 原因:中断处理程序本身出错,或者 IDT 描述符错误
- 解决方案:检查 IDT 描述符的段选择子是否正确,确保处理程序代码在正确的内存区域
-
页错误循环:
- 原因:页错误处理程序访问未映射的内存
- 解决方案:确保处理程序只使用已映射的内存,避免在处理程序中访问非法地址
-
定时器频率不准确:
- 原因:
CLOCK_TICK_RATE常量不准确,或分频值计算错误 - 解决方案:确认主板使用的是标准 1193180 Hz 晶振,检查分频值计算公式
- 原因:
下一章将实现内存管理,让内核具备动态分配和释放内存的能力。
参考
- Intel 64 and IA-32 Architectures Software Developer’s Manual - Chapter 6: Interrupt and Exception Handling
- 8259A PIC Datasheet - 8259A 可编程中断控制器官方手册
- 8254 PIT Datasheet - 8254 可编程间隔定时器官方手册
- OSDev Wiki - Interrupts - OSDev 中断系统概览
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






