☰
中断与FIFO队列:自制操作系统从轮询到事件驱动的关键一步
2026/10/14 16:40:25 网站建设 项目流程

中断和FIFO队列,是自制操作系统从“静态展示”走向“动态交互”的一道分水岭。前几章我们完成的是内存检测、显存操作、GDT/IDT 初始化这类线性流程——CPU 老老实实从头跑到尾,代码是“死”的。但从键盘、定时器、磁盘这些外设介入开始,硬件就再也不会等 CPU 忙完才办事了。它会主动发出信号打断 CPU 正在执行的指令流,逼着 CPU 立刻处理外设的诉求。这个“打断”的机制就是中断。而 FIFO 队列,则是用来解决中断处理程序与主程序之间速度不匹配、数据需要缓冲这个核心矛盾的。这一章做完,系统才能真正响应用户的操作——按下键盘、时钟滴答,都会成为串联整个内核后续模块的引擎。

这套东西适合谁看?正在跟着教程做 OS 开发的人、对 PC 硬件中断机制好奇的人,以及写单片机程序但想理解 RTOS 里队列原理的人。操作系统和单片机的驱动开发在中断和缓冲这块的思路是互通的,搞懂了这一章,后面看什么设备驱动都会觉得亲切很多。

1. 为什么操作系统离不开中断

1.1 CPU 与外部设备之间的通信困境

没有中断之前,CPU 要读取键盘输入只能靠“轮询”——不停去读键盘控制器的状态寄存器,看有没有按键事件发生。这就像你在家等外卖,每隔三秒钟就跑到门口猫眼看一下外卖员来了没有,完全不敢离开门口半步,期间什么正事也干不了。这就是轮询的致命缺点:CPU 的绝大部分算力都浪费在“看一眼、没来、再看一眼”上面,并且只要轮询的间隔稍长,还容易错过事件。

中断完全改变了这个模式。外设发生变化时,会通过一根物理信号线(在 x86 上通常是 INTR 引脚)主动告诉 CPU:“我有事,你过来处理一下”。CPU 在执行当前指令的过程中,会不断检查这根信号线。如果发现中断请求,并且当前没有屏蔽中断,CPU 就会暂停手头的指令流,保存现场,跳转到事先注册好的中断处理函数,执行完后再回到刚才被中断的地方继续干活。这就像外卖到了,外卖员打电话通知你下楼取餐,你正常在屋里干自己的事,接到电话再下楼,效率天差地别。

1.2 中断的完整处理流程

x86 的中断分为两类:一类是 CPU 内部触发的中断(比如除法错误、页错误),这类叫异常;另一类是外部设备触发的硬件中断,也叫外部中断。第 7 章我们重点处理的是外部中断,但整个处理机制从入口到出口是共通的。

一个完整的外部中断处理流程大致是这样:外设拉高中断信号线,8259A 可编程中断控制器接收到这个信号,根据优先级决定是否向 CPU 发出 INTR 请求。CPU 在当前指令边界响应中断后,会做这么几件事:标志寄存器到栈里,CS 和 IP 到栈里,这相当于把手头的工作现场保存下来;然后根据中断向量号查询 IDT(中断描述符表),取出对应的门描述符,跳转到中断处理程序。中断处理程序执行完,执行一条iret指令,依次弹出 IP、CS、标志寄存器,CPU 无缝回到被打断的代码继续运行。

我前面说“当前指令边界”,意味着中断并不是随时都能打断的。CPU 至少要等当前指令执行完,并且在某些指令(比如 mov 大块数据)执行过程中,硬件会处理得更慢一些。另外,CPU 还有一个很有意思的机制:响应中断时硬件会自动把IF(中断允许标志位)清零,自动屏蔽后续中断,防止中断处理程序被新的中断搞乱。

1.3 中断处理程序为什么必须短小精悍

中断处理程序有几个硬性约束:它执行期间,当前代码是被晾在一边的,尤其如果中断处理程序长时间不返回,其他中断就会被屏蔽(或者堆积),系统整体实时性会急剧下降。另一个问题是,中断处理程序运行在被打断的现场之上,能使用的“上下文”非常有限,连当前执行到哪个函数都不清楚。

