前言:从“主动轮询”到“事件驱动”

        在前面的章节中,我们的内核本质上是一个顺序执行的死循环。无论是检查串口输入还是等待定时器,都依赖低效的忙等待(Busy Waiting)。这种模式不仅浪费CPU周期,更无法应对硬件的异步事件和程序的非法操作。

        本章我们将为内核装上“神经系统”:中断描述符表(IDT)。通过配置IDT并实现中断服务例程(ISR),CPU将能够在硬件事件发生或程序出错时,自动暂停当前执行流,跳转到我们预设的处理函数,完成后再无缝恢复现场。这是实现多任务调度、设备驱动和用户态隔离的绝对前提。

本章里程碑:

  • ✅ 理解x86中断/异常机制与IDT结构
  • ✅ 编写汇编ISR桩代码(Stub)保存/恢复上下文
  • ✅ 实现C语言中断分发器与基础异常处理(#PF, #GPF等)
  • ✅ 重编程8259 PIC,解除IRQ屏蔽并设置正确偏移
  • ✅ 接入PIT定时器,实现精确的毫秒级Tick计数

核心概念:IDT、ISR与8259 PIC
中断处理的完整生命周期

        当CPU收到中断信号(硬件引脚触发或int n指令)时,硬件会自动完成以下动作:

  1. 根据中断号在IDT中查找对应的门描述符;
  2. 若权限检查通过,将EFLAGS、CS、EIP压入当前栈;
  3. 若为中断门(Interrupt Gate),自动清除IF位屏蔽可屏蔽中断;
  4. 跳转到门描述符指定的入口地址执行ISR。

⚠️ 致命陷阱:CPU只负责压入EFLAGS/CS/EIP,不会保存通用寄存器。如果ISR中修改了EAX/EBX等寄存器却未恢复,返回后原程序的执行状态将被彻底破坏。因此必须在汇编层手动实现完整的上下文保存。

为什么必须重编程8259 PIC?

        PC架构的8259 PIC默认将IRQ0-7映射到IDT向量0x08-0x0F,这与CPU保留的异常向量(如#DF、#PF)完全冲突。如果不重新映射,一次键盘按下就会被误当作双重故障(Double Fault)处理,导致系统立即Triple Fault重启。标准做法是将IRQ0-7重映射到0x20-0x27,IRQ8-15映射到0x28-0x2F。

向量范围用途备注
0x00-0x1FCPU保留异常#DE, #DB, #NMI, #BP, #OF, #BR, #UD, #NM, #DF, #TS, #NP, #SS, #GP, #PF, #MF, #AC, #MC, #XM
0x20-0x2F硬件IRQIRQ0(PIT)=0x20, IRQ1(KBD)=0x21, IRQ8(RTC)=0x28 等
0x30-0xFF软件中断/自定义系统调用通常使用0x80或SYSCALL指令

实战代码
汇编ISR桩代码:保存与恢复上下文
; isr_stub.asm
extern isr_dispatcher

; 宏:生成带错误码的ISR桩
%macro ISR_ERRCODE 1
global isr_stub_%1
isr_stub_%1:
    ; CPU已自动压入错误码,无需额外push
    push dword %1       ; 压入中断号
    jmp isr_common_stub
%endmacro

; 宏:生成无错误码的ISR桩(需手动补0占位)
%macro ISR_NOERRCODE 1
global isr_stub_%1
isr_stub_%1:
    push dword 0        ; 占位,保持栈帧统一
    push dword %1       ; 压入中断号
    jmp isr_common_stub
%endmacro

; 生成所有需要的桩
ISR_NOERRCODE 0   ; #DE
ISR_NOERRCODE 1   ; #DB
ISR_NOERRCODE 2   ; NMI
ISR_NOERRCODE 3   ; #BP
ISR_NOERRCODE 14  ; #PF
ISR_ERRCODE   13  ; #GPF
ISR_NOERRCODE 32  ; IRQ0 (PIT)
ISR_NOERRCODE 33  ; IRQ1 (Keyboard)

; 公共处理流程
isr_common_stub:
    ; 保存所有通用寄存器
    pusha               ; EAX, ECX, EDX, EBX, ESP, EBP, ESI, EDI
    
    mov ax, ds
    push eax            ; 保存数据段选择子
    
    mov ax, 0x10        ; 加载内核数据段
    mov ds, ax
    mov es, ax
    mov fs, ax
    mov gs, ax
    
    push esp            ; 传递栈指针作为interrupt_frame_t*参数
    call isr_dispatcher
    add esp, 4          ; 清理参数
    
    pop eax             ; 恢复原始数据段
    mov ds, ax
    mov es, ax
    mov fs, ax
    mov gs, ax
    
    popa                ; 恢复通用寄存器
    add esp, 8          ; 跳过中断号和错误码
    iret                ; 返回被中断的代码
C语言中断分发器与异常处理
// interrupt.c
#include "interrupt.h"
#include "serial.h"

typedef struct {
    uint32_t edi, esi, ebp, esp_dummy, ebx, edx, ecx, eax; // pusha顺序
    uint32_t ds;
    uint32_t int_no, err_code;
    uint32_t eip, cs, eflags;
} __attribute__((packed)) interrupt_frame_t;

static const char *exception_names[] = {
    "Division By Zero", "Debug", "NMI", "Breakpoint",
    "Overflow", "Bound Range Exceeded", "Invalid Opcode", "Device Not Available",
    "Double Fault", "Coprocessor Segment Overrun", "Invalid TSS", "Segment Not Present",
    "Stack-Segment Fault", "General Protection Fault", "Page Fault", "Reserved",
    "x87 FPU Error", "Alignment Check", "Machine Check", "SIMD Exception"
};

void isr_dispatcher(interrupt_frame_t *frame) {
    if (frame->int_no < 32) {
        // CPU异常处理
        kprintf("\n[EXCEPTION] %s (int=%d, err=0x%x)\n",
                exception_names[frame->int_no], frame->int_no, frame->err_code);
        kprintf("  EIP=0x%x CS=0x%x EFLAGS=0x%x\n", 
                frame->eip, frame->cs, frame->eflags);
        
        if (frame->int_no == 14) { // Page Fault
            uint32_t cr2;
            asm volatile("mov %%cr2, %0" : "=r"(cr2));
            kprintf("  CR2(fault addr)=0x%x\n", cr2);
        }
        
        // 致命异常直接停机
        if (frame->int_no == 8 || frame->int_no == 13 || frame->int_no == 14) {
            kprintf("[KERNEL] Fatal exception. Halting.\n");
            for (;;) asm volatile("cli; hlt");
        }
    } else if (frame->int_no >= 32 && frame->int_no < 48) {
        // 硬件IRQ处理
        uint8_t irq = frame->int_no - 32;
        
        if (irq == 0) {
            // PIT Tick处理(下一章详细展开)
            extern volatile uint32_t tick_count;
            tick_count++;
        }
        
        // 发送EOI信号给PIC
        if (irq >= 8) outb(0xA0, 0x20); // Slave PIC
        outb(0x20, 0x20);               // Master PIC
    }
}
8259 PIC重编程与IDT安装
// pic.c & idt.c 合并关键逻辑
#define IDT_ENTRIES 256

typedef struct {
    uint16_t base_low;
    uint16_t sel;
    uint8_t  always0;
    uint8_t  flags;
    uint16_t base_high;
} __attribute__((packed)) idt_entry_t;

static idt_entry_t idt[IDT_ENTRIES];

static void idt_set_gate(uint8_t num, uint32_t handler, uint16_t sel, uint8_t flags) {
    idt[num].base_low  = handler & 0xFFFF;
    idt[num].base_high = (handler >> 16) & 0xFFFF;
    idt[num].sel       = sel;
    idt[num].always0   = 0;
    idt[num].flags     = flags;
}

void pic_remap(void) {
    // ICW1: 开始初始化序列
    outb(0x20, 0x11); outb(0xA0, 0x11);
    // ICW2: 设置向量偏移
    outb(0x21, 0x20); // Master: IRQ0-7 → 0x20-0x27
    outb(0xA1, 0x28); // Slave:  IRQ8-15 → 0x28-0x2F
    // ICW3: 级联关系
    outb(0x21, 0x04); outb(0xA1, 0x02);
    // ICW4: 8086模式
    outb(0x21, 0x01); outb(0xA1, 0x01);
    // OCW1: 暂时屏蔽所有IRQ
    outb(0x21, 0xFF); outb(0xA1, 0xFF);
}

void interrupt_init(void) {
    pic_remap();
    
    // 安装异常处理桩
    idt_set_gate(0,  (uint32_t)isr_stub_0,  0x08, 0x8E);
    idt_set_gate(13, (uint32_t)isr_stub_13, 0x08, 0x8E);
    idt_set_gate(14, (uint32_t)isr_stub_14, 0x08, 0x8E);
    
    // 安装IRQ处理桩
    idt_set_gate(32, (uint32_t)isr_stub_32, 0x08, 0x8E); // PIT
    idt_set_gate(33, (uint32_t)isr_stub_33, 0x08, 0x8E); // Keyboard
    
    // 加载IDT
    struct { uint16_t limit; uint32_t base; } __attribute__((packed)) idtr = {
        .limit = sizeof(idt) - 1,
        .base  = (uint32_t)&idt
    };
    asm volatile("lidt %0" :: "m"(idtr));
    
    // 仅开放PIT和键盘中断
    outb(0x21, 0xFC); // 11111100b: IRQ0+IRQ1 unmasked
    
    // 启用中断
    asm volatile("sti");
    
    kprintf("[INT] IDT loaded, PIC remapped, interrupts enabled.\n");
}

关键细节解析

1. 为什么ISR必须用纯汇编写入口?

        C编译器不知道中断上下文的特殊性。它可能在函数开头就使用寄存器而不保存,或者以不符合中断栈帧的方式访问参数。只有汇编能精确控制每一条指令,确保在进入C代码前栈帧完全符合interrupt_frame_t布局。

2. 为什么异常处理中要区分错误码?

        CPU对部分异常(#DF, #TS, #NP, #SS, #GPF, #AC)会自动压入错误码,对其他异常则不会。如果ISR桩不统一栈帧格式,C代码就无法用同一个结构体解析。无错误码的异常必须手动push 0占位,这就是两个宏存在的意义。

3. EOI为什么有时要发两次?

        8259采用主从级联架构。IRQ8-15由Slave PIC管理,但Slave的输出连接到Master的IRQ2。处理完Slave上的中断后,必须先向Slave发EOI,再向Master发EOI,否则两者都会认为中断未处理完毕,后续同级或低级中断将被永久阻塞。


调试Checklist:中断相关崩溃排查
症状可能原因排查方法
启用中断后立即Triple FaultIDT未正确加载 / 向量指向无效地址lidt前kprintf打印IDTR内容;QEMU -d int确认触发的向量号
键盘按下无反应但PIT正常PIC掩码错误 / IRQ1未映射读取OCW1寄存器验证掩码;确认ISR 33已安装且EOI正确发送
ISR返回后程序行为异常寄存器未完整保存/恢复 / DS未还原检查pusha/popa配对;确认DS在调用C函数前后被正确切换和恢复
#PF处理中再次#PFISR本身访问了未映射地址高半核环境下确认ISR代码和数据都在0xC0000000以上;检查栈是否在高地址空间
中断嵌套导致栈溢出中断门vs陷阱门选择错误硬件IRQ应使用中断门(0x8E)自动关中断;软件断点可用陷阱门(0xEF)允许嵌套

🔧 黄金法则:永远不要在中断处理程序中执行耗时操作或分配内存。ISR应当尽可能短小,只做必要的硬件应答和状态标记,复杂工作推迟到主循环或内核线程中处理。


本章小结与下一步

        今天我们赋予了内核感知世界的能力:

  • ✅ 搭建了完整的IDT框架与安全的汇编/C混合ISR模型
  • ✅ 正确处理了CPU异常,具备了基本的自我保护能力
  • ✅ 重编程了8259 PIC,解除了硬件中断的向量冲突
  • ✅ 接入了PIT定时器,获得了精确的时间基准

        从此,内核不再是一个盲目的执行机器,而是一个能够实时响应外部事件、检测内部错误、维护系统稳定的真正操作系统核心。中断机制是并发、调度和I/O的共同基石。

下一章预告:《进程与调度(上):让多个程序“同时”运行》

        有了定时器和中断,我们终于可以抢占CPU了。下一章将定义进程控制块(PCB)、实现上下文切换汇编原语、构建简单的轮转调度器,让你的OS第一次同时运行两个独立的程序。


参考资料
  • Intel SDM Vol.3 Chapter 6 (Interrupt and Exception Handling)
  • OSDev Wiki - Interrupt Descriptor Table / 8259 PIC / PIT
  • Bran's Kernel Development Tutorial (Interrupts章节)
  • 本系列完整代码:[你的GitHub仓库链接](Commit: i0n7t0r)

📝 作者注:这是《从零手写操作系统》系列的第07篇。中断是OS开发中第一个需要同时精通硬件规范和汇编技巧的章节。如果你在这里感到痛苦,请记住:每一个成熟的OS开发者都曾对着错误的栈帧布局熬过无数个深夜。建议先用QEMU的info registers和xp命令逐字节验证中断发生时的栈状态,再逐步放开更多中断源。下一章,我们让内核学会“分身术”!

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