导引:点亮什么、为什么、设计图
这一章我们要点亮什么
一件最直观的事:敲键盘,屏幕上就出现对应的字符。a 键出 a,Shift+a 出 A,回车换行,退格往回抹。整个链路是:你按下键 → 键盘控制器发 IRQ1 → 我们的 handler 读扫描码、解码、入队 → 主循环从队列里取出来、回显到 013 搭好的 console。
这件小事背后,其实点亮了好几个「第一次」:
这是内核第一个真正的输入设备。之前所有的交互都是单向的——内核往外吐串口、吐屏幕。从这一章起,外面能往里塞东西了。
这是第一个非时钟中断的真 handler。回想 011:我们装了一整套 PIC + IDT,IRQ0(时钟)挂了真 handler,但 IRQ1 到 IRQ15 全是那个「发个 EOI 就把数据丢掉」的 default handler。键盘挂上 IRQ1,是这套中断体系第一次为一个真正的外部设备干活。
它还顺带示范了一个在内核里反复出现的结构:中断里生产、主循环里消费的环形缓冲区。中断 handler 要快、不能阻塞,所以它只负责「把数据收下来塞进队列」;真正怎么处理这些数据(回显、将来交给 shell),是主循环的事。这个「ISR 入队 + 轮询出队」的分工,后面还会在很多地方见到。
为什么现在需要它
先说为什么是现在。013 让屏幕能显示了,可一个只能显示、不能接收输入的系统,离「能用」还差得远。你能让它说话,却没法回应它。键盘是最自然的第一个输入设备——每个 PC 都有,PS/2 接口简单(不需要 USB 那套复杂的枚举),而且它的中断 IRQ1 我们在 011 装中断体系时就已经 remap 好了、给它留了 vector 0x21,只是当时挂的是个空 handler。现在把真 handler 挂上去,水到渠成。
再往前看一步:键盘回显是后面所有用户态交互的预演。再过几个 tag,我们要做一个 shell,shell 的本质就是「读键盘输入 → 解析命令 → 输出结果」。而「读键盘输入」这一步,正是这一章搭好的 Keyboard::poll。所以这一章不仅是「让键盘响」,它还在给 shell 铺最底下那层路:一个能取出解码后按键事件的接口。
还有一笔小账要还。011 的时候,PIT 的 handler 里有一句每秒打印一次 [TICK] uptime: Ns 的输出,当时用它验证时钟在走。可现在键盘要回显了,如果每秒还有一行 [TICK] 刷进来,你敲键的回显就会被它搅得乱七八糟。所以这一章顺手把那句 tick 打印删掉了——它的历史使命(验证时钟)已经完成,现在该让位给真正的交互输出。这是个很典型的「调试代码用完就撤」的小动作。
设计图
整个键盘子系统,核心是一个生产者/消费者的环形缓冲区,夹在「中断」和「主循环」这两个异步的世界之间:
展开代码 (共 29 行)收起代码
按键物理动作
│
▼
PS/2 键盘控制器 (i8042)
│ 把扫描码放进输出缓冲, 拉 IRQ1
▼
┌─────────────────────────────────────────┐
│ CPU 收到中断, 跳到 irq1_stub (vector 0x21) │
│ → keyboard_irq1_handler (C 桥) │
│ → Keyboard::irq1_handler(frame): │ ← 中断上下文, 要快
│ sc = io_inb(0x60) │
│ 解码 make/break + modifier + ASCII │
│ enqueue(KeyEvent) ──────┐ │
│ PIC::send_eoi(1) │ │
└───────────────────────────────┼───────────┘
▼
┌─────────────────┐
│ ring buffer │ ← 64 项, 满则丢
│ queue_[64] │
└────────┬─────────┘
▼ 主循环 (进程上下文)
┌─────────────────────────────────────────┐
│ while (1) { │
│ hlt; // 睡到下一个中断 │
│ while (Keyboard::poll(ev)) │ ← 慢慢消费
│ if (ev.pressed && ev.ascii) │
│ console.putc(ev.ascii); │
│ } │
└─────────────────────────────────────────┘PS/2 控制器只有两个端口要记:0x60 是数据口(读它拿扫描码、写它发命令数据),0x64 是状态/命令口(读它看状态、写它发控制器命令)。handler 每次从 0x60 读一个字节,这个字节就是全部的输入。