如果让我给自制操作系统这个项目排一个难度榜,“中断”这一章绝对进前三。前面几章你还能靠着操作显存、读写端口、改GDT这种“看得见摸得着”的方式硬闯过去,到了中断这里,很多问题开始变得不可见了:程序跑得好好的,突然重启;键盘敲一下有反应,第二下就彻底失灵;IDT加载完直接eip乱飞。这些症状都和中断处理流程里的某个小细节有关,排查起来相当考验耐心。
这一篇拆解的重点是自制操作系统第7章的中断部分,核心讨论三件事:中断到底怎么从硬件一路传到CPU、内核侧应该如何注册和响应中断、以及为什么这一章必须和FIFO队列配合使用。适合正在写操作系统实验、或者看内核源码时被IDT和IRQ绕晕的开发者。文中所有代码基于32位保护模式的x86平台,你会看到可复用的IDT描述符构造、8259A初始化、汇编中断入口和环形队列实现。
1. 为什么自制操作系统做到中断这一章,难度突然上了一个台阶
1.1 从一次键盘按键说起:轮询为什么不好使
在没写中断之前,我和大多数自制系统开发者走的是同一条路:轮询。具体来说,就是主循环里反复读取键盘控制器的状态端口,判断有没有按键事件。核心逻辑大概是:
while (1) { uint8_t status = inb(0x64); if (status & 0x01) { uint8_t code = inb(0x60); handle_key(code); } }这段代码在真实环境里不会出什么大错,键盘按下后状态位总会置1,数据总会出现在0x60端口里。但它有几个很直接的问题。
最明显的是CPU时间被浪费。轮询期间,整个CPU一直在执行“读端口、比较、跳转”这几条指令,哪怕键盘八百年不响,CPU照样转个不停。往大了说,这是把宝贵的算力全部花在了“等待”上。往小了说,你在QEMU里跑还好,放到真实硬件上CPU温度都能感受到差别。
还有一个更隐蔽的痛点:轮询没有“优先级”概念。如果主循环里有一段耗时操作,比如显示一个旋转动画或者模拟复杂计算,等你回到轮询点的时候,用户可能已经连敲了十几下键盘,而你只能读到最后一个扫描码,中间的输入全部丢失。硬件不会等你,数据缓冲区就那么大,装不下就丢。
所以“中断”不是操作系统开发者的发明,它是一条古老且朴素的协作规则:外设有事就主动通知CPU,没事就别吵它。这和我们平时等人一样,与其隔几分钟去门口看一眼,不如安个门铃,门铃响了再去开门,效率完全不在一个量级。
1.2 中断机制的本质:一次带现场保护的异步跳转
中断的底层逻辑并不复杂。CPU在执行指令流时,外部硬件通过INTR引脚送来一个高电平信号,CPU在当前指令结束后检查到该信号,便会暂停当前执行流,转而跳转到一段预先注册好的处理代码。这段代码处理完事件后,再用iret指令跳回原来的指令流,继续往下执行。
关键点在于“暂停”不是粗暴的打断。CPU会自动把当前的eflags、cs、eip压入栈中,相当于给现场拍了一张快照。等中断处理完成,iret会从栈里恢复这三个值,程序就能像什么都没发生过一样继续运行。这种自动保存现场、自动恢复现场的机制,是所有中断处理能够安全运行的前提。
中断和普通的函数调用不能混为一谈。调用一个C函数,编译器会帮你安排好参数、返回值、局部变量,整个过程都在一个既定的调用约定里。而中断处理则完全不同:事件可能在任意一条指令之后发生,寄存器状态可能是任意值,你根本不直到当前栈顶是什么。所以“保护现场”这件事,必须由开发者用汇编手动完成,不能完全依赖编译器。
我在项目里第一次体会到这个区别是在触发定时器中断的时候。当时我只是简单地把C函数地址放进了IDT,然后点击运行,结果系统直接重启。后来才明白,C函数被调用时会假设栈上有一个正常的返回地址和参数布局,而中断入口处的栈布局完全不一样。你必须在汇编层先做完现场保存,再调C函数,最后恢复现场并iret。
1.3 第 7 章为什么把“中断”和“FIFO”放在一起讲
观察中断处理机制你会发现一个矛盾:中断处理函数追求“短小精悍”,因为中断期间CPU处于一种特殊状态,不能长时间停留在里面;但外设产生的事件往往需要复杂处理,比如键盘扫描码要翻译成ASCII、要回显到屏幕、甚至要放进文本输入缓冲区。
这个矛盾怎么解决?中间垫一个FIFO队列,也就是先进先出的环形缓冲区。
中断处理函数只做最轻量的事情:从端口读一个字节,塞进队列,清中断,然后立刻返回。真正耗时的“翻译扫描码”“显示字符”“处理逻辑”统统搬到主循环里,等队列里有数据再慢慢消费。这种设计把“事件产生”和“事件处理”在时空上分离开,中断处理函数保持极短,整个系统就不会因为某个外设的突发流量而卡死。
这也是第7章把中断和FIFO放在一起拆解的原因:没有FIFO的中断处理是不完整的。想通了这一层,后面再看按键回显、串口输入、鼠标处理,都会遵循同一个套路。
2. 中断的硬件链路:CPU、8259A、IDT的完整配合
2.1 中断源有哪几类,哪些值得优先处理
x86体系下的中断源大致分三类。第一类是外部硬件中断,来自CPU的INTR引脚,典型代表是键盘、定时器、串口、磁盘控制器。这类中断由8259A或APIC统一管理,是自制操作系统最先需要打通的环节。
第二类是CPU内部异常,比如除法错误、无效操作码、页错误、保护错误。这类中断不需要外部硬件触发,CPU在执行指令过程中发现问题后自己产生中断号。异常处理的好坏直接决定系统的稳健性:一个未处理的异常可能导致系统静默死机,开发者只能对着屏幕发呆。
第三类是软中断,由int n指令主动触发。操作系统提供系统调用服务时通常会用到它。不过在自制系统的早期阶段,软中断可以往后放,先把外部硬件中断跑通再说。
优先级角度建议按这个顺序推进:先打通外部中断中的键盘和定时器,再补几个关键异常处理函数用于调试,最后再考虑软中断。不要把系统调用机制和第7章混在一起做,否则排查问题时变量太多,很容易把人绕晕。
2.2 8259A 存在的意义和主从级联关系
CPU的INTR引脚只有一个,但外设可能成百上千。不可能给每个外设都拉一根线直连CPU,所以硬件上设计了中断控制器来中转。早期PC上用的是8259A可编程中断控制器,它负责收集各个外设的中断请求,做一个优先级仲裁之后,再统一向CPU的INTR引脚发信号。
8259A在经典PC上以主从级联方式出现,主片端口是0x20和0x21,从片端口是0xA0和0xA1。主片管理IRQ0到IRQ7,从片管理IRQ8到IRQ15。如果某个中断来自从片,从片会通过主片的IRQ2引脚向上报告,CPU收到后需要在中断处理流程里额外确认具体是哪个从设备发出的。
这个主从关系在初始化阶段需要格外注意。每个8259A都要单独发送初始化命令字,主片和从片的命令内容不完全相同。很多人初始化时只写了主片,回头看键盘中断能用、定时器也能用,就没管从片,结果接鼠标的时候彻底卡住。建议从一开始就把主从片都初始化好,哪怕暂时不用从片,也把它的中断全部屏蔽掉。
2.3 IDT 和 IDTR:CPU 的中断跳转表
CPU接收到中断信号后,怎么知道该跳到哪段代码去执行?答案是查表。这张表叫IDT,中断描述符表,最多可以容纳256个表项,每个表项8字节。中断号本质上就是这张表的索引,CPU拿到中断号后,从对应表项里读出段选择子和偏移地址,然后执行跳转。
IDT表项的核心字段包括:
| 字段 | 作用 |
|---|---|
| 偏移地址低16位 | 处理函数入口地址的低位 |
| 段选择子 | 处理函数所在代码段的索引 |
| 保留字段 | 固定设为0 |
| 类型属性 | P位、DPL、门类型 |
| 偏移地址高16位 | 处理函数入口地址的高位 |
有了这张表,CPU就能完成“中断号 → 处理函数地址”的映射。加载这张表的指令是lidt,操作对象是一个6字节的内存区域,包含16位的表限长和32位的表基址。我调试时踩过的坑之一就是lidt的指针结构没有严格packed,编译器偷偷塞了填充字节,导致加载的基址错位,系统直接跑飞。
顺带提一下GDT。IDT里的段选择子必须指向一个有效的代码段描述符,所以GDT必须先于IDT准备好。如果你之前的章节已经能进入保护模式并跳转到C代码,那么GDT大概率是可用的,直接复用内核代码段的选择子就可以。
2.4 中断门、陷阱门、任务门该选哪种
IDT表项的类型属性字段可以区分三种门:中断门、陷阱门、任务门。名称听着复杂,实际差异很聚焦。
中断门的特点是在跳转进入处理函数之前,CPU会自动把eflags的IF位清零,也就是关中断。这意味着在中断处理期间,新的外部中断不能再打断当前正在执行的处理流程,相当于天然防嵌套。大部分中断处理都应该用中断门,尤其是键盘、定时器这类高频外设。
陷阱门不会修改IF位。某些异常处理希望保持原有的中断使能状态时才会用到,比如调试器、单步跟踪场景。任务门则是硬件级别的任务切换机制,在现代操作系统里很少使用,自制系统几乎可以忽略。
我的第7章代码里所有中断都注册为中断门,类型属性写0x8E。这个值的二进制含义是:P=1(段存在)、DPL=0(内核态访问)、type=0xE(32位中断门)。如果你用0x8F,就是陷阱门。两者差别很小,但在“中断处理中突然来第二个中断”的场景下,行为会明显不同。
3. 中断模块的三件套:IDT 装载、8259A 初始化、统一入口
3.1 用代码把 IDT 描述符填好并装载
IDT和GDT一样,本质是一块普通内存,需要开发者自己组织字节布局。我的做法是定义两个结构体,一个描述IDT表项,一个描述lidt指令要用的指针区域:
struct idt_entry { uint16_t offset_low; uint16_t selector; uint8_t reserved; uint8_t type_attr; uint16_t offset_high; } __attribute__((packed)); struct idt_ptr { uint16_t limit; uint32_t base; } __attribute__((packed));注意这个packed属性一定不能丢。如果编译器按默认对齐方式在结构体里塞了填充字节,IDT表项的位置就会错开几个字节,CPU读到的段选择子、偏移地址全是乱码,启动必挂。
填表项时还需要小心地址拆分。32位保护模式下,一个中断处理函数的地址是32位的,但描述符里偏移被拆成低16位和高16位。不要直接整个赋给一个字段,务必分别取高位和低位写入:
static struct idt_entry idt[256]; void set_idt_entry(uint8_t vec, uint32_t handler, uint16_t selector, uint8_t attr) { struct idt_entry *e = &idt[vec]; e->offset_low = handler & 0xFFFF; e->offset_high = (handler >> 16) & 0xFFFF; e->selector = selector; e->reserved = 0; e->type_attr = attr; }装载过程则是先把idt数组的地址和长度填入idt_ptr,然后执行lidt指令。推荐把它写成一个小函数:
void load_idt(void) { struct idt_ptr ptr; ptr.limit = sizeof(idt) - 1; ptr.base = (uint32_t)&idt; __asm__ volatile("lidt %0" : : "m"(ptr)); }装载IDT只是注册了256个表项,真正让中断生效还需要两个条件:对应的8259A初始化完成,以及处理器开中断,也就是执行sti指令。
3.2 8259A 重映射中断号:为什么不能沿用默认值
8259A出厂默认的中断号映射非常坑:IRQ0对应int 8,IRQ1对应int 9。问题是int 8到int 15在x86上已经被CPU异常占用了,比如double fault是int 8,一般保护错误是int 13。如果键盘中断默认映射到int 9,那它和“协处理器段溢出”这类异常就会撞车,系统行为完全不可预测。
所以初始化8259A时,第一件事就是把IRQ号重新映射到空闲的中断号区间。常见的做法是把主片IRQ0映射到int 0x20,从片IRQ0映射到int 0x28。这样整个外部中断区间是0x20到0x2F,和CPU异常区间完全隔离。
8259A的初始化必须按固定顺序发送初始化命令字:
void pic_init(void) { // 向主片和从片发送ICW1:边沿触发、级联、需要ICW4 outb(0x20, 0x11); outb(0xA0, 0x11); // ICW2:设置中断号基址 outb(0x21, 0x20); // 主片 IRQ0 -> int 0x20 outb(0xA1, 0x28); // 从片 IRQ0 -> int 0x28 // ICW3:级联设置,主片IRQ2连接从片 outb(0x21, 0x04); outb(0xA1, 0x02); // ICW4:8086模式 outb(0x21, 0x01); outb(0xA1, 0x01); // OCW1:屏蔽中断,只放开IRQ0和IRQ1 outb(0x21, 0xFC); outb(0xA1, 0xFF); }这段代码最后的0xFC是中断屏蔽寄存器IMR的内容,二进制为11111100,表示IRQ0和IRQ1未被屏蔽,其余全部屏蔽。IRQ0是定时器,IRQ1是键盘,这两个正好是第7章的起步组合。刚开始做中断时建议只放开这两个,其他外设的中断请求在没初始化前一律屏蔽,能避免大量莫名其妙的干扰。
ICW1为什么要写0x11?0x11的bit4是1表示要发送ICW4,bit1为1表示级联模式,bit0为1表示还需要写ICW1的边沿触发配置。这里不要自作聪明改成0x10,否则8259A可能不按你预期的模式工作。
3.3 汇编统一入口:现场保护的关键动作
IDT表项里的处理函数地址,指向的应该是汇编写的中断入口stub,而不是直接指向C函数。原因前面提到过:中断现场必须手动保存,C函数没有这个能力。
用宏批量生成stub是比较省力的做法。每个外部中断都长得很像,唯一区别是中断号:
; 无错误码的中断 %macro ISR_NOERR 1 global isr%1 isr%1: push 0 ; 占位,伪装错误码 push %1 ; 压入中断号 jmp isr_common %endmacro ; CPU会压入错误码的异常 %macro ISR_ERR 1 global isr%1 isr%1: push %1 ; 中断号 jmp isr_common %endmacro为什么要区分有错误码和无错误码?因为CPU在响应某些异常时,会自动往栈里压入一个error code,方便处理程序了解异常发生的原因。但外部中断不会压错误码。为了保证进入公共入口时栈布局统一,所有无错误码的中断都手动补一个0占位。
接下来是公共入口的核心逻辑。进入这里时,栈上已经有中断号、错误码或占位0、以及CPU自动压入的eip、cs、eflags。我们需要继续把通用寄存器和段寄存器全部压栈:
extern isr_handler isr_common: pusha ; 保存 EAX, ECX, EDX, EBX, ESP, EBP, ESI, EDI push ds push es push fs push gs ; 切换内核数据段 mov ax, 0x10 mov ds, ax mov es, ax mov fs, ax mov gs, ax ; 提取中断号和错误码,传给C函数 mov eax, [esp + 48] ; 中断号 mov ebx, [esp + 52] ; 错误码/占位0 push ebx ; C函数第二个参数 push eax ; C函数第一个参数 call isr_handler add esp, 8 pop gs pop fs pop es pop ds popa add esp, 8 ; 跳过错误码和中断号 iret这里的栈偏移48可能让不少人困惑,实际上可以自己算一遍。isr_common刚进入时,栈顶是刚push的中断号,再往高地址是错误码。接着pusha压入32字节寄存器,再压入4个段寄存器各4字节共16字节。当前栈顶向上偏移16加32等于48字节处,正好是压栈前的中断号。中断号再向上4字节是错误码,所以第二个参数从esp+52取出。
还有一件事容易被忽略:在call isr_handler之前,要把段寄存器切到内核数据段。IDT表项选的段选择子通常是内核代码段0x08,但它只保证代码段的eip正确,数据访问用的DS、ES还是之前的值。如果中断发生时DS恰好指向一个无效段,C函数里随手访问一个全局变量就会触发保护异常。
3.4 在 C 层注册中断处理函数并分发
汇编入口最终统一调用一个C函数isr_handler,接收两个参数:中断号和错误码。有了这层统一入口,C侧就可以维护一张函数指针表,把每个中断号对应的处理函数挂上去:
typedef void (*interrupt_handler_t)(uint32_t); static interrupt_handler_t handler_table[256]; void register_interrupt_handler(uint8_t vec, interrupt_handler_t handler) { handler_table[vec] = handler; } void isr_handler(uint32_t int_no, uint32_t err_code) { // 外部中断统一在这里清EOI if (int_no >= 32 && int_no <= 47) { outb(0x20, 0x20); // 主片EOI if (int_no >= 40) { outb(0xA0, 0x20); // 从片EOI } } if (handler_table[int_no]) { handler_table[int_no](err_code); } }这个分发逻辑把中断处理和业务处理解耦开。后续要加定时器,就注册一个定时器中断处理函数;要加键盘,就注册一个键盘中断处理函数。每增加一个外设,不需要再碰汇编代码,只改C层注册和对应处理逻辑就行。
清EOI的位置有几个细节值得注意。EOI全称是End of Interrupt,发送方式是往主片端口0x20写0x20。如果不发EOI,8259A会一直认为当前中断还在处理中,后续的中断请求全部无法上报,表现就是“中断只触发一次就再也不响了”。从片中断的话,需要先给从片发EOI再给主片发,顺序不要反。
关于EOI位置,有些资料写“先尽快发EOI再处理逻辑”,我自己的习惯有所不同:对于键盘这种短操作,先读取数据、写入FIFO、再发EOI更合适。因为EOI发出后同IRQ级别的新请求理论上又变得活跃了,如果此时处理代码还没退出中断门,虽然IF被清零不会立刻重入,但8259A端的In-Service位已经清除,后续请求的丢失概率会上升。当然,这只在极短操作用途里成立,一旦处理逻辑变长,最稳妥的做法还是先EOI,把重入问题交给硬件和中断门处理。
4. 中断处理程序里的原则:快进快出,剩下的交给 FIFO
4.1 为什么中断处理程序不能做重活
中断处理程序最忌讳的一件事,就是长时间占用CPU。原因首先是中断门的IF清零机制,中断处理过程中,外部中断被屏蔽,新的中断请求只能在8259A端排队。如果处理代码执行太久,同一IRQ的后续事件会积累或丢失。定时器中断一秒钟可能有几十上百次,键盘也可能在短时间内连续产生多个扫描码,任何一个处理环节变慢都会直接拖垮整个系统的实时性。
另一个原因和栈有关。中断发生时,CPU可能正在使用某个较小的临时栈,如果中断处理函数里调用深层函数、分配大数组,栈很容易溢出。在没有专门设置中断栈的自制系统里,这几乎意味着系统崩溃。
所以我把中断处理程序的代码约束到几行以内:读端口、写FIFO、清中断标志。凡是超过这个范围的操作,全部丢给主循环。这条原则不是性能优化建议,而是中断处理的基本纪律,违反了就会在不知不觉中埋下雷。
4.2 FIFO 的设计与实现:键盘事件缓冲
FIFO队列我选了环形缓冲区实现,做到固定内存、O(1)入队出队、无动态分配。数据结构如下:
#define FIFO_SIZE 64 struct fifo { uint8_t buf[FIFO_SIZE]; uint32_t head; uint32_t tail; uint32_t count; };head指向下一个要读取的位置,tail指向下一个要写入的位置,count记录当前数据量。这三个字段配合起来,可以区分“空”和“满”两种边界状态。不用count也实现判断空满,比如满的时候tail+1等于head,但这种写法在处理边界时容易出错,尤其是容量不是2的幂次时。用count字段最直观,也最容易调试。
入队和出队的核心代码如下:
int fifo_put(struct fifo *f, uint8_t data) { if (f->count == FIFO_SIZE) { return -1; } f->buf[f->tail] = data; f->tail = (f->tail + 1) % FIFO_SIZE; f->count++; return 0; } int fifo_get(struct fifo *f, uint8_t *out) { if (f->count == 0) { return -1; } *out = f->buf[f->head]; f->head = (f->head + 1) % FIFO_SIZE; f->count--; return 0; }64字节的FIFO对键盘来说绝对够用。人的打字速度无论如何达不到每秒几十个字节,即便一次性按下几十个键,扫描码也就挤占一小段缓冲。真正需要大缓冲区的是串口或者网络设备,键盘这个场景64字节已经是很宽裕了。
4.3 主循环消费 FIFO:中断与业务逻辑彻底解耦
有了FIFO,键盘中断处理函数就可以写得极其干净:
void keyboard_handler(uint32_t err_code) { uint8_t scancode = inb(0x60); fifo_put(&key_fifo, scancode); }短短三行,不做翻译、不回显、不做任何字符拼接。实际代码里还可以加一句状态检查,确认0x64端口输出缓冲区确实有数据,但IRQ1能触发本身就意味着有数据,直接读0x60通常也不会出错。加入这类防御性检查不是为了应对硬件,而是为了排查问题时少一层猜测。
主循环侧则负责真正的业务逻辑:
while (1) { uint8_t scancode; if (fifo_get(&key_fifo, &scancode) == 0) { char ch = translate_scancode(scancode); console_put_char(ch); } // 这里还可以继续做其他任务 }这样设计的好处是,键盘处理逻辑不会阻塞在中断上下文中,主循环有足够时间处理字符翻译和显示。即使某个字符翻译函数写得特别慢,影响的只是“消费速度”,不会打断中断产生端的实时性。等队列满了,新事件会丢弃,但主循环很快会消费腾出空间,系统整体还是均匀的。
4.4 开中断、关中断与临界区的取舍
中断处理引入了并发,自然要考虑临界区问题。在我的场景里,FIFO入队只发生在中断处理上下文中,FIFO出队只发生在主循环中。因为中断门会自动清除IF位,中断处理期间不会发生嵌套,所以入队端的原子性有保障。
出队端在主循环中调用时,理论上可能被另一个中断打断。如果这个中断处理函数碰巧访问同一个FIFO,就会出现head、tail、count三者状态不一致的风险。解决手段很朴素:在出队前后关中断、开中断:
uint8_t scancode; __asm__ volatile("cli"); int got = fifo_get(&key_fifo, &scancode); __asm__ volatile("sti");cli到sti之间的代码必须足够短。一个中断只会延迟几个微秒到几十微秒,用户完全感知不到,但安全性提升明显。反过来,如果临界区里塞了显示字符、格式化字符串这种慢操作,关中断时间过长,定时器和键盘事件就会堆积,系统给人的感觉就是卡顿、掉键。
所以这里有一个基本判断标准:临界区只保护共享数据结构的“几步操作”,任何涉及I/O、显示、复杂计算的工作都移到临界区外。
5. 实际调试中断模块时最常踩的五个坑
5.1 键盘中断触发一次后再无响应
这个现象大概率出自三种原因:EOI没发送、IMR屏蔽位没正确设置、或者中断处理流程中某个环节触发了异常但没有任何输出。
EOI问题的排查最简单:在keyboard_handler开头和结尾分别往调试串口写一个字符,确认中断是否真的进入了处理函数。如果只进了一次,后面再也进不来,基本是EOI没发或者8259A状态不对。检查出中断处理函数里有没有往0x20写0x20。
IMR问题则要看初始化顺序。8259A初始化最后一个步骤是写OCW1,也就是屏蔽寄存器。键盘对应IRQ1,IMR寄存器的bit1必须是0,才能放行键盘中断。如果初始化时为了省事直接写0xFF,所有外部中断都会被屏蔽,键盘自然没反应。
还有一种情况是键盘控制器本身没准备好。键盘控制器上电后,需要等到它完成自检,可以读取0x64状态字的bit1确认输入缓冲区是否空闲。如果过早发送配置命令,后续中断就完全不正常。这个属于比较深的水,建议先确认前两项再说。
5.2 中断处理里调用现成输出函数导致死机
我自己在这个坑里栽过两次。第一次是图方便,在keyboard_handler里直接调用了console_put_char来回显字符。看起来逻辑很顺,但很快发现按下按键后系统直接卡死或花屏。
问题出在自制的控制台输出函数并不是线程安全的,它维护光标位置和当前行,如果中断打断了主循环里正在执行的输出流程,两个上下文同时改光标位置和显存,数据就会彻底错乱。更严重的是,如果输出函数内部有循环等待某个标志,而那个标志主循环才有机会更新,中断处理就把系统锁死了。
正确做法还是把输出放到主循环,中断处理只负责把扫描码塞进FIFO。如果实在需要在调试时从中断里输出信息,最稳的方式是用串口,而且尽量保证串口初始化足够简单,在中断里只调用outb发送单个字符。
5.3 IDT 装载后系统直接跑飞
lidt指令本身不会出错,它只是把一个6字节的指针加载到IDTR寄存器。系统跑飞大概率是描述符表项里的地址或属性不对。
检查表项时先确认handler地址没有写错。32位地址拆成两个16位字段填入时,高位字段容易写反或漏写。另一个高频错误是段选择子指向的段描述符应为代码段,如果误填了数据段选择子,CPU在跳转执行时会触发段错误。
还有一个容易忽略的点:IDT所在的内存区域必须已经完成映射初始化。如果内核还在实模式下,直接对高于1MB的地址填入IDT表基址,加载后CPU去读表时访问不到内容,什么都不用谈。先确认IDT数组所在的物理地址确实可用,再谈描述符内容。
5.4 FIFO 消费时读到错乱数据
FIFO结构体本身很简单,出问题通常出在共享访问上。最常见的场景是主循环读FIFO时,正好被键盘中断打断,键盘中断往同一个FIFO写数据,把head、tail、count读写成中间状态。下一次主循环继续读就会拿到错误的索引或错误的count。
解决方式前面说过了,出队前cli、出队后sti。这个操作放在中断处理里额外保险,但如果你只关心键盘一个中断源,入队操作已经处于中断门保护下,不需要再cli。
另一种错乱来自扫描码本身。键盘按下时会发送通码,松开时会发送断码,断码通常是通码加上0x80掩码。如果不区分通码断码,一个按键会被读成两个事件。早期调试时可以只显示通码对应的字符,断码直接丢弃,等系统稳定了再做完整的按键状态机。
5.5 中断号与 IRQ 映射对应不上,触发错乱
3259A初始化完成后,IRQ1对应int 0x21,IRQ0对应int 0x20。但如果你没做重映射,IRQ1对应int 9,和CPU异常撞车。这种错误的症状非常诡异:按下键盘时可能触发的是异常处理函数,而不是键盘处理函数,屏幕上出现一串寄存器转储。
排查方法是在isr_handler分发入口加一个临时调试标志,在中断发生处打点,输出中断号。如果按下键盘时打印出的中断号不是0x21,说明8259A初始化没有生效,回到ICW2的设置上检查。
另一个容易混淆的点是中断向量号在软件里可用任意数值。外部中断通过8259A映射到0x20到0x2F,并不意味着软件中断指令int 0x20也会触发定时器中断。这是两套机制,软中断只是借用同一个IDT表,IDT表项指向什么函数就是什么函数,和8259A没有直接关系。调试时不要把int 0x20当成“模拟定时器中断”来验证8259A。
6. 从键盘中断继续向前:定时器、多级中断与个人心得
6.1 用定时器中断给系统加一个“心跳”
键盘中断打通的代码稍微改改,就能用在定时器上。定时器对应IRQ0,重映射后是int 0x20。初始化代码已经放开了IRQ0,注册一个定时器处理函数即可:
static volatile uint32_t tick_count = 0; void timer_handler(uint32_t err_code) { tick_count++; }这个tick_count就是操作系统的“心跳”。有了它,可以实现简单的时钟显示、延时函数、甚至时间片轮转调度。配合FIFO的思路,还能做出sleep这类功能:主循环记录开始时的tick,每隔一段时间检查差值是否超过目标值。
定时器中断的高频特性给了我们一个锻炼中断纪律的好机会。8259A的定时器默认频率是18.2Hz,不高,但如果中断处理函数里写了耗时的屏幕刷新逻辑,你很快就能感受到卡顿。最好的做法是timer_handler里只递增计数器,屏幕刷新全部放主循环。
等到你能熟练处理定时器中断之后,再回去读多任务或抢占式调度的代码,思路会顺畅很多。定时器中断的本质是“定期打断当前执行流”,这正是时间片轮转调度的前置条件。
6.2 从 8259A 走向 APIC:中断机制还能怎么进化
第7章用的8259A已经是经典PC时代的产物。现代CPU和主板使用APIC架构,支持更多的中断源、更好的优先级管理、以及多处理器间的中断分发。但8259A的学习价值一点不减,它是理解APIC的极佳跳板。
8259A的核心概念——中断源收集、优先级仲裁、向量号映射、EOI协议——在APIC里依然存在,只是实现方式从端口操作变成了内存映射寄存器,从单一主片扩展到多处理器各自本地APIC加一个I/O APIC。明白了8259A怎么工作,切换到APIC只是换一套寄存器操作API。
不过在自制系统项目里,我不建议太早换APIC。先让8259A和IDT这套基础链路完全稳定,做出一个能响应键盘输入、跑字符界面、显示时钟的小内核,再考虑现代架构的改造。技术选型的关键不是“越新越好”,而是“当前阶段够用且能暴露核心问题”。
6.3 做中断模块这段时间,我最想分享的几句话
我深刻的体会是,中断处理这件事,“规范”比“技巧”重要得多。技巧类的东西,比如栈偏移怎么算、ICW顺序怎么记,都可以靠资料查清楚,但你能不能坚持“中断里只做短活、业务全放主循环”这条原则,决定系统后续会不会在某个奇怪场景下崩溃。
之前有一段时间,我为了一时方便,在定时器中断里加入了LED闪烁和调试输出,当时跑得很欢快。直到后面加入串口驱动,两个中断源同时工作,问题立刻爆发:串口事件丢失、LED闪烁不规律、键盘响应变慢。最后把中断处理函数里所有“看着轻量但实际耗时”的代码全部搬出去,才恢复正常。这个教训让我记住了:中断处理函数不是不能写业务代码,而是它的使命本来就不是处理业务。
如果这一章你能顺利跑通键盘输入加屏幕回显,说明你已经具备了最基本的“硬件事件驱动”思维。接下来无论是加一个简单的shell、实现内存分配,还是做任务切换,都会在这个基础上自然地展开。自制操作系统最迷人的地方就在于:看似枯燥的底层中断机制,会一步步支撑起一个活生生的系统。
最后再分享一个小技巧。调试中断问题时,尽量在关键位置用串口输出单字符或短标记,速度极快,对中断时序的影响几乎可以忽略。屏幕显示也可以,但会延迟很高,不适合高频中断场景。串口打点是自制系统调试中性价比最高的手段,没有之一。