上一篇的异常由正在执行的指令触发。时钟中断来自处理器外部:即使内核没有执行 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
2
3
4
5
6
7
8
9
10
11
12
13
PIT channel 0 周期输出

主 PIC 的 IRQ0 输入
↓ IMR、优先级及 CPU 当前 IF 允许
CPU 接受可屏蔽外部中断

IDT[0x20] → isr32 → exception_common

trap_dispatch(frame) → timer_irq()

tick 加一,向主 PIC 发 EOI

恢复寄存器,IRETD,回到被打断的位置

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
2
3
4
5
pic_write(0x20, 0x11); pic_write(0xa0, 0x11);
pic_write(0x21, 0x20); pic_write(0xa1, 0x28);
pic_write(0x21, 0x04); pic_write(0xa1, 0x02);
pic_write(0x21, 0x01); pic_write(0xa1, 0x01);
pic_write(0x21, 0xff); pic_write(0xa1, 0xff);

四组初始化值分别对应 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
2
3
timer_outb(0x43, 0x34);
timer_outb(0x40, 11932 & 0xff);
timer_outb(0x40, 11932 >> 8);

控制字 0x34 的二进制表示为 00 11 010 0

1
2
3
4
00    channel 0
11 先写低字节,再写高字节
010 Mode 2,周期分频
0 二进制计数

因此分频值必须分两次写入同一个数据口。11932 等于 0x2e9c,先写 0x9c,再写 0x2e。若交换两个字节,装入的就不再是预期计数值。

Mode 2 的输出周期为 N 个输入时钟周期,其中低电平持续一个输入周期。其周期输出使边沿触发的 PIC 可以重复接收 IRQ0 请求。Intel 8254 手册 的控制字和 Mode 2 章节说明了写入顺序、输出波形及计数限制。

QEMU 的 PIT 输入常量 1193182 计算,目标 100 Hz 所需的分频值为:

1
2
3
1193182 / 100 = 11931.82
四舍五入:N = 11932
实际分频频率:1193182 / 11932 ≈ 99.998491 Hz

这是基于输入常量的算术结果,没有通过墙钟测出六位小数的精度。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
2
3
4
5
6
extern void (*exception_stubs[33])(void);

for (unsigned i = 0; i < 33; ++i) {
uint32_t address = (uint32_t)exception_stubs[i];
idt[i] = (struct idt_gate){address, 0x08, 0, 0x8e, address >> 16};
}

IRQ0 不携带 CPU 错误码,isr32 先压软件零,再压向量 32,进入已有公共入口。保存段寄存器、通用寄存器、清 DF 和对齐 C 调用栈的规则与上一篇相同。

1
2
3
4
5
frame + 48    vector = 32
frame + 52 error = 0,汇编补入
frame + 56 被中断位置的 EIP
frame + 60 CS = 8
frame + 64 中断前的 EFLAGS

当前始终在 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
2
3
4
if (frame->vector == 32) {
timer_irq();
return;
}

公共入口随后恢复寄存器、移除向量与错误码两个槽,再执行 iretd。恢复后的 EFLAGS 可以重新允许 IRQ,但 PIC 是否接受下一次同级请求,还取决于 EOI。

EOI 处理的是 PIC 的在服务状态

IMR 与 ISR 是两个独立状态。IMR 是中断屏蔽寄存器,位值 1 表示屏蔽;ISR 是在服务寄存器,对应位表示 PIC 已交付、尚未结束服务的中断。

正常 IRQ0 handler 的工作只有增加计数和向主片发送 EOI。源码保留一个故障实验分支,模式 2 故意跳过 EOI:

1
2
3
4
5
void timer_irq(void)
{
++timer_ticks;
if (timer_mode != 2) timer_outb(0x20, 0x20);
}

这里向主命令口写入的 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
2
3
4
5
6
7
8
__asm__ volatile("cli" : : : "memory");
uint32_t now = timer_ticks;
if (now >= printed + 10) {
printed = now;
kprintf("TIMER ticks=%u\n", now);
timer_observed();
}
if (mode == 0 && now >= 20) break;

还没有达到退出条件时,正常模式执行同一条内联汇编中的 sti; hlt

1
__asm__ volatile("sti; hlt" : : : "memory");

这个序列依赖前面已经关闭 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 检查点,显示 20 tick

截图停在第二次前台打印后的 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
2
3
4
unzip os-day-08-source.zip
cd os-day-08/examples/build-an-os
podman run --rm -v "$PWD:/work" -w /work localhost/build-an-os:day01 make check-timer
podman run --rm -v "$PWD:/work" -w /work localhost/build-an-os:day01 make check

两条检查串行执行,因为 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 OKCONSOLE OK,然后运行两个诊断变体。

make check 继续覆盖此前的装载、E820、A20、保护模式、运行库、控制台和异常路径。异常门检查只把第 32 项更新为在场,其余尚未使用的高位表项仍要求不在场;INT3 恢复和真实 #DE/#UD/#GP 检查继续保留。

重新生成本篇截图可执行:

1
podman run --rm -v "$PWD:/work" -w /work localhost/build-an-os:day01 make screenshot

产物为 build/day08-screen.pngbuild/gdb-timer-normal.txt 保存 frame 和 PIC 检查,build/timer-normal-serial.txt 保存正常输出;两个诊断镜像的逐次采样和汇总也位于 build/

这些结果覆盖固定 QEMU 环境中的真实运行,没有物理硬件测试。本篇也没有实现 APIC、从片 IRQ、伪中断处理、抢占或调度;当前新增能力仅是外部 IRQ0 进入内核、更新计数并恢复原执行现场。

练习

  1. 11932 展开为高低字节,再按 1193182 / N 计算频率,解释为什么这仍不足以证明软件计时精度。
  2. 对照诊断日志中的 IMR 和 ISR,说明为什么 0xff → 0xfe 能恢复正常 EOI 镜像,却不能恢复漏 EOI 镜像。
  3. 在临时实验副本中标出“检查计数”和 HLT 之间可能接收 IRQ 的位置,对比 IF=1 检查与 CLI 后检查的区别;保留正常源码中的紧邻 sti; hlt

上一篇:07 - 异常入口。下一篇:09 - 键盘与事件队列