从零写OS内核 | 键盘——按键是怎么变成字符的

你有没有想过这个过程:你按下一个键 → 屏幕上出现一个字符。这中间操作系统经历了什么?键盘扫描码怎么变成 scancode?USB HID 和 PS/2 有什么区别?

从机械键盘矩阵到 Linux input 子系统,本文把”按键到字符”的完整链路拆解清楚,给你一张可以照着调试的地图。


一、键盘的物理层:矩阵扫描原理

普通键盘内部是一个 N×M 的行/列矩阵,每个按键是矩阵交点上的一个开关。

Bash
        Column 0   Column 1   Column 2   Column 3
           │         │         │         │
   Row 0 ──○─────────○─────────○─────────○  ← ESC, 1, 2, 3...
   Row 1 ──○─────────○─────────○─────────○  ← Tab, Q, W, E, R...
   Row 2 ──○─────────○─────────○─────────○  ← CapsLock, A, S, D, F...
   ...

扫描过程:

  1. 键盘控制器输出 Row 0 低电平,其余 Row 高电平
  2. 读取 Column 电平,确定哪个 Column 有低电平(被按下)
  3. 交点确定 → 按键坐标 (Row, Column) 确认
  4. 通过查表把 (Row, Column) 转换为 Scan Code

PS/2 键盘的 Scan Code Set 2 例子:

  • A:键盘发 0x1C
  • Shift+A:先发 0x12(Left Shift Make),再发 0x1C
  • 松开 A:发 0xF0 + 0x1C(Break Code)

Bash
# Linux 查看当前键盘的 scan code(按实际按键测试)
$ showkey -s
# 按下任意键会显示该键的 scancode(十进制)
# 按 ESC 或等待 10 秒退出

# 查看键码映射
$ dumpkeys | head -20
keycode  1 = Escape
keycode  2 = one
keycode  3 = two

二、PS/2 协议:老式键盘的通信语言

PS/2 键盘使用一种简单的同步串行协议:每帧 11 位(1 Start + 8 Data + 1 Parity + 1 Stop),时钟由键盘提供(频率 ~10kHz)。

Bash
    Start  D0   D1   D2   D3   D4   D5   D6   D7  Parity Stop
  ┌──────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬──────┐
  │  0   │ x  │ x  │ x  │ x  │ x  │ x  │ x  │ x  │  p  │  1  │
  └──────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴──────┘

PS/2 键盘控制器(i8042)的工作流程:

Bash
  PS/2 Keyboard ──→ i8042 Controller ──→ PS/2 Controller IRQ1 ──→ Keyboard Driver
        │                  │                       │
   发出 scan code   存入 output buffer      触发键盘中断
                        │
                   读 port 0x60

Linux 查看 i8042 控制器状态:

Bash
$ dmesg | grep -i "8042|keyboard|ps/2"
[    0.921] i8042: PNP: No KBD controller at 00:06
# 或正常情况:
[    1.234] i8042: Keyboard port at 0x060,0x064, irq 1
[    1.235] input: AT Translated Set 2 keyboard as /devices/platform/i8042/serio0/input/input0

三、USB HID:现代键盘的通用协议

USB 键盘使用 HID(Human Interface Device) 类协议。HID 的核心是 报告描述符(Report Descriptor),它告诉主机这个设备发送什么数据、什么格式。

键盘 HID 报告描述符(简化版):

C
// HID Report Descriptor 片段(键盘)
0x05, 0x07,   // Usage Page (Keycodes)
0x19, 0xE0,   // Usage Min (Left Ctrl)
0x29, 0xE7,   // Usage Max (Right GUI)
0x15, 0x00,   // Logical Min (0)
0x25, 0x01,   // Logical Max (1)
0x75, 0x01,   // Report Size (1 bit per modifier key)
// Modifier keys (Ctrl, Shift, Alt, GUI)...
0x81, 0x02,   // Input (Data, Variable, Absolute)
// 6 bytes for keycode array (同时按下的键)
0x26, 0xFF, 0x00,  // Logical Max (255)
0x75, 0x08,        // Report Size (8 bits)
0x95, 0x06,        // Report Count (6 keycodes)
0x81, 0x00,        // Input (Data, Array)

