从零写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)决定是否可恢复

Bash
中断和异常的分类:

硬件中断(异步)
  ├── 可屏蔽中断 (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 格式(门描述符)

Bash
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 条目对应表(常见异常和中断)

Bash
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 设置流程

Asm
// 设置 IDT(简化版)
setup_idt:
    # 加载 IDT 基址到 IDTR
    lidt idt_descriptor

    # 对每个中断向量填写 gate
    movw $0x08, %ax        # 代码段选择子(Ring 0 代码段)
    movb $0x8E, %dl        # P=1, DPL=00, D=132位中断门)

    # 填写 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 address

3. 硬件中断:PIC 和 LAPIC

外设不能直接发中断给 CPU——需要一个中断控制器来汇总、屏蔽、优先级排序。

3.1 8259A PIC(可编程中断控制器)

传统的 8259A PIC 管理 8 个 IRQ 引脚,两个级联可管理 15 个 IRQ:

Bash
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 0x28

3.2 LAPIC(本地 APIC)

现代多核系统中,每个 CPU 核心有一个本地 APIC(LAPIC),负责接收本地中断(时钟、APIC 自身)和转发外部中断。

Bash
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 → CPU

LAPIC 比 PIC 更强大:支持 24 个可编程 IRQ(IOAPIC),优先级基于向量号而非引脚号,支持多核中断分发(IPI)。

3.3 中断处理流程(硬件中断)

Bash
键盘按下 → 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 的职责

Bash
#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)

Bash
#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 实践:观察中断和异常

Bash
# 查看注册的中断处理函数(/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

Bash
# 查看每个 CPU 核心的中断统计
cat /proc/softirqs

# 观察中断上半部/下半部(tasklet/softirq)
watch -n1 "cat /proc/softirqs | grep NET"

Bash
# 用 perf 观察中断频率
perf record -e irq:irq_handler_entry -a sleep 5
perf report

Bash
# 手动触发一个 #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)

Asm
# 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 屏蔽码

    ret

6.2 中断处理函数框架(arch/x86/interrupt.S)

Asm
# 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)

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 的主要差异

  1. 没有软中断(softirq):Linux 把中断处理分成”上半部”(执行快,禁止中断)和”下半部”(可以延迟执行),wandos 目前没有这个分层
  2. 没有 APIC 时钟中断:wandos 的时钟中断依赖 PIC(IRQ0),没有利用 LAPIC 的高级特性
  3. 没有 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 原版。

  1. 下载 wandos

    Bash
    git clone https://github.com/zhangfuwen/wandos.git
    cd wandos
  2. 找到对应模块:查看 arch/x86/ 下的 IDT 相关代码

  3. 实现作业:根据文中”动手环节”章节的要求,完成代码编写

  4. 提交作业:Fork 仓库,提交你的改动,在 GitHub 上开一个 Pull Request


仓库:https://github.com/golang12306/os-kernel-from-scratch

最后修改: 2024年9月24日

作者

评论

发表评论

您的邮箱地址不会被公开。