从零编写操作系统 09 - 键盘与事件队列:把中断接收和字符处理分开
键盘接入后,内核需要同时处理两种节奏:设备产生扫描码时必须及时接收,字符转换、退格和整行输出则可以留在前台执行。把打印放进 IRQ1 会延长关中断时间,也会让输入设备直接依赖控制台。
本篇在已有异常入口和 PIC 重映射上接入 PS/2 键盘。IRQ1 只接收原始字节并放进队列;主循环负责 US set 1 解析与简单行编辑。输入回车后显示 LINE: ...,尚未执行命令,也没有 shell。
IRQ1 接到已有中断入口
第 08 篇把主 PIC 的向量起点设为 0x20。键盘接主片 IRQ1,因此向量为 0x21,即十进制 33。
1 | |
kernel/exceptions.c 现在初始化前 34 个 IDT 表项,0—31 为异常,32 为时钟,33 为键盘。其余表项仍不在场,不能因为增加一个设备就把未知向量全部指向一个空处理函数。
分发入口新增的分支很短:
1 | |
IRQ1 继续使用前文的 68 字节同特权级 frame。汇编补入零错误码、保存寄存器并清 DF,C 处理完成后沿原路径恢复现场。
真实设备中断探针记录了 vector=33、error=0、CS=8。保存现场的 IF 为 1,进入 C 处理函数后 IF 和 DF 都为 0:前者说明中断打断了允许外部中断的前台,后者符合中断门和 C 入口约定。
正常启动仍先执行时钟演示;演示结束后 IRQ0 被屏蔽。键盘初始化最终写主 PIC 的 IMR 为 0xfd,只开放 IRQ1,从片继续全部屏蔽。因此本篇空闲唤醒不依赖周期 tick。
先确定设备输出哪一种扫描码
字符 a 不会直接从端口读成 ASCII 0x61。当前解析器按 US set 1 解释数据,普通 A 键按下是 0x1e,释放是 0x9e;字符大小写还要结合修饰键状态。
键盘扫描码集合与控制器 translation 是两个独立设置。若键盘输出 set 2,控制器可能把它转换成 set 1;也可以关闭转换,直接要求键盘输出 set 1。初始化必须明确选择一条路径,否则正确性会依赖 BIOS 留下的状态。
本篇关闭 translation,并显式设置键盘为 set 1。QEMU 官方固定版本中的 i8042 控制器实现 与 PS/2 键盘实现 分别保存和处理这两个设置。这里引用 v7.2.0 源码;实验实际运行的是 QEMU 7.2.22,两者不混作同一个版本。
初始化全过程 IF=0,PIC 的 IRQ1 也保持屏蔽。控制器和键盘命令的顺序如下:
| 阶段 | 操作 | 用途 |
|---|---|---|
| 隔离设备 | 0xad、0xa7 |
关闭键盘、鼠标接口 |
| 清理输出 | 最多读取 256 个旧字节 | 避免把残留输入误认作应答 |
| 设置控制器 | 读模式字,清 translation 和设备 IRQ 位 | 确定原始扫描码路径 |
| 设置键盘 | 开接口,发送 F5 F0 01 F4 |
停扫描、选择 set 1、开扫描 |
| 开放中断 | 开控制器 IRQ1,写 PIC 0xfd |
开始运行期接收 |
F5、F0、参数 01 和 F4 每一步都要求返回 0xfa ACK。每次读写等待最多轮询 100000 次;超时或未知回复会返回失败,输出 KEYBOARD INIT FAIL,不会无限等待或无限重发。
这是一种有界的启动初始化,未实现运行期命令队列。Caps Lock 只改变软件大小写状态,不发送 LED 命令,因此 ACK 不会与日常扫描码混在 IRQ 消费路径里。
端口 0x64 用来读取状态或写控制器命令,0x60 用来读数据或写设备数据。读取数据前检查输出缓冲区非空;状态中的 AUX、超时或校验错误使当前字节不能作为普通键盘输入。
64 个槽为什么只存 63 个字节
kernel/keyboard.c 用固定数组保存扫描码,不在中断中分配内存:
1 | |
head 指向生产者下次写入的位置,tail 指向消费者下次读取的位置。两者相等表示空;若推进后的 head 等于 tail,表示满。
1 | |
保留一个空槽可以用两个索引区分空和满,所以 64 个物理槽的可用容量为 63 字节。一次普通按键通常有按下、释放两个字节,容量不能直接说成“63 次敲键”。
队列满时丢弃新字节,保留已排队的顺序,并递增 keyboard_dropped。当前实现没有覆盖最旧字节,也没有从 IRQ 中等待前台腾出空间。
IRQ 接收函数的完整工作如下:
1 | |
每次最多读一个字节。无数据、异常字节和队列满路径也都会到达主 PIC EOI;它们不能因提前返回而留下未完成的 IRQ 服务状态。
这里没有解析扫描码,没有调用 kprintf,也没有修改当前编辑行。设备接收和用户可见输出之间的依赖被限制在原始字节队列上。
该队列的并发条件是单 CPU、一个 IRQ 生产者、一个前台消费者。前台通过短 CLI 临界区访问队列,IRQ 入口本身保持 IF=0;不能把这些 volatile 声明单独搬到多核程序中,当作通用无锁队列。
从检查空队列到 HLT
前台取出一个字节后立即开中断,再做解析和输出。空队列时执行紧邻的 sti; hlt:
1 | |
检查空队列与准备睡眠在 IF=0 下衔接,sti; hlt 放在同一段汇编中。若检查后已经出现待处理 IRQ,它能唤醒这次休眠,不会形成“字节已入队,CPU 却睡到下一次事件”的空隙。
keyboard_consumer_paused 是 GDB 诊断开关。置 1 后前台暂停消费,但 IRQ1 继续接收;它为满队列测试提供可重复的入口,正常使用时保持零。
GDB 断点本身会停止 CPU,因此判断实际空闲状态使用 HMP info registers。本次空闲记录为 EFL=00000246、HLT=1,IF 位已设置,CPU 确实处于休眠状态。
扫描码解析保存哪些状态
解析器只接受一个原始字节并返回一个字符或零,既不操作端口,也不访问队列:
1 | |
这样的函数可以直接编译进宿主检查程序,复用内核实际解析代码。宿主检查只证明给定字节序列的结果;设备初始化、IRQ 入口和队列还需要 QEMU 运行检查。
普通 set 1 字节的高位表示释放。释放事件通常不输出字符,却必须更新 Shift 和 Caps Lock 的按键状态;忽略所有 break code 会造成修饰键一直保持按下。
左 Shift 的 code 为 42,右 Shift 为 54,两者分别保存。同时按住两侧 Shift 后释放左侧,右侧仍应让字母大写,因此不能用一个布尔值在任意 Shift 释放时直接清零。
Caps Lock 在从释放到按下时翻转一次。caps_down 使重复 make code 不会连续切换 Caps;释放事件再清除按下标记。
1 | |
字母使用 Shift 与 Caps 的异或结果。标点只查 Shift 映射表,因此 Caps 不会把 1 改成 !;Shift 会。
映射表限制为 US 布局,零表项被忽略。这里没有键盘布局切换、Unicode 输入法或扩展方向键动作。
E0 与 Pause 需要明确消费边界
多字节序列不能逐个当普通键处理。QEMU v7.2.0 的 set 1 按键编码 中,普通 PrintScreen 使用带 E0 前缀的序列,其中含有形似 Shift 的字节:
1 | |
当前策略是忽略所有 E0 扩展键:遇到 E0 设置标记,下一个非前缀字节消费后返回零。这样 E0 2A 和 E0 AA 不会写入普通左 Shift 状态。
Pause 使用精确尾序列匹配。E1 开始后依次期望 1D 45 E1 9D C5;全部匹配则恢复普通状态,整个按键不输出字符。
1 | |
尾序列失配时,已匹配部分被丢弃,当前字节再按普通解析流程处理一次。这是有限的失配处理规则,不需要递归,也不会无限跳过输入。
这些规则不能恢复任意损坏的数据流。孤立 E0 后若接普通 A 键,该字节仍会被当成扩展序列的一部分而吞掉;丢失 Shift 的释放码也不能靠观察下一个字母推断出正确状态。
宿主检查专门覆盖完整 Pause、PrintScreen、部分 Pause 失配、截断 E0,以及特殊序列后的普通字符。通过这些检查只能说明声明支持的边界成立。
溢出后丢弃当前行并重置状态
原始字节丢失可能破坏前缀和修饰键状态,因此前台不能在 keyboard_dropped 变化后继续提交旧行。主循环在关中断的短临界区执行:
1 | |
清队列防止残余字节继续参与解析,清 parser 同时清理前缀、左右 Shift 与 Caps 状态,清当前行避免把不完整输入当作一条正常提交。随后开中断,打印溢出计数和重试提示。
提示要求释放全部按键再重试。软件清零无法知道哪些键仍被物理按住,也不恢复设备 LED;若仍按着 Shift,清零后的软件状态可能与实际按键状态不同。
这里还有另一种容量限制:keyboard_line[64] 最多保存 63 个字符,最后一格放 \0。行满后忽略新字符,但仍处理退格和回车;它不会触发原始队列溢出复位。
退格删除行末字符并调用 console_putc('\b')。VGA 路径擦除一个单元格,UART 只发出 BS 字节;串口终端是否视觉擦除由终端决定,不能把 BS 本身当成“退格、空格、退格”完整擦除序列。
回车输出 LINE: 当前内容 并清空编辑行。前台没有命令解析或命令执行入口,输入 help 也只会回显 LINE: help。
用真实 IRQ 检查字符与溢出
HMP 正常序列为 a b backspace shift-c caps_lock d caps_lock pause print e ret。它同时经过设备编码、控制器、IRQ1、队列、解析器和行编辑,预期是:
1 | |
b 被退格删除,Shift 使 c 大写,Caps 使 d 大写,第二次 Caps 恢复小写;Pause 与 PrintScreen 不产生字符,之后的 e 也没有被伪 Shift 污染。
满队列实验暂停前台消费,再发送 45 次 a 的按下和释放。90 个普通扫描码超过 63 字节容量,本次暂停时记录 dropped=27、尚未 reset。
恢复消费标记后,用 Enter 唤醒空闲 CPU。此次唤醒又造成一个丢弃,截图计数为 28;前台清状态后重试 o k ret,得到 LINE: ok,最终 keyboard_resets=1。
检查程序要求丢弃计数大于零,不固定为 27 或 28,因为唤醒事件与消费恢复的调度会影响最终计数。恢复后 PIC 记录为 imr=fd isr=00。
实际 make run 的 curses 窗口还输入了 a b Backspace c Enter,屏幕显示 LINE: ac。随后切换到 QEMU monitor 执行 quit,退出码为 0。这条路径不直接调用宿主解析函数。
下载与重跑
本篇完整源码提交为 9b3952af499ed77e5b87af950d408448d4c92e48,提供源码附件。解压目录前缀为 os-day-09/,使用第 01 篇已有工具链容器运行:
1 | |
需要交互输入时执行:
1 | |
退出 curses 窗口时,按 Esc 后按 2 切换到 QEMU monitor,再输入 quit 并回车。
两条检查串行执行,避免占用同一 GDB 端口。固定实验配置为 QEMU 7.2.22、pc-i440fx-7.2、qemu32、TCG、单 CPU、64 MiB 与 SeaBIOS。
check-keyboard.sh 先用已有 C 编译器运行解析器断言,再通过 GDB 内嵌 Python 执行 tools/check-keyboard.py。该容器没有独立 python3,因此不能把脚本入口随意换成 python3 tools/check-keyboard.py。
build/keyboard-frame.txt 保存中断现场检查,keyboard-overflow.txt 保存满队列记录,keyboard-idle.txt 保存实际休眠状态。串口日志为 build/keyboard-serial.txt,截图由键盘检查生成在 build/day09-screen.png。
源码附件独立解压后的累计 make check 已通过,继续覆盖启动装载、E820、A20、保护模式、C 运行库、控制台、异常与时钟诊断。正常内核现在进入持久键盘循环,旧控制台检查停在本篇新增的 console_demo_done 检查点;原 VGA、串口、IF 和 PIC 断言保留。
固定 QEMU 环境中的运行结果不等同于物理键盘兼容性测试。USB 键盘、运行期命令应答、多核队列和扩展键动作均不在本篇实现范围内。
练习
- 手工追踪“左右 Shift 同时按下、释放左 Shift、按 A、释放右 Shift”的 parser 字段变化,解释单个 Shift 布尔值会在哪一步出错。
- 分别输入完整 Pause、
E1 1D 1E和E0 1E 1E,对照宿主检查说明消费边界;不要把后两组结果解释为任意丢码恢复能力。 - 在实验副本中比较行满与原始队列满:前者为何仍可退格提交,后者为何必须废弃当前行并要求释放全部按键?
上一篇:08 - 时钟中断。下一篇:10 - 物理页分配。