一个 USB 键盘中断传输的 8 字节数据:

Byte 0 Byte 1 Byte 2-7
Modifiers (Ctrl/Shift/Alt) Reserved 最多 6 个同时按下的键码

例如按 A[0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00]

  • 0x04 = HID Usage ID for ‘A’(HUT spec 定义)

四、Linux input 子系统:统一的输入抽象

Linux 为了统一键盘、鼠标、触摸板等各种输入设备,设计了 input 子系统。所有输入设备最终都通过 /dev/input/eventX 向用户空间提供事件。

Bash
  键盘硬件 (PS/2 / USB HID)
        │
        ▼
   input_set_drvdata()
        │
        ▼
  ┌─────────────────────────────────┐
  │      input subsystem core        │
  │  (input_register_device/device) │
  └──────────────┬──────────────────┘
                 │ evdev
        ┌────────┴────────┐
        │                 │
  /dev/input/event0   /dev/input/event1
        │                 │
   按键事件           鼠标事件

实战:用 evtest 测试键盘事件:

Bash
$ evtest /dev/input/event0
Input driver version is 1.0.1
Input device ID: bus 0x11 vendor 0x1d2b product 0x1 version 0x100
Input device name: "AT Translated Set 2 keyboard"

Testing ... (interrupt to exit)
Event: time 1699999999.123456, type 4 (EV_MSC), code 4 (MSC_SCAN), value 28
# 28 = 0x1C = 'A' 的 scan code
Event: time 1699999999.123467, type 1 (EV_KEY), code 30 (KEY_A), value 1
# 按下 A,value=1 表示按下,value=0 表示松开
Event: time 1699999999.123478, type 4 (EV_MSC), code 4 (MSC_SCAN), value 28
Event: time 1699999999.123489, type 1 (EV_KEY), code 30 (KEY_A), value 0
# 松开 A,value=0

查看所有输入设备:

Bash
$ ls /dev/input/
by-id/  by-path/  event0  event1  event2  mice  mouse0

$ xinput list
⎡ Virtual core pointer                          id=2    [master pointer  (3)]
⎜ ↳ Virtual core XTEST pointer                id=3    [slave  pointer  (2)]
⎣ Virtual core keyboard                       id=1    [master keyboard (2)]
    ↳ Power Button                            id=6    [slave  keyboard (2)]
    ↳ AT Translated Set 2 keyboard            id=7    [slave  keyboard (2)]

五、wandos 的键盘输入

wandos 目前在 kernel/input.cpp 实现了简单的 PS/2 键盘中断处理:

Cpp
// wandos kernel/input.cpp(简化)
void keyboard_interrupt() {
    uint8_t scancode = inb(0x60);  // 读 i8042 数据端口

    // 简单的 scan code → ASCII 转换
    if (scancode < 0x80) {
        char c = scancode_to_ascii(scancode);
        if (c) {
            putchar(c);  // 输出到 VGA 或串口
        }
    }
}

wandos 当前支持的键盘功能:

  • PS/2 键盘中断接收
  • Scan Code Set 2 到 ASCII 的基本转换
  • 通过 VGA 文本模式输出到屏幕

wandos 尚未实现的:

  • USB HID 键盘(需要 USB 协议栈)
  • input 子系统(统一输入抽象层)
  • TTY 线路规范(ICRNL、IXON 等)

公众号 footer

相关阅读:

  • 《从零写OS内核 | 总线与地址空间——CPU 是怎么找到每个设备的》(343)
  • 《从零写OS内核 | I2C 总线——嵌入式系统的血管》(348)

动手环节:
运行 evtest /dev/input/eventX(X 依次尝试),找到你的键盘设备,然后按几个键观察 EV_MSC/SCANCODE 和 EV_KEY 的对应关系。

仓库地址:
github.com/golang12306/os-kernel-from-scratch


关注公众号「AI不着急」,回复”资料”获取内核学习路线图。

最后修改: 2024年12月25日

作者

评论

发表评论

您的邮箱地址不会被公开。