Cinux · 听见键盘:第一个真正的输入设备与 IRQ 万字详解
一、引言:让内核第一次“听见”用户
很多从零开始写操作系统的同学,第一个里程碑通常是让屏幕输出“Hello, World”。这一步很提气,但仔细想想会发现:能打印文字,只是内核“会说话”,并不代表它“会听”。屏幕输出是单向的,用户永远只能在冷冰冰的字符面前观看,而无法向系统传达意图。
真正让一个内核“活”起来的分水岭,是输入。从输入开始,操作系统才具备交互能力,用户才能通过键盘下达命令、输入文本、调试程序。而在所有输入设备里,键盘是最经典、最基础,也最适合作为操作系统输入子系统第一课的设备。它不像鼠标那样需要处理复杂的位移和点击语义,也不像磁盘那样依赖庞大的 DMA 和文件系统,它只用一根中断线和几个端口,就能把“人按下的键”送到内核。
在 Cinux 这个教学内核中,键盘驱动是第一个真正意义上的“中断驱动设备驱动”。本文将围绕键盘输入这条主线,从 PS/2 硬件、8042 控制器、8259A 中断控制器、IRQ 概念,一直讲到扫描码解析、环形缓冲区和完整驱动实现。文章会尽可能把每一步的“为什么”讲清楚,并给出可以落地运行的代码。
你不需要提前精通硬件,只要熟悉 C 语言、了解 x86 保护模式和基本的内联汇编/端口读写,就可以跟着本文把键盘驱动完整跑通。
二、输入设备全景:键盘为何是第一个
操作系统的设备种类繁多,按交互方向可以分为:
- 输出设备:显示器、串口、打印机等,数据从内核流向设备。
- 输入设备:键盘、鼠标、触摸板、扫描仪等,数据从外部流向内核。
- 存储设备:硬盘、SSD、U 盘等,兼具读写,且通常有块设备抽象。
- 网络设备:网卡,收发包,涉及协议栈和异步事件。
键盘之所以适合作为第一个“真正的输入设备”,有以下几个原因:
- 交互频率适中:人的打字速度远低于机器的处理速度,驱动实现上不需要复杂的性能优化。
- 数据量小:每个按键只产生一个或几个字节的扫描码。
- 典型的中断驱动模式:键盘不是轮询设备,按键到达时硬件主动通过 IRQ1 通知 CPU,这正好用来学习中断机制。
- 语义清晰:按键到字符的映射直观,便于验证驱动是否正确。
更关键的是,键盘驱动是理解“中断驱动 I/O”的绝佳例子。你把代码写好后,内核不再需要死循环去问“键盘上有没有键被按下”,而是键盘一有动作,硬件就打断 CPU 当前的工作,进入你的中断处理函数。这个模型一旦掌握,后续的鼠标、串口、网卡驱动都能顺理成章地理解。
三、键盘硬件:PS/2 接口与 8042 控制器
3.1 PS/2 接口的定位
现代 PC 上的键盘和鼠标大多走 USB,但在操作系统教学和经典 PC 体系结构中,PS/2 键盘依然是绕不开的第一课。原因在于 PS/2 协议足够简单:它是一根串行数据线加一根时钟线的同步串行协议,键盘设备主动产生时钟信号,主板上的控制器负责接收。
PS/2 键盘的按键信息不是直接进入 CPU 的,而是先被主板上的键盘控制器处理。传统 PC 中使用的是 Intel 8042 或其兼容芯片,因此“8042”成了键盘控制器的代名词。
3.2 8042 控制器的寄存器
与之编程打交道的是两个 I/O 端口:
- 数据端口 0x60:读写键盘/鼠标的数据字节。
- 命令/状态端口 0x64:写命令、读状态。
读端口 0x64 得到的状态寄存器中,最关键的是两个位:
| 位 | 含义 |
|---|---|
| bit 0 | 输出缓冲区满(Output Buffer Full)。为 1 表示有数据可读,数据在 0x60。 |
| bit 1 | 输入缓冲区满(Input Buffer Full)。向 0x60/0x64 写数据前应先确认其为 0。 |
读取键盘数据的典型步骤就是:先读 0x64 状态,确认 bit 0 为 1,再从 0x60 读出一个字节扫描码。
严格来说,8042 同时管理键盘和鼠标,PS/2 鼠标的数据也可能出现在 0x60。为了避免混淆,更严谨的驱动会在状态寄存器中区分数据来源,但在只实现键盘的第一阶段,我们通常默认 0x60 的数据来自键盘。
3.3 端口读写的基础封装
在 Cinux 中,我们首先封装端口读写函数。x86 上使用 in 和 out 指令完成端口 I/O,通常在 C 中使用内联汇编或独立的汇编函数实现:
// port_io.h
#ifndef PORT_IO_H
#define PORT_IO_H
#include <stdint.h>
static inline void outb(uint16_t port, uint8_t value) {
__asm__ volatile ("outb %0, %1" : : "a"(value), "Nd"(port));
}
static inline uint8_t inb(uint16_t port) {
uint8_t ret;
__asm__ volatile ("inb %1, %0" : "=a"(ret) : "Nd"(port));
return ret;
}
static inline void io_wait(void) {
// 向未使用的端口写入,制造一个小的延迟等待
outb(0x80, 0);
}
#endif
这个封装是整个键盘驱动,乃至整个设备管理的基础。后续所有与硬件打交道的代码都会复用 outb 和 inb。
四、中断与 IRQ:从硬件信号到 CPU 响应
4.1 为什么必须用中断
键盘事件是异步的:你无法预知用户会在哪一纳秒按下 A 键。如果 CPU 采用轮询方式,就需要在系统空闲时不停地读取 0x60 检查是否有新按键。这在单任务内核里勉强可行,但会浪费大量 CPU 时间,而且一旦系统正在执行其他耗时任务,按键就可能被错过。
中断机制解决的就是这个问题。键盘上有按键动作时,硬件会拉高 CPU 的 INTR 引脚,CPU 在当前指令执行完毕后自动保存现场并跳转到对应的中断处理程序。处理完毕后恢复现场继续执行被打断的代码。整个过程由硬件保证,驱动程序只需要注册好处理函数。
4.2 IRQ 与中断向量号
PC 体系结构中,外设通过中断请求线(Interrupt Request,IRQ)向 CPU 报告事件。传统 PC 由两片 8259A PIC 级联提供 16 条 IRQ,编号 0 到 15。键盘固定使用 IRQ1。
IRQ 是硬件层面的编号,而 CPU 在保护模式下响应中断时,需要根据中断向量号去 IDT(中断描述符表)中查表。两者之间存在映射关系。默认情况下,IRQ0 到 IRQ7 映射到向量 0x08 到 0x0F,但这会与 x86 的异常向量冲突,因此保护模式下几乎所有内核都会重映射 PIC,把 IRQ0 到 IRQ15 映射到向量 0x20 到 0x2F。
重映射后,键盘的 IRQ1 对应向量号 0x21。这个数字在后续的 IDT 注册和代码中会反复出现。
4.3 中断描述符表 IDT
为了让 CPU 找到键盘中断处理函数,需要在 IDT 的第 0x21 项安装一个中断门。中断门的字段包括处理函数的段选择子和偏移地址。
下面是一个简化的 IDT 中断门安装代码:
// idt.c
#include <stdint.h>
typedef struct {
uint16_t offset_low;
uint16_t selector;
uint8_t zero;
uint8_t type_attr;
uint16_t offset_high;
} __attribute__((packed)) idt_entry_t;
typedef struct {
uint16_t limit;
uint32_t base;
} __attribute__((packed)) idt_ptr_t;
idt_entry_t idt[256];
idt_ptr_t idt_ptr;
void idt_set_gate(uint8_t vector, uint32_t handler, uint16_t selector, uint8_t attr) {
idt_entry_t *entry = &idt[vector];
entry->offset_low = handler & 0xFFFF;
entry->selector = selector;
entry->zero = 0;
entry->type_attr = attr;
entry->offset_high = (handler >> 16) & 0xFFFF;
}
void idt_init(void) {
idt_ptr.limit = sizeof(idt) - 1;
idt_ptr.base = (uint32_t)&idt;
__asm__ volatile ("lidt %0" : : "m"(idt_ptr));
}
安装中断门时的 type_attr 通常设置为 0x8E,表示处于 Ring 0、存在、32 位中断门。
五、8259A:可编程中断控制器
5.1 PIC 的作用
CPU 本身只有一个 INTR 引脚,但外部设备有十几种中断源。8259A PIC 的作用就是汇集多个 IRQ 输入,并送出一个信号给 CPU,同时负责优先级仲裁和中断向量号生成。
传统 PC 使用两片 8259A:主片连接 IRQ0 到 IRQ7,从片连接 IRQ8 到 IRQ15。从片的输出接入主片的 IRQ2,两者级联形成“一主一从”的结构。
| IRQ | 典型设备 |
|---|---|
| IRQ0 | 系统定时器(PIT) |
| IRQ1 | 键盘 |
| IRQ2 | 从片级联 |
| IRQ3 | 串口 COM2 |
| IRQ4 | 串口 COM1 |
| IRQ5 | 声卡/并口 |
| IRQ6 | 软盘控制器 |
| IRQ7 | 并口 |
| IRQ8 | 实时时钟 RTC |
| IRQ14 | 主硬盘控制器 |
5.2 重映射 PIC
因为默认的中断向量映射和 x86 异常冲突,内核启动后必须把主片 IRQ0 到 IRQ7 重映射到 0x20 到 0x27,把从片 IRQ8 到 IRQ15 重映射到 0x28 到 0x2F。初始化通过向 PIC 的命令端口和数据端口写入 ICW 序列完成:
// pic.c
#include "port_io.h"
#define PIC1_COMMAND 0x20
#define PIC1_DATA 0x21
#define PIC2_COMMAND 0xA0
#define PIC2_DATA 0xA1
#define ICW1_ICW4 0x01 // 需要 ICW4
#define ICW1_INIT 0x10 // 初始化命令
void pic_remap(uint8_t offset1, uint8_t offset2) {
uint8_t mask1 = inb(PIC1_DATA);
uint8_t mask2 = inb(PIC2_DATA);
// 开始初始化:ICW1,两片都需要 ICW4
outb(PIC1_COMMAND, ICW1_INIT | ICW1_ICW4);
io_wait();
outb(PIC2_COMMAND, ICW1_INIT | ICW1_ICW4);
io_wait();
// ICW2:设置新的中断向量基址
outb(PIC1_DATA, offset1);
io_wait();
outb(PIC2_DATA, offset2);
io_wait();
// ICW3:告诉主片从片接在 IRQ2 上;告诉从片自己的级联身份
outb(PIC1_DATA, 0x04);
io_wait();
outb(PIC2_DATA, 0x02);
io_wait();
// ICW4:8086/88 模式
outb(PIC1_DATA, 0x01);
io_wait();
outb(PIC2_DATA, 0x01);
io_wait();
// 恢复原来的中断屏蔽字
outb(PIC1_DATA, mask1);
outb(PIC2_DATA, mask2);
}
void pic_send_eoi(uint8_t irq) {
if (irq >= 8) {
outb(PIC2_COMMAND, 0x20); // 从片 EOI
}
outb(PIC1_COMMAND, 0x20); // 主片 EOI
}
初始化后,调用 pic_remap(0x20, 0x28) 即可得到我们期望的映射。键盘是 IRQ1,落在主片,中断向量为 0x21。处理完键盘中断后,只需要向主片发送 EOI:
outb(0x20, 0x20);
5.3 中断屏蔽
8259A 还有一个中断屏蔽寄存器(IMR),每个 IRQ 对应一位。向数据端口 0x21 写入的字节直接决定主片哪些 IRQ 被屏蔽,0 表示允许,1 表示屏蔽。键盘 IRQ1 对应 bit 1。因此,要允许键盘中断,必须确保 IMR 的 bit 1 为 0:
// 允许 IRQ1(键盘)和 IRQ0(定时器)
outb(0x21, inb(0x21) & ~0x03);
很多内核因为漏掉这一步,导致键盘中断始终不触发。这是一个非常典型的“坑”。
六、键盘中断的完整处理流程
把前面的知识串起来,可以得到一次按键从硬件到驱动的完整事件链:
- 用户按下按键,键盘内部芯片产生扫描码并通过 PS/2 时钟和数据线发送。
- 8042 控制器把扫描码放入自己的输出缓冲区,并将状态寄存器 bit 0 置 1。
- 8042 通过 IRQ1 线向 8259A 主片发起中断请求。
- 8259A 在中断屏蔽允许的情况下,向 CPU 的 INTR 引脚发送信号,并在中断应答周期中提供向量号 0x21。
- CPU 保存现场,根据 IDT 中 0x21 号中断门跳转到键盘中断入口。
- 中断入口保存通用寄存器后,调用 C 语言编写的中断处理函数。
- 处理函数检查 0x64 状态、从 0x60 读取扫描码、解析并放入缓冲区。
- 处理完毕后向 8259A 发送 EOI,恢复现场,
iret返回被打断的代码。
下面给出键盘中断的汇编入口。该入口负责保存寄存器、调用 C 处理函数、恢复寄存器并发送 EOI:
; interrupt.s
[bits 32]
extern keyboard_handler
global keyboard_interrupt_entry
keyboard_interrupt_entry:
cli
pushad
call keyboard_handler
popad
mov al, 0x20
out 0x20, al
sti
iretd
需要注意的是,在中断入口中发送 EOI 的位置可以灵活安排,但必须保证每次进入中断后 EOI 只发送一次。如果把 EOI 放在 call 之前,就要避免 C 处理函数的返回值相关寄存器被破坏。
七、扫描码:把电信号翻译成“键”
7.1 Make Code 与 Break Code
从 0x60 读出的一个字节称为扫描码。PS/2 键盘对一次完整的按键动作会发送两类扫描码:
- 通码(Make Code):按键按下时发送,最高位为 0。
- 断码(Break Code):按键释放时发送,最高位为 1。
以字母 A 为例,按下时发送扫描码 0x1E,松开时发送 0x9E。两者恰好相差 0x80。因此,在只关心“哪个键被按下”的简化实现中,可以先判断扫描码的最高位,过滤掉断码:
if (scancode & 0x80) {
// 这是断码,按键被释放,暂时忽略
} else {
// 这是通码,按键被按下,开始解析
}
但断码并非毫无用处。后续处理 Shift、Ctrl、Alt 等修饰键时,必须依赖断码来更新按键状态,否则系统就不知道用户何时松开了 Shift。
7.2 常用键位扫描码
下面是 US 键盘布局中部分常用按键的通码:
| 按键 | 通码 | 按键 | 通码 |
|---|---|---|---|
| A | 0x1E | Space | 0x39 |
| B | 0x30 | Enter | 0x1C |
| C | 0x2E | Backspace | 0x0E |
| 1 | 0x02 | Left Shift | 0x2A |
| 2 | 0x03 | Right Shift | 0x36 |
| 9 | 0x0A | Ctrl | 0x1D |
| 0 | 0x0B | Alt | 0x38 |
| Esc | 0x01 | Caps Lock | 0x3A |
7.3 双字节扫描码
随着键盘扩展键增多,最早的 8 位扫描码空间已经不够用。PS/2 键盘引入了扩展键机制:部分按键的通码和断码以 0xE0 前缀开头,后面再跟一个字节。例如:
- 右 Ctrl:
E0 1D - 右 Alt:
E0 38 - 方向键上:
E0 48 - 方向键下:
E0 50
更少见的是 0xE0 后接两个字节的情况。这类复杂按键建议在驱动的后续版本中专门处理,第一版可以先把它们安全忽略。
7.4 扫描码到 ASCII 的映射
驱动最基本的职责之一,是把扫描码转换成可读的 ASCII 字符。最简单的方式是维护一张查找表,下标为扫描码,值为对应字符:
static const char ascii_table[] = {
0, 0, '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '=',
'\b', // 0x0E Backspace
'\t', // 0x0F Tab
'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '[', ']',
'\n', // 0x1C Enter
0, // 0x1D Ctrl
'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', ';', '\'', '`',
0, // 0x2A Left Shift
'\\', 'z', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '/',
0, // 0x36 Right Shift
'*', // 0x37 小键盘 *
0, // 0x38 Alt
' ', // 0x39 Space
0, // 0x3A Caps Lock
};
这张表只覆盖了无修饰键的小写字母和数字符号。要处理大写字母,需要结合 Shift 状态对字符做二次转换,本文第十节会详细展开。
八、键盘驱动设计:环形缓冲区
8.1 为什么需要缓冲区
键盘中断处理程序运行在中断上下文中,有两条重要原则:
- 尽快执行:中断处理必须快,不能让 CPU 在中断态停留过久,否则会阻塞系统其他任务,甚至丢失后续中断。
- 不能依赖慢速逻辑:处理函数里不要做复杂的格式化输出、等待、锁竞争等。
但按键最终要被控制台或 shell 消费,消费速度不一定跟得上输入速度。缓冲区的引入就是把“生产”和“消费”解耦:键盘中断只负责把字符塞进缓冲区,上层程序按自己的节奏从中取走。
8.2 环形缓冲区的结构
环形缓冲区是最经典的生产者-消费者数据结构。它用一块固定大小的内存,头和尾两个指针绕圈推进:
// kb_buffer.h
#ifndef KB_BUFFER_H
#define KB_BUFFER_H
#include <stdint.h>
#define KB_BUFFER_SIZE 256
typedef struct {
char buf[KB_BUFFER_SIZE];
uint32_t head; // 写入位置
uint32_t tail; // 读取位置
uint32_t count; // 当前元素个数
} kb_buffer_t;
void kb_buffer_init(kb_buffer_t *kb);
int kb_buffer_put(kb_buffer_t *kb, char c);
int kb_buffer_get(kb_buffer_t *kb, char *out);
int kb_buffer_empty(kb_buffer_t *kb);
int kb_buffer_full(kb_buffer_t *kb);
#endif
// kb_buffer.c
#include "kb_buffer.h"
void kb_buffer_init(kb_buffer_t *kb) {
kb->head = 0;
kb->tail = 0;
kb->count = 0;
}
int kb_buffer_put(kb_buffer_t *kb, char c) {
if (kb->count == KB_BUFFER_SIZE) {
return -1; // 缓冲区已满
}
kb->buf[kb->head] = c;
kb->head = (kb->head + 1) % KB_BUFFER_SIZE;
kb->count++;
return 0;
}
int kb_buffer_get(kb_buffer_t *kb, char *out) {
if (kb->count == 0) {
return -1; // 缓冲区为空
}
*out = kb->buf[kb->tail];
kb->tail = (kb->tail + 1) % KB_BUFFER_SIZE;
kb->count--;
return 0;
}
int kb_buffer_empty(kb_buffer_t *kb) {
return kb->count == 0;
}
int kb_buffer_full(kb_buffer_t *kb) {
return kb->count == KB_BUFFER_SIZE;
}
使用一个独立的 count 变量来记录元素数量,虽然多占用几个字节,但能避免只用 head 和 tail 时“满”和“空”状态无法区分的问题。对教学内核来说,这是最不容易出错的方案。
8.3 并发安全
键盘中断会随时打断正在运行的代码。如果主程序正在从缓冲区读字符,中断处理程序又突然“插一脚”往里写,可能产生数据竞争。在单核系统中,最简单的做法是:
- 在读取缓冲区的代码前后加上
cli/sti关闭和恢复中断。 - 或者只在中断关闭的临界区内操作 head、tail、count。
本教程的缓冲区操作不需要锁,因为当前 Cinux 尚运行在单核且无线程抢占的环境中。一旦未来加入多任务抢占,必须重新评估缓冲区并发安全。
九、Cinux 键盘驱动完整实现
9.1 驱动初始化
初始化阶段需要完成三件事:注册 IDT 中断门、取消键盘中断屏蔽、安装中断处理函数。如下所示:
// keyboard.c
#include "idt.h"
#include "pic.h"
#include "port_io.h"
#include "kb_buffer.h"
#define KEYBOARD_IRQ 1
#define KEYBOARD_VECTOR 0x21
kb_buffer_t kb_buf;
extern void keyboard_interrupt_entry(void);
void keyboard_init(void) {
kb_buffer_init(&kb_buf);
// 在 IDT 中注册键盘中断入口
idt_set_gate(KEYBOARD_VECTOR, (uint32_t)keyboard_interrupt_entry,
0x08, 0x8E);
// 清除 8042 输出缓冲区中的残留数据
while (inb(0x64) & 0x01) {
inb(0x60);
}
// 允许键盘 IRQ1,同时保留其他中断原状态
outb(0x21, inb(0x21) & ~(1 << KEYBOARD_IRQ));
}
这里假设 idt_set_gate 已经从前面的 IDT 模块中导出,keyboard_interrupt_entry 是汇编入口的符号。
9.2 中断处理函数
C 层处理函数的核心逻辑非常精简:确认数据有效、读取扫描码、转换字符、写入缓冲区。这里给出不含修饰键处理的简化版本:
// keyboard_handler.c
#include "keyboard.h"
#include "kb_buffer.h"
#include "port_io.h"
static const char ascii_table[] = {
0, 0, '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '=',
'\b', '\t',
'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '[', ']', '\n',
0,
'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', ';', '\'', '`',
0, '\\',
'z', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '/', 0, '*',
0, ' ', 0
};
void keyboard_handler(void) {
uint8_t status = inb(0x64);
if (!(status & 0x01)) {
return; // 输出缓冲区为空,可能是幽灵中断
}
uint8_t scancode = inb(0x60);
// 过滤断码
if (scancode & 0x80) {
return;
}
// 过滤扩展键前缀
if (scancode == 0xE0) {
return;
}
char ch = ascii_table[scancode];
if (ch != 0) {
kb_buffer_put(&kb_buf, ch);
}
}
这个处理函数把每个有效按键转换成一个字符并放入缓冲区。对于方向键、功能键等不产生 ASCII 字符的按键,当前版本直接忽略,这是可以接受的起点。
9.3 顶层读取接口
控制台、shell 或用户程序不应该直接访问 kb_buf,而应通过统一的读取函数获取字符。这个函数同时承担“阻塞等待输入”或“非阻塞读取”的角色:
// keyboard.h
#ifndef KEYBOARD_H
#define KEYBOARD_H
#include <stdint.h>
void keyboard_init(void);
void keyboard_handler(void);
int keyboard_getchar(void); // 非阻塞读取
char keyboard_getchar_blocking(void); // 阻塞读取
#endif
// keyboard_api.c
#include "keyboard.h"
#include "kb_buffer.h"
int keyboard_getchar(void) {
char ch;
if (kb_buffer_get(&kb_buf, &ch) == 0) {
return (int)(unsigned char)ch;
}
return -1; // 无输入
}
char keyboard_getchar_blocking(void) {
int ch;
do {
ch = keyboard_getchar();
} while (ch < 0);
return (char)ch;
}
阻塞读取采用简单的忙等实现。在没有任务调度器的阶段,这是最直接的办法;等到内核具备多任务功能后,这里应改成“让出 CPU 并等待键盘中断唤醒”的阻塞语义。
9.4 构建与链接
将驱动加入内核的编译和链接列表,并确保在 C 运行时初始化完成后调用 keyboard_init()。由于驱动依赖 IDT 和 PIC,调用顺序必须是:
- 初始化 IDT。
- 重映射 PIC。
- 初始化键盘驱动。
- 开启硬件中断
sti。
如果顺序颠倒,比如在 PIC 重映射之前就注册键盘中断,执行时可能跳入错误的向量,轻则按键无反应,重则直接三重故障重启。
十、特殊键与组合键处理
10.1 修饰键状态机
前面的简化驱动只能输入小写字母和数字。要让键盘真正可用,必须处理大写、符号和组合键。实现思路是维护一个修饰键状态结构:
typedef struct {
uint8_t left_shift;
uint8_t right_shift;
uint8_t left_ctrl;
uint8_t right_ctrl;
uint8_t left_alt;
uint8_t right_alt;
uint8_t caps_lock;
} keyboard_state_t;
在中断处理函数中,遇到修饰键的通码,把对应状态置 1;遇到断码,把状态清零。例如 Shift 通码为 0x2A 或 0x36,断码为 0xAA 或 0xB6:
switch (scancode) {
case 0x2A: state.left_shift = 1; break;
case 0xAA: state.left_shift = 0; break;
case 0x36: state.right_shift = 1; break;
case 0xB6: state.right_shift = 0; break;
case 0x1D: state.left_ctrl = 1; break;
case 0x9D: state.left_ctrl = 0; break;
case 0x3A:
if (!(scancode & 0x80)) {
state.caps_lock = !state.caps_lock;
}
break;
default:
break;
}
Caps Lock 是开关型按键,每按一次翻转状态,所以只处理通码即可实现切换。
10.2 大小写转换
有了 Shift 状态后,普通字母在按下时先取小写,再判断是否需要转大写。判断条件为:
int shift_active = state.left_shift || state.right_shift;
int upper = shift_active ^ state.caps_lock;
if (ch >= 'a' && ch <= 'z' && upper) {
ch = ch - 'a' + 'A';
}
这里用异或而不是简单的“Shift 有效就大写”,就是为了正确处理 Caps Lock 与 Shift 的组合:Caps Lock 打开且按住 Shift 时,应该输出小写字母。
10.3 符号键与 Shift
数字行的符号在不同 Shift 状态下含义不同:不按 Shift 输入 1,按 Shift 输入 !。可以为每种修饰状态维护不同的映射表:
static const char ascii_shifted[] = {
0, 0, '!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '_', '+',
// ... 其余与普通表类似
};
实际代码中,解析完扫描码后根据 shift_active 选择普通表还是 Shift 表即可。对于 ;、'、, 等键,同样需要为它们提供 Shift 版本。
10.4 组合键的语义
Ctrl 和 Alt 一般不与字符简单叠加,而是作为快捷命令的前缀。在 shell 层,它们可以表示为特殊的输入序列。教学中常见的折中方案是:
- Ctrl+A 到 Ctrl+Z 映射为控制字符 0x01 到 0x1A。
- 单独按下 Alt 或以 Alt 开头的组合暂不支持,只在驱动层记录状态。
- 功能键 F1 到 F12 可映射到约定的扩展字节,上层再根据字节决定行为。
具体到 Cinux,可以先只实现 Ctrl 组合键,因为终端类程序对 Ctrl+C、Ctrl+D 这类控制字符依赖很强。
十一、调试与常见坑
11.1 中断完全没反应
这是键盘驱动开发中最常见的问题。请按顺序检查:
- 是否开启了
sti:CPU 的 IF 标志必须为 1 才能响应可屏蔽中断。 - 是否重映射了 PIC:如果没有重映射,键盘中断可能落在错误向量上。
- 是否解除 IRQ1 屏蔽:主片 IMR 的 bit 1 必须为 0。
- IDT 表项是否正确:0x21 号表项应指向汇编入口,段选择子和属性正确。
可以用一个最小内核启动后主动按几次键,观察系统是否进入中断,再逐项排查。
11.2 按键丢失或重复
- 丢失:通常是缓冲区已满但仍收到按键。中断处理函数中如果
kb_buffer_put返回负数,应当丢弃该字符并记录错误,而不是覆盖旧数据。 - 重复字符:PS/2 键盘支持“自动重复”,按住一个键一段时间后,键盘会持续发送通码,但很多键盘不发送断码。驱动中通常直接接受这些通码,实现系统级的按键重复。如果重复速度异常,可能是没有正确处理断码或状态错乱。
11.3 输出大小写混乱
Caps Lock 和 Shift 的逻辑最容易写错。强烈建议把修饰键状态机的判断逻辑独立成一个函数,并用代码审查或单元测试穷举四种组合:
- Shift 关、Caps 关:小写。
- Shift 开、Caps 关:大写。
- Shift 关、Caps 开:大写。
- Shift 开、Caps 开:小写。
只要这四种情况测试通过,大小写混乱的问题基本可以根除。
11.4 幽灵中断
有时读取状态寄存器发现输出缓冲区满,但 0x60 中的数据并非来自键盘,而是鼠标或虚假数据。严格处理方式是读取状态寄存器中的来源位,识别数据是否真的来自键盘。教学内核第一版可以先忽略,但建议在注释中标记未来改进点。
11.5 调试技巧
在没有调试器的裸机环境下,打印调试信息是重要手段。但在键盘中断处理函数中打印大量日志会拖慢中断响应。推荐的做法是:
- 把最近几个扫描码记录到全局调试数组,主循环或串口调试程序定期打印。
- 在中断入口只做最小记录,不调用格式化输出。
- 使用串口而非显示器输出调试信息,避免与显卡驱动互相干扰。
十二、性能与扩展:从键盘到输入子系统
12.1 键盘驱动之上的抽象
当键盘可以稳定输入字符后,下一步通常是引入终端抽象(TTY/console)。字符从键盘缓冲区流入终端,终端再根据行缓冲、回显、特殊字符处理等规则把内容交给 shell。键盘驱动本身只负责最底层的按键采集,不应承担回显、命令解析等上层职责。
在 Cinux 中,建议在 keyboard_getchar_blocking 之上实现一个简单的行缓冲输入:
1. 每次读取一个字符。
2. 如果是普通可打印字符,回显到屏幕并存入行缓冲。
3. 如果是 Enter,结束当前行并提交给调用方。
4. 如果是 Backspace,删除行缓冲末尾字符并在屏幕上退格。
这样 shell 拿到的就是一行完整的命令,而不是一个个孤立的字符。
12.2 与鼠标、串口的统一
键盘驱动跑通后,你会发现自己已经掌握了中断驱动设备开发的“套路”:
- 找到设备的 IRQ。
- 注册中断处理函数。
- 读取设备寄存器或端口。
- 解析数据并放入缓冲区。
- 向上层提供统一接口。
PS/2 鼠标同样通过 IRQ12,数据也从 0x60 读取,只是数据包结构不同;串口使用 IRQ3/IRQ4,通过 0x3F8 等端口访问。虽然细节不同,但整体框架完全一致。很多内核会把这种“中断 + 缓冲 + 上层接口”的模式抽象成通用设备对象,Cinux 在后续扩展时也可以采用这一思路。
12.3 性能考量
键盘中断频率很低,性能不是瓶颈,但有几个细节值得注意:
- 中断处理函数保持 O(1) 和极短路径,避免在中断中调用可能阻塞的函数。
- 缓冲区容量要能覆盖用户快速输入的峰值,256 字节在一般情况下足够。
- 在缓冲区满时采取“丢弃最新”或“丢弃并告警”的策略,选择其一并保持一致。
12.4 多任务环境下的演进
当 Cinux 具备进程和调度器后,键盘读取要发生质变:
- 阻塞读取不再忙等,而是将当前任务挂起,直到有按键到来。
- 键盘中断处理完成后唤醒等待输入的任务。
- 输入可能被路由到前台进程,而不是固定的控制台。
这些变化都属于上层输入子系统的演进,底层扫描码解析和 PS/2 读取逻辑可以一直沿用。
十三、总结
键盘驱动看似简单,却串联起了操作系统输入子系统中几乎所有的核心概念:硬件接口、I/O 端口、中断控制器、IDT、IRQ、扫描码、缓冲区、生产者-消费者模型和上层抽象。完成它,意味着内核第一次真正“听见”了用户。
回顾整条链路,我们可以归纳出几个关键结论:
- PS/2 键盘通过 8042 控制器接入系统,数据在端口 0x60,状态在 0x64。
- 键盘固定使用 IRQ1,重映射 PIC 后对应中断向量 0x21。
- 中断处理必须快,读取扫描码和写入缓冲区是最小职责,其余工作交给上层。
- 环形缓冲区是解耦中断生产与上层消费的最自然结构。
- 修饰键状态机让键盘从“识别按键”升级为“理解输入语义”。
如果你的 Cinux 已经完成了键盘驱动,不妨试着在它之上实现一个简易 shell:回显字符、处理 Backspace、执行 help 和 clear 等简单命令。你会发现,从“能打印”到“能交互”,看似只是一小步,却让整个系统从静态演示变成了可以被真正使用的操作系统雏形。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐
所有评论(0)