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

摘要:你敲下一个按键,字符就出现在屏幕上——这中间发生了什么?本篇文章从硬件矩阵扫描、PS/2 协议、USB HID 描述符、到 Linux input 子系统,逐层剖析键盘输入的完整数据通路。wandos 目前无 USB HID 实现,本文 USB HID 部分仅在 Linux 环境有效。


1. 硬件层:矩阵扫描

1.1 矩阵是如何组织的

键盘按键排成行列矩阵(Row × Column),每个按键是行线和列线的交叉点。按下按键等于短路对应的行线和列线。扫描过程是:

  1. 控制器依次将每一列设为低电平
  2. 读取所有行的电平状态
  3. 有按键按下时,对应行的 GPIO 会因短路而读到低电平

Bash
        Column0   Column1   Column2   Column3
Row0  ──┬────────┬────────┬────────┐
        │ K11    │ K12    │ K13    │ K14
Row1  ──┼────────┼────────┼────────┼─────
        │ K21    │ K22    │ K23    │ K24
Row2  ──┼────────┼────────┼────────┼─────
        │ K31    │ K32    │ K33    │ K34
Row3  ──┴────────┴────────┴────────┴─────

扫描 Column0 时:
- Row0=0, Row1=0, Row2=0, Row3=1 → K11, K21, K31 被按下
- Row0=1, Row1=0, Row2=0, Row3=0 → K22, K32 被按下

以 4×8 矩阵为例,8 行 × 4 列 = 32 个按键只需 12 个 GPIO 引脚(远少于 32 根独立布线)。这是矩阵扫描节省 IO 的核心原理。

1.2 按键抖动(Debounce)

机械按键在按下和释放瞬间会有几毫秒的抖动(bounce),需要 debounce 处理:

C
// 简单的软件 debounce
#define DEBOUNCE_MS 10
uint32_t last_key_state = 0;
uint32_t key_state = 0;

void scan_keyboard(void) {
    uint32_t new_state = read_matrix();
    if (new_state != last_key_state) {
        sleep_ms(DEBOUNCE_MS);  // 等待抖动结束
        new_state = read_matrix();
        if (new_state != last_key_state) {
            // 状态真正变化了
            uint32_t changed = new_state ^ last_key_state;
            for (int i = 0; i < 32; i++) {
                if (changed & (1 << i)) {
                    bool pressed = (new_state >> i) & 1;
                    key_event(i, pressed);
                }
            }
            last_key_state = new_state;
        }
    }
}

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

2.1 物理层:Clock + Data

PS/2 接口有 6 针(Mini-DIN),但只用到 2 根信号线:

  • Clock:Host 控制的时钟线,频率 10-16.7 kHz
  • Data:数据线,LSB 先发

Bash
Host → Keyboard  (Host-to-device)
         ┌────┐
  Clock ─┤    ├──── Clock
  Data  ─┤    ├──── Data
         └────┘

Keyboard → Host  (Device-to-host)  
         ┌────┐
  Clock ─┤    ├──── Clock  (Keyboard 控制)
  Data  ─┤    ├──── Data
         └────┘

2.2 数据帧格式

每个字节发 11 位:1 start bit + 8 data bits + 1 parity + 1 stop bit

Bash
  Bit0   Bit1-8    Bit9    Bit10
  START   DATA     PARITY   STOP
    0    8 bits     0/1      1
  (低)   (LSB→MSB)  奇校验   (高)

2.3 键盘扫描码(Scan Codes)

键盘按下/释放不是发送 ASCII 码,而是发送扫描码(Scan Code)。以 Boot Protocol 为例:

  • 按键按下:发送一个或两个字节的扫描码(MAKE code)
  • 按键释放:发送 0xF0 前缀 + 扫描码(BREAK code)

Bash
示例:按下 A 键(键盘布局左上角)
→ 发送 0x1C (MAKE)
释放 A 键
→ 发送 0xF0 0x1C (BREAK)

示例:按下左 Shift + A(大写 A)
→ 发送 0x12 (L-Shift MAKE)
→ 发送 0x1C (A MAKE)
→ 发送 0xF0 0x1C (A BREAK)
→ 发送 0xF0 0x12 (L-Shift BREAK)

2.4 Linux 查看 PS/2 键盘数据

Bash
# 用 showkey 读取原始扫描码(需要本地终端)
$ sudo showkey --scancodes
# 按下 A 键输出类似:
# keycode 30 press
# keycode 30 release

# 用 showkey --keycodes 看键码
$ sudo showkey --keycodes
# keycode 30 = KEY_A

/dev/input/eventX 是 input 子系统的标准化接口,可以读取按键事件:

Bash
$ sudo evtest /dev/input/event4
Input driver version is 1.0.1
Input device ID: bus 0x11 vendor 0x1a2b product 0x1c2d version 0x0100
  ...
Event: time 1625123456.123456, type 4 (EV_MSC), code 4 (MSC_SCAN), value 1c
Event: time 1625123456.123456, type 1 (EV_KEY), code 30 (KEY_A), value 1
Event: time 1625123456.123456, type 0 (EV_SYN), code 0 (SYN_REPORT), value 0

3. USB HID:现代键盘的数据格式

3.1 HID 描述符

USB HID 设备通过 HID 描述符告诉 Host 自己的数据格式。键盘 HID 描述符的核心是 Report Descriptor

