一、引言:让内核第一次“听见”用户

很多从零开始写操作系统的同学,第一个里程碑通常是让屏幕输出“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 上使用 inout 指令完成端口 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

这个封装是整个键盘驱动,乃至整个设备管理的基础。后续所有与硬件打交道的代码都会复用 outbinb

四、中断与 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);

很多内核因为漏掉这一步,导致键盘中断始终不触发。这是一个非常典型的“坑”。

六、键盘中断的完整处理流程

把前面的知识串起来,可以得到一次按键从硬件到驱动的完整事件链:

  1. 用户按下按键,键盘内部芯片产生扫描码并通过 PS/2 时钟和数据线发送。
  2. 8042 控制器把扫描码放入自己的输出缓冲区,并将状态寄存器 bit 0 置 1。
  3. 8042 通过 IRQ1 线向 8259A 主片发起中断请求。
  4. 8259A 在中断屏蔽允许的情况下,向 CPU 的 INTR 引脚发送信号,并在中断应答周期中提供向量号 0x21。
  5. CPU 保存现场,根据 IDT 中 0x21 号中断门跳转到键盘中断入口。
  6. 中断入口保存通用寄存器后,调用 C 语言编写的中断处理函数。
  7. 处理函数检查 0x64 状态、从 0x60 读取扫描码、解析并放入缓冲区。
  8. 处理完毕后向 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 键盘布局中部分常用按键的通码:

按键通码按键通码
A0x1ESpace0x39
B0x30Enter0x1C
C0x2EBackspace0x0E
10x02Left Shift0x2A
20x03Right Shift0x36
90x0ACtrl0x1D
00x0BAlt0x38
Esc0x01Caps Lock0x3A

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,调用顺序必须是:

  1. 初始化 IDT。
  2. 重映射 PIC。
  3. 初始化键盘驱动。
  4. 开启硬件中断 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、执行 helpclear 等简单命令。你会发现,从“能打印”到“能交互”,看似只是一小步,却让整个系统从静态演示变成了可以被真正使用的操作系统雏形。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