🔥 机械键盘固件开发从入门到精通:HWKeyboard模块全解析
作为一名嵌入式开发老司机,今天带大家拆解一个完整的机械键盘固件代码。即使你是单片机小白,看完这篇教程也能轻松理解机械键盘的工作原理,甚至自己动手复刻一个!
🚀 项目整体概览
这个hw_keyboard.cpp
模块实现了一个完整的机械键盘固件,基于STM32单片机开发,主要功能包括:
- 按键矩阵扫描与状态读取
- 机械按键消抖处理
- 键位映射与HID协议数据生成
- RGB灯效控制(支持WS2812B灯带)
- 特殊功能键(Fn键、触控条)处理
整个固件就像一个智能中转站,把物理按键的按下抬起动作,转换成电脑能理解的键盘信号,同时控制炫酷的RGB灯光效果。
🧩 核心模块拆解
一、延时函数:精确掌控时间
inline void DelayUs(uint32_t _us)
{
for (int i = 0; i < _us; i++) // 外层循环,每次循环代表1微秒
for (int j = 0; j < 8; j++) // 内层循环,调整延时时长(不同芯片需调整)
__NOP(); // 空操作,单纯耗时
}
白话解析:
- 这是一个微秒级的延时函数,精确控制程序暂停的时间
- 双重循环结构,外层控制延迟多少微秒,内层循环次数需要根据芯片主频调整
__NOP()
是"No Operation"的缩写,就是让CPU空转一个周期- 为什么需要它?键盘需要精确的时间控制,比如消抖、LED控制都需要
二、按键扫描模块:监听键盘上的每一次敲击
uint8_t* HWKeyboard::ScanKeyStates()
{
memset(spiBuffer, 0xFF, IO_NUMBER / 8 + 1); // 将spiBuffer缓冲区全部置为0xFF,准备接收数据
PL_GPIO_Port->BSRR = PL_Pin; // 设置锁存引脚为高电平,锁存当前按键状态
spiHandle->pRxBuffPtr = (uint8_t*) spiBuffer; // 设置SPI接收缓冲区指针
spiHandle->RxXferCount = IO_NUMBER / 8 + 1; // 设置SPI接收字节数
__HAL_SPI_ENABLE(spiHandle); // 使能SPI外设
while (spiHandle->RxXferCount > 0U) // 循环直到所有数据接收完毕
{
if (__HAL_SPI_GET_FLAG(spiHandle, SPI_FLAG_RXNE)) // 检查SPI接收缓冲区非空标志
{
(*(uint8_t*) spiHandle->pRxBuffPtr) = *(__IO uint8_t*) &spiHandle->Instance->DR; // 从SPI数据寄存器读取数据到缓冲区
spiHandle->pRxBuffPtr += sizeof(uint8_t); // 指针后移
spiHandle->RxXferCount--; // 剩余接收字节数减一
}
}
__HAL_SPI_DISABLE(spiHandle); // 禁用SPI外设
PL_GPIO_Port->BRR = PL_Pin; // 设置锁存引脚为低电平,完成采样
return scanBuffer; // 返回扫描缓冲区指针
}
白话解析:
- 这个函数就像键盘的"耳朵",不断监听按键是否被按下
PL_Pin
是一个特殊引脚,拉高时会把所有按键的状态"拍照"保存- SPI通信就像快递系统:
spiBuffer
是装数据的袋子pRxBuffPtr
是指向袋子的手指RxXferCount
是需要接收的数据数量
- 数据接收完成后,返回的
scanBuffer
里存着所有按键的当前状态(1表示未按下,0表示按下)
三、按键消抖模块:解决机械按键的"手抖"问题
void HWKeyboard::ApplyDebounceFilter(uint32_t _filterTimeUs)
{
memcpy(debounceBuffer, spiBuffer, IO_NUMBER / 8 + 1); // 备份当前SPI缓冲区到消抖缓冲区
DelayUs(_filterTimeUs); // 延时一段时间,等待抖动消除
ScanKeyStates(); // 再次扫描按键状态
uint8_t mask;
for (int i = 0; i < IO_NUMBER / 8 + 1; i++) // 遍历所有字节
{
mask = debounceBuffer[i] ^ spiBuffer[i]; // 计算两次扫描的不同位
spiBuffer[i] |= mask; // 将有变化的位强制置为1(消除抖动影响)
}
}
白话解析:
- 机械按键按下时会像"手抖"一样产生短暂的多次通断,这就是抖动
- 消抖处理就像拍照时的防抖功能:
- 先保存第一次拍的照片(按键状态)
- 等一小会儿(
_filterTimeUs
微秒) - 再拍一张照片(再次扫描按键)
- 比较两张照片,如果有不同,就认为是"抖动",修正这些差异
- 这个过程很重要,否则键盘会误判你按了多次键
四、键位映射模块:把物理按键变成电脑认识的键
uint8_t* HWKeyboard::Remap(uint8_t _layer)
{
int16_t index, bitIndex; // 定义索引变量
memset(remapBuffer, 0, IO_NUMBER / 8); // 清空重映射缓冲区
for (int16_t i = 0; i < IO_NUMBER / 8; i++) // 遍历每个字节
{
for (int16_t j = 0; j < 8; j++) // 遍历每个bit
{
index = (int16_t) (keyMap[0][i * 8 + j] / 8); // 计算当前物理按键在scanBuffer中的字节索引
bitIndex = (int16_t) (keyMap[0][i * 8 + j] % 8); // 计算当前物理按键在该字节中的位索引
if (scanBuffer[index] & (0x80 >> bitIndex)) // 检查该物理按键是否被按下(高电平为未按下,低电平为按下)
remapBuffer[i] |= 0x80 >> j; // 如果按下,则在remapBuffer中对应位置标记为1
}
remapBuffer[i] = ~remapBuffer[i]; // 取反,转换为"按下为1,未按下为0"
}
memset(hidBuffer, 0, KEY_REPORT_SIZE); // 清空HID报告缓冲区
int i = 0, j = 0;
while (8 * i + j < IO_NUMBER - 6) // 遍历所有可用按键(排除最后6个保留位)
{
for (j = 0; j < 8; j++)
{
index = (int16_t) (keyMap[_layer][i * 8 + j] / 8 + 1); // 计算映射后按键在hidBuffer中的字节索引(+1跳过修饰键)
bitIndex = (int16_t) (keyMap[_layer][i * 8 + j] % 8); // 计算映射后按键在该字节中的位索引
if (bitIndex < 0)
{
index -= 1; // 位索引为负时,向前借一字节
bitIndex += 8;
} else if (index > 100)
continue; // 越界保护
if (remapBuffer[i] & (0x80 >> j)) // 如果该按键被按下
hidBuffer[index + 1] |= 1 << (bitIndex); // 在hidBuffer中对应位置标记为1(+1跳过Report-ID)
}
i++;
j = 0;
}
return hidBuffer; // 返回HID报告缓冲区
}
白话解析:
- 这个模块就像一个"翻译官",把物理按键的位置翻译成电脑认识的键码
- 过程分两大步:
- 第一步:把原始扫描结果(
scanBuffer
)转换成中间格式(remapBuffer
)- 这一步是把"物理按键位置"变成"逻辑按键位置"
- 使用
keyMap[0]
查表,找到每个物理按键对应的逻辑位置
- 第二步:把中间格式(
remapBuffer
)转换成USB-HID标准格式(hidBuffer
)- 这一步是把"逻辑按键位置"变成"标准键码"
- 使用
keyMap[_layer]
查表,支持多层键位映射(比如Fn组合键)
- 第一步:把原始扫描结果(
- 最终生成的
hidBuffer
就是可以直接发送给电脑的USB-HID报告
五、RGB灯效控制模块:让键盘"发光发热"
void HWKeyboard::SetRgbBufferByID(uint8_t _keyId, HWKeyboard::Color_t _color, float _brightness)
{
// 防止全0导致ws2812b协议错误
if (_color.b < 1)_color.b = 1; // 蓝色分量最小为1,避免全0
for (int i = 0; i < 8; i++) // 遍历8位
{
rgbBuffer[_keyId][0][i] =
((uint8_t) ((float) _color.g * _brightness) >> brightnessPreDiv) & (0x80 >> i) ? WS_HIGH : WS_LOW; // 绿色分量
rgbBuffer[_keyId][1][i] =
((uint8_t) ((float) _color.r * _brightness) >> brightnessPreDiv) & (0x80 >> i) ? WS_HIGH : WS_LOW; // 红色分量
rgbBuffer[_keyId][2][i] =
((uint8_t) ((float) _color.b * _brightness) >> brightnessPreDiv) & (0x80 >> i) ? WS_HIGH : WS_LOW; // 蓝色分量
}
}
void HWKeyboard::SyncLights()
{
while (isRgbTxBusy); // 等待上一次DMA传输完成
isRgbTxBusy = true; // 标记DMA忙
HAL_SPI_Transmit_DMA(&hspi2, (uint8_t*) rgbBuffer, LED_NUMBER * 3 * 8); // 通过DMA发送RGB数据
while (isRgbTxBusy); // 等待DMA完成
isRgbTxBusy = true; // 再次标记DMA忙
HAL_SPI_Transmit_DMA(&hspi2, wsCommit, 64); // 发送ws2812b协议结尾信号
}
白话解析:
- 这个模块负责控制键盘上的RGB灯,让键盘变得炫酷
SetRgbBufferByID
函数像是一支神奇的画笔:_keyId
:选择要涂色的灯珠_color
:选择RGB颜色(红、绿、蓝三原色)_brightness
:控制颜色的亮度(0.0-1.0)
- WS2812B是一种智能LED灯珠,需要特殊的信号格式:
- 每个灯珠需要24位数据(8位绿+8位红+8位蓝)
WS_HIGH
和WS_LOW
是两种不同的电平时序,用来表示1和0- 所有灯珠串联在一起,数据像多米诺骨牌一样传递
SyncLights
函数使用DMA(直接内存访问)技术快速发送数据:- DMA可以在不占用CPU的情况下传输数据
- 发送完所有LED数据后,还要发送一个结束信号(
wsCommit
)
六、特殊功能键处理模块:Fn键和触控条
bool HWKeyboard::FnPressed()
{
return remapBuffer[9] & 0x02; // 检查remapBuffer第9字节的第2位(Fn键状态)
}
uint8_t HWKeyboard::GetTouchBarState(uint8_t _id)
{
uint8_t tmp = (remapBuffer[10] & 0b00000001) << 5 | // 取remapBuffer第10字节的各个位,重新排列组合
(remapBuffer[10] & 0b00000010) << 3 |
(remapBuffer[10] & 0b00000100) << 1 |
(remapBuffer[10] & 0b00001000) >> 1 |
(remapBuffer[10] & 0b00010000) >> 3 |
(remapBuffer[10] & 0b00100000) >> 5;
return _id == 0 ? tmp : (tmp & (1 << (_id - 1))); // 返回全部状态或指定触控条状态
}
白话解析:
- 这部分处理键盘上的特殊功能键:Fn键和触控条
FnPressed
函数检查Fn键是否按下:- 简单查看
remapBuffer
中的特定位,1表示按下,0表示未按下 - Fn键在这个键盘中位于第9字节的第2位(从0开始计数)
- 简单查看
GetTouchBarState
函数读取触控条状态:- 触控条有多个触摸点,每个点对应
remapBuffer[10]
的一位 - 函数进行位重排,使触摸点按从左到右的顺序排列
- 参数
_id
为0时返回所有触摸点状态,否则返回特定触摸点状态
- 触控条有多个触摸点,每个点对应
七、HID报告处理模块:电脑与键盘的"对话"
uint8_t* HWKeyboard::GetHidReportBuffer(uint8_t _reportId)
{
switch (_reportId)
{
case 1:
hidBuffer[0] = 1; // 设置报告ID为1
return hidBuffer; // 返回主报告缓冲区
case 2:
hidBuffer[KEY_REPORT_SIZE] = 2; // 设置报告ID为2
return hidBuffer + KEY_REPORT_SIZE; // 返回备用报告缓冲区
default:
return hidBuffer; // 默认返回主报告缓冲区
}
}
bool HWKeyboard::KeyPressed(KeyCode_t _key)
{
int index, bitIndex;
if (_key < RESERVED) // 判断是否为保留键
{
index = _key / 8; // 计算字节索引
bitIndex = (_key + 8) % 8; // 计算位索引
} else
{
index = _key / 8 + 1; // 计算字节索引(跳过修饰键)
bitIndex = _key % 8; // 计算位索引
}
return hidBuffer[index + 1] & (1 << bitIndex); // 检查对应位是否为1(按下)
}
void HWKeyboard::Press(HWKeyboard::KeyCode_t _key)
{
int index, bitIndex;
if (_key < RESERVED)
{
index = _key / 8;
bitIndex = (_key + 8) % 8;
} else
{
index = _key / 8 + 1;
bitIndex = _key % 8;
}
hidBuffer[index + 1] |= (1 << bitIndex); // 设置对应位为1(按下)
}
void HWKeyboard::Release(HWKeyboard::KeyCode_t _key)
{
int index, bitIndex;
if (_key < RESERVED)
{
index = _key / 8;
bitIndex = (_key + 8) % 8;
} else
{
index = _key / 8 + 1;
bitIndex = _key % 8;
}
hidBuffer[index + 1] &= ~(1 << bitIndex); // 清除对应位(释放)
}
白话解析:
- 这部分处理键盘的HID报告,这是键盘与电脑通信的"官方语言"
GetHidReportBuffer
函数准备不同类型的HID报告:- 报告ID 1:标准键盘报告
- 报告ID 2:扩展功能报告(如多媒体键、自定义功能键)
KeyPressed
函数检查某个键是否被按下:- 通过计算键码在HID报告中的位置(字节索引和位索引)
- 特殊处理小于
RESERVED
的键(可能是修饰键如Ctrl、Shift等)
Press
和Release
函数模拟按键按下和释放:- 直接修改HID报告缓冲区中对应键的状态位
- 这允许程序在不实际按键的情况下发送按键信号
📊 完整工作流程
一个按键从按下到被电脑识别的全过程:
硬件初始化:
- 设置SPI通信参数
- 配置GPIO引脚
- 初始化RGB灯为熄灭状态
按键扫描循环:
while(1) { ScanKeyStates(); // 扫描按键矩阵,读取原始状态 ApplyDebounceFilter(5000); // 应用5ms消抖滤波 uint8_t layer = FnPressed() ? 1 : 0; // 根据Fn键状态选择映射层 Remap(layer); // 重映射键位,生成HID报告 // 发送HID报告给电脑 uint8_t* report = GetHidReportBuffer(1); USB_SendData(report, KEY_REPORT_SIZE); // 更新RGB灯效 UpdateRgbEffects(); SyncLights(); HAL_Delay(10); // 10ms扫描周期 }
关键环节解析:
- 按键扫描:使用SPI读取74HC165移位寄存器中的按键状态
- 消抖处理:比较两次扫描结果,忽略抖动引起的差异
- 重映射处理:物理按键位置→逻辑按键位置→标准HID键码
- RGB控制:设置每个LED的RGB值,通过DMA高速传输数据
- USB通信:定期发送HID报告给电脑,告知当前按键状态
💡 小白开发指南
开发环境搭建
硬件准备:
- STM32F1/F4系列单片机(如STM32F103C8T6)
- 74HC165移位寄存器(扩展输入IO)
- WS2812B RGB灯珠
- 机械键盘轴体和轴座
- PCB电路板
软件工具:
- STM32CubeIDE或Keil MDK(代码编写和编译)
- STM32CubeMX(单片机外设配置)
- PCB设计软件(如立创EDA、Altium Designer)
从零开始的实现步骤
项目结构设计:
- main.c // 主程序入口 - hw_keyboard.h // HWKeyboard类声明 - hw_keyboard.cpp // HWKeyboard类实现 - usb_device.c // USB设备配置 - key_map.h // 键位映射表
关键硬件连接:
STM32 SPI1_MISO <- 74HC165 QH (串行数据输出) STM32 SPI1_CLK -> 74HC165 CLK (时钟信号) STM32 GPIO_PL -> 74HC165 PL (锁存信号) STM32 SPI2 -> WS2812B数据线(通过电平转换)
代码实现步骤:
- 实现
ScanKeyStates
函数,通过SPI读取按键状态 - 添加
ApplyDebounceFilter
消抖处理 - 实现
Remap
函数,完成键位映射 - 添加RGB灯效控制函数
- 最后实现USB通信部分
- 实现
测试调试方法:
- 分阶段测试:先测试按键扫描,再测试灯效控制
- 使用串口打印中间变量进行调试
- 使用示波器观察SPI和WS2812B信号波形
📚 进阶知识点
1. 如何定制键位映射
键位映射是通过keyMap
二维数组实现的:
// 示例键位映射表(简化版)
const uint16_t keyMap[2][64] = {
// Layer 0: 标准层
{
KEY_ESC, KEY_1, KEY_2, KEY_3, /* 更多键... */
},
// Layer 1: Fn层
{
KEY_GRAVE, KEY_F1, KEY_F2, KEY_F3, /* 更多键... */
}
};
定制步骤:
- 测量物理按键矩阵位置
- 确定每个位置对应的标准键码(参考USB HID标准)
- 填写到
keyMap
数组中
2. RGB灯效编程技巧
// 彩虹灯效示例
void RainbowEffect() {
static uint8_t hue = 0;
for(int i = 0; i < LED_NUMBER; i++) {
// 创建彩虹色相滚动效果
Color_t color = HsvToRgb(hue + i * 255 / LED_NUMBER, 255, 255);
keyboard.SetRgbBufferByID(i, color, 0.5f); // 亮度50%
}
keyboard.SyncLights();
hue++; // 颜色循环移动
}
// HSV转RGB颜色转换
Color_t HsvToRgb(uint8_t h, uint8_t s, uint8_t v) {
Color_t rgb = {0, 0, 0};
// 转换算法实现
// ...
return rgb;
}
3. 性能优化技巧
扫描频率优化:
- 降低扫描频率可节省CPU资源
- 但过低会导致输入延迟
- 推荐扫描频率:100Hz(10ms周期)
DMA使用:
- 使用DMA传输RGB数据,释放CPU资源
- 使用中断而非轮询等待DMA完成
内存优化:
- 使用位操作减少内存使用
- 共用缓冲区减少RAM占用
🎯 实战项目:DIY全彩RGB机械键盘
完成这个教程后,你可以尝试以下项目:
简易版:61键迷你键盘
- 标准QWERTY布局
- 单色背光
- 两层键位映射
进阶版:64键配置RGB
- 增加方向键
- 全RGB背光
- 多种灯效模式
大师版:分体式人体工学键盘
- 左右分离设计
- 每键RGB可寻址
- 支持无线蓝牙连接
通过本教程的学习,你已经掌握了机械键盘固件开发的核心技术。从简单的按键扫描到复杂的RGB控制,从底层硬件操作到高层次的用户体验,一步步揭开了机械键盘的神秘面纱。希望这份教程能帮助你开启DIY键盘的奇妙旅程!