从零编写操作系统 08 - 时钟中断:让外部事件打断当前执行
上一篇的异常由正在执行的指令触发。时钟中断来自处理器外部:即使内核没有执行 int,定时器也能发出请求,经中断控制器送到 CPU,再通过 IDT 进入处理函数。
本篇接入传统 PC 的 8259A PIC 与 8254 PIT,只开放 IRQ0。正常内核收到 20 次时钟中断后关闭外部 IRQ,继续原有控制台自检;两个独立诊断镜像验证屏蔽、恢复和漏发 EOI 的后果。入口仍沿用上一篇的保存与恢复路径,尚未加入调度或任务切换。
从 PIT 输出到 IDT 第 32 项
PIT 的 channel 0 产生周期信号,连接主 PIC 的 IRQ0 输入。PIC 根据屏蔽位和优先级判断是否请求 CPU 响应;CPU 接受请求后获得向量,再查 IDT。
1 | |
IRQ 编号与 CPU 向量不是同一编号空间。IRQ0 是 PIC 的第零条输入线,本篇把它映射到向量 0x20,十进制为 32。CPU 异常使用的低位向量因此可以与外部 IRQ 分开。
同一个数值 0x20 还会作为主 PIC 命令端口出现。端口号是本实验 PC/AT 平台的 I/O 地址,向量基址则是初始化 PIC 时写入的配置值,不能因为数值相同就混用概念。
重映射 PIC,并从明确的屏蔽状态开始
两片 PIC 各有命令口和数据口。主片使用 0x20/0x21,从片使用 0xa0/0xa1。初始化控制字按顺序写入,不能把中间某一项当成独立寄存器配置随意移动。
timer_demo() 首先执行 cli,再设置模式和计数,随后完成两片初始化:
1 | |
四组初始化值分别对应 ICW1 到 ICW4:
| 控制字 | 主片 / 从片 | 本篇配置的含义 |
|---|---|---|
| ICW1 | 0x11 / 0x11 |
开始初始化,边沿触发,级联,后续有 ICW4 |
| ICW2 | 0x20 / 0x28 |
主片向量 32–39,从片向量 40–47 |
| ICW3 | 0x04 / 0x02 |
主片 IR2 接从片;从片位于编号 2 的连接位置 |
| ICW4 | 0x01 / 0x01 |
8086 模式,使用手工 EOI |
主片 ICW3 的 0x04 是位图,表示第二位为 1;从片的 0x02 是连接位置编号。两者描述同一条级联接线,却采用不同编码。ICW2 的低三位由 IRQ 级别填充,所以向量基址按 8 对齐。Intel 8259A 手册第 9–12 页 给出了这些初始化格式。
初始化后显式把两片的 IMR 写为 0xff,全部屏蔽。PIT 配置完毕再把主片改为 0xfe,只允许 IRQ0。从片保持 0xff,主片 IRQ2 也没有开放;本篇没有可运行的从片 IRQ 路径。
不恢复固件留下的旧 mask,是因为旧配置可能允许键盘等其他设备,而当前 IDT 只增加了 IRQ0 的入口。ICW1 初始化过程还会清除 IMR,因此整个配置阶段都在 IF=0 下完成,不能只依赖最终那次屏蔽写入。
pic_write() 在每次 PIC 写入后向 0x80 输出零,保留传统 PC 的 I/O 延迟写法。这里描述的是源码采用的端口操作;QEMU 验证不能据此证明任意实物主板的时序适配。
PIT Mode 2 与约 100 Hz 的分频值
PIT 控制口是 0x43,channel 0 数据口是 0x40。本篇固定使用 Mode 2,不提供任意频率配置接口:
1 | |
控制字 0x34 的二进制表示为 00 11 010 0:
1 | |
因此分频值必须分两次写入同一个数据口。11932 等于 0x2e9c,先写 0x9c,再写 0x2e。若交换两个字节,装入的就不再是预期计数值。
Mode 2 的输出周期为 N 个输入时钟周期,其中低电平持续一个输入周期。其周期输出使边沿触发的 PIC 可以重复接收 IRQ0 请求。Intel 8254 手册 的控制字和 Mode 2 章节说明了写入顺序、输出波形及计数限制。
按 QEMU 的 PIT 输入常量 1193182 计算,目标 100 Hz 所需的分频值为:
1 | |
这是基于输入常量的算术结果,没有通过墙钟测出六位小数的精度。CPU 处理延迟、模拟器调度和 IRQ 屏蔽还会影响软件观测,100 次 handler 调用不能直接当成精确一秒。
常见的另一控制字 0x36 选择 Mode 3,输出占空比约为 50%。Mode 3 的完整周期同样是 N 个输入周期,不能因为存在高低两段,就把 IRQ 频率算成两倍。当前源码使用的是 0x34,不要把 Mode 3 的解释套到本次波形上。
本篇的固定值也避开了 Mode 2 的特殊计数边界:1 不允许作为该模式的计数值;16 位编码 0 表示 65536,并不表示关闭定时器。停止本次 IRQ 演示通过屏蔽 PIC 和保持 IF=0 完成。
IRQ0 复用统一入口与返回帧
上一版汇编生成 32 个入口,本版把入口和指针表的 %rep 都扩展为 33。C 侧也把 exception_stubs 和初始化循环扩展到 33 项,新增 IDT[32],第 33–255 项继续保持不在场。
1 | |
IRQ0 不携带 CPU 错误码,isr32 先压软件零,再压向量 32,进入已有公共入口。保存段寄存器、通用寄存器、清 DF 和对齐 C 调用栈的规则与上一篇相同。
1 | |
当前始终在 Ring 0,同级 frame 仍为 68 字节。硬件保存中断前的 EFLAGS,随后中断门清实时 IF;公共入口再用 cld 满足 C 对 DF 的要求。因此“保存的 IF=1”和“handler 内 IF=0”可以同时成立。
首次真实 IRQ0 的 GDB 检查读到了向量 32、错误码 0、CS=8、保存的 IF=1,以及进入 C 时 IF=0、DF=0。检查点设在 timer_irq 第一条指令,使用公共入口保留在 EBX 中的 frame 指针读取现场。
分派函数对向量 32 直接调用时钟处理并返回,不进入异常打印与 panic 分支:
1 | |
公共入口随后恢复寄存器、移除向量与错误码两个槽,再执行 iretd。恢复后的 EFLAGS 可以重新允许 IRQ,但 PIC 是否接受下一次同级请求,还取决于 EOI。
EOI 处理的是 PIC 的在服务状态
IMR 与 ISR 是两个独立状态。IMR 是中断屏蔽寄存器,位值 1 表示屏蔽;ISR 是在服务寄存器,对应位表示 PIC 已交付、尚未结束服务的中断。
正常 IRQ0 handler 的工作只有增加计数和向主片发送 EOI。源码保留一个故障实验分支,模式 2 故意跳过 EOI:
1 | |
这里向主命令口写入的 0x20 是非特定 EOI 命令。当前只处理主片 IRQ0,无须给从片 EOI;将来处理真正来自从片的 IRQ,通常需要先结束从片服务,再结束主片级联服务。
iretd 恢复 CPU 的返回现场,不会清 PIC 的 ISR。EOI 则通知 PIC 结束服务,不会替 CPU 设置 IF。这两个操作分别完成设备控制器和处理器的收尾,少一个都可能妨碍后续 IRQ。Intel 8259A 手册第 13–17 页 说明了 EOI、IMR 和 ISR 的区别。
本配置关闭自动 EOI。在普通全嵌套优先级下,漏发 IRQ0 的 EOI 会使 ISR0 保持置位,阻止后续同级及低优先级请求。把 IMR 从 0xff 改回 0xfe 只恢复允许条件,不清除已经置位的 ISR0。
前台打印,检查条件后再睡眠
IRQ 可以打断前台的任意执行位置。如果 handler 也调用控制台,就可能在前台更新光标或输出字符到一半时再次进入同一设施。本篇把打印留在前台,handler 不调用串口、VGA 或格式化函数。
计数声明为 volatile uint32_t timer_ticks,使前台观察保留为实际访问。这里是单 CPU,前台读取及打印前执行 cli,以避免 IRQ0 在这段临界区改变计数;volatile 本身不提供多核同步。
正常模式的循环逻辑如下,保留了源码中的判断顺序:
1 | |
还没有达到退出条件时,正常模式执行同一条内联汇编中的 sti; hlt:
1 | |
这个序列依赖前面已经关闭 IF。Intel 的 STI 指令说明 规定,从 IF=0 执行 STI 后,可屏蔽外部中断的识别延迟到下一条指令完成。紧邻的 HLT 因此可以先进入等待,再由中断唤醒。
若在 IF=1 时检查“暂时无工作”,随后单独执行 HLT,IRQ 可能发生在检查与睡眠之间。handler 已更新计数并返回,前台却仍按旧判断睡下。本实验还有周期 IRQ,下一次通常能再次唤醒,但会引入多余等待;不能用这个现象证明睡眠顺序正确。
同一个 volatile asm 防止编译器在 STI 与 HLT 之间插入 C 代码,memory clobber 约束编译器对内存访问的移动。它们与 CPU 的 IF 规则共同构成当前单核实现的条件,不能扩展解释为通用多核内存屏障。
正常模式在快照达到 20 后直接跳出,IF 此时仍为零。两片 PIC 再次写为 0xff,打印 TIMER OK - IRQ disabled for console regression,随后 kernel_main() 重新初始化控制台,运行换行、滚屏、格式化与 CONSOLE OK 自检。
截图停在第二次前台打印后的 timer_observed,此时 tick 为 20。它位于重新初始化并清理控制台之前,因而保留时钟输出;它不是控制台回归完成后的最终画面。截图后脚本继续执行,检查返回位置、IF 和 PIC 状态。
用独立诊断镜像区分屏蔽与漏 EOI
正常镜像只运行有限 tick,适合观察返回及控制台回归。持续采样使用 mbr-timer-monitor.img;漏 EOI 使用 mbr-timer-noeoi.img。两者分别调用 timer_demo(1) 和 timer_demo(2)。
诊断循环每轮把 timer_mask_requested 写入主 IMR,再读回 timer_mask_actual;随后写 OCW3=0x0b,从主命令口读取 ISR 到 timer_isr。因此 GDB 不仅能看到请求值,还能读到实际 mask 和服务状态。
诊断模式在开放 IF 后继续忙轮询,没有执行 HLT。这是为了屏蔽 IRQ0 后仍能处理 GDB 写入的恢复请求:当前没有开放其他 IRQ,若前台已经因 HLT 停下,恢复变量本身不会产生中断唤醒 CPU。该循环只用于实验,不是正常空闲路径。
每次采样后 GDB 执行 detach,让 QEMU 恢复运行,宿主等待 1 秒再连接读取。改变 mask 后也先等待,再比较稳定区间;停在断点内的时间不计作运行区间。
本次重新运行的样本为:
| 诊断镜像 | 开放 IRQ0 | 屏蔽后 | 恢复后 | 恢复后的 ISR |
|---|---|---|---|---|
| 正常 EOI | 99 → 220 | 233 → 233 | 345 → 451 | 0 → 0 |
| 漏 EOI | 1 → 1 | 1 → 1 | 1 → 1 | 1 → 1 |
屏蔽期读回主 IMR 为 255,即 0xff;恢复期为 254,即 0xfe。正常 EOI 镜像在屏蔽期间停止增长,恢复后继续增长。漏 EOI 镜像的计数始终为 1,ISR0 在恢复 IMR 后仍为 1,直接显示这两个寄存器的不同作用。
这些数值是一次运行的样本,不是固定断言。附件 docs/day08-evidence.md 还记录了另一轮样本,起止计数不同;检查要求的是增长、稳定及 ISR 状态,而非一秒必须增加恰好 100。
PIT 在屏蔽期间仍可产生请求,但 PIC 不会把每个周期积存成精确计数队列。恢复后观测到增长不能解释为补齐了全部漏过的 tick。计数器记录的是实际进入 handler 的次数,也尚未处理长期运行中的 32 位回绕。
若一次 tick 都没有,应沿 IF、IMR、IDT 入口和 PIT 配置逐项检查。若第一次之后停止,EOI 和 ISR 是优先检查对象;仅凭“只有一次”仍不足以断言所有程序都有同一个根因。本实验通过故障变体与 ISR 读数建立了对应关系,没有实施额外软件补发 EOI 的实验。
复现与累计回归
本篇完整源码提交为 ba236d1c7b51167a1b4cbc11a7dd1ae3b1f33366,提供源码附件。解压目录前缀为 os-day-08/,使用第 01 篇已有工具链容器即可运行:
1 | |
两条检查串行执行,因为 GDB 都使用容器内端口 1234。实测 QEMU 为 7.2.22,机器配置继续使用 pc-i440fx-7.2、TCG、qemu32、单 CPU、64 MiB 和 SeaBIOS;机器兼容版本与 QEMU 程序版本要分别记录。
check-timer 检查首个硬件 IRQ frame、前台 10/20 tick、有限循环退出时 IF=0,以及两片 PIC 的 imr=ff isr=00。正常串口日志还必须同时包含 TIMER OK 和 CONSOLE OK,然后运行两个诊断变体。
make check 继续覆盖此前的装载、E820、A20、保护模式、运行库、控制台和异常路径。异常门检查只把第 32 项更新为在场,其余尚未使用的高位表项仍要求不在场;INT3 恢复和真实 #DE/#UD/#GP 检查继续保留。
重新生成本篇截图可执行:
1 | |
产物为 build/day08-screen.png。build/gdb-timer-normal.txt 保存 frame 和 PIC 检查,build/timer-normal-serial.txt 保存正常输出;两个诊断镜像的逐次采样和汇总也位于 build/。
这些结果覆盖固定 QEMU 环境中的真实运行,没有物理硬件测试。本篇也没有实现 APIC、从片 IRQ、伪中断处理、抢占或调度;当前新增能力仅是外部 IRQ0 进入内核、更新计数并恢复原执行现场。
练习
- 将
11932展开为高低字节,再按1193182 / N计算频率,解释为什么这仍不足以证明软件计时精度。 - 对照诊断日志中的 IMR 和 ISR,说明为什么
0xff → 0xfe能恢复正常 EOI 镜像,却不能恢复漏 EOI 镜像。 - 在临时实验副本中标出“检查计数”和 HLT 之间可能接收 IRQ 的位置,对比 IF=1 检查与 CLI 后检查的区别;保留正常源码中的紧邻
sti; hlt。
上一篇:07 - 异常入口。下一篇:09 - 键盘与事件队列。

