1. 中断是什么,为什么操作系统绕不开它
先说一个我经常被问到的点:很多人学操作系统,一开始对着进程、内存、文件系统这些大块头猛啃,觉得中断这东西不就是“硬件有事叫一下 CPU”嘛,简单。可真到自己动手写一个内核、把中断打开之后,才发现满屏的异常、死锁、寄存器错乱,连键盘输入都收不到。中断这个看似不起眼的机制,其实是整个操作系统能够运转的地基。
回到我们自制操作系统的第 7 章,这一章的核心任务就是把“中断”和“FIFO 缓冲区”串起来。我最初做这一章时也有点轻视它,心想无非就是设置 IDT、写几个 handler、再搞个队列存键盘扫描码。但真正从裸机环境一路调试下来,我最大的感受是:中断驱动的难点不在“怎么写”,而在“怎么设计和排错”。这篇文章我就结合自己做这一章的完整过程,把中断的原理、FIFO 的设计取舍、键盘驱动的接入方式、以及那些让我抓狂的 bug 一起讲清楚,希望能帮正在啃自制操作系统的朋友少走弯路。
先给还没上车的读者交代一下背景。我们在前几章已经搞定了内存检测、分页、GDT 等基础工作,现在系统已经能在屏幕上打印字符、跑简单的 C 代码。但这一切还是“轮询式”的:CPU 主动去问键盘有没有输入、主动去问定时器有没有触发。这种做法的缺点很明显——CPU 一直在空转,而且非常容易漏掉瞬时事件。中断机制就是为了解决这个问题而存在的:外设主动通知 CPU “我有事”,CPU 暂停当前工作,跳转到对应的处理函数,处理完再回到原来的位置继续干活。
这个“暂停”和“恢复”听起来简单,但真正实现时要处理和寄存器保存、栈切换、中断优先级、嵌套中断等一大堆细节。第 7 章的安排非常合理:先实现中断本身,再引入 FIFO 来缓存数据,最后把键盘和定时器接进来。这种顺序不是随意安排的,中断如果没有缓冲区来“蓄水”,高频事件一来就会丢数据;而缓冲区如果不依赖中断,就只能靠轮询,又回到了老路。
2. IDT 与中断处理的底层机制
2.1 中断向量、IDT 和 CPU 的自动流程
首先要建立一个整体画面。x86 架构下,CPU 支持 256 个中断向量,编号从 0 到 255。前 32 个(0 到 31)是 CPU 内部异常和保留向量,比如除法错误(#DE)、缺页异常(#PF)、一般保护错误(#GP)等等;从 32 开始才是用户可自定义的外部中断(IRQ),我们在操作系统中通常把 IRQ0(定时器)映射到向量 32,把 IRQ1(键盘)映射到向量 33,以此类推。
这里有一个非常关键的概念:CPU 如何知道每个中断向量对应的处理函数在哪里?答案是中断描述符表(IDT,Interrupt Descriptor Table)。IDT 本质就是一个数组,每个元素是一个 64 位的门描述符,里面保存着处理函数的偏移地址、段选择子、属性信息(如 DPL 权限级别、P 标志位、类型)。CPU 收到中断时,会根据中断向量号去 IDT 中查找对应描述符,然后自动完成一系列压栈和跳转操作。
我画过很多次这个流程才真正理解:当外部中断到达时,CPU 会先把当前 EFLAGS、CS、EIP(如果是特权级变化还会压入 SS 和 ESP)压入当前栈,然后查找 IDT,找到处理函数后跳转过去执行。处理函数执行完毕,通过iret指令恢复之前保存的 EFLAGS、CS、EIP,继续执行被中断的程序。这一整套动作是 CPU 硬件自动完成的,不需要软件介入。
那为什么需要我们自己写代码呢?因为 IDT 需要我们自己构建,处理函数需要我们自己编写,中断控制器(PIC)也需要我们初始化。硬件只提供了机制,策略完全由操作系统决定。
2.2 从 PIC 到中断描述符表的初始化顺序
x86 传统上使用两片级联的 8259A 可编程中断控制器(PIC)来管理外部中断。主片控制 IRQ0 到 IRQ7,从片控制 IRQ8 到 IRQ15,从片通过主片的 IRQ2 级联。由于 CPU 在实模式下前 32 个向量已经被 CPU 内部异常占用,所以必须把 PIC 的 IRQ 重新映射到 32 之后。这就是为什么你会看到经典的初始化代码里对 0x20 和 0x28 这两个偏移做重映射:
void init_pic(void) { // 向主片和从片发送初始化命令字 io_out8(PIC0_ICW1, 0x11); // 边沿触发、级联模式、需要 ICW4 io_out8(PIC0_ICW2, 0x20); // 主片 IRQ0-7 映射到中断向量 0x20-0x27 io_out8(PIC0_ICW3, 0x04); // 主片的 IRQ2 用于级联从片 io_out8(PIC0_ICW4, 0x01); // 普通全嵌套模式、非缓冲 io_out8(PIC1_ICW1, 0x11); io_out8(PIC1_ICW2, 0x28); // 从片 IRQ8-15 映射到中断向量 0x28-0x2f io_out8(PIC1_ICW3, 0x02); // 从片连接到主片的 IRQ2 引脚 io_out8(PIC1_ICW4, 0x01); io_out8(PIC0_IMR, 0xf9); // 屏蔽除 IRQ0(定时器)和 IRQ1(键盘)以外的中断 io_out8(PIC1_IMR, 0xef); // 屏蔽从片除 IRQ12(鼠标)以外的中断 }这段代码是很多自制操作系统的标配,但真正理解每一行含义的人不多。ICW1 是初始化命令字,0x11 表示需要发送 ICW4,并且是级联模式。ICW2 设置中断向量基址,这个基址必须对齐到 8 的倍数,因为每个 PIC 管理 8 个中断。ICW3 在主片和从片的含义不同:主片是位图,每一位对应一个 IRQ 是否级联了从片,IRQ2 对应 bit2,所以写 0x04;从片则直接写级联到主片的 IRQ 引脚号,也就是 0x02。ICW4 设置模式,普通全嵌套、非缓冲就够用。
初始化完成后,还要设置中断屏蔽寄存器(IMR)。注意 IMR 的每一位对应一个 IRQ,1 表示屏蔽,0 表示允许。屏蔽位的作用非常实用:在中断处理程序还没写好的时候,先把所有中断屏蔽掉,可以有效避免系统一启动就收到键盘乱码中断导致崩溃。
2.3 键盘中断到 C 函数的调用链
有了 PIC 和 IDT 之后,我们需要把 IRQ1(键盘)对应的中断向量 33 连接到实际的键盘处理函数。这里有一个汇编与 C 语言衔接的关键问题:CPU 中断现场需要保存/恢复寄存器,这部分最好由汇编完成,而具体业务逻辑(比如把扫描码放入 FIFO)则写在 C 里,可读性和可维护性都好得多。
我的做法是写一个统一的汇编入口,叫做asm_inthandler21。它的职责就是三件事:压寄存器、调用 C 函数、恢复寄存器。最终通过iret返回被中断的任务。
asm_inthandler21: pusha push ds push es push fs push gs mov ax, ss mov ds, ax mov es, ax call inthandler21 pop gs pop fs pop es pop ds popa iret这里不少初学者会问:为什么明明在保护模式下,还要关心ds、es这些段寄存器?因为中断可能发生在任意上下文,而操作系统内核代码的段选择子与用户程序可能不同。为了保证 C 代码中使用的数据段是内核数据段,我们必须在入口处把ss的值赋给ds和es。这也是一种“保险”写法,实战中非常可靠。
那inthandler21这个 C 函数里做了什么?我上一章做的 FIFO 缓冲区在这里派上了用场:从键盘端口读取扫描码,放入 FIFO,然后把中断结束命令发送给 PIC。之所以要先读端口再发送 EOI(End of Interrupt,中断结束命令),是因为一旦发送 EOI,PIC 就会允许下一个中断进来,如果键盘扫描码还在端口里没被读走,下一个中断又会触发,造成数据错乱。
3. FIFO 缓冲区的设计与实现思路
3.1 为什么中断处理里必须引入缓冲区
在真正动手之前,我们先看一个非常现实的场景。你按一下键盘上的某个键,键盘控制器会产生一个扫描码,并通过 IRQ1 通知 CPU。如果 CPU 正在忙别的事情,没有来得及响应,扫描码并不会消失,它会一直停留在键盘控制器的端口里等待读取。问题在于:如果你连续快速输入多个字符,键盘控制器只有很小的缓冲空间,新的扫描码可能覆盖旧的,导致输入丢失。
定时器中断的情况更严重。默认的定时器频率(比如每秒 100 次)意味着每 10 毫秒就有一个中断触发。如果在中断处理函数里做太多耗时操作,下一次中断到来时,上一次中断还没处理完,数据就会堆积。一个典型的错误是直接在中断处理里调用打印函数,在屏幕上输出调试信息。这在实践中有很大概率造成输出乱码,甚至死锁,因为打印过程本身可能依赖某些全局状态,而中断打断了正在打印的代码,造成不可重入。
于是 FIFO 缓冲区就成了中断处理和主循环之间的“蓄水池”。中断处理函数只做最少的操作:读取硬件数据、写入 FIFO、发送 EOI,然后立刻返回。主循环在适当的时候从 FIFO 中取出数据并处理。这样中断处理函数保持极短,不容易丢失数据,主循环也能以自己的节奏消费数据。
3.2 环形缓冲区的基本结构和操作
FIFO 的核心实现是环形缓冲区(Ring Buffer),它比链表实现简单高效,而且不需要动态分配内存。环形缓冲区的数据结构通常包含数据数组、写入位置(下一个写入点)、读取位置(下一个读取点)和一个计数变量,用来区分“空”和“满”两种边界状态。
#define FIFO_SIZE 32 struct FIFO { unsigned char buffer[FIFO_SIZE]; int next_r; // 下一个读取位置 int next_w; // 下一个写入位置 int free; // 剩余空闲字节数 }; void fifo_init(struct FIFO *fifo) { fifo->next_r = 0; fifo->next_w = 0; fifo->free = FIFO_SIZE; } int fifo_put(struct FIFO *fifo, unsigned char data) { if (fifo->free == 0) { return -1; // 缓冲区已满 } fifo->buffer[fifo->next_w] = data; fifo->next_w++; if (fifo->next_w == FIFO_SIZE) { fifo->next_w = 0; } fifo->free--; return 0; } int fifo_get(struct FIFO *fifo) { if (fifo->free == FIFO_SIZE) { return -1; // 缓冲区为空 } int data = fifo->buffer[fifo->next_r]; fifo->next_r++; if (fifo->next_r == FIFO_SIZE) { fifo->next_r = 0; } fifo->free++; return data; }这里用free而不是count来做状态判断,是一个很实用的小技巧。用count也没问题,但free让判断“满”的条件更直观:free == 0就是满,free == FIFO_SIZE就是空。两个指针回绕时直接判断是否等于数组大小,然后归零,比取模运算更高效。因为这些函数会在中断上下文里被调用,性能略微重要一些,而取模运算在这种场景下完全没必要。
3.3 缓冲区大小的权衡之道
FIFO 大小选择是个容易被忽略的问题。缓冲区太小,如果主循环处理不及时,数据就会溢出;缓冲区太大,浪费宝贵的内存,而且在操作系统早期阶段,内存不是无限制的。
以键盘为例,键盘扫描码的数据量其实很小,人就算快速输入,每秒也就 10 到 20 个字符,32 字节的 FIFO 已经非常充裕。但如果接下来要接鼠标,鼠标的数据包更复杂,包含按钮状态和位移信息,同时还带有一定的连续数据流特征,128 字节会更稳妥。
我的实际经验是:小缓冲区(16-32 字节)适合键盘这类事件驱动型设备,大缓冲区(128 或 256 字节)适合定时器或鼠标这类数据流型设备。做这一章时,我一开始给定时器分了 32 字节的 FIFO,结果发现配合高分辨率计时需求时经常丢数据,后来改成 128 字节就稳定了。
4. 中断处理的完整实现过程
4.1 实现一个可用的 IDT 加载函数
IDT 的构建是 C 语言和汇编协作的部分。我们需要一个结构体来描述门描述符,然后填充并加载到 CPU。加载 IDT 的指令是lidt,它接收一个 48 位的伪描述符,包含 IDT 表的大小和基址。
struct GATE_DESCRIPTOR { short offset_low; // 偏移地址低 16 位 short selector; // 段选择子 char dw_count; // 固定为 0 char attribute; // 类型属性 short offset_high; // 偏移地址高 16 位 }; void set_gate(struct GATE_DESCRIPTOR *gate, int offset, int selector, int attribute) { gate->offset_low = offset & 0xffff; gate->selector = selector; gate->dw_count = 0; gate->attribute = attribute; gate->offset_high = (offset >> 16) & 0xffff; }这里要注意一个历史遗留问题:dw_count字段在 32 位模式下通常置 0,但在某些文档里被称为“字计数”,用于 286 时代的任务切换。我们不用管它,写 0 即可。attribute通常设置为 0x8e,表示这是一个 32 位中断门,DPL 为 0,P 位为 1。
将 IDT 表项填好后,需要把表信息加载给 CPU。这一步需要写汇编函数,因为 C 语言没有直接操作lidt指令的内建形式:
load_idt: mov edx, [esp + 4] lidt [edx] ret调用时传入一个IDTR结构体指针即可。这个结构体是 6 字节布局:前 2 字节是 IDT 表长度减 1,后 4 字节是 IDT 基址。这里有个容易踩的坑:表长度减 1,不是表长度本身。因为描述符从 0 开始计数,最大有效偏移是长度减 1。我一开始就在这里翻过车,导致加载后系统一收到中断就重启。
4.2 用 C 语言编写 256 个中断向量的默认处理
一个完整的 IDT 通常要填满 256 个表项。但实际操作系统的早期阶段,我们不可能为每个向量都写一个专门的处理器。比较合理的做法是,为所有未使用的向量填入一个统一的默认处理函数,以防意外触发时系统直接崩溃。
我实现了一个asm_inthandler_default,它的行为和键盘处理器类似,但更简单:只保存寄存器、打印一条异常信息、然后死循环。原因是,未使用的向量一旦触发,说明内核存在 bug(比如访问了非法内存、执行了非法指令),这时最好的策略是停下来暴露问题,而不是假装无事发生继续跑。
void default_exception_handler(void) { // 暂时用简单的方式显示错误信息 cons_putstr("!!! Unexpected Exception !!!\n"); for (;;) { // 挂起 } }这里要注意的是,很多异常发生后 CPU 压入栈的现场和外部中断不同。比如缺页异常会额外压入一个错误码,如果我们的处理器没有正确处理这个错误码,iret时栈布局错乱,就会引发新一轮异常。后来我为了把这块彻底搞明白,专门写了一组带错误码的处理器,把错误码取出来打印。但这一章只聚焦外部中断,用统一处理函数兜底就够了。
4.3 注册键盘与定时器的中断处理入口
接下来是把键盘(IRQ1)和定时器(IRQ0)对应到真正的处理函数。我在init_pic之后,调用set_gate把asm_inthandler21和asm_inthandler20注册到 IDT 的第 33 和第 32 号向量。
set_gate(&idt[0x20], (int)asm_inthandler20, 2 << 3, 0x8e); set_gate(&idt[0x21], (int)asm_inthandler21, 2 << 3, 0x8e);第一个参数是 IDT 的索引,第二个参数是汇编函数的地址,第三个参数是段选择子(这里用内核代码段,索引为 2 对应的选择子乘以 8 是因为 x86 段选择子的低两位是 RPL,TI 位为 0)。第四个参数 0x8e 代表 32 位中断门属性。
如果你用的是不同内核代码段,这一段要按你的段选择子来调。很多人直接把这里写成 8,那是默认的平坦模型内核代码段选择子,但如果你的 GDT 布局不是这样,就会因为跳转到了错误的段而触发异常。
4.4 定时器中断的 FIFO 接入
键盘中断比较直观:从端口 0x60 读取扫描码,放入 FIFO。定时器中断则是一个很有意思的入口,它把“时间”这种抽象概念变成一串可以读取的数据。
我的定时器中断处理函数如下:
void inthandler20(int *esp) { io_out8(PIC0_OCW2, 0x60); // 向主片发送 EOI fifo_put(&timer_fifo, timer_count); timer_count++; }这段代码有几个关键细节。第一,PIC0_OCW2端口写入 0x60,意思是向主片发送 EOI,其中的 0x60 编码包含了 IRQ0 的标识和 EOI 命令。这里不是随便写一个值,而是要确保位字段正确,否则 PIC 不会正确清除中断状态。第二,把当前计数timer_count写入 FIFO,而不是写一个固定值,这样主循环读取时就能知道这是第几次中断,从而计算时间差。
第三,esp参数是关键。虽然inthandler20里没有直接使用esp,但汇编入口调用 C 函数时会把寄存器压栈后的栈指针传进来。如果我们后续需要根据中断现场做任务切换或调试,这个指针非常有用。这一章暂时不深入任务切换,但保留这个习惯有利于后续扩展。
4.5 键盘扫描码的读取与 FIFO 写入
键盘中断处理函数比定时器稍微复杂一点,因为键盘控制器本身有状态寄存器,而且存在按键按下和松开两种事件,产生的扫描码也不同。
void inthandler21(int *esp) { unsigned char scan_code; io_out8(PIC0_OCW2, 0x61); // 向主片发送 EOI scan_code = io_in8(PORT_KEY_DATA); // 从 0x60 读取扫描码 fifo_put(&key_fifo, scan_code); }很多第一次写操作系统的同学会困惑:为什么要先发 EOI 再读端口?这里我再次强调一下顺序的重要性。正确的顺序是:先发送 EOI 给 PIC,让中断控制器解除屏蔽状态,然后读取键盘端口。因为键盘控制器在发送中断之前已经准备好了扫描码,所以读取不会丢数据。而如果先读取再发送 EOI 也没有错,但此时如果键盘又产生了一个新的扫描码,第二次中断会在第一次 EOI 之后立即触发,我们仍然来得及在 FIFO 里写入,影响不大。
真正不能颠倒的是“读端口”和“下一次中断使能”之间的关系。我在实践中遇到过一种奇怪的现象:连续按键时偶尔会死循环在中断里。后来跟踪发现,如果不发 EOI,PIC 会一直认为当前中断未处理完,不再发送后续中断,但 CPU 又不停地响应某个残留的中断状态,导致处理函数反复进入。这个排查过程让我对 EOI 的重要性刻骨铭心。
5. 从裸机调试到实测:中断处理的效果验证
5.1 键盘输入的演示逻辑
完成以上代码后,我在主循环里写了这样一个逻辑:每次从key_fifo读出一个数据,就把扫描码以十六进制形式显示在屏幕上。如果读到的是数字键或字母键对应的扫描码,则显示对应的 ASCII 字符。
for (;;) { int data = fifo_get(&key_fifo); if (data >= 0) { cons_putstr("Key data: "); cons_put_hex(data, 2); cons_putchar('\n'); // 根据扫描码表简单映射 if (data < sizeof(key_table)) { cons_putchar(key_table[data]); cons_putchar('\n'); } } }这段逻辑很直观,但第一次跑出来的时候我特别兴奋:按下一个键,屏幕上立即出现对应的十六进制数和字符,说明中断路径完全打通了。当然,这中间有一个问题:实际键盘产生的扫描码分为按下时的“通码”和松开时的“断码”。比如A键按下是 0x1e,松开时会先发送 0xe0 或 0xf0(取决于扫描码集),再发送 0x9e。如果不加处理,按一下键就会在 FIFO 里留下两个甚至三个数据。后面如果要支持组合键、大小写、功能键,还需要更复杂的键盘状态机,但这一章的演示只需要把原始扫描码打印出来就足够了。
5.2 定时器 tick 的计数显示
键盘通路打通后,我又在屏幕上显示定时器计数。每次定时器中断都会向timer_fifo放入一个递增数,主循环定时读取并显示。你可以看到数字以肉眼可见但非常稳定的频率增长,这证明定时器中断和 FIFO 配合良好。
一个有意思的测试是:同时开启键盘和定时器中断,然后在键盘输入的同时观察定时器计数是否还在正常增长。如果两者相互干扰,多半是 IDT 表项错误、PIC 屏蔽设置错误或者中断处理函数没有发 EOI。我当时的输出非常稳定,键盘事件不会打乱定时器的节奏,说明 FIFO 在这中间起到了很好的隔离作用。
5.3 中断触发频率与 FIFO 容量的压测心得
功能跑通之后,我做了个小实验:把定时器中断频率调高到每秒 1000 次(也就是每 1 毫秒触发一次),然后观察 FIFO 是否溢出。由于主循环读取 FIFO 的速度远快于 1000 次/秒,32 字节的 FIFO 依然够用。但如果我在中断处理里加入了不必要的打印操作,FIFO 就会开始堆积,甚至溢出。
这个实验让我彻底明白了“中断处理必须短小精悍”这条铁律。中断处理函数里的每一个附加操作,都是在占用 CPU 时间,而这些时间原本可以用来处理其他中断或执行主循环。把耗时操作放到主循环,不仅仅是代码风格问题,更是系统稳定性的关键。
6. 避坑手册:中断章节最常见的 7 个问题
6.1 中断触发后系统重启或反复重启
这是最常见也最让人沮丧的问题。通常原因是 IDT 表项属性错误或段选择子不对,加载非法门导致 CPU 抛出 #GP 异常,而异常向量又没有正确处理,于是系统陷入异常循环甚至重启。
我的排查方法很笨但很有效:先把所有外部中断屏蔽,只保留定时器中断,然后逐步打开其他中断。如果打开某个中断就重启,问题基本锁定在这个中断对应的 IDT 表项或处理函数上。再配合在屏幕上打印调试信息,基本能定位到是哪一段代码出了问题。
6.2 数据写入 FIFO 后读不出来
出现这个情况,先检查中断处理函数是否真的被调用了。可以在中断处理函数入口处写入一个固定的 LED 状态或屏幕字符来确认。如果处理函数确实调用了,但 FIFO 仍然读不出数据,那多半是 FIFO 的next_r和next_w初始化有问题,或者主循环读的是另一个 FIFO 实例。
那个让我印象深刻的问题是结构体赋值问题。我在初始化时用fifo_init(&key_fifo)初始化,但在中断处理里却用了另一个同名局部变量,结果当然读不到数据。后来我统一使用全局实例,并仔细检查传递地址,才解决。
6.3 EOI 漏发导致中断停止
中断处理函数忘记向 PIC 发送 EOI 时,PIC 认为中断还在被处理,后续相同优先级的中断不会再次触发。表现是系统只处理了一次中断,之后就再无响应。
这里要重点检查两个细节:一是 EOI 的值是否正确,主片是0x60加上要清除的 IRQ 号,从片则是先给主片发,再给从片发;二是发送顺序,从片中断必须先向从片发 EOI,再向主片发 EOI,否则主片可能提前重新开放中断,而后续从片中断仍被阻塞。
6.4 扫描码丢失或乱码
键盘扫描码丢失多半是因为 FIFO 太小,或者主循环消费速度太慢。乱码则是因为没有处理断码。很多自制系统教程在这一章只放通码,但我建议还是把断码逻辑一并考虑,至少要知道0xe0和0xf0的含义。否则后续实现组合键时会发现功能键状态完全对不上。
6.5 在中断处理函数中调用打印函数导致死锁
我自己踩过最大的坑,就是在中断处理函数里调用屏幕打印。表面上看,打印字符只是往显存里写几个字节,但如果主循环正在做同样的事情,并且打印函数里有“移动光标”“更新光标坐标”这类非原子操作,中断就会把这些操作打断,造成光标位置错乱或数据覆盖。
更严重的情况是,如果在打印函数里使用了锁或自旋等待,中断处理函数会一直等待锁释放,而持有锁的主循环又因为中断卡住无法继续,形成死锁。经验只有一条:中断处理函数里尽量不要调用任何可能阻塞或重入的功能函数。
6.6 IDT 表在内存中的对齐问题
虽然 x86 对 IDT 基址没有严格的对齐要求,但如果编译器和链接脚本把 IDT 放在了未初始化或未映射的内存区域,lidt加载后一触发中断就会出错。建议在链接脚本里显式把 IDT 放到一个已知的内核数据段,并且初始化之前对该内存区域做清零。
如果后续开了分页,还要注意 IDT 所在页面不能是只读或未映射的。因为 CPU 在中断响应时需要通过 IDT 获取处理函数地址,这个过程涉及一次读取,如果页面未映射,就会产生双重异常。
6.7 寄存器保存不完整导致中断返回崩溃
汇编入口如果只保存了部分寄存器,iret返回后主程序会检测到寄存器被破坏,行为不可预期。我用pusha/popa保存和恢复通用寄存器,再手动保存ds、es、fs、gs,基本能覆盖所有情况。这里有一个容易忽略的地方:pusha和popa的顺序必须对称,否则栈顶数据完全错乱。
如果你打算在 C 函数里做任务切换或修改esp,还要格外小心,不能简单依赖pusha/popa的对称性。此时最好把任务上下文保存到专门的结构体里。这一章不涉及,但提前了解对后续章节非常有利。
7. 中断与 FIFO 的协同扩展思路
第 7 章做完了,外设输入输出最基础的骨架已经搭好。但这个骨架的潜力远不止键盘和定时器。
鼠标的接入本质上与键盘类似,只是数据包格式不同。鼠标中断会连续发出 3 个字节的数据(按钮状态、X 位移、Y 位移),所以需要更大的 FIFO,并且在主循环中按照 3 字节一组解析。第 7 章的 FIFO 结构完全可以支撑,只要把大小加大,并在解析时做好状态同步。
更深一层的扩展是“中断驱动的任务切换”。我们现在的定时器中断只是往 FIFO 里塞一个计数,但如果把任务切换函数挂在定时器中断处理里,就实现了时间片轮转调度。这是后续章节的重点,而它依赖的中断现场保存逻辑,在第 7 章已经埋下了伏笔。
另外,如果在中断入口处把当前的esp保存到任务结构体中,在主循环中切换到另一个任务的栈,再通过iret返回,就能实现基于 IRET 的任务切换机制。这种切换方式在很多自制内核中被采用,比 TSS 切换更灵活,也更轻量。
FIFO 本身也可以扩展成通用的事件队列。比如把键盘事件包装成结构体,不止包含扫描码,还包含按键时间、按键类型(按下/松开/重复)、扩展键标志等。这样上层应用直接消费语义化的事件,而不是面对一堆原始扫描码。我在第 7 章结束后就做了这个重构,后续实现控制台输入时非常省力。
回顾这一章的开发过程,我最大的体会是:中断不是孤立存在的技术点,它和数据结构、硬件访问、并发控制紧密交织。FIFO 看似简单,但它的正确性直接影响中断处理函数的稳定性和外设数据的可靠性。写自制操作系统,真正的收获不在于每一行代码的堆叠,而在于你终于能理解,那些宏大系统背后的精妙协作是如何一环扣一环建立起来的。