QMK 键盘固件 USB 输入链路解析:从按键到扫描码、键码再到屏幕字符的完整原理
【免费下载链接】qmk_firmwareOpen-source keyboard firmware for Atmel AVR and Arm USB families项目地址: https://gitcode.com/GitHub_Trending/qm/qmk_firmware
当你按下 QMK 机械键盘上的一个键位时,屏幕上出现的字符并不是按键"直接"产生的:物理电接触、矩阵扫描、HID 扫描码上报、内核键码映射、操作系统布局翻译,是一条层层解耦的单向流水线。理解这条链路,是修改 QMK 固件前必须建立的认知基础——它会清楚地告诉你:固件只能改"哪个扫描码被送出",而无法改变"操作系统把键码翻译成哪个字符"。读完全文,你将掌握从keyboard_task()主循环到UC()Unicode 键码的完整实现路径,以及为什么 QWERTY US 布局下某个键永远打不出€、又该如何绕过布局限制输入 Unicode 字符。
一、宏观链路:一次击键背后发生了什么
文档 docs/how_keyboards_work.md 首先给出一个最简化的动作链路图:
+------+ +-----+ +----------+ +----------+ +----+ || User |-------->| Key |------>| Firmware |----->| USB wire |---->| OS | +------+ +-----+ +----------+ +----------+ +----+即:用户 → 按键(物理触发)→ 键盘固件 → USB 线缆(HID 协议)→ 操作系统。任何一次击键,事件都沿这条链单向传递;每个环节的职责边界不同,决定了你在固件层能做什么、不能做什么。下面按文档的四个阶段逐一展开,并在每阶段补充 QMK 仓库中的源码证据。
二、阶段一:你按下按键(You Press a Key)
按下按键的瞬间,键盘固件有能力注册这一事件——包括"按下(pressed)""按住(held)""抬起(released)"三种状态。
文档指出,这一过程通常通过周期性扫描按键状态实现,而扫描速度受三个因素制约:
- 机械轴体本身的响应时间(物理下限);
- 传输协议(此处为 USB HID);
- 所用软件的主循环实现。
在 QMK 源码中,这条"周期性扫描"链路的核心是 quantum/keyboard.c 中的主任务函数:
// quantum/keyboard.c (约 L704-L713) /** \brief Main task that is repeatedly called as fast as possible. */ void keyboard_task(void) { __attribute__((unused)) bool activity_has_occurred = false; if (matrix_task()) { last_matrix_activity_trigger(); activity_has_occurred = true; } quantum_task(); ... }keyboard_task()被尽可能快地反复调用,每一步先执行matrix_task()扫描键位矩阵(只有矩阵状态发生变化时才返回真),再进入quantum_task()。后者在末尾调用host_task()(同一文件约 L701 行),把处理后的按键结果上报给宿主——这正是链路图中 "Firmware → USB wire" 一跳的落地实现。
从源码结构看,"按下 / 按住 / 抬起"三态的识别依赖矩阵前后两帧的差值比较:matrix_task()内部保存matrix_previous,与当前行状态逐行比对得出matrix_changed,从而区分"新按下"与"持续按住",抬起则对应上一帧为真、当前帧为假。这正是文档所说"固件可以注册 pressed / held / released"的底层机制。
三、阶段二:固件到底发了什么(What the Firmware Sends)
这是整条链路中最容易被误解的一环。文档的核心论断是:
固件不发送实际的字母或字符,只发送扫描码(scancode)。
USB HID 规范(HID Usage Tables,HUT 1.12)规定了键盘可以合法上报的扫描码范围:一个预定义的简单数字列表,从0x00到0xE7。固件负责为键盘的每个物理按键分配一个扫描码。
由此得出 QMK 使用者必须记住的一条边界:
通过修改固件,你只能改变某个键通过 USB 送出哪个扫描码,不能改变这个扫描码最终变成什么字符。
在 QMK 仓库中,"分配扫描码"这件事发生在 keymap 层。完整的可用键码清单见 docs/keycodes.md,其中基础键码一节(Basic Keycodes)完整覆盖了 HID 使用表定义的字符键、功能键与修饰键,例如:
| 键码 | 别名 | 说明 |
|---|---|---|
KC_A | — | a和A |
KC_1 | — | 1和! |
KC_ENTER | KC_ENT | Return (Enter) |
KC_LEFT_SHIFT | KC_LSFT | 左 Shift |
KC_LEFT_GUI | KC_LGUI、KC_LCMD、KC_LWIN | 左 GUI(Windows/Command/Super 键) |
KC_AUDIO_VOL_UP | KC_VOLU | 音量增大 |
文档特别强调了扫描码与键码(keycode)的区分:扫描码是 USB 线上的裸编号(0x00–0xE7),而 QMK 的KC_*宏是构建期符号。发送路径在 quantum/action.c、quantum/action_util.c 等文件中实现——它们将动作解析后的结果填入 HID 键盘报告并调用 USB 协议栈发送。QMK 针对不同硬件平台集成了相应的 USB 库,见仓库lib/目录下的 LUFA(Atmel AVR 的 USB 外设框架)、VUSB(虚拟 USB 外设,用于无硬件 USB 控制器的 MCU)等,这些就是链路图中 "USB wire" 一段在固件侧的实际载体。
四、阶段三:事件输入子系统 / 内核做了什么(Scancode → Keycode)
扫描码经过 USB 到达主机后,并不是直接可用。文档指出:
扫描码会被映射为键码(keycode),该映射依赖于键盘(以 systemd 的
hwdb.d/60-keyboard.hwdb文件为例)。如果没有这个映射,操作系统将收不到有效键码,也就无法对这次按键做任何有用处理。
这意味着:
- 键码与扫描码的对应关系由操作系统侧的映射表决定(Linux 下即内核 HID 驱动 + systemd hwdb 一类机制);
- 固件层无法干预这一步。同一扫描码在不同操作系统、不同 hwdb 版本下理论上可能映射到不同键码;
- 这也是"为什么同一把键盘接 Windows 和 Linux 表现略有差异"的根源之一。
对 QMK 开发者的实际含义是:调试"某个键没反应"时,先确认固件是否真的上报了预期扫描码(可用qmk console观察keyboard report),再怀疑主机侧映射。
五、阶段四:操作系统做了什么(Keycode → Character)
键码到达操作系统后,还需要一块软件把它匹配为实际字符——依据就是当前生效的键盘布局(keyboard layout)。文档给出了 QWERTY 布局下映射表的样本:
| keycode | character |
|---|---|
| 0x04 | a/A |
| 0x05 | b/B |
| 0x06 | c/C |
| ... | ... |
| 0x1C | y/Y |
| 0x1D | z/Z |
| ... | ... |
注意两个细节:
- 同一个键码在 Shift 按下/未按下时分别对应小写/大写(
a与A),大小写切换仍发生在主机侧,固件只需正确上报 Shift 修饰键状态; - 映射表是布局的函数——换布局,同一键码翻译成不同字符。
六、回到固件:QMK 如何用布局名来定义键
既然布局是固定的(除非你自创布局),QMK 的 keymap 语法就允许直接用布局中的键名调用键码,而不是手写十六进制编号。文档举例:KC_A实际代表 QWERTY 下的0x04。完整清单即 docs/keycodes.md 与 docs/keycodes_basic.md。
在 keymap 中的实际写法(QMK 标准 keymap 结构)形如:
#include QMK_KEYBOARD_H const uint16_t PROGMEM keymaps[][MATRIX_ROWS][MATRIX_COLS] = { [0] = LAYOUT( KC_A, KC_B, KC_C, KC_ENTER, KC_SPC, KC_A ), [1] = LAYOUT( UC(0x20AC), UM(0), UP(1, 2), ... ) };第 0 层用KC_A(即 QWERTY 下0x04)这样的布局名定义;第 1 层则引出 QMK 的 Unicode 键码,下一节详述。
七、你能发送的字符是有界的(List of Characters You Can Send)
文档在此抛出一个关键结论:
撇开快捷键不谈,有限数量的键码被映射到有限布局上,意味着你能分配给某个键的字符,只能是该布局中已存在的字符。
文档给出的经典反例:如果你的布局是QWERTY US,想把某个键直接配成输出€(欧元符号)——做不到,因为 QWERTY US 布局里没有这条映射。可行的出路是改用QWERTY UK或QWERTY US International这类包含该符号的布局(这属于主机侧布局设置,而非固件改动)。
文档同时回答了"为什么不设计一个覆盖全部 Unicode 的键盘布局":
通过 USB 可用的键码数量有限,根本不允许这么做。
HID 使用表预留的字符键码(0x04–0x24区段的字母数字符号加上0xE0–0xE7等)总共只有几十个,而 Unicode 码点有上百万——数量级差距决定了"布局内直发"这条路必然止步于有限字符集。这也是前文"固件只能改扫描码"论断的边界来源。
八、(也许)输入 Unicode 字符的办法(How to (Maybe) Enter Unicode Characters)
既然布局内字符集有界,QMK 提供了一条绕行路径:
让固件发送按键序列,去触发目标操作系统的软件 Unicode 输入方式(例如通过十六进制输入方式),从而实际上不依赖操作系统里定义的布局输入任意字符。
QMK 中对应的实现是 Unicode 键码族(见 docs/keycodes.md 的 "Unicode Support" 一节):
| 键码 | 说明 |
|---|---|
UC(c) | 发送 Unicode 码点c(支持到0x7FFF) |
UM(i) | 发送unicode_map表中索引i的码点 |
UP(i, j) | 发送索引i的码点;若 Shift/Caps 处于开启状态则发送j |
QK_UNICODE_MODE_NEXT/QK_UNICODE_MODE_PREVIOUS | 在已选输入模式间切换 |
QK_UNICODE_MODE_MACOS | 切换到 macOS 输入方式 |
QK_UNICODE_MODE_LINUX | 切换到 Linux 输入方式 |
QK_UNICODE_MODE_WINDOWS | 切换到 Windows 输入方式 |
QK_UNICODE_MODE_BSD | 切换到 BSD 输入方式(尚未实现) |
QK_UNICODE_MODE_WINCOMPOSE | 切换到 Windows 下基于 WinCompose 的输入方式 |
QK_UNICODE_MODE_EMACS | 切换到 Emacs(C-x 8 RET)输入方式 |
这些键码的底层实现位于 quantum/unicode/unicode.h 与 quantum/unicode/unicode.c,配套的处理逻辑在 quantum/process_keycode/process_unicode_common.c。源码中定义的输入模式枚举与键码一一对应:
// quantum/unicode/unicode.h (约 L42-L51) enum unicode_input_modes { UNICODE_MODE_MACOS, // macOS using Unicode Hex Input UNICODE_MODE_LINUX, // Linux using IBus UNICODE_MODE_WINDOWS, // Windows using EnableHexNumpad UNICODE_MODE_BSD, // BSD (not implemented) UNICODE_MODE_WINCOMPOSE, // Windows using WinCompose UNICODE_MODE_EMACS, // Emacs UNICODE_MODE_COUNT };注意:这里的UC()并非通过单字节扫描码直达字符,而是在固件内部展开成一段普通按键序列(例如 macOS 模式走 Unicode Hex Input,即Control + Option + 十六进制码 + Enter一类组合),逐键通过正常 HID 报告送出。也就是说,"打一个€"在总线上体现为若干次普通的修饰键 + 字符键上报。
这条路径的三大代价
文档明确列出了这种"按键序列模拟输入法"的局限,使用 Unicode 键码前必须清楚:
- 绑定单一操作系统:切换 OS 需要重新编译固件(因为模拟的按键序列是 OS 特定的);
- 同一 OS 内并非所有软件都支持:目标程序必须实现了相应的 Unicode 输入入口(例如 Windows 的 EnableHexNumpad、macOS 的 Unicode Hex Input 开关);
- 部分系统上仅能输入 Unicode 的子集,而非全码位区。
这就是文档标题中 "(Maybe)" 一词的含义:该方案可用,但不是无条件可用。
九、全链路小结:每一层你能改什么
| 环节 | 载体 | 可修改性(固件视角) |
|---|---|---|
| 按下 → 扫描 | 矩阵 + quantum/keyboard.c 主循环 | 固件内:防抖、扫描率、矩阵映射均可改 |
| 扫描码上报 | HID 报告(0x00–0xE7) | 固件内:唯一可以直接决定"发什么扫描码"的地方 |
| 扫描码 → 键码 | 内核 / hwdb 映射 | 固件不可干预(主机侧) |
| 键码 → 字符 | 操作系统键盘布局 | 固件不可干预(换布局只能改 OS 设置) |
| 任意 Unicode 字符 | Unicode 键码UC/UM/UP+ 键序列模拟 | 固件内可用,但受三大代价限制 |
回到文档开篇的链路图,可以把它重读为一句话:User 产生事件,Key 传递状态,Firmware 决定扫描码,USB wire 承载 HID 协议,OS 完成键码与字符的两级翻译。你在 QMK 中修改 keymap,作用面严格限定在第三个节点;任何期望"固件直接打出某个字符"的想法,都会被第 3、4 环节的主机侧映射规则重新解释。掌握这条边界后,docs/keymap.md、docs/feature_layers.md 等后续主题中的按键行为设计,才能建立在正确的心智模型之上。
【免费下载链接】qmk_firmwareOpen-source keyboard firmware for Atmel AVR and Arm USB families项目地址: https://gitcode.com/GitHub_Trending/qm/qmk_firmware
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考