早期计算机没有中断机制。CPU 想知道键盘是否有输入,只能反复检查键盘控制器的状态位,这叫轮询(polling)。轮询的代价是巨大的:CPU 把时间浪费在无意义的检查上,无法做其他事情。中断机制的发明解决了这个问题,让硬件主动通知 CPU,CPU 只在需要时才响应。
打个比方:你在做作业时,突然电话响了。你需要:
- 暂停当前的作业
- 记住做到哪一步(保存上下文)
- 接听电话(处理中断)
- 记录电话内容(处理中断请求)
- 恢复做作业(恢复上下文)
CPU 也是如此。中断机制让 CPU 能够:
- 响应外部硬件设备的通知(键盘、鼠标、网卡等)
- 处理内部的异常情况(除零、内存访问错误等)
- 实现定时任务和时间片调度
- 在用户态和内核态之间切换
中断描述符表(IDT)
CPU 收到中断信号后,需要知道该执行什么代码。IDT 是一个表格,将中断向量号(0-255)映射到中断处理程序的地址。就像一个”电话簿”,CPU 查询 IDT 就知道该拨打哪个处理程序的”电话”。
IDT 包含 256 个描述符,每个描述符 8 字节。x86 保护模式下,IDT 替代了实模式的中断向量表(IVT)。
IDT 描述符结构(8字节):+----------------+----------------+| offset_low | selector | 字节 0-3| (16 bits) | (16 bits) |+----------------+----------------+| reserved | type_attr | 字节 4-7| (8 bits) | (8 bits) |+----------------+----------------+| offset_high | | 字节 8-11| (16 bits) | |+----------------+----------------+关键字段说明:
offset_low/offset_high:中断处理程序的地址(32 位)selector:代码段选择子,通常指向内核代码段type_attr:类型和属性,包括:- Present 位:描述符是否有效
- DPL(Descriptor Privilege Level):特权级(0=内核,3=用户)
- Type:中断门(0xE)或陷阱门(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 | | GS | <- 低地址| ESP | | FS || EFLAGS | | ES || CS | | DS || EIP | | EDI || Error Code | | ESI |(可选) | EBP | | ESP (dummy)| | EBX | | EDX | | ECX | | EAX | | 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 扬声器
频率计算:
输出频率 = 输入频率 / 分频值 = 1193180 Hz / 分频值例如,要实现 50Hz 的定时中断:
分频值 = 1193180 / 50 = 23863工作模式:
- 模式3:方波发生器,最适合定期中断
- 访问模式:先读写低字节,后读写高字节
初始化代码如下:
void init_timer(uint32_t frequency){ // 注册定时器中断处理程序 register_interrupt_handler(IRQ0_INT_NUM, &timer_callback);
// 计算分频值 uint32_t divisor = (CLOCK_TICK_RATE + frequency / 2) / frequency;
// 发送命令字节 outb(PIT_CMD, PIT_CHANNEL0 | PIT_MODE3 | PIT_ACCESS_BOTH);
// 发送分频值(先低字节,后高字节) outb(PIT_CH0, divisor & 0xFF); outb(PIT_CH0, (divisor >> 8) & 0xFF);}代码实现
文件结构
07.kernel-idt/├── boot/│ ├── mbr.S # 主引导记录│ └── loader.S # 加载器├── kernel/│ ├── include/│ │ ├── types.h # 基础类型│ │ ├── vga.h # VGA 驱动│ │ ├── io.h # 端口 I/O│ │ ├── ports.h # 硬件端口定义│ │ ├── gdt.h # GDT 定义│ │ ├── interrupt.h # 中断接口│ │ └── timer.h # 定时器接口│ ├── interrupt/│ │ ├── interrupt.c # 中断处理实现│ │ ├── interrupt.S # 中断汇编存根│ │ └── timer.c # 定时器驱动│ ├── mm/│ │ ├── gdt.c # GDT 初始化│ │ └── gdt.S # GDT 汇编│ ├── lib/│ │ └── string.c # 字符串函数│ ├── drivers/│ │ └── vga.c # VGA 驱动│ ├── 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 晶振,检查分频值计算公式
- 原因:
小结
中断系统让内核从”被动轮询”变为”主动响应”。IDT 将中断向量映射到处理函数,PIC 管理硬件中断的优先级和路由,PIT 提供了时钟中断,这是多任务调度的时间基准。CPU 异常则让内核能够捕获和处理处理器内部的错误条件。下一章将实现内存管理,让内核具备动态分配和释放内存的能力。
参考
- Intel 64 and IA-32 Architectures Software Developer’s Manual - Chapter 6: Interrupt and Exception Handling
- 8259A PIC Datasheet
- 8254 PIT Datasheet
- OSDev Wiki - Interrupts
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






