从零写OS内核|346:键盘——按键是怎么变成字符的
摘要:你敲下一个按键,字符就出现在屏幕上——这中间发生了什么?本篇文章从硬件矩阵扫描、PS/2 协议、USB HID 描述符、到 Linux input 子系统,逐层剖析键盘输入的完整数据通路。wandos 目前无 USB HID 实现,本文 USB HID 部分仅在 Linux 环境有效。
1. 硬件层:矩阵扫描
1.1 矩阵是如何组织的
键盘按键排成行列矩阵(Row × Column),每个按键是行线和列线的交叉点。按下按键等于短路对应的行线和列线。扫描过程是:
- 控制器依次将每一列设为低电平
- 读取所有行的电平状态
- 有按键按下时,对应行的 GPIO 会因短路而读到低电平
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 处理:
// 简单的软件 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 先发
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。
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)
示例:按下 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 键盘数据
# 用 showkey 读取原始扫描码(需要本地终端)
$ sudo showkey --scancodes
# 按下 A 键输出类似:
# keycode 30 press
# keycode 30 release
# 用 showkey --keycodes 看键码
$ sudo showkey --keycodes
# keycode 30 = KEY_A/dev/input/eventX 是 input 子系统的标准化接口,可以读取按键事件:
$ 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 03. USB HID:现代键盘的数据格式
3.1 HID 描述符
USB HID 设备通过 HID 描述符告诉 Host 自己的数据格式。键盘 HID 描述符的核心是 Report Descriptor:
// 键盘 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 端点传输):
Byte 0: modifier (0x00 = 无修饰键)
Byte 1: reserved (0x00)
Byte 2-7: key codes (0x04 = 'a' in USB HID usage table, 其余 0x00)同时按 Shift + A:
Byte 0: 0x02 (左 Shift = bit 1)
Byte 1: 0x00
Byte 2: 0x04 (HID usage for 'a' → 但系统会应用 Shift 转换为 'A')
Byte 3-7: 0x003.3 查看键盘 HID 报告
$ 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 层:
用户空间 (evtest, xinput, SDL, ...)
↓ 字符设备 /dev/input/eventX
内核 input core (input.c)
↓
硬件驱动层 (usbhid, atkbd, psmouse, ...)4.2 input_event 结构
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 键盘驱动注册
// 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 实战
$ 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
$ 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 -> ../event45. 按键到字符的完整流程
硬件层:矩阵扫描
↓ 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
评论