C
// 键盘 HID Report Descriptor (简化版)
0x05, 0x07,    // Usage Page (Keyboard)
0x19, 0xE0,    // Usage Minimum (Keyboard LeftControl)
0x29, 0xE7,    // Usage Maximum (Keyboard Right GUI)
0x95, 0x08,    // Report Count (8 modifier keys)
0x75, 0x01,    // Report Size (1 bit per modifier)
0x81, 0x02,    // Input (Data, Var, Abs) ← Modifier keys
0x95, 0x01,    // Report Count (1)
0x75, 0x08,    // Report Size (8 bits)
0x81, 0x01,    // Input (Const) ← Reserved (1 byte)
0x95, 0x06,    // Report Count (6 keys)
0x75, 0x08,    // Report Size (8 bits per key)
0x65, 0x00,    // Unit Exponent (0)
0x55, 0x00,    // Unit (-)
0x81, 0x00,    // Input (Data, Array) ← Key array (6 keys simultaneous)

这段描述符定义了键盘的 Report 格式:1 byte modifier + 1 byte reserved + 6 bytes key codes = 8 字节一个 Report

3.2 USB HID 报告数据

按下 A 键时,HID 报告为(通过 Interrupt 端点传输):

Bash
Byte 0: modifier (0x00 = 无修饰键)
Byte 1: reserved (0x00)
Byte 2-7: key codes (0x04 = 'a' in USB HID usage table, 其余 0x00)

同时按 Shift + A:

Bash
Byte 0: 0x02 (左 Shift = bit 1)
Byte 1: 0x00
Byte 2: 0x04 (HID usage for 'a' → 但系统会应用 Shift 转换为 'A')
Byte 3-7: 0x00

3.3 查看键盘 HID 报告

Bash
$ sudo usbhid-dump -d 046d:c52b 2>/dev/null | grep -v "^0.*:" | head -5
001:003:046d:c52b:4   report 2 size 8
 0000:  00 00 00 00 00 00 00 00   # 无按键

 0000:  02 00 04 00 00 00 00 00   # L-Shift + 'a' = 'A'

wandos 目前无 USB HID 实现,无法直接连接 USB 键盘进行调试。


4. Linux input 子系统

4.1 分层架构

Linux input 子系统分为 3 层:

Bash
用户空间 (evtest, xinput, SDL, ...)
    ↓ 字符设备 /dev/input/eventX
内核 input core (input.c)
    ↓ 
硬件驱动层 (usbhid, atkbd, psmouse, ...)

4.2 input_event 结构

C
struct input_event {
    struct timeval time;  // 时间戳
    __u16 type;          // EV_KEY / EV_REL / EV_ABS / EV_LED ...
    __u16 code;          // KEY_A / REL_X / ABS_X / LED_NUML ...
    __s32 value;         // 1=按下, 0=释放, 2= autorepeat
};

4.3 键盘驱动注册

C
// drivers/input/keyboard/atkbd.c (PS/2 键盘驱动)
static int __init atkbd_init(void) {
    struct input_dev *dev;
    dev = input_allocate_device();
    dev->name = "AT Translated Set 2 keyboard";
    dev->evbit[BIT_WORD(EV_KEY)] = BIT_MASK(EV_KEY);

    // 注册所有按键
    for (i = 0; i < 256; i++)
        set_bit(atkbd_keycode[i], dev->keybit);

    input_register_device(dev);
}

4.4 evtest 实战

Bash
$ sudo evtest /dev/input/event4
...(选择键盘设备)
Event: time 1625123456.123456, type 4 (EV_MSC), code 4 (MSC_SCAN), value 1c
Event: time 1625123456.123456, type 1 (EV_KEY), code 30 (KEY_A), value 1
Event: time 1625123456.123456, type 0 (EV_SYN), code 0 (SYN_REPORT), value 0

每一组事件以 EV_SYN (SYN_REPORT) 结束,意思是”这一次完整事件包结束了”。在 HID 报告里对应全 0 report(无按键)或新的按键组合。

4.5 /dev/input/by-id 和 by-path

Bash
$ ls -la /dev/input/by-id/
total 1 root root   usb-Logitech_USB_Receiver-event-kbd -> ../event4
$ ls -la /dev/input/by-path/
total 1 root root   pci-0000:00:14.0-usb-0:1:1.0-event-kbd -> ../event4

5. 按键到字符的完整流程

Bash
硬件层:矩阵扫描
  ↓ GPIO 中断或轮询
PS/2 / USB HID 传输层
  ↓ 扫描码 (PS/2) / HID Report (USB)
USB/PS/2 主机控制器
  ↓ URB/中断传输
Linux input 子系统 (input.c)
  ↓ input_event (EV_KEY, KEY_A, value=1)
Input Handler (evdev)
  ↓ /dev/input/eventX
用户空间:evdev + X11/Wayland + IME
  ↓
显示缓冲区 → 屏幕

6. wandos 对比:简化的键盘模型

wandos 当前没有 USB HID 也没有 PS/2 驱动。wandos 的外设模型是直接内存映射 GPIO 键盘——矩阵扫描在固件层完成,直接发送扫描码给内核中断处理函数,没有经过 input 子系统的标准化抽象。如果要实现标准 input 子系统,需要先实现 GPIO 中断和 input device 注册。


公众号底部

📢 关注公众号【内核栈】,获取更多 OS/内核干货

💡 本文属于「从零写OS内核」系列,欢迎回顾:

  • 篇 345:《USB 协议》
  • 篇 344:《时钟与复位》
  • 篇 343:《总线与地址空间》

🔗 回复「键盘」获取键盘矩阵扫描参考代码


作者:内核栈 | 原创出品
本文永久链接:https://kernelstack.dev/346-keyboard

最后修改: 2024年4月16日

作者

评论

发表评论

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