前言
最近在做一个机械键盘的项目,客户要求必须支持全键无冲。说实话,之前只知道无冲是个卖点,真正动手做才发现这里面水挺深的------涉及到矩阵扫描原理、硬件电路设计、USB协议限制等一系列问题。今天把整个研究过程整理出来,希望能帮到同样在做键盘或者矩阵按键项目的朋友。
什么是键盘冲突
先搞清楚一个概念:键盘冲突到底是什么?
简单说就是:同时按下多个键时,键盘无法正确识别所有按键,甚至会误报没按下的键。
这个问题在游戏玩家群体里特别敏感。比如玩FPS游戏,你同时按住W(前进)+ Shift(加速)+ 空格(跳跃),结果键盘告诉电脑你只按了W和Shift,跳跃没触发,角色就跳不起来------这就是键盘冲突造成的。
常见的描述方式:
- 2KRO:最多同时识别2个按键
- 6KRO:最多同时识别6个按键(USB HID标准)
- NKRO:N-Key Rollover,全键无冲,理论上可以同时按下所有键都能正确识别
键盘矩阵扫描原理
要理解冲突问题,先得搞懂键盘是怎么检测按键的。
为什么用矩阵结构
一个标准104键键盘,如果每个按键单独接一根线到MCU,需要104个IO口------这显然不现实。所以键盘普遍采用矩阵扫描结构。
矩阵的核心思想是把按键排列成行和列的交叉点,比如8行×16列的矩阵可以容纳128个按键,但只需要8+16=24个IO口。
COL0 COL1 COL2 COL3 ... COL15
| | | | |
ROW0 ----+------+------+------+----...--+
| | | | |
ROW1 ----+------+------+------+----...--+
| | | | |
ROW2 ----+------+------+------+----...--+
. . . . .
. . . . .
ROW7 ----+------+------+------+----...--+
每个交叉点放一个按键开关
扫描过程
矩阵扫描的基本流程:
- 所有列线配置为输入,开启上拉电阻(默认高电平)
- 逐行输出低电平进行扫描
- 读取所有列的状态,低电平表示该位置按键被按下
- 切换到下一行,重复扫描
c
// 简化的矩阵扫描代码
#define ROW_NUM 8
#define COL_NUM 16
// 行引脚(输出)
const uint8_t row_pins[ROW_NUM] = {PA0, PA1, PA2, PA3, PA4, PA5, PA6, PA7};
// 列引脚(输入,上拉)
const uint8_t col_pins[COL_NUM] = {PB0, PB1, PB2, PB3, PB4, PB5, PB6, PB7,
PC0, PC1, PC2, PC3, PC4, PC5, PC6, PC7};
// 按键状态矩阵
uint8_t key_matrix[ROW_NUM][COL_NUM];
void matrix_scan(void)
{
for (int row = 0; row < ROW_NUM; row++) {
// 1. 当前行输出低电平
gpio_write(row_pins[row], 0);
// 2. 短暂延时,等待电平稳定
delay_us(5);
// 3. 读取所有列
for (int col = 0; col < COL_NUM; col++) {
// 低电平表示按下
key_matrix[row][col] = (gpio_read(col_pins[col]) == 0) ? 1 : 0;
}
// 4. 恢复当前行为高电平
gpio_write(row_pins[row], 1);
}
}
看起来很简单对吧?问题来了。
鬼键(Ghost Key)问题
问题复现
假设有个简化的3×3矩阵:
COL0 COL1 COL2
| | |
ROW0 ---[A]----[B]----[C]---
| | |
ROW1 ---[D]----[E]----[F]---
| | |
ROW2 ---[G]----[H]----[I]---
现在同时按下A、B、D三个键,分析一下扫描过程会发生什么:
扫描ROW0时:
- ROW0输出低电平
- A被按下 → COL0读到低电平 ✓
- B被按下 → COL1读到低电平 ✓
- 到这里都正常
扫描ROW1时:
- ROW1输出低电平
- D被按下 → COL0读到低电平 ✓
但问题来了,看这条电流路径:
ROW1(低) → D(按下) → COL0 → A(按下) → ROW0(此时是高阻态)
↓
然后通过B(按下) → COL1
由于A、B、D都被按下,形成了一条电流泄漏路径。扫描ROW1时,电流可以从ROW1经过D到COL0,再经过A到ROW0,然后经过B到COL1。这导致COL1也读到了低电平!
MCU会误认为E键也被按下了------但实际上E根本没按。这个凭空出现的E键就叫鬼键(Ghost Key)。
鬼键产生的条件
分析上面的例子,鬼键产生需要满足:
- 至少同时按下3个键
- 这3个键构成一个"L"形或矩形的三个角
- 此时第四个角就会产生鬼键
用矩阵坐标来说:如果(r1,c1)、(r1,c2)、(r2,c1)三个位置都被按下,那么(r2,c2)就会产生鬼键。
c1 c2
| |
r1 --*------*-- * = 按下的键
| |
r2 --*-----[?]-- [?] = 鬼键位置
这是矩阵结构的固有缺陷,纯软件层面无法解决。
硬件解决方案:二极管隔离
原理分析
既然问题出在电流可以反向流动形成回路,那解决方案就很直接:加二极管,让电流只能单向流动。
改造后的电路:
COL0 COL1 COL2
| | |
ROW0 --[A]>----[B]>----[C]>---
| | |
ROW1 --[D]>----[E]>----[F]>---
| | |
ROW2 --[G]>----[H]>----[I]>---
[X]> 表示按键串联一个二极管,阳极接行,阴极接列
每个按键位置的详细电路:
ROW ────┬──── 按键开关 ────┬──── 二极管(阳极) ──>|── COL
│ │
行线 二极管阴极接列线
或者更常见的画法:
ROW ──────○/ ○──────|>|────── COL
按键开关 二极管
为什么二极管能解决问题
还是刚才的例子,同时按下A、B、D:
扫描ROW1时:
- ROW1输出低电平
- D的二极管:阳极(ROW1)为低,阴极(COL0)通过上拉为高 → 二极管导通 → COL0被拉低 ✓
那之前的泄漏路径呢?
ROW1(低) → D → COL0 → [试图经过A反向流向ROW0]
↓
A的二极管反向截止!电流过不去
二极管阻止了电流从COL0反向流回ROW0,泄漏路径被切断,鬼键问题解决。
二极管选型
实际设计中,二极管的选型需要注意:
-
正向压降要小:压降太大会影响低电平的判断阈值。一般选肖特基二极管(压降约0.3V)或普通开关二极管1N4148(压降约0.7V)
-
封装要小:104键要焊104个二极管,封装太大PCB放不下。常用SOD-323、SOD-123封装
-
响应速度:扫描频率通常1-10kHz,普通二极管都能满足
常用型号:
- 1N4148W(SOD-123封装,便宜)
- BAV70(SOT-23双二极管,省空间)
- BAT54S(肖特基,低压降)
硬件设计实例
这是一个4×4矩阵的原理图片段:
VCC
|
[R] 10K上拉电阻
|
COL0 ───────────┴─────────────────────────────────
| | | |
1N4148 1N4148 1N4148 1N4148
|> |> |> |>
| | | |
ROW0 ─────────[SW]──────[SW]──────[SW]──────[SW]───
| | | |
| | | |
ROW1 ─────────[SW]──────[SW]──────[SW]──────[SW]───
| | | |
...
PCB布局时,二极管通常放在按键旁边,阴极统一朝向列线方向。
软件层面的优化
硬件加了二极管后,矩阵本身不会产生鬼键了。但要实现完整的NKRO,软件层面还有工作要做。
去抖动处理
机械开关在按下/松开瞬间会有抖动,表现为电平快速跳变:
理想波形: ______|‾‾‾‾‾‾‾‾‾‾
实际波形: ______|||‾‾‾‾‾‾‾‾
↑
抖动区域(约5-20ms)
不处理抖动会导致一次按键被识别成多次。常用的去抖方法:
c
#define DEBOUNCE_COUNT 5 // 去抖计数阈值
typedef struct {
uint8_t current; // 当前稳定状态
uint8_t raw; // 原始读取值
uint8_t counter; // 计数器
} key_state_t;
key_state_t key_states[ROW_NUM][COL_NUM];
void debounce_update(int row, int col, uint8_t raw_value)
{
key_state_t *state = &key_states[row][col];
if (raw_value != state->current) {
// 状态不一致,累加计数
state->counter++;
if (state->counter >= DEBOUNCE_COUNT) {
// 连续多次读到新状态,确认切换
state->current = raw_value;
state->counter = 0;
// 触发按键事件
if (raw_value) {
key_pressed(row, col);
} else {
key_released(row, col);
}
}
} else {
// 状态一致,清零计数
state->counter = 0;
}
state->raw = raw_value;
}
void matrix_scan_with_debounce(void)
{
for (int row = 0; row < ROW_NUM; row++) {
gpio_write(row_pins[row], 0);
delay_us(5);
for (int col = 0; col < COL_NUM; col++) {
uint8_t raw = (gpio_read(col_pins[col]) == 0) ? 1 : 0;
debounce_update(row, col, raw);
}
gpio_write(row_pins[row], 1);
}
}
扫描频率设计
扫描太慢会有延迟感,太快浪费CPU。一般原则:
- 扫描周期 < 去抖时间/去抖计数,确保去抖算法正常工作
- 总延迟(扫描周期×去抖计数)控制在10-20ms内,人感觉不到
假设去抖计数为5,希望总延迟在10ms:
- 扫描周期 = 10ms / 5 = 2ms
- 扫描频率 = 500Hz
实际项目中1000Hz扫描频率比较常见,配合5次去抖,响应延迟5ms。
c
// 定时器中断,1ms触发一次
void TIM2_IRQHandler(void)
{
if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET) {
matrix_scan_with_debounce();
TIM_ClearITPendingBit(TIM2, TIM_IT_Update);
}
}
void scan_timer_init(void)
{
// 配置TIM2,1kHz中断
TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure;
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);
TIM_TimeBaseStructure.TIM_Period = 1000 - 1; // 1ms
TIM_TimeBaseStructure.TIM_Prescaler = 72 - 1; // 72MHz/72 = 1MHz
TIM_TimeBaseStructure.TIM_ClockDivision = 0;
TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up;
TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure);
TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE);
NVIC_EnableIRQ(TIM2_IRQn);
TIM_Cmd(TIM2, ENABLE);
}
USB HID协议与NKRO
硬件和扫描都搞定了,还有最后一道坎:USB协议限制。
标准Boot Protocol的6KRO限制
USB HID键盘有个Boot Protocol,是为了兼容BIOS等不支持复杂HID报告的场景。它的报告格式是固定的8字节:
Byte 0: 修饰键 (Ctrl, Shift, Alt, GUI)
Byte 1: 保留 (0x00)
Byte 2-7: 最多6个普通按键的键码
这就是为什么很多键盘标称6KRO------不是硬件不行,是USB报告格式只能装6个键码。
标准Boot Protocol报告描述符:
c
const uint8_t hid_report_desc_boot[] = {
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x06, // Usage (Keyboard)
0xA1, 0x01, // Collection (Application)
// 修饰键 (8个bit)
0x05, 0x07, // Usage Page (Key Codes)
0x19, 0xE0, // Usage Minimum (224) - Left Ctrl
0x29, 0xE7, // Usage Maximum (231) - Right GUI
0x15, 0x00, // Logical Minimum (0)
0x25, 0x01, // Logical Maximum (1)
0x75, 0x01, // Report Size (1)
0x95, 0x08, // Report Count (8)
0x81, 0x02, // Input (Data, Variable, Absolute)
// 保留字节
0x95, 0x01, // Report Count (1)
0x75, 0x08, // Report Size (8)
0x81, 0x01, // Input (Constant)
// 6个按键码
0x95, 0x06, // Report Count (6)
0x75, 0x08, // Report Size (8)
0x15, 0x00, // Logical Minimum (0)
0x25, 0x65, // Logical Maximum (101)
0x05, 0x07, // Usage Page (Key Codes)
0x19, 0x00, // Usage Minimum (0)
0x29, 0x65, // Usage Maximum (101)
0x81, 0x00, // Input (Data, Array)
0xC0 // End Collection
};
NKRO报告格式
要实现全键无冲,需要换一种报告格式。思路是:用bitmap代替键码数组,每个键占1bit。
c
// NKRO报告结构
// Byte 0: 修饰键 (8 bits)
// Byte 1-15: 120个按键的bitmap (实际用不了这么多)
const uint8_t hid_report_desc_nkro[] = {
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x06, // Usage (Keyboard)
0xA1, 0x01, // Collection (Application)
// Report ID (区分多个报告)
0x85, 0x01, // Report ID (1)
// 修饰键 (8 bits)
0x05, 0x07, // Usage Page (Key Codes)
0x19, 0xE0, // Usage Minimum (224)
0x29, 0xE7, // Usage Maximum (231)
0x15, 0x00, // Logical Minimum (0)
0x25, 0x01, // Logical Maximum (1)
0x75, 0x01, // Report Size (1)
0x95, 0x08, // Report Count (8)
0x81, 0x02, // Input (Data, Variable, Absolute)
// 普通按键 bitmap (120 bits = 15 bytes, 覆盖键码0-119)
0x05, 0x07, // Usage Page (Key Codes)
0x19, 0x00, // Usage Minimum (0)
0x29, 0x77, // Usage Maximum (119)
0x15, 0x00, // Logical Minimum (0)
0x25, 0x01, // Logical Maximum (1)
0x75, 0x01, // Report Size (1)
0x95, 0x78, // Report Count (120)
0x81, 0x02, // Input (Data, Variable, Absolute)
0xC0 // End Collection
};
这样一来,理论上可以同时报告120个按键------远超实际键盘的按键数。
USB报告发送
c
// NKRO报告缓冲区
typedef struct {
uint8_t report_id; // Report ID = 0x01
uint8_t modifiers; // 修饰键
uint8_t keys[15]; // 按键bitmap
} nkro_report_t;
nkro_report_t nkro_report = {.report_id = 0x01};
// 键码到bitmap位置的转换
void set_key_in_report(uint8_t keycode, uint8_t pressed)
{
if (keycode >= 0xE0 && keycode <= 0xE7) {
// 修饰键
uint8_t bit = keycode - 0xE0;
if (pressed) {
nkro_report.modifiers |= (1 << bit);
} else {
nkro_report.modifiers &= ~(1 << bit);
}
} else if (keycode < 120) {
// 普通按键
uint8_t byte_index = keycode / 8;
uint8_t bit_index = keycode % 8;
if (pressed) {
nkro_report.keys[byte_index] |= (1 << bit_index);
} else {
nkro_report.keys[byte_index] &= ~(1 << bit_index);
}
}
}
// 按键事件处理
void key_pressed(int row, int col)
{
uint8_t keycode = keymap[row][col]; // 从键位映射表获取键码
set_key_in_report(keycode, 1);
usb_hid_send_report(&nkro_report, sizeof(nkro_report));
}
void key_released(int row, int col)
{
uint8_t keycode = keymap[row][col];
set_key_in_report(keycode, 0);
usb_hid_send_report(&nkro_report, sizeof(nkro_report));
}
6KRO与NKRO的兼容
实际产品通常同时支持两种模式,因为有些老系统不认NKRO报告。可以通过组合键切换,或者自动检测。
c
typedef enum {
MODE_6KRO,
MODE_NKRO
} keyboard_mode_t;
keyboard_mode_t current_mode = MODE_6KRO; // 默认6KRO保证兼容
void toggle_nkro_mode(void)
{
if (current_mode == MODE_6KRO) {
current_mode = MODE_NKRO;
// 切换到NKRO报告描述符
usb_hid_set_report_desc(hid_report_desc_nkro, sizeof(hid_report_desc_nkro));
} else {
current_mode = MODE_6KRO;
usb_hid_set_report_desc(hid_report_desc_boot, sizeof(hid_report_desc_boot));
}
// 重新枚举USB设备
usb_reconnect();
}
完整实现示例
把上面的模块整合起来,给一个相对完整的实现框架:
c
/**
* keyboard_matrix.h - 键盘矩阵扫描与NKRO实现
*/
#ifndef __KEYBOARD_MATRIX_H
#define __KEYBOARD_MATRIX_H
#include <stdint.h>
#define ROW_NUM 8
#define COL_NUM 16
#define KEY_COUNT (ROW_NUM * COL_NUM)
#define DEBOUNCE_THRESHOLD 5
#define SCAN_INTERVAL_MS 1
// 按键状态
typedef struct {
uint8_t pressed; // 当前稳定状态
uint8_t debounce_cnt; // 去抖计数
} key_state_t;
// 初始化
void keyboard_init(void);
// 矩阵扫描(由定时器中断调用)
void keyboard_scan(void);
// 获取按键状态
uint8_t keyboard_is_pressed(uint8_t row, uint8_t col);
// 切换NKRO模式
void keyboard_toggle_nkro(void);
#endif
c
/**
* keyboard_matrix.c - 实现
*/
#include "keyboard_matrix.h"
#include "gpio.h"
#include "usb_hid.h"
#include "keymap.h"
// 引脚定义
static const uint8_t row_pins[ROW_NUM] = {PA0, PA1, PA2, PA3, PA4, PA5, PA6, PA7};
static const uint8_t col_pins[COL_NUM] = {PB0, PB1, PB2, PB3, PB4, PB5, PB6, PB7,
PC0, PC1, PC2, PC3, PC4, PC5, PC6, PC7};
// 按键状态矩阵
static key_state_t key_states[ROW_NUM][COL_NUM];
// 上次的报告,用于检测变化
static uint8_t last_report[17];
// NKRO报告
static struct {
uint8_t report_id;
uint8_t modifiers;
uint8_t keys[15];
} nkro_report = {.report_id = 0x01};
// 6KRO报告
static struct {
uint8_t modifiers;
uint8_t reserved;
uint8_t keys[6];
} boot_report;
static uint8_t nkro_enabled = 0;
// ============ 硬件初始化 ============
static void gpio_init_matrix(void)
{
// 行:推挽输出,默认高电平
for (int i = 0; i < ROW_NUM; i++) {
gpio_config(row_pins[i], GPIO_MODE_OUTPUT_PP);
gpio_write(row_pins[i], 1);
}
// 列:输入,内部上拉
for (int i = 0; i < COL_NUM; i++) {
gpio_config(col_pins[i], GPIO_MODE_INPUT_PULLUP);
}
}
void keyboard_init(void)
{
gpio_init_matrix();
// 清空状态
for (int r = 0; r < ROW_NUM; r++) {
for (int c = 0; c < COL_NUM; c++) {
key_states[r][c].pressed = 0;
key_states[r][c].debounce_cnt = 0;
}
}
}
// ============ 矩阵扫描 ============
static void update_key_state(uint8_t row, uint8_t col, uint8_t raw)
{
key_state_t *state = &key_states[row][col];
if (raw != state->pressed) {
state->debounce_cnt++;
if (state->debounce_cnt >= DEBOUNCE_THRESHOLD) {
state->pressed = raw;
state->debounce_cnt = 0;
// 更新报告
uint8_t keycode = keymap_get(row, col);
update_report(keycode, raw);
}
} else {
state->debounce_cnt = 0;
}
}
void keyboard_scan(void)
{
for (int row = 0; row < ROW_NUM; row++) {
// 拉低当前行
gpio_write(row_pins[row], 0);
// 等待电平稳定(关键!太短会读错)
delay_us(5);
// 读取所有列
for (int col = 0; col < COL_NUM; col++) {
uint8_t raw = (gpio_read(col_pins[col]) == 0) ? 1 : 0;
update_key_state(row, col, raw);
}
// 恢复当前行
gpio_write(row_pins[row], 1);
}
// 发送报告(如果有变化)
send_report_if_changed();
}
// ============ 报告生成与发送 ============
static void update_report(uint8_t keycode, uint8_t pressed)
{
// 修饰键处理
if (keycode >= 0xE0 && keycode <= 0xE7) {
uint8_t bit = keycode - 0xE0;
if (pressed) {
nkro_report.modifiers |= (1 << bit);
boot_report.modifiers |= (1 << bit);
} else {
nkro_report.modifiers &= ~(1 << bit);
boot_report.modifiers &= ~(1 << bit);
}
return;
}
// NKRO模式:bitmap
if (keycode < 120) {
uint8_t byte_idx = keycode / 8;
uint8_t bit_idx = keycode % 8;
if (pressed) {
nkro_report.keys[byte_idx] |= (1 << bit_idx);
} else {
nkro_report.keys[byte_idx] &= ~(1 << bit_idx);
}
}
// 6KRO模式:键码数组
if (pressed) {
// 找空位放入
for (int i = 0; i < 6; i++) {
if (boot_report.keys[i] == 0) {
boot_report.keys[i] = keycode;
break;
}
}
} else {
// 找到并移除
for (int i = 0; i < 6; i++) {
if (boot_report.keys[i] == keycode) {
boot_report.keys[i] = 0;
break;
}
}
}
}
static void send_report_if_changed(void)
{
uint8_t *report;
uint8_t size;
if (nkro_enabled) {
report = (uint8_t *)&nkro_report;
size = sizeof(nkro_report);
} else {
report = (uint8_t *)&boot_report;
size = sizeof(boot_report);
}
// 对比是否有变化
if (memcmp(last_report, report, size) != 0) {
usb_hid_send(report, size);
memcpy(last_report, report, size);
}
}
// ============ 模式切换 ============
void keyboard_toggle_nkro(void)
{
nkro_enabled = !nkro_enabled;
// 清空报告
memset(&nkro_report.keys, 0, sizeof(nkro_report.keys));
memset(&boot_report.keys, 0, sizeof(boot_report.keys));
nkro_report.modifiers = 0;
boot_report.modifiers = 0;
// 实际产品可能需要重新枚举USB
// usb_reconnect();
}
uint8_t keyboard_is_pressed(uint8_t row, uint8_t col)
{
if (row >= ROW_NUM || col >= COL_NUM) return 0;
return key_states[row][col].pressed;
}
调试技巧
做键盘项目时踩过的坑,分享几个调试经验:
1. 扫描延时不能省
行切换后必须加延时再读列,否则会读到上一行的残留电平。5-10us通常够用,如果矩阵大或者走线长,可能需要更长。
c
// 错误示例
gpio_write(row_pins[row], 0);
uint8_t val = gpio_read(col_pins[col]); // 可能读错!
// 正确示例
gpio_write(row_pins[row], 0);
delay_us(5); // 等电平稳定
uint8_t val = gpio_read(col_pins[col]);
2. 二极管方向别装反
装反了会导致整行或整列不工作。调试时可以用万用表二极管档测一下通断方向。
3. NKRO在BIOS中可能不工作
很多BIOS只支持Boot Protocol,进BIOS设置时发现键盘失灵,多半是这个原因。所以默认模式最好是6KRO。
4. 按键抖动比想象的严重
机械轴的抖动有时能持续10-20ms,去抖参数设太小会有连击问题。用示波器实测一下自己用的轴体最保险。
总结
实现一个全键无冲的键盘,需要软硬件配合:
| 层面 | 问题 | 解决方案 |
|---|---|---|
| 硬件矩阵 | 鬼键 | 每个按键串联二极管 |
| 扫描算法 | 抖动 | 软件去抖(计数器法) |
| USB协议 | 6键限制 | NKRO报告格式(bitmap) |
这套方案在我的项目上已经稳定跑了几个月,应付日常使用和游戏都没问题。代码可以直接拿去改,有问题评论区见。
参考资料:
- USB HID Usage Tables (usb.org)
- QMK Firmware 源码
- 《嵌入式系统设计与实践》