基于STM32的USB键盘DIY:从硬件选型到HID协议实战
1. 项目缘起为什么选择STM32自制USB键盘几年前我还在用着一把老旧的薄膜键盘手感松散偶尔还会出现按键失灵。当时市面上主流的机械键盘要么价格不菲要么轴体手感不合心意。作为一个嵌入式开发者一个念头冒了出来既然我能用单片机控制LED、驱动电机那能不能自己做一把完全符合个人习惯的键盘呢这个想法就像一颗种子最终生根发芽催生了这个项目——基于STM32的USB键盘。你可能觉得现在键盘选择这么多何必大费周章自己动手这正是DIY的魅力所在。市售键盘的键位布局、轴体类型、灯光效果都是固定的而自制键盘意味着你可以拥有独一无二的“客制化”体验。你可以将ESC键放在右手边可以为FPS游戏设计一个专用的宏按键区域甚至可以把键盘做成异形贴合你的手掌弧度。更重要的是整个过程是对USB HID协议、STM32外设编程、电路设计的一次绝佳实践其收获远不止于得到一把键盘。STM32系列单片机特别是经典的STM32F103系列如本项目可能用到的STM32F103RCT6因其丰富的外设尤其是全速USB设备接口、强大的社区支持和极高的性价比成为嵌入式DIY项目的首选。它内置的USB控制器可以完美模拟成一个标准的USB HID人机接口设备键盘让电脑将其识别为即插即用的输入设备无需安装任何额外驱动。这为我们实现一个功能完整、性能稳定的自制键盘提供了坚实的技术基础。本系列教程将采用“保姆级”的讲解方式从零开始手把手带你完成硬件选型、电路设计、固件编程到最终调试的全过程。即使你只有基础的C语言和单片机知识也能跟着一步步实现。这是系列的第一篇我们将聚焦于项目的整体规划、核心硬件选型以及开发环境的搭建为后续的实战编码打下坚实基础。2. 核心硬件选型与电路设计思路一把键盘的核心硬件无外乎三部分主控MCU、按键矩阵、以及连接它们的电路。我们的目标是设计一个稳定、可靠且易于扩展的系统。2.1 主控芯片为什么是STM32F103RCT6在众多STM32型号中我最终选择了STM32F103RCT6作为本项目的主控。这个选择是基于以下几个关键考量内置全速USB 2.0设备控制器这是最关键的一点。STM32F103系列芯片内置了USB外设支持全速12 Mbps模式足以满足键盘这种低速HID设备的需求。我们无需外接复杂的USB协议芯片如CH340、FT232等直接用芯片的USB DP/DM引脚连接即可大大简化了硬件设计和成本。足够的GPIO与内存RCT6拥有51个GPIO口这对于构建一个按键矩阵来说绰绰有余。即使是一个104键的全尺寸键盘通过矩阵扫描也只需要不到20个IO口。它拥有256KB的Flash和48KB的RAM足以容纳复杂的键盘固件包括USB协议栈、按键扫描、宏功能、RGB灯效控制等。极高的性价比与生态作为“单片机界的常青树”STM32F103系列价格低廉资料极其丰富。无论是标准外设库SPL还是HAL库都有成熟的USB HID例程可供参考。市面上大量的开发板如“最小系统板”也基于此芯片采购和调试都非常方便。其他实用外设除了USB它还有多个定时器可用于按键消抖、扫描、PWM调光、SPI/I2C可连接OLED屏幕、EEPROM存储配置、ADC可做模拟摇杆等为键盘的附加功能如屏幕、旋钮、摇杆留出了充足的升级空间。注意STM32F103的USB和JTAG/SWD调试接口的某些引脚是复用的如PA13, PA14, PA15。在设计电路时如果需要保留调试功能务必查阅数据手册处理好复用关系通常需要断开USB连接才能进行SWD调试反之亦然。2.2 按键矩阵设计从原理到布线键盘上的每一个键如果都单独占用一个GPIO口那IO资源将很快耗尽。因此所有键盘都采用“矩阵扫描”法。我们将所有按键排列成行Row和列Column的网格每个按键位于某一行和某一列的交叉点上。工作原理初始化所有行为输出模式并输出高电平所有列为输入模式并启用内部上拉电阻这样默认读到的就是高电平。扫描过程循环将每一行依次设置为低电平拉低这一行其他行保持高电平。在每一行被拉低时快速读取所有列的电平状态。如果某个交叉点的按键被按下当它所在的行被拉低时电流会从被拉低的行经过按键流向该列。由于列是输入上拉此时该列的电平就会被拉低。因此如果检测到某一列为低电平就说明该列与当前被拉低的行的交叉点处的按键被按下了。通过“当前行号”和“检测到低电平的列号”就能唯一确定是哪个键被按下。设计实例以4x4矩阵为例 假设我们设计一个16键的小键盘。选择4个GPIO作为行线ROW0-ROW3设置为推挽输出。选择4个GPIO作为列线COL0-COL3设置为输入上拉。将16个按键焊接在行线与列线的交叉点上。 这样我们用8个IO口就控制了16个按键。对于一个104键键盘通常采用8x16或类似的矩阵只需要24个IO口。电路设计要点二极管隔离这是专业键盘设计的关键必须在每个按键上串联一个开关二极管如1N4148阴极接行线阳极接列线。它的作用是防止“鬼键”现象。当同时按下三个或以上特定位置的按键时没有二极管的矩阵会产生错误的按键信号。二极管确保了电流只能从行流向列不能反向从而彻底解决了鬼键问题。对于自制键盘尤其是游戏键盘强烈建议加上二极管。消抖处理按键是机械触点按下和弹起时会产生数毫秒的抖动会被误判为多次按下。消抖主要在软件中实现如延时检测或状态机但硬件上可以在按键两端并联一个小电容如0.1uF来滤除部分毛刺不过这不是必须的软件消抖更常用。走线规划在绘制PCB或使用洞洞板焊接时规划好行线和列线的走线使其整齐有序便于检查和调试。对于大面积矩阵可以考虑设计成多层PCB。2.3 其他外围电路与物料清单除了核心的MCU和按键我们还需要一些外围电路来让整个系统工作起来。电源电路STM32F103的供电电压是3.3V。USB接口提供5V电源。我们需要一个LDO低压差线性稳压器如AMS1117-3.3将USB的5V转换为稳定的3.3V给MCU和整个系统供电。输入和输出端需要并联滤波电容如10uF和0.1uF。USB接口采用最常见的Micro-USB或Type-C接口。连接非常简单USB的VBUS接5V电源输入D-和D分别接STM32的PA11DM和PA12DP引脚GND接系统地。在D和D-数据线上通常建议串联一个22欧姆的电阻并靠近芯片放置用于阻抗匹配提升信号质量。对于全速USB在D线上还需要一个1.5kΩ的上拉电阻连接到3.3VSTM32内部通常可软件控制此上拉。调试接口强烈建议引出SWD调试接口SWDIO, SWCLK, GND, 3.3V。这将是你下载程序和调试代码的生命线。可以使用标准的4针SWD接口或兼容ST-Link的排针。复位与启动电路一个简单的RC复位电路10kΩ电阻上拉到3.3V0.1uF电容到地连接到NRST引脚。BOOT0引脚通过一个10kΩ电阻下拉到地设置为从主Flash启动。晶体振荡器STM32F103需要外部高速晶振通常为8MHz来产生系统时钟。USB模块对时钟精度有一定要求必须使用外部晶振不能仅用内部RC振荡器。同时还需一个32.768kHz的低速晶振可选用于RTC等。基础物料清单BOM示例MCUSTM32F103RCT6或兼容的最小系统板 x1机械轴体如Cherry MX Gateron Kailh等 x N根据键数键帽 x N套二极管1N4148 x N与键数相同USB接口Micro-USB或Type-C母座 x1LDO稳压芯片AMS1117-3.3 x1晶振8MHz x1 32.768kHz x1可选电容10uF电解电容 x2 0.1uF (104) 陶瓷电容 x10电阻22欧姆 x2 1.5kΩ x1 10kΩ x若干PCB或洞洞板 x1连接线、排针、焊锡等耗材若干3. 开发环境搭建与工程初始化工欲善其事必先利其器。一个顺手的开发环境能极大提升效率。这里我推荐使用STM32CubeIDE它是ST官方推出的免费集成开发环境集成了代码编辑、编译、调试和STM32CubeMX图形化配置工具对新手非常友好。3.1 安装STM32CubeIDE与必备工具下载与安装前往ST官网下载对应你操作系统Windows/macOS/Linux的STM32CubeIDE安装包。安装过程基本是“下一步”到底。安装USB驱动为了让电脑能识别ST-Link调试器需要安装驱动。如果你使用官方的ST-Link/V2通常CubeIDE会自带或提示安装。如果使用常见的DAP-Link或J-Link则需要安装对应的驱动。一个通用的方法是安装Zadig工具它可以为很多USB设备安装WinUSB或libusb驱动特别适合那些在设备管理器里显示为未知设备的调试器。硬件连接将你的ST-Link或其他调试器通过SWD接口连接到目标板你的STM32芯片。ST-Link的3.3V、SWDIO、SWCLK、GND分别对应连接。然后通过USB线将ST-Link连接到电脑。3.2 使用STM32CubeMX创建USB HID键盘工程STM32CubeMX是一个图形化的引脚和中间件配置工具能自动生成初始化代码是我们项目的起点。新建项目打开CubeIDE选择“Start new STM32 project”。在芯片选择器中输入“STM32F103RC”然后选择“STM32F103RCTx”。点击“Next”。系统核心SYS配置在“Pinout Configuration”标签页左侧找到“System Core” - “SYS”。将“Debug”设置为“Serial Wire”。这会将PA13和PA14用于SWD调试而不是JTAG可以释放出PA15等引脚用作普通IO。时钟RCC配置找到“System Core” - “RCC”。将“High Speed Clock (HSE)”设置为“Crystal/Ceramic Resonator”。这告诉芯片我们使用外部8MHz晶振。USB外设配置在左侧“Connectivity”分类下找到“USB”。将“Mode”设置为“Device (FS)”即全速设备模式。此时软件会自动将PA11和PA12配置为USB_DM和USB_DP。你可以在中间的芯片图形上看到这两个引脚变绿。中间件Middleware配置在左侧找到“Middleware”分类下的“USB_DEVICE”。在“Class For FS IP”下拉菜单中选择“Human Interface Device (HID)”。这是最关键的一步将USB设备类设置为HID。下方会多出一个“Configuration”标签。点击进入。在“HID”页面你可以看到系统已经为我们预定义了一个“Custom HID”配置。我们需要修改它。将“Use custom HID class”保持勾选。点击“Device Descriptor”修改设备描述符。将“Product ID (PID)”可以自定义一个比如0x5710避免与现有设备冲突。“Manufacturer String”和“Product String”可以改成你自己的信息比如“MyDIYKeyboard”。点击“HID”部分下的“Report Descriptor”。这里定义了HID设备向主机报告数据的格式。我们需要将其替换成标准键盘的报告描述符。你可以先清空现有内容从ST的HID例程或USB官方文档中复制一个标准的“Boot Keyboard”报告描述符过来。这是一个字节数组描述了按键、修饰键Ctrl, Shift等的报告格式。这是USB键盘的灵魂我们将在下一篇详细剖析。GPIO配置按键矩阵示例假设我们设计了一个4x4矩阵。我们需要配置4个引脚为输出行4个引脚为输入上拉列。在芯片图上找到你想用的引脚例如PB0-PB3作为行ROW0-ROW3PB4-PB7作为列COL0-COL3。点击PB0选择“GPIO_Output”。然后在右侧的“GPIO”设置中将输出电平初始值设为“High”高电平。同理配置PB1, PB2, PB3。点击PB4选择“GPIO_Input”。在右侧“GPIO”设置中将“GPIO Pull-up/Pull-down”设置为“Pull-up”上拉。同理配置PB5, PB6, PB7。时钟树配置点击顶部的“Clock Configuration”标签。这里系统通常已经根据HSE8MHz自动计算出了配置。你需要确保最终给USB模块的时钟是48MHz。STM32的USB模块要求精确的48MHz时钟。系统通常会自动配置PLL将8MHz倍频到72MHz系统时钟然后通过一个分频器给USB提供48MHz。检查“USB Clock Mux”是否显示为48MHz。如果不是你需要手动调整PLL倍频系数和分频设置直到USB时钟为48MHz。生成工程代码点击顶部菜单栏的“Project” - “Generate Code”或按快捷键AltK。设置工程名称、路径、选择“Toolchain/IDE”为“STM32CubeIDE”。代码结构选择默认即可。点击“Generate Code”。CubeIDE会自动创建并打开一个新的工程。3.3 工程结构解析与用户代码区域代码生成后你会看到工程浏览器里有一系列文件夹。核心文件如下Core/Inc/和Core/Src/存放主函数main.c、系统初始化、时钟配置等核心文件。用户代码应添加在/* USER CODE BEGIN */和/* USER CODE END */注释对之间这样当你用CubeMX重新生成代码时这些代码不会被覆盖。USB_DEVICE/App/和USB_DEVICE/Target/存放USB设备相关的代码。其中usbd_hid.c和usbd_hid.h是HID类的核心我们后续需要修改其中的发送报告函数。Drivers/STM32 HAL库的驱动文件。千万不要手动修改Core/Src里那些没有USER CODE注释的文件以及Drivers/下的文件。现在如果你编译并下载这个初始工程到板子上将板子通过USB连接到电脑电脑应该能识别到一个新的USB HID设备在设备管理器的“人体学输入设备”下可以看到虽然它还不能发送任何按键数据。这说明我们的USB底层配置和硬件连接基本成功了。4. USB HID协议与键盘报告描述符初探要让电脑真正把我们的设备识别为键盘并响应按键我们必须深入理解USB HID协议尤其是“报告描述符”。这部分内容有点枯燥但它是实现键盘功能的理论基石理解了它后面编程就水到渠成了。4.1 USB HID通信的基本模型USB HID设备的通信是基于“报告”的。主机电脑通过控制传输获取设备的描述符通过中断传输来收发报告数据。描述符告诉主机“我是什么设备”、“我有哪些能力”。包括设备描述符、配置描述符、接口描述符、端点描述符以及最重要的报告描述符。报告是实际传输的数据包包含了具体的输入如按键值、输出如LED状态或特性数据。端点USB通信的逻辑通道。HID设备必须至少有一个中断输入端点IN Endpoint用于向主机发送报告如按键按下。通常还会有一个中断输出端点OUT Endpoint用于接收主机发来的报告如控制键盘的NumLock, CapsLock, ScrollLock指示灯。4.2 解剖键盘报告描述符报告描述符用一种紧凑的、类似汇编的语言描述了报告数据的格式和含义。它定义了报告中有哪些字段如按键1、按键2、…、修饰键每个字段占多少位它们的逻辑范围如0-101表示按键编号和用途。一个最简单的“Boot Keyboard”报告描述符兼容BIOS的键盘如下所示十六进制数组0x05, 0x01, // Usage Page (Generic Desktop) 0x09, 0x06, // Usage (Keyboard) 0xa1, 0x01, // Collection (Application) 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,Var,Abs) // 这8个bit代表8个修饰键Ctrl, Shift, Alt, GUI 0x95, 0x01, // Report Count (1) 0x75, 0x08, // Report Size (8) 0x81, 0x01, // Input (Const,Array,Abs) // 保留字节 0x95, 0x05, // Report Count (5) 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,Abs) // 这5个字节代表最多6个实际报告是5个这里有个细节同时按下的普通键 0x95, 0x01, // Report Count (1) 0x75, 0x08, // Report Size (8) 0x15, 0x00, // Logical Minimum (0) 0x25, 0x01, // Logical Maximum (1) 0x05, 0x08, // Usage Page (LEDs) 0x19, 0x01, // Usage Minimum (1) 0x29, 0x05, // Usage Maximum (5) 0x91, 0x02, // Output (Data,Var,Abs) // 这5个bit代表键盘指示灯状态 0x95, 0x01, // Report Count (1) 0x75, 0x03, // Report Size (3) 0x91, 0x01, // Output (Const,Array,Abs) // 输出报告填充位 0xc0 // End Collection逐段解读0x05, 0x01声明后续的用途Usage属于“通用桌面控制”页面。0x09, 0x06声明这个集合Collection的用途是“键盘”。0xa1, 0x01开始一个应用集合Application Collection。接下来定义输入报告的第一个字段8个1-bit的变量。它们对应8个修饰键左Ctrl, 左Shift, 左Alt, 左GUI, 右Ctrl, 右Shift, 右Alt, 右GUI。每个bit为1表示按下0表示释放。逻辑范围是0-1。一个8-bit的常量数组保留字节必须存在但值固定。定义输入报告的第二个字段5个8-bit的数组。每个字节代表一个普通按键的“Usage ID”。Usage ID 0表示没有按键1-101对应键盘上不同的键可在HID Usage Tables文档中查到例如4是‘a’和‘A’5是‘b’和‘B’。逻辑范围是0-101。这里Report Count是5但标准Boot Keyboard报告实际上有6个按键槽位。这是一个常见的混淆点。实际上这个描述符定义的报告长度是8 bits修饰键 8 bits保留 5*8 bits按键 48 bits 6字节。但描述符里只定义了5个按键槽位主机和设备的实现通常会理解并处理6个字节的报告。更严谨的做法是定义6个。我们后续可以修正。定义输出报告5个1-bit的变量对应Num Lock, Caps Lock, Scroll Lock, Compose, Kana指示灯。主机通过这个报告控制键盘上的LED。0xc0结束集合。这个描述符定义了一个包含8字节的报告1字节修饰键1字节保留6字节按键码共可报告6个同时按下的键。以及一个包含1字节实际只用低5位的输出报告用于LED。4.3 在CubeMX中配置报告描述符理解了报告描述符后我们需要把它放到我们的工程里。回到CubeMX的“USB_DEVICE”配置界面在“HID”的“Report Descriptor”框中将上述描述符数组可能需要根据你的理解调整按键槽位数量粘贴进去。或者你可以先使用一个简单的描述符等工程框架搭好后再在代码中修改。一个更清晰、定义了6个按键槽位的报告描述符示例0x05, 0x01, // Usage Page (Generic Desktop) 0x09, 0x06, // Usage (Keyboard) 0xA1, 0x01, // Collection (Application) // 修饰键字节 0x05, 0x07, // Usage Page (Key Codes) 0x19, 0xE0, // Usage Minimum (Left Control) 0x29, 0xE7, // Usage Maximum (Right GUI) 0x15, 0x00, // Logical Minimum (0) 0x25, 0x01, // Logical Maximum (1) 0x75, 0x01, // Report Size (1 bit per field) 0x95, 0x08, // Report Count (8 fields, one for each modifier) 0x81, 0x02, // Input (Data, Variable, Absolute) // Modifier byte // 保留字节 0x95, 0x01, // Report Count (1) 0x75, 0x08, // Report Size (8 bits) 0x81, 0x01, // Input (Constant) // Reserved byte // 按键码字节 (6 keys) 0x95, 0x06, // Report Count (6) // 这里改为6 0x75, 0x08, // Report Size (8 bits per key) 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) // Key codes (6 bytes) // LED输出报告 0x05, 0x08, // Usage Page (LEDs) 0x19, 0x01, // Usage Minimum (Num Lock) 0x29, 0x05, // Usage Maximum (Kana) 0x95, 0x05, // Report Count (5) 0x75, 0x01, // Report Size (1) 0x91, 0x02, // Output (Data, Variable, Absolute) // LED report // 输出报告填充位 0x95, 0x01, // Report Count (1) 0x75, 0x03, // Report Size (3 bits) 0x91, 0x01, // Output (Constant) // Padding 0xC0, // End Collection将这个数组粘贴到CubeMX的报告描述符配置框中重新生成代码。这样USB协议栈就会使用这个描述符来与主机通信。下一步我们就要编写代码根据按键矩阵的扫描结果来填充这个报告并发送给主机。