mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
2354 字
6 分钟
中断系统:IDT、PIC 与定时器
2021-06-05

早期计算机没有中断机制。CPU 想知道键盘是否有输入,只能反复检查键盘控制器的状态位,这叫轮询(polling)。轮询的代价是巨大的:CPU 把时间浪费在无意义的检查上,无法做其他事情。中断机制的发明解决了这个问题,让硬件主动通知 CPU,CPU 只在需要时才响应。

打个比方:你在做作业时,突然电话响了。你需要:

  1. 暂停当前的作业
  2. 记住做到哪一步(保存上下文)
  3. 接听电话(处理中断)
  4. 记录电话内容(处理中断请求)
  5. 恢复做作业(恢复上下文)

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:

graph TD A[IRQ0 定时器] --> M[主 PIC Master] B[IRQ1 键盘] --> M C[IRQ2 级联] --> M D[IRQ3 COM2] --> M E[IRQ4 COM1] --> M M -->|IRQ2| S[从 PIC Slave] M --> CPU[CPU INTR引脚] F[IRQ8 RTC] --> S G[IRQ9 重定向] --> S H[IRQ10 保留] --> S I[IRQ11 保留] --> S J[IRQ12 鼠标] --> S K[IRQ13 FPU] --> S L[IRQ14 IDE主] --> S O[IRQ15 IDE从] --> S M ---|向量0x20-0x27| V1[中断向量] S ---|向量0x28-0x2F| V2[中断向量]

初始化序列(ICW1-ICW4)

  1. ICW1:发送初始化命令,告诉 PIC 开始初始化
  2. ICW2:设置中断向量偏移(主 PIC = 0x20,从 PIC = 0x28)
  3. ICW3:配置主从级联关系
  4. ICW4:设置工作模式(8086 模式)

EOI(End of Interrupt): 处理完中断后,必须向 PIC 发送 EOI 信号,告诉它可以接受下一个中断。如果是从 PIC 的中断(IRQ8-15),需要同时向主 PIC 和从 PIC 发送 EOI。

关键接口:

  • pic_init(master_offset, slave_offset):初始化 PIC
  • pic_send_eoi(irq):发送中断结束信号

CPU 异常#

硬件中断来自外部设备,而 CPU 异常来自处理器内部。两者的处理机制相似,但触发方式不同。CPU 在执行过程中可能遇到错误条件(如除零、页错误),异常处理程序可以优雅地处理这些情况或终止有问题的程序。

x86 定义了 32 个异常向量(0-31):

向量名称类型说明
0#DEFault除零错误
6#UDFault无效操作码
8#DFAbort双重错误
13#GPFault通用保护错误
14#PFFault页错误

异常类型

  • 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

中断处理流程#

flowchart TD A[中断发生] --> B{中断类型} B -->|CPU异常| C[ISR入口<br/>isr_common_stub] B -->|硬件中断| D[IRQ入口<br/>irq_common_stub] C --> E[保存所有寄存器<br/>pusha + 段寄存器] D --> E E --> F[设置内核数据段<br/>ds=es=fs=gs=KERNEL_DS] F --> G[调用C处理函数<br/>isr_handler/irq_handler] G --> H{有注册处理程序?} H -->|是| I[执行注册的处理程序] H -->|否| J[显示未处理中断信息] I --> K[发送EOI信号<br/>仅IRQ需要] J --> K K --> L[恢复寄存器<br/>popa + 段寄存器] L --> M[清理栈<br/>add esp,8] M --> N[iret返回<br/>恢复CPU状态]

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 1
global isr%1
isr%1:
push dword 0 ; 错误码占位(因为CPU不会压入)
push dword %1 ; 中断号
jmp isr_common_stub
%endmacro
; 带错误码的CPU异常
%macro ISR_ERR 1
global isr%1
isr%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-idt
make all # 编译
make run # 运行
make clean # 清理

预期输出#

Started in 16-bit real mode (BIOS)
Now in 32-bit protected mode (direct video)
Now Enable Page
Hello, 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=0x28
tick=1
tick=2
tick=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 异常
}

踩坑记录#

  1. 中断后系统崩溃

    • 原因:寄存器保存/恢复不正确,段选择子错误
    • 解决方案:检查 pusha/popa 和段寄存器保存是否正确,确保 IDT 描述符的段选择子指向正确的代码段
  2. 定时器不工作

    • 原因:PIC 未正确初始化或未发送 EOI
    • 解决方案:确保 pic_init 被调用,并且 irq_handler 中调用了 pic_send_eoi
  3. 双重错误(Double Fault)

    • 原因:中断处理程序本身出错,或者 IDT 描述符错误
    • 解决方案:检查 IDT 描述符的段选择子是否正确,确保处理程序代码在正确的内存区域
  4. 页错误循环

    • 原因:页错误处理程序访问未映射的内存
    • 解决方案:确保处理程序只使用已映射的内存,避免在处理程序中访问非法地址
  5. 定时器频率不准确

    • 原因CLOCK_TICK_RATE 常量不准确,或分频值计算错误
    • 解决方案:确认主板使用的是标准 1193180 Hz 晶振,检查分频值计算公式

小结#

中断系统让内核从”被动轮询”变为”主动响应”。IDT 将中断向量映射到处理函数,PIC 管理硬件中断的优先级和路由,PIT 提供了时钟中断,这是多任务调度的时间基准。CPU 异常则让内核能够捕获和处理处理器内部的错误条件。下一章将实现内存管理,让内核具备动态分配和释放内存的能力。

参考#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

中断系统:IDT、PIC 与定时器
https://blog.souloss.com/posts/os/interrupt-idt-pic/
作者
Souloss
发布于
2021-06-05
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时