从零写OS内核 | 中断和异常处理——CPU是怎么被外设”打断”的
你的程序正在运行,突然键盘按了一下——操作系统怎么知道的?CPU 怎么从正在执行的代码瞬间切换到处理键盘输入的?答案是中断。
中断是硬件和软件交互的核心机制:外设通过中断通知 CPU”我这里有事要处理”,CPU 暂停当前工作,转去执行对应的中断处理函数(ISR,Interrupt Service Routine),处理完再回来。
但中断不只是硬件通知——CPU 本身也会触发异常(Exception),比如除零、访问无效内存、非法指令等。这些也是”被中断”,只是来源是 CPU 内部而非外部硬件。
今天,我们来搞清楚中断和异常处理的完整链路。
1. 中断和异常的本质区别
硬件中断(Interrupt):
- 来源:外部硬件(键盘、网卡、硬盘、时钟等)
- 触发条件:外设通过中断引脚(IRQ)向 CPU 发送信号
- 处理方式:暂停当前任务,执行 ISR,结束后恢复
CPU 异常(Exception):
- 来源:CPU 自身在执行指令过程中检测到错误
- 触发条件:除零、越界访问、无效操作码、page fault 等
- 处理方式:根据异常类型(fault/trap/abort)决定是否可恢复
中断和异常的分类:
硬件中断(异步)
├── 可屏蔽中断 (IRQ) — IF=1 时响应,IF=0 时忽略
└── 不可屏蔽中断 (NMI) — 硬件故障,如内存奇偶校验错误
CPU 异常(同步)
├── Fault(故障)— 可修复,可回到触发指令重新执行
│ ├── #DE(除零)
│ ├── #PF(Page Fault)
│ └── #GP(通用保护)
├── Trap(陷阱)— 有意触发,执行后继续下一条指令
│ ├── #DB(调试断点)
│ └── #OF(溢出)
└── Abort(中止)— 不可恢复,记录错误,终止进程
└── #DF(双重故障)2. IDT:中断描述符表
x86 通过 IDT(Interrupt Descriptor Table) 来描述每个中断/异常对应的处理函数。和 GDT 类似,IDT 也是一个表,但每个 entry 的格式不同。
2.1 IDT entry 格式(门描述符)
IDT Gate Descriptor(8字节 = 64位)格式:
┌──────────────────────────────────────────────────────────────┐
│ Bit 63-48: Offset [31:16] (处理函数地址高16位) │
│ Bit 47-40: Reserved / Selector (代码段选择子) │
│ Bit 39-36: Zero │
│ Bit 35-32: Gate Type(门类型) │
│ Bit 31-16: Offset [15:0] (处理函数地址低16位) │
│ Bit 15-8: Selector (代码段选择子) │
│ Bit 7-0: IST / Reserved │
└──────────────────────────────────────────────────────────────┘
Gate Type 值:
0x5E = 64位 Interrupt Gate(中断门)
0x6E = 64位 Trap Gate(陷阱门)
0x7E = 64位 Task Gate(任务门,较少用)
Interrupt Gate vs Trap Gate 的区别:
Interrupt Gate:跳转时自动关闭中断(IF=0),防止嵌套
Trap Gate:跳转时保持 IF 不变,允许嵌套中断(但要小心)2.2 IDT 条目对应表(常见异常和中断)
IDT 向量分配(x86 通用约定):
向量号 名称 类型 说明
0x00 #DE Fault 除零错误
0x01 #DB Fault/Trap 调试
0x02 NMI Interrupt 不可屏蔽中断
0x03 #BP Trap 断点(INT3)
0x04 #OF Trap 溢出(INTO)
0x05 #BR Fault 越界(BOUND)
0x06 #UD Fault 无效操作码
0x07 #NM Fault 设备不可用(无数学协处理器)
0x08 #DF Abort 双重故障
0x09 Copr — 协处理器段超限(已废弃)
0x0A #TS Fault 无效TSS
0x0B #NP Fault 段不存在
0x0C #SS Fault 栈故障
0x0D #GP Fault 通用保护(最常见)
0x0E #PF Fault 页 fault(最常见)
0x0F — Reserved
0x10 #MF Fault x87 浮点错误
...
0x20-0x2F 外设中断(IRQ0-IRQ15,对应 8259A PIC)
0x80 系统调用 (Linux INT 0x80)2.3 IDT 设置流程
// 设置 IDT(简化版)
setup_idt:
# 加载 IDT 基址到 IDTR
lidt idt_descriptor
# 对每个中断向量填写 gate
movw $0x08, %ax # 代码段选择子(Ring 0 代码段)
movb $0x8E, %dl # P=1, DPL=00, D=1(32位中断门)
# 填写 vector 0(#DE 除零)
movl $handler_0, (idt_base + 0*8) # offset 低32位
movw $0, (idt_base + 0*8 + 4) # selector
movb $0, (idt_base + 0*8 + 5) # zero + type
movw $0, (idt_base + 0*8 + 6) # offset 高16位
# 填写 vector 0x0E(#PF 页 fault)
movl $handler_pf, (idt_base + 0x0E*8)
...
idt_descriptor:
.word 256 * 8 - 1 # IDT limit = 256 entries × 8 bytes
.quad idt_base # IDT base address3. 硬件中断:PIC 和 LAPIC
外设不能直接发中断给 CPU——需要一个中断控制器来汇总、屏蔽、优先级排序。
3.1 8259A PIC(可编程中断控制器)
传统的 8259A PIC 管理 8 个 IRQ 引脚,两个级联可管理 15 个 IRQ:
8259A PIC 级联结构:
CPU
↑
│
┌───┴───┐
│ 8259A │ ← 主 PIC(IRQ0-IRQ7)
└───┬───┘
│
┌───┴───┐
│ 8259A │ ← 从 PIC(IRQ8-IRQ15)
└───┬───┘
/|
IRQ0-7 IRQ8-15
主 PIC 端口:0x20(命令), 0x21(数据)
从 PIC 端口:0xA0(命令), 0xA1(数据)
向量映射:主 PIC IRQ0 → IDT 0x20
从 PIC IRQ0 → IDT 0x283.2 LAPIC(本地 APIC)
现代多核系统中,每个 CPU 核心有一个本地 APIC(LAPIC),负责接收本地中断(时钟、APIC 自身)和转发外部中断。
LAPIC 结构:
┌──────────────────────────────────────┐
│ ID Register (0xFEE00020) │ ← LAPIC ID(每个核唯一)
│ Version Register (0xFEE00030) │
│ TPR (Task Priority Register) │ ← 屏蔽低优先级中断
│ PPR (Processor Priority Register) │
│ EOI (0xFEE000B0) │ ← 中断结束,写 0
│ LVT (Local Vector Table) │ ← 本地中断配置
│ INR (In-Service Register) │ ← 正在处理的中断
│ IRR (Interrupt Request Register) │ ← 待处理的中断
└──────────────────────────────────────┘
IOAPIC → 系统总线 → LAPIC → CPULAPIC 比 PIC 更强大:支持 24 个可编程 IRQ(IOAPIC),优先级基于向量号而非引脚号,支持多核中断分发(IPI)。
3.3 中断处理流程(硬件中断)
键盘按下 → IRQ1 → 主 PIC → 发送 INT 信号给 CPU
↓
CPU 完成当前指令
↓
从 IDT[IRQ1_vector] 读取 gate 描述符
↓
检查 gate 类型,保存上下文(CS/EIP/EFLAGS)
↓
如果是中断门,自动关闭 IF(可屏蔽中断)
↓
跳转到 ISR 入口(handler_ keyboard)
↓
ISR 处理键盘读取(从端口 0x60 读扫描码)
↓
写 LAPIC EOI(通知中断处理完成)
↓
恢复上下文,返回被中断的代码继续执行4. 异常处理:Page Fault 的完整旅程
以 #PF(Page Fault)为例,完整走一遍异常处理:
4.1 触发条件
CPU 执行指令访问虚拟地址 → MMU 查页表 → PTE 的 Present=0 → 触发 #PF
4.2 #PF handler 的职责
#PF Exception Handler(简化流程):
handler_pf:
# 保存错误码和触发地址
push %rax
movl %cr2, %rax # CR2 存触发 fault 的线性地址
push %rax
push %ds
push %es
...
# 判断是哪种 fault
movl %cr2, %edi # 虚拟地址
movl 8(%rsp), %esi # 错误码
# 检查地址是否在合法 VMA
find_vma(mm, addr)
je valid_access
# 非法访问 → SIGSEGV
send_sigsegv(current, addr)
jmp restore_and_exit
valid_access:
# 页面不在内存 → 换入
handle_page_fault(vma, addr, error_code)
# 换入后更新 PTE,重新执行触发指令
restore_and_exit:
# 恢复上下文
# iretq 回到触发指令(Fault 可重新执行)4.3 错误码格式(#PF)
#PF Error Code 位域:
┌──────┬──────┬──────┬──────┐
│ P │ W │ U │ RSVD │
└──────┴──────┴──────┴──────┘
Bit 0: P (Present) = 0 → 页不存在,= 1 → 权限错误
Bit 1: W (Write) = 1 → 写操作,= 0 → 读操作
Bit 2: U (User) = 1 → 用户态,= 0 → 内核态
Bit 3: RSVD = 1 → 写入了保留位(如页表项的保留位)
组合判断:
P=0, W=0, U=0 → 内核读不存在的页(可能是换出)
P=0, W=1, U=1 → 用户态写不存在页(COW 或换入)
P=1, W=1, U=0 → 内核写只读页(bug)5. Linux 实践:观察中断和异常
# 查看注册的中断处理函数(/proc/interrupts)
cat /proc/interrupts
CPU0
0: 0 IO-APIC 2 edge timer
1: 5 IO-APIC 1 edge i8042
8: 1 IO-APIC 0 edge rtc0
9: 0 IO-APIC 9 fasteoi acpi
12: 100 IO-APIC 12 edge i8042
24: 0 Local-APIC 2 edge intel_bxt_hrt
# NMI 计数器(看硬件错误)
cat /proc/interrupts | grep NMI# 查看每个 CPU 核心的中断统计
cat /proc/softirqs
# 观察中断上半部/下半部(tasklet/softirq)
watch -n1 "cat /proc/softirqs | grep NET"# 用 perf 观察中断频率
perf record -e irq:irq_handler_entry -a sleep 5
perf report# 手动触发一个 #PF(读取 /proc/self/maps 之外的有效地址)
# 不会崩溃,只会产生 page fault
python3 -c "
import mmap
m = mmap.mmap(-1, 4096, prot=mmap.PROT_READ)
# 读取映射区以外地址会触发 SIGSEGV,不是 page fault
# 读取已映射但未分配的匿名页会产生 soft page fault
"6. 从零实现:wandos 的 IDT 和中断处理
⚠️ wandos 当前状态:arch/x86/ 目录下有 IDT 初始化代码,wandos 通过 boot.asm 设置初始 IDT,然后在内核代码中配置具体的中断处理函数。
6.1 IDT 初始化(arch/x86/idt.S)
# wandos IDT 初始化(简化版)
# 源码:arch/x86/idt.S
.section .text
.global idt_init
.type idt_init, @function
idt_init:
# 加载 IDT 基址和界限到 IDTR
movq $idt_table, %rax
movw $(256 * 16 - 1), %cx # 256 个门描述符 × 16字节(64位)
lidt (%rcx, %rax)
# 初始化 PIC(屏蔽所有 IRQ,等待 LAPIC 配置)
movb $0x11, %al # ICW1: 初始化 + 需要 ICW4
outb %al, $0x20 # 主 PIC 命令端口
outb %al, $0xA0 # 从 PIC 命令端口
movb $0x20, %al # IRQ0 → 中断向量 0x20
outb %al, $0x21 # 主 PIC 屏蔽码
movb $0x28, %al # IRQ8 → 中断向量 0x28
outb %al, $0xA1 # 从 PIC 屏蔽码
ret6.2 中断处理函数框架(arch/x86/interrupt.S)
# wandos 通用中断处理宏
# 源码:arch/x86/interrupt.S
# 定义通用的中断入口
.macro INTERRUPT_HANDLER name
.global name
name:
# 保存寄存器上下文
pushq %rax
pushq %rbx
pushq %rcx
...
# 调用 C 处理函数
movq $name, %rdi # 中断向量号作参数
call interrupt_handler_c
# 恢复寄存器
popq %rcx
popq %rbx
popq %rax
# 中断返回(自动恢复 IF)
iretq
.endm
# 为每个向量生成处理函数
INTERRUPT_HANDLER handler_0 # 除零
INTERRUPT_HANDLER handler_1 # 调试
...
INTERRUPT_HANDLER handler_14 # Page Fault
...
INTERRUPT_HANDLER handler_32 # IRQ0 时钟
INTERRUPT_HANDLER handler_33 # IRQ1 键盘6.3 C 语言中断处理(arch/x86/idt.c)
// wandos IDT C 代码
// 源码:arch/x86/idt.c
// IDT 门描述符结构
struct idt_gate {
uint16_t offset_low; // 处理函数地址低16位
uint16_t selector; // 代码段选择子
uint8_t ist; // IST 表
uint8_t type_attr; // Gate Type + 属性
uint16_t offset_mid; // 处理函数地址中16位
uint32_t offset_high; // 处理函数地址高32位
uint32_t reserved;
} __attribute__((packed));
// 设置单个 IDT entry
void idt_set_gate(int vector, void *handler,
uint8_t dpl, bool user) {
struct idt_gate *gate = &idt_table[vector];
uint64_t addr = (uint64_t)handler;
gate->offset_low = addr & 0xFFFF;
gate->offset_mid = (addr >> 16) & 0xFFFF;
gate->offset_high = (addr >> 32) & 0xFFFFFFFF;
gate->selector = 0x08; // 内核代码段
gate->ist = 0;
gate->type_attr = 0x8E; // P=1, DPL=0, 64位中断门
if (user) {
gate->type_attr = 0xEE; // DPL=3,允许用户态调用
}
}
// Page Fault handler
void page_fault_handler(uint64_t fault_addr, uint64_t error_code) {
// 判断 fault 类型
bool present = (error_code & 1);
bool write = (error_code & 2);
bool user = (error_code & 4);
if (!present && !user) {
// 内核态访问未映射页面 → 可能是换页
// 或内核 bug(空指针解引用)
panic("Kernel page fault at %pn", fault_addr);
} else if (!present) {
// 用户态换入页面
handle_user_page_fault(fault_addr);
} else {
// 权限错误(写只读、用户访问内核页)
send_sigsegv(current_task, fault_addr);
}
}6.4 wandos 和 Linux 的主要差异
- 没有软中断(softirq):Linux 把中断处理分成”上半部”(执行快,禁止中断)和”下半部”(可以延迟执行),wandos 目前没有这个分层
- 没有 APIC 时钟中断:wandos 的时钟中断依赖 PIC(IRQ0),没有利用 LAPIC 的高级特性
- 没有 tasklet 和 workqueue:中断下半部的异步执行机制,wandos 尚未实现
7. 动手环节:实现一个自定义异常处理
今天的目标:修改 os-kernel-from-scratch 里的 idt demo,给 IDT 添加一个自定义门(INT 0x80 的简化版),实现一个”系统调用”风格的入口。
任务 1:设置 INT 0x80 为自定义门
在 IDT 中把向量 0x80 设置为一个中断门,指向你自己的 handler。调用 int $0x80 能进入这个处理函数。
任务 2:实现系统调用传递参数
handler 接收三个参数:通过寄存器传递(eax=系统调用号,ebx/ecx/edx=参数)。handler 根据 eax 的值执行不同操作。
任务 3:实现一个简单系统调用
实现一个 write(int fd, char *buf, int len) 系统调用,在屏幕上打印字符串。
验收标准
int $0x80能在屏幕上打印 “Hello from syscall!”- 多次调用
int $0x80不崩溃 - 可以从用户态 C 代码调用这个系统调用(不是内联汇编)
8. 踩坑与注意事项
坑 1:中断门 vs 陷阱门选错导致死循环
如果把异常向量(Fault)配置为 Interrupt Gate(自动关闭 IF),异常处理完后 iret 返回时 IF=0,所有后续可屏蔽中断被屏蔽。如果异常处理本身依赖某个外设中断(如 NMI 或时钟),系统会看起来”挂死”。正确做法:异常用 Trap Gate,保持 IF 不变。
坑 2:中断处理函数没有保存完整上下文
x86-64 中断会自动保存 SS/RSP/RFLAGS/CS/RIP,但其他通用寄存器需要手动保存。如果 handler 用了 rax/rbx 但没有 push,恢复时数据会错乱,导致返回后程序行为不可预测。
坑 3:Page Fault handler 里再次触发 fault
在 #PF handler 里访问虚拟内存(如读取 struct mm_struct),如果访问的地址本身也需要换页,会再次触发 #PF → 递归嵌套。如果 nesting 太深会导致 stack overflow(Page Fault on the stack)。解决方案:handler 里用临时映射或内核线性映射(永久映射的内核区域),不触发二次 fault。
坑 4:LAPIC EOI 忘记写
外设中断处理完成后,必须写 LAPIC EOI 寄存器(端口 0xFEE000B0,写 0),否则 LAPIC 认为这个中断还没处理完,不会发新的同类中断。漏写 EOI 的症状:某个外设中断只触发一次,之后再也收不到。
写在最后
中断是硬件和软件握手的最基本方式——CPU 把控制权交给软件,软件处理完后把控制权还回去。这个模式无处不在:键盘按键、磁盘读写完成、网络包到达、进程时间片用完,都靠中断通知操作系统。
理解 IDT 的结构和中断/异常的区别,你才能真正理解操作系统是如何”实时响应”硬件事件的,以及为什么一个程序死循环不会让整台机器挂掉——因为时钟中断还在运行,调度器还能抢占。
下篇预告:系统调用——用户态怎么触发内核代码。
相关阅读
- Linux Kernel Source:
arch/x86/kernel/idt.c(IDT 设置) - Linux Kernel Source:
arch/x86/mm/fault.c(Page Fault 处理) - Intel SDM Volume 3: Chapter 6(中断和异常)
- wandos:
arch/x86/idt.S,arch/x86/interrupt.S,arch/x86/idt.c - 本文 Demo: https://github.com/golang12306/os-kernel-from-scratch (demos/idt/)
- https://github.com/zhangfuwen/wandos — Linux 内核教程
- 下一篇:《从零写OS内核 | 系统调用——用户态怎么触发内核代码》
动手环节
想深入理解本文内容?动手实践是最好的方式:
今天的目标:下载 wandos 代码仓库,理解 IDT 实现,对比 Linux 原版。
-
下载 wandos:
git clone https://github.com/zhangfuwen/wandos.git cd wandos -
找到对应模块:查看
arch/x86/下的 IDT 相关代码 -
实现作业:根据文中”动手环节”章节的要求,完成代码编写
-
提交作业:Fork 仓库,提交你的改动,在 GitHub 上开一个 Pull Request
评论