所以业界对中断处理程序有一个约定俗成的要求:只做必要的事情,尽快返回。什么是必要的事情?对设备而言就是读取硬件寄存器,清除中断状态,把数据记录下来。至于后续的数据解析、界面更新、业务逻辑,全部放到主循环或内核进程里做。问题来了:中断处理程序产生的数据,主循环怎么拿到?总不能每次都读全局变量吧。如果一次按键就产生一个字节,而主程序处理速度比中断产生数据的速率慢,那数据就会丢。这就是 FIFO 队列登场的根本原因——它作为中断处理程序和主循环之间的数据仓库,缓冲速度差异,承上启下。

2. 8259A 可编程中断控制器的初始化与配置

2.1 8259A 的工作方式与端口

在 PC 架构里,硬件中断默认不会直接连到 CPU,而是先经过 8259A 中断控制器统一管理。8259A 的主要职责是接收多达 8 路中断请求输入,按优先级挑选一路,再统一向 CPU 发出中断请求。由于经典的 PC 有两片 8259A 级联,所以系统能管理的中断请求线路最多达到 15 路(主片 8 路,从片 8 路,从片占用主片的一路 IR 线)。

8259A 的编程通过端口读写实现。主片的端口是0x20和0x21,从片是0xA0和0xA1。端口地址看起来不连续,这是历史遗留的布局,我们直接照着用就行。初始化时使用的是 ICW(初始化命令字),运行期间设置屏蔽和发送中断结束信号使用的是 OCW(操作命令字)。

2.2 ICW 初始化序列与向量重映射

给 8259A 下发 ICW,是一个严格按顺序执行的流程,顺序错误会导致控制器状态错乱。标准的初始化序列如下:

