☰
中断与FIFO缓冲区:自制操作系统第7章实战全解析
2026/10/14 16:43:57 网站建设 项目流程

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 看似简单,但它的正确性直接影响中断处理函数的稳定性和外设数据的可靠性。写自制操作系统,真正的收获不在于每一行代码的堆叠,而在于你终于能理解,那些宏大系统背后的精妙协作是如何一环扣一环建立起来的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询