3. 中断向量表:IVT/IDT结构、中断向量分配、异常与中断的区别

好,咱们接着聊中断。上一节我讲了中断是怎么“敲门”的,那CPU怎么知道敲门的是谁?该去哪找对应的处理程序?这就得靠一张“地图”——中断向量表。

说白了,中断向量表就是一张地址映射表。每个中断源都有一个编号,叫“中断向量号”。CPU拿到这个号,就去表里查,找到对应的处理函数入口地址,然后跳过去执行。就这么简单。

3.1 从IVT到IDT:历史的演进

最早期的x86系统,用的是IVT(Interrupt Vector Table)。它固定在内存地址0x0000处,一共256个条目,每个条目4字节,存的就是段地址和偏移量。我刚开始学汇编时,还手动改过IVT来挂中断,那时候觉得特酷。

但IVT有个硬伤——它必须放在内存最低端,而且不能挪地方。这在实模式下没问题,到了保护模式就不行了。保护模式下内存是分段的,地址空间也大了,你总不能把一张固定位置的表塞进复杂的段机制里吧?

所以后来Intel推出了IDT(Interrupt Descriptor Table)。IDT可以放在内存的任何位置,由IDTR(IDT寄存器)指向它。每个条目也从4字节变成了8字节,结构更复杂,能存更多的属性信息。

关键区别:

  • IVT:固定位置,实模式专用,条目4字节
  • IDT:任意位置,保护模式/长模式专用,条目8字节(x86)或16字节(x64)

嗯,这里要注意:ARM、RISC-V这些架构也有类似的概念,但叫法不同。ARM叫“异常向量表”,RISC-V叫“trap vector”。但本质都一样——一张表,一个基址寄存器。

3.2 IDT的结构:每个条目到底存了什么?

咱们以x86-64为例,IDT的每个条目叫门描述符(Gate Descriptor)。它长这样:

// 伪代码描述IDT条目结构
struct IDT_entry {
    uint16_t offset_low;    // 处理函数地址的低16位
    uint16_t selector;      // 段选择子
    uint8_t  ist;           // 中断栈表(Interrupt Stack Table)
    uint8_t  type_attr;     // 类型和属性
    uint16_t offset_mid;    // 处理函数地址的中间16位
    uint32_t offset_high;   // 处理函数地址的高32位
    uint32_t reserved;      // 保留位
};

你看,一共16字节。其中最关键的是type_attr字段,它决定了这个门是中断门、陷阱门还是任务门。我个人习惯把中断门和陷阱门记成:中断门会自动关中断(IF清零),陷阱门不会。为什么?因为中断门处理的是外部硬件中断,处理期间不想被打扰;陷阱门处理的是软件主动触发的异常,比如系统调用,你关了中断反而可能出问题。

门类型 属性值 是否关中断 典型用途
中断门 0x8E 硬件中断(IRQ)
陷阱门 0x8F 系统调用、异常
任务门 0x85 视情况 任务切换(已很少用)

避坑指南:我曾经在移植一个RTOS时,把系统调用的门设成了中断门。结果每次调用系统服务,全局中断都被关了,导致高优先级的中断丢失。查了两天才发现是type_attr写错了。所以,软件中断用陷阱门,硬件中断用中断门,这个千万别搞混。

3.3 中断向量分配:谁占哪个号?

中断向量号一共256个(0~255)。但并不是所有号都能随便用。x86架构下,前32个(0~31)是保留给异常和陷阱的,由Intel硬性规定。比如:

  • 0号:除法错误(Divide Error)
  • 3号:断点(Breakpoint)
  • 6号:非法操作码(Invalid Opcode)
  • 13号:通用保护故障(General Protection Fault)
  • 14号:页故障(Page Fault)

从32号开始,才是给外部硬件中断用的。但这里有个历史遗留问题——8259A可编程中断控制器默认把IRQ0映射到向量号8,这跟异常8(双重故障)冲突了。所以操作系统启动时,必须重新编程8259A,把IRQ0重映射到32或更高。我记得Linux内核里就有这么一段代码,叫i8259_init(),专门干这事。

注意:在x86-64的APIC模式下,中断向量分配更灵活。但基本原则不变:0~31是异常,32~255是硬件中断和软件中断。你写驱动时,申请中断向量号一定要避开异常区,否则系统会直接崩溃。

3.4 异常与中断:到底有什么区别?

这个问题我面试时经常问。很多人答不上来,或者答得模棱两可。其实区分起来很简单:

中断(Interrupt)异步的。它来自CPU外部,比如网卡来了一个数据包,键盘按了一下。CPU不知道它什么时候来,来了就得停下当前工作去处理。处理完了再回来继续。

异常(Exception)同步的。它来自CPU内部,是执行指令时触发的。比如你执行了div r0, #0,除零了,CPU当场就给你一个异常。同一个程序,同样的输入,每次执行到这条指令都会触发同一个异常。

你想想看,这个区别在实际开发中意味着什么?

  • 中断处理程序必须非常快,因为它在“偷”CPU的时间。你处理慢了,下一个中断可能就丢了。
  • 异常处理程序可以稍微慢一点,因为它是程序逻辑的一部分。比如页故障异常,处理程序可以去磁盘上换页,花几毫秒都行。

另外,异常还分三种:

  1. 故障(Fault):比如页故障。CPU执行指令时发现问题,但还没执行完。处理完后,会重新执行那条指令。
  2. 陷阱(Trap):比如断点。CPU执行完指令后触发,处理完后执行下一条指令。
  3. 中止(Abort):比如双重故障。CPU发现严重问题,无法恢复,直接放弃。

一句话总结:中断是“外部事件”,异常是“内部错误”。中断是异步的,异常是同步的。中断处理完回到原指令继续,异常处理完可能重试、跳过或崩溃。

3.5 实战:如何设置一个IDT条目?

说了这么多理论,咱们来点实际的。假设你在写一个简单的内核,想给键盘中断(IRQ1,向量号33)注册一个处理函数。代码大概长这样:

// 假设键盘中断处理函数
void keyboard_handler() {
    // 读键盘端口,获取扫描码
    uint8_t scancode = inb(0x60);
    // 处理按键...
    // 发送EOI到8259A
    outb(0x20, 0x20);
}

// 设置IDT条目
void set_idt_entry(int vector, void (*handler)(), uint8_t type_attr) {
    uint64_t addr = (uint64_t)handler;
    idt[vector].offset_low  = addr & 0xFFFF;
    idt[vector].selector    = 0x08;  // 内核代码段选择子
    idt[vector].ist         = 0;
    idt[vector].type_attr   = type_attr;
    idt[vector].offset_mid  = (addr >> 16) & 0xFFFF;
    idt[vector].offset_high = (addr >> 32) & 0xFFFFFFFF;
    idt[vector].reserved    = 0;
}

// 初始化
void init_idt() {
    set_idt_entry(33, keyboard_handler, 0x8E); // 中断门
    // 加载IDT
    __asm__ volatile("lidt idt_ptr");
}

这段代码我简化了很多,但核心逻辑就是:把处理函数的地址拆成三段,填进IDT条目里。然后告诉CPU“IDT在这呢”。

小技巧:我建议你在调试阶段,把每个IDT条目的type_attr都打印出来看看。有一次我发现某个条目莫名其妙变成了0x00,结果是内存越界写坏了IDT。这种bug特别难查,提前检查能省很多时间。

好了,这一节的内容就到这。中断向量表是CPU中断机制的基石,理解了它,你就能明白CPU是怎么“按图索骥”找到处理程序的。下一节咱们聊聊中断优先级和嵌套,那才是真正考验实时系统设计能力的地方。