void init_pic(void) { // 主 8259A outb(0x20, 0x11); // ICW1: 边沿触发、级联模式、需要 ICW4 outb(0x21, 0x20); // ICW2: 中断向量基址 0x20,主片 IRQ0-7 映射到 INT 0x20-0x27 outb(0x21, 0x04); // ICW3: 主片的 IRQ2 上连接了从片 outb(0x21, 0x01); // ICW4: 8086 模式,普通 EOI // 从 8259A outb(0xA0, 0x11); // ICW1 outb(0xA1, 0x28); // ICW2: 中断向量基址 0x28,从片 IRQ8-15 映射到 INT 0x28-0x2F outb(0xA1, 0x02); // ICW3: 从片级联编号为 2 outb(0xA1, 0x01); // ICW4 }

这里最关键的是 ICW2 的设定。x86 在实模式下有一个很头疼的历史问题:硬件中断向量号默认可能落在 8 到 15 之间,而这个区间正好是 CPU 异常(比如双重故障)占用的向量。如果我们不做重映射,一旦键盘中断到达,CPU 可能把它当作一个异常来处理,系统直接就乱套了。所以必须把主片中断向量基址重映射到0x20开始的位置,从片映射到0x28。后面在写 IDT 时,只需要把向量0x20到0x2F全部注册成我们的中断处理入口即可。

2.3 中断屏蔽字与 EOI 的发送

初始化完成后,我们还需要通过 OCW1 写中断屏蔽寄存器(IMR),决定哪些中断请求被允许。IMR 的每一位对应一条 IRQ 线路,位为 1 表示屏蔽,为 0 表示允许。默认情况下 8259A 会把所有中断都屏蔽,我们需要显式打开想用的那几路:

void enable_irq(int irq) { int port = (irq < 8) ? 0x21 : 0xA1; unsigned char mask = inb(port) & ~(1 << (irq & 7)); outb(port, mask); }

比如要开启主片的键盘中断(IRQ1)和定时器中断(IRQ0),对应的屏蔽字初始值应该是0xFC(二进制11111100,bit0 和 bit1 清零)。这里有一个很多新手会忽略的细节:中断处理结束后,必须在对应片的端口写入 EOI 命令(OCW2,值0x20)。如果是来自从片的中断,要先向从片发 EOI,再向主片发 EOI。这一步不做,8259A 会认为中断还在处理中,后续同级别或更低级的中断全被屏蔽——症状就是按了一次按键,之后就再也听不到键盘中断了。

3. FIFO 队列的数据结构与实现

3.1 为什么中断处理中需要一个队列

想象一下键盘场景。每次按键,键盘控制器会向 CPU 发出一次中断请求,中断处理程序需要从端口0x60读取扫描码。如果用户打字速度很快,瞬间可能产生几十个扫描码。但主循环里可能有更耗时的任务,比如屏幕刷新、磁盘读写,无法在扫描码到来的一瞬间立刻响应。这时候如果中断处理程序只把扫描码放在一个全局变量里,下一个扫描码到来就会覆盖前一个,数据就直接丢了。

FIFO 队列解决的问题是:把中断处理程序产生的一串数据按顺序缓存起来,等主循环处理完手头的事情再来取。它就像驿站门口的收发件箱——快递员(中断处理程序)只管把包裹按顺序放进箱子,收件人(主循环)有空了再按顺序取走。队列保证了数据的先后顺序,也保证了不丢失(只要箱子够大)。

3.2 环形缓冲区:最常见的 FIFO 实现

实现 FIFO 有很多方案。最简单的思路是用数组加头尾指针,每次取数据时把后面的数据全部往前挪——但这样插入和删除都是 O(n),中断处理程序里做这种操作太奢侈了。更优的方案是环形缓冲区,也就是让数组首尾逻辑相连,读指针和写指针始终往前移动,到达末尾后回绕到数组开头。

环形缓冲区的本质是把线性数组“掰弯”成一个环。如果缓冲区有 256 个字节,读写指针的取值范围就限制在 0 到 255,加 1 之后再与 255 做按位与运算,就能自然回绕。得益于 2 的幂次这个特性,取模运算可以直接优化成位运算,在中断处理程序这种对性能敏感的环境里非常实用。

3.3 队列结构体与核心操作的完整实现

我在模拟项目 X 的操作系统里用的是这样的结构体:

#define FIFO_SIZE 256 struct FIFO { unsigned char data[FIFO_SIZE]; int read_pos; int write_pos; int used; };

data数组存放数据;read_pos指向下一个要读取的位置;write_pos指向下一个要写入的位置;used记录当前队列里有多少数据。用一个额外的used字段而不是直接靠read_pos == write_pos来区分空和满,这是我最推荐的做法,理由后面会单独说。

初始化很简单:

void fifo_init(struct FIFO *fifo) { fifo->read_pos = 0; fifo->write_pos = 0; fifo->used = 0; }

压入一个数据:

int fifo_push(struct FIFO *fifo, unsigned char c) { if (fifo->used >= FIFO_SIZE) { return -1; // 队列已满 } fifo->data[fifo->write_pos] = c; fifo->write_pos = (fifo->write_pos + 1) & (FIFO_SIZE - 1); fifo->used++; return 0; }

取走一个数据:

int fifo_pop(struct FIFO *fifo, unsigned char *c) { if (fifo->used <= 0) { return -1; // 队列已空 } *c = fifo->data[fifo->read_pos]; fifo->read_pos = (fifo->read_pos + 1) & (FIFO_SIZE - 1); fifo->used--; return 0; }

这两个操作都是 O(1) 的,性能极好,而且不会产生内存碎片。用used字段来区分空满,让队列的判断逻辑变得非常直白,这是我在实际开发中反复对比后觉得最不容易出错的方案。

4. 将 FIFO 接入中断处理流程

4.1 键盘中断处理程序的编写

现在我们把 FIFO 接入键盘中断。键盘中断的向量号是0x21。在 IDT 初始化时,我们把这个向量指向一个汇编入口,入口负责保存现场、切换到内核栈、调用 C 函数、恢复现场、执行iret返回。汇编入口看起来是这样:

; 中断处理公共入口 extern key_handler_c global asm_key_handler asm_key_handler: ; 保存 CPU 现场 push eax push ecx push edx push ds push es push fs push gs ; 调用 C 语言处理函数 call key_handler_c ; 恢复现场 pop gs pop fs pop es pop ds pop edx pop ecx pop eax iret

C 语言的key_handler_c只做两件事:读取键盘扫描码,然后把扫描码压入 FIFO。代码如下:

extern struct FIFO keyboard_fifo; void key_handler_c(void) { unsigned char scancode = inb(0x60); fifo_push(&keyboard_fifo, scancode); // 发送 EOI,告诉主片中断处理完毕 outb(0x20, 0x20); }

这里压入的是键盘控制器0x60端口读出来的原始扫描码。扫描码按下和松开各有一个值,松开时最高位会被置 1。比如普通按键 A 的按下扫描码是0x1E,松开就是0x9E。主循环拿到扫描码之后再去解析含义,而不是在中断处理程序里做字符转换——因为字符转换依赖大小写状态、换挡键状态,这些状态本身又依赖按键序列,放到主循环做最合理。

4.2 主循环一侧的取数逻辑

主循环中读取 FIFO 的代码放在任务调度的空闲时段里,比如定时器中断的间隙。读取逻辑通常这样写:

void main_loop(void) { unsigned char scancode; while (1) { if (fifo_pop(&keyboard_fifo, &scancode) == 0) { // 解析扫描码并显示字符 handle_scancode(scancode); } // 其他任务... } }

这里有个明显的竞争风险:主循环在调用fifo_pop的瞬间,键盘中断可能恰好发生,中断处理程序执行fifo_push,两者同时操作used和读写指针,理论上会出问题。单核单线程情况下,这种竞争的窗口极小,但设计严谨的代码不能靠运气。我的做法是在主循环读队列前短暂关中断,读完立刻开中断:

void main_loop(void) { unsigned char scancode; while (1) { cli(); int got = fifo_pop(&keyboard_fifo, &scancode); sti(); if (got == 0) { handle_scancode(scancode); } } }

cli和sti分别清位和设置标志寄存器的 IF 位。在主循环侧关中断不会影响中断嵌套的需求,因为中断处理程序里本身就处于 IF 清零状态,硬件已经保证了嵌套安全。用这种简单粗暴的互斥方式,队列的读写操作就变成了不可被中断的临界区。

4.3 定时器中断与多队列扩展思路

把键盘跑通之后,我强烈建议立刻加一路定时器中断作为扩展验证。定时器中断(IRQ0,向量0x20)比键盘更规律、触发频率更高,最适合用来测试队列在高压下的稳定性。定时器的中断处理程序里同样只做一件事:把当前系统时钟计数加一,然后向 FIFO 里压入一个时间戳或者一个固定标志,主循环读取后用于任务调度计时。

如果键盘中断和定时器中断都往同一个 FIFO 里写,切不可取——键盘扫描码和时钟计数混在一起,主循环根本没法区分。正确的做法是为每个设备分配独立的 FIFO。我把键盘和定时器分别定义成两个队列:

struct FIFO keyboard_fifo; struct FIFO timer_fifo;

这样每个设备队列互不干扰,主循环可以在任意时刻分别查询两个队列是否有新数据。等后面加鼠标、串口、磁盘时,继续沿用“每设备一队列”的模型就好。这个思路本质上就是现代操作系统里“每设备一个等待队列”的最简化雏形。

5. 调试心得与常见问题排查

5.1 中断处理程序跑飞:最关键的死循环排查

中断处理程序最常出的问题,是入口汇编忘记保存某些寄存器,导致返回后主程序崩溃。排查这类问题,我曾经在现象上吃过亏:中断第一次发生很顺利,第二次就死机。最后发现是入口汇编保存的寄存器不完整,中断处理程序里调用的 C 函数把ebx之类的寄存器改掉了,返回后主循环突然拿到一个垃圾指针,直接崩掉。从那次之后我给自己立了个规矩:中断入口里把通用寄存器和段寄存器全部保存,一个都不省。

另一个很隐蔽的坑:如果在中断处理程序里访问了全局变量,而这个变量在 C 代码里被声明为普通的 int,编译器可能会优化掉一些看似无用的读取。建议把中断处理程序和主循环共享的所有队列标志变量都加上volatile修饰。中断处理程序里的变量访问必须每次直接读内存,不能依赖寄存器里的缓存副本。

5.2 矩形缓冲区“空”的判断问题

FIFO 空满判断有好几种常见方案:一种只用read_pos == write_pos判断,但这样空和满的条件完全相同,必须额外牺牲一个存储单元才能区分;另一种用镜像指针的 Trick,原理稍复杂。我最终用used计数的方案,直观还排除了边界歧义。

但我必须提醒:used字段的更新必须放在所有操作的最后一步。如果fifo_push先把used++再写数据,就存在一个窗口,主循环发现队列有数据但data[write_pos]还没写进去,取出来的就是脏数据。反过来fifo_pop也要先取数据再把used--执行完,不能先减计数再取。这类顺序问题在低负载下很难暴露,一旦中断频率一高就会偶发错误,排查起来非常费神。

5.3 中断完全没有触发:常见三连检查

如果键盘中断死活不触发,按我的经验按顺序查这几处:第一,init_pic是否真的在初始化 IDT 之后执行?IDT 里对应的门描述符如果没注册,CPU 遇到未注册的向量会抛异常,而不是进入处理函数;第二,IMR 屏蔽字是否清零了对应位?我遇到过一次用了默认0xFF,中断全被屏蔽,调了半天才发现是屏蔽字没写;第三,是否给 8259A 发了 EOI?如果上一次中断没有正确结束,8259A 会认为系统还在处理中,后续同级别中断不会再次触发。

排查时建议先接一个最简单的定时器中断验证整体链路。定时器是一直在跑的,如果定时器中断能触发,说明 8259A 和 IDT 基本是好的,问题多半出在键盘控制器侧。

5.4 队列长度设置的实践建议

FIFO 的长度设置是典型的工程权衡。在我的模拟项目 X 里,键盘队列用了 256 字节,定时器队列只用了 128。为什么?键盘的突发数据量取决于用户打字速度,极快的手速加上重复按键,一秒撑死也就几十字节,256 字节绰绰有余。定时器中断更特殊,我设计成主循环每轮都会读空它,所以 128 字节足够应付中断频率和任务延迟的波动。

如果队列设得太小,中断高频期间数据会丢;设得太大又浪费内存,而操作系统内核里其他模块同样很吃内存。更麻烦的是队列已满时fifo_push返回 -1,如果中断处理程序忽略这个返回值,数据会静默丢失。我建议至少做到两点:一是检查fifo_push的返回值,二是写一个调试函数统计丢包次数,在开发阶段把丢包问题直接暴露在屏幕上。

5.5 中断延迟的观察方法

做完中断之后,很值得写一段测量中断延迟的代码。最简单的做法是:在定时器中断处理程序里读取TSC时间戳计数器,把它和上一次中断读的差值打印出来。实测下来,普通环境下定时器中断的延迟稳定在一个很小的波动范围,如果波动突然放大,往往说明中断处理程序里做了不该做的事,比如做字符串格式化、打印日志。这个经验帮我抓住过好几次不分场合乱加输出的毛病。

6. 这一章做完了,系统能做什么

这一章实现的中断和 FIFO,带来的能力提升是质变的。最直接的就是键盘输入不再依赖轮询,你可以写一个真正像样的命令行外壳了。用户在终端里敲命令,键盘中断把扫描码压入 FIFO,主循环解析命令并执行,整个过程是事件驱动的——这正是现代操作系统交互模型的地基。

定时器中断带来的则是多任务的雏形。有了固定的时钟节拍,你可以在每次定时器中断之后切换当前执行的任务上下文,实现简单的时间片轮转调度。后面第 8 章、第 9 章如果要做内存管理和多进程,定时器就是那个驱动一切运转的发条。

我个人在实际操作中体会最深的一点是:FIFO 队列本身不算难,难的是“什么时候往队列里放数据、什么时候取数据”这个边界意识。中断处理程序里只放,主循环里只取,谁也不越界,这个系统就不会出大问题。最后再分享一个小技巧:在完成 FIFO 之后,我给每个队列的状态(写指针、读指针、已用量)专门保留了一块显存区域用于实时显示。代码跑起来,你能亲眼看到时钟中断往队列里压数据、主循环一点点取空,那种“数据在流动”的直观感受,比单纯看调试信息爽得多,排查问题也快得多。

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

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

立即咨询