STM32复合键鼠实战:一个HID接口同时实现键盘和鼠标
简介基于STM32CubeMX生成的STM32F103C8T6 USB复合HID设备工程源码包面向需要完成键盘与鼠标双功能嵌入式产品的开发者重点解决复合设备枚举、多接口端点分配、HID报告描述符构造与上报逻辑等关键问题。压缩包共626个文件体积约7.58MB其中345个C源码、114个头文件、43个汇编文件及28个ICF链接配置文件包含可直接编译的MDK工程、全套HAL库驱动、USB初始化与中断处理代码以及工程配置相关的.ioc和.mxproject文件目录层级清晰便于定位和修改。已有3414人学习下载。通过工程示例可系统理解STM32CubeMX配置USB OTG FS为设备模式、添加HID类并生成底层代码的完整流程掌握键盘与鼠标报告描述符的编写方式以及复合设备在主机端被识别为两个独立HID接口的实现细节。适合已有STM32基础、希望进阶USB协议开发的学习者参考也可直接作为课程设计、毕设或小型人机交互项目的起点。1. 一个 interface 挂两个 HID 逻辑设备复合键鼠工程的正确打开方式标题里那个“个interface”我第一眼看到还以为是漏了一个字拆完工程才发现这个表述其实很贴切要做的是让同一根 USB 线上枚举出一个设备这个设备只有一个 HID 接口却能同时被主机认成键盘和鼠标。难点不在 CubeMx 配置界面而在 HID 报告描述符——它决定主机把你识别成什么。默认生成的 HID 例程只带一个键盘集合想让鼠标也出现在同一个接口里就得用 Report ID 把输入报告拆成两路。这篇文章适合手里有一块 STM32F103C8T6 最小系统板、想把它做成自定义键鼠外设、按键宏板或者工控操作台的开发者。不是玄学关键就那么几处。2. CubeMx 里的三步硬配置时钟、USB 中间件和 D/D- 上拉2.1 时钟树上必须抠出 48MHzPLL 72MHz 再 1.5 分频的默认链路STM32F103C8T6 这个芯片的 USB 是全速设备主机端要求设备时钟是 48MHz±0.25%这个精度直接决定枚举能不能成功。很多人第一步就翻车是因为在 CubeMx 里只勾了 USB Device 没配时钟生成代码后插上电脑没有任何反应或者提示“未知 USB 设备”。正确做法是先在 RCC 里打开 HSE外部高速时钟选 Crystal/Ceramic Resonator也就是常见的 8MHz 无源晶振。时钟树配置顺序我一般这样走HSE 选外部晶振 8MHzPLL Source 选 HSEPLLMUL 设为 x9这样 SYSCLK 是 72MHz。然后 USB Prescaler 设为 1.5USB 时钟 72MHz / 1.5 48MHz。这里有个容易误解的地方不是直接把系统时钟设成 48MHz而是让 PLLCLK 输出 72MHz再由 USB 分频器砍到 48MHz。时钟项典型值说明HSE8MHz外部无源晶振PLL SourceHSE走 PLL 倍频链路PLLMULx9SYSCLK 8 x 9 72MHzSYSCLK72MHzF103 满主频APB1 Prescaler/236MHz注意别把外设总线压太高USB Prescaler/1.5得到精确 48MHz USB 时钟有开发者图省事直接用 HSI 内部时钟如果 HSI 经过校准短时间能枚举成功但内部 RC 振荡器温漂大连续跑十几分钟就可能出现随机掉线、重启后不复位的怪毛病。我一般不建议拿 HSI 做 USB除非你的板子没法放晶振这种情况宁可先校准再上机也别裸跑。2.2 中间件选型HID 类而不是 CDC端点大小和轮询间隔时钟配好后在 CubeMx 左侧 Connectivity 里勾选 USBMode 选 Device也就是让 F103 作为设备端工作。然后在 Middleware 里打开 USB_DEVICEClass for FS IP 下拉选择 Human Interface Device。注意不要误选 Communication Device Class那是虚拟串口选错的话生成的工程没有 HID 描述符文件。CubeMx 自动生成的 USB 配置里HID 类默认占用一个中断输入端点地址是 0x81最大包大小 HID_EPIN_SIZE 默认 64 字节。另一个端点 0x01 作为中断输出端点用于接收主机下发的 LED 状态这类输出报告。端点描述符里的轮询间隔 INTERVAL 默认是 10ms这个值决定主机每隔多久来端点取一次数据键鼠都够用如果你想鼠标刷新率更高改成 1ms 也行代价是 CPU 中断频率变高、总线占用变大。我在下面代码部分会用 1ms 间隔演示但实际项目里建议先保持 10ms稳定再调。比较值得说的是中间件里那个 Composite Device 选项。我见过很多人在这一步顺手把 Composite 勾上结果生成的 USB 设备描述符里有多个接口枚举出来是一个 CDC 抽象串口加一个 HID 接口根本就不是我们想要的“一个 interface 两个 HID 逻辑设备”。这里记得保持默认让 HID 类只占一个接口后面复合全靠报告描述符实现。2.3 硬件最小系统PA11、PA12 与 1.5k 上拉的三处检查软件配完硬件上最容易把时间耗死的是 USB 引脚没有正确上拉。F103C8T6 的 USB 引脚固定是 PA11USB_DM和 PA12USB_DP不能随意映射。CubeMx 生成代码后会把这两个引脚自动配置为复用功能正常情况下不需要你手动操作但有一个外部条件是 CubeMx 管不着的全速 USB 设备要求在 D 引脚上有一个 1.5kΩ 电阻上拉到 3.3V主机通过检测 D 被拉高来识别这是一个全速设备。很多 F103 最小系统板已经板载了这颗电阻但有个别精简板子留了焊盘没焊这种情况下设备枚举会时有时无。我拆板时的检查顺序是先量 PA12 到 3.3V 之间有没有 1.5k 通路再量 PA11 有没有被拉到地或者接到别的外设最后确认板子上的 USB 座子 D-/D 没有接反。这三个检查做下来能排除掉至少一半的枚举失败问题。3. HID 报告描述符就是协议本体用 Report ID 拆出键盘和鼠标3.1 CubeMx 生成的默认描述符为什么只能当单一键盘CubeMx 生成工程后在 usbd_hid.c 里有一个HID_ReportDesc数组那里面默认是一段标准键盘报告描述符一个 Application Collection用途是 Keyboard里面定义 8 字节输入报告、1 字节输出报告对应 NumLock/CapsLock/ScrollLock 这几个 LED。这段描述符告诉主机我这个 HID 设备是一个键盘输入报告里第一个字节是修饰键第二个字节是保留位后面六个字节是普通按键键码。问题在于鼠标也是一个逻辑设备它想在这个接口里共存就必须在同一个报告描述符里增加第二个 Application Collection用途是 Mouse。可如果两个集合都发同样的报告格式主机无法区分这个输入报告到底是键盘状态还是鼠标位移。HID 规范的解法是引入报告 IDReport ID每个逻辑集合定义自己的 ID那么每一份输入报告的第一个字节就是所属集合的 ID主机靠它分流。这也是整个复合键鼠工程里最核心的一个概念。3.2 改写 usbd_hid_report_desc键盘集合加鼠标集合的完整数组直接替换usbd_hid.c里的HID_ReportDesc数组下面是完整可编译的描述符。键盘集合 Report ID 为 1鼠标集合 Report ID 为 2。__ALIGN_BEGIN static uint8_t HID_ReportDesc[HID_REPORT_DESC_SIZE] __ALIGN_END { /* 键盘顶层集合, Report ID 1 */ 0x05, 0x01, /* Usage Page (Generic Desktop) */ 0x09, 0x06, /* Usage (Keyboard) */ 0xA1, 0x01, /* Collection (Application) */ 0x85, 0x01, /* Report ID (1) */ 0x05, 0x07, /* Usage Page (Keyboard/Keypad) */ 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) */ 0x95, 0x01, /* Report Count (1) */ 0x75, 0x08, /* Report Size (8) */ 0x81, 0x01, /* Input (Constant) */ 0x95, 0x05, /* Report Count (5) */ 0x75, 0x01, /* Report Size (1) */ 0x05, 0x08, /* Usage Page (LEDs) */ 0x19, 0x01, /* Usage Minimum (1) */ 0x29, 0x05, /* Usage Maximum (5) */ 0x91, 0x02, /* Output (Data, Variable, Absolute) */ 0x95, 0x01, /* Report Count (1) */ 0x75, 0x03, /* Report Size (3) */ 0x91, 0x01, /* Output (Constant) */ 0xC0, /* End Collection */ /* 鼠标顶层集合, Report ID 2 */ 0x05, 0x01, /* Usage Page (Generic Desktop) */ 0x09, 0x02, /* Usage (Mouse) */ 0xA1, 0x01, /* Collection (Application) */ 0x85, 0x02, /* Report ID (2) */ 0x09, 0x01, /* Usage (Pointer) */ 0xA1, 0x00, /* Collection (Physical) */ 0x05, 0x09, /* Usage Page (Buttons) */ 0x19, 0x01, /* Usage Minimum (1) */ 0x29, 0x03, /* Usage Maximum (3) */ 0x15, 0x00, /* Logical Minimum (0) */ 0x25, 0x01, /* Logical Maximum (1) */ 0x75, 0x01, /* Report Size (1) */ 0x95, 0x03, /* Report Count (3) */ 0x81, 0x02, /* Input (Data, Variable, Absolute) */ 0x75, 0x01, /* Report Size (1) */ 0x95, 0x05, /* Report Count (5) */ 0x81, 0x01, /* Input (Constant) */ 0x05, 0x01, /* Usage Page (Generic Desktop) */ 0x09, 0x30, /* Usage (X) */ 0x09, 0x31, /* Usage (Y) */ 0x09, 0x38, /* Usage (Wheel) */ 0x15, 0x81, /* Logical Minimum (-127) */ 0x25, 0x7F, /* Logical Maximum (127) */ 0x75, 0x08, /* Report Size (8) */ 0x95, 0x03, /* Report Count (3) */ 0x81, 0x06, /* Input (Data, Variable, Relative) */ 0xC0, /* End Collection */ 0xC0 /* End Collection */ };这段代码的逻辑得拆开看键盘集合里先声明 Report ID 为 1然后定义 8 个修饰键位Ctrl、Shift、Alt、GUI 等作为绝对量每个占 1 bit这是标准键盘输入报告的前两个字节的位置。LED 输出部分用的是 Output 类型方向是从主机到设备用来点亮小键盘指示灯。鼠标集合里 Report ID 为 2按键部分占 3 bit 的绝对量X、Y、滚轮三个 8 bit 相对量所以鼠标输入报告长度是 5 字节含 Report ID。3.3 HID_REPORT_DESC_SIZE 同步改长度常被漏掉的第二步描述符数组改完很多人直接编译然后插上电脑发现设备管理器报“设备无法启动”或者“HID 描述符请求失败”。原因基本是同一个usbd_hid.h里的HID_REPORT_DESC_SIZE宏还保持默认的键盘描述符长度。主机请求描述符时设备按宏定义的长度回传如果长度与数组实际内容不一致主机解析到一半就把描述符掐断了。上面这段数组实际长度是 103 字节所以宏同步改成#define HID_REPORT_DESC_SIZE 103U这里有个更稳妥的做法直接用sizeof(HID_ReportDesc)替代手工数字这样即使以后在数组里加一个集合宏也不会失配。我每次改完描述符都会在代码里强制写一句静态断言防止下次改数组时忘记同步#if (sizeof(HID_ReportDesc) ! HID_REPORT_DESC_SIZE) #error HID Report Descriptor size mismatch #endif这个错误不会在编译期报出来但枚举阶段会非常隐蔽地失败值得花三十秒加上。4. 键码和鼠标位移怎么按时发出去SendReport 的调用时机与报告格式4.1 报告缓冲第一个字节是 Report ID不是修饰键描述符改对了设备管理器里能看到一个 HID 设备且能识别成键鼠但如果你直接调用USBD_HID_SendReport把键码发出去主机收到的可能是一串乱码。原因往往是发送缓冲区的第一个字节没有放 Report ID。标准 HID 报告在存在 Report ID 时输入报告的第一个字节必须是对应的 ID也就是键盘报告第一个字节固定写 0x01鼠标报告第一个字节固定写 0x02。很多人从默认键盘例程改过来发送缓冲区第一个字节放修饰键主机拿到后按 Report ID 修饰键值去解析接口自然就错了。uint8_t kbdBuf[8] {0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; uint8_t mouseBuf[5] {0x02, 0x00, 0x00, 0x00, 0x00}; void SendKey(uint8_t keycode) { memset(kbdBuf[2], 0x00, 6); // 清零保留位和6个键码区 kbdBuf[2] 0x00; // 保留字节固定为0 kbdBuf[3] keycode; // 第一个普通键位 USBD_HID_SendReport(hUsbDeviceFS, kbdBuf, 8); } void MoveMouse(int8_t dx, int8_t dy) { mouseBuf[1] 0x00; // 按键0表示没有按下 mouseBuf[2] (uint8_t)dx; // X方向相对位移 mouseBuf[3] (uint8_t)dy; // Y方向相对位移 mouseBuf[4] 0x00; // 滚轮 USBD_HID_SendReport(hUsbDeviceFS, mouseBuf, 5); }这两个函数是复合键鼠工程的主干。发送键盘报告时len 参数必须与描述符定义的输入报告长度一致8 字节鼠标是 5 字节。如果你用同一个缓冲区发不同 ID 的报告一定要在填充内容前整个清零否则上一个报告的残留数据会被一起发出去主机端的键码列表里会凭空多出几个键。4.2 从回调里接输出报告键盘 LED 和自定义命令怎么区分USB HID 设备除了往主机发输入报告还要能接收主机下发的输出报告典型就是键盘的 NumLock、CapsLock 指示状态。在 CubeMx 生成的代码里输出报告到达设备后由HID_OutEvent这样的回调函数处理具体函数名看你的 CubeMx 版本但逻辑是一样的先读报告第一个字节区分 Report ID再按对应集合的格式解析。static int8_t HID_OutEvent(uint8_t *buf, uint16_t len) { if (buf[0] 0x01) { SetKbdLed(buf[1]); // 键盘LED输出只取第一字节 } else if (buf[0] 0x02) { HandleCustomCommand(buf[1]); // 预留后续扩展命令通道 } return USBD_OK; }这里的buf[0]就是 Report ID。很多人把键盘输出报告做成 8 字节长结果接收回调里直接对buf[0]做 LED 开关解析被主机发来的其他数据干扰。正确理解是键盘集合的输出报告在描述符里只有 1 字节有效数据5 个 LED 位加 3 个常量位所以回调里只用buf[1]。若你以后再加第三个 Report ID 做自定义命令这里的else if就是天然的扩展点。4.3 USB 中断里直接发报告的卡死风险用状态机接管发送时机还有一个实战中常见的坑在按键中断服务函数里直接调用USBD_HID_SendReport如果上一次发送还没完成这个函数返回USBD_BUSY此时你直接丢弃这帧数据鼠标移动就会偶发跳变。连续高频调用还会让 USB 端点缓冲区处于半忙状态表现为每隔一段时间设备没反应。我一般的做法是设置一个发送待处理标志把真正发送的动作放到主循环里做volatile uint8_t usbSendPending 0; uint8_t nextKbdBuf[8]; void KeyScan_ISR(void) { PrepareKeyReport(nextKbdBuf); usbSendPending 1; // 只置标志不在中断里发送 } void main_loop(void) { if (usbSendPending) { USBD_StatusTypeDef st USBD_HID_SendReport(hUsbDeviceFS, nextKbdBuf, 8); if (st USBD_OK) { usbSendPending 0; } } }USBD_HID_SendReport不是绝对禁止在中断里调用但 F103 的 USB 栈在端点 DMA 模式没有开启时发送期间不能被打断重组数据。用标志位把发送挪到主循环虽然多了几十微秒延迟但换来发送时序可控对键鼠这种低频设备完全够用。如果鼠标要求高刷新率就把发送间隔降到 1ms在主循环里用系统 tick 定时发送同样不要在一个循环里连发两帧。5. 复合键鼠避坑清单枚举失败到随机断连的五条血泪记录5.1 枚举阶段设备完全不出现、报代码 10、反复重枚举现象一插上 USB 后主机没有任何反应设备管理器里连未知设备都没有。打开 CubeMx 的时钟树看一眼发现 USB Prescaler 没有设置USB 时钟不是 48MHz。解决按前面的表格把 PLL 配成 72MHzUSB 分频设为 1.5。如果用的是内部 HSI先校准再上机或者直接换外部晶振。现象二设备出现在设备管理器里但显示“该设备无法启动代码 10”。这时候拿 HID 描述符工具抓取设备报告能看到报告描述符长度与数组内容不一致。解决把HID_REPORT_DESC_SIZE改成sizeof(HID_ReportDesc)重新插拔。这类问题在改过描述符后特别容易犯尤其是增加集合后忘了同步宏。现象三BIOS 界面下键鼠完全无反应进了 Windows 才正常。这个不算配置错误BIOS 固件对 USB 键鼠走的是 Boot Protocol只认固定的 Boot Keyboard 和 Boot Mouse 描述符不解析带 Report ID 的复合描述符。解决不去硬调进系统后交给操作系统标准 HID 驱动解析即可。5.2 已枚举但行为异常误识别、乱码、掉点现象四按键按下后主机端键盘日志显示一连串 0x85 0x01或者鼠标点击变成了键盘输入。原因几乎都是发送缓冲区第一个字节不是 Report ID而是按键修饰符。解决检查kbdBuf[0]是否固定是 1mouseBuf[0]是否固定是 2。在使用memset清缓冲区时注意不要把下标 0 也清掉清零操作从kbdBuf[1]开始最安全。现象五键鼠都能识别但鼠标快速移动时掉点严重甚至路由都晃不动。原因轮询间隔设成了 1ms同时主循环频繁调用SendReport端点缓冲区来不及被主机取走返回USBD_BUSY后当前帧被丢弃。解决把端点描述符的 INTERVAL 改回 10ms鼠标移动数据在应用层做一次“最近的一次位移覆盖上一次未发送的位移”的合并不要逐像素点发送。这个坑最容易在游戏鼠标项目里碰到建议一开始保守调参。再补一条很多人会碰到的配置坑CubeMx 中间件里勾选了 Composite Device 后枚举出来的设备不是一个 HID 接口两个逻辑设备而是一个 CDC 虚拟串口加一个 HID 接口。解决把 Composite Device 关掉保持 HID 类的单一接口复合功能只靠报告描述符承担。这个选项在 CubeMx 某些版本里默认是关闭的但改过配置的人偶尔会误开。6. 实战验证三板斧Bus Hound 抓枚举、Python 分读两个 Report ID、再加一条自定义命令通道6.1 Bus Hound 抓枚举看报告描述符的顶层集合数量代码烧进去后先用总线抓包工具过一遍枚举。抓包时重点看两处第一处是设备返回的报告描述符长度应该与本机宏定义一致第二处是报告描述符内容里有两个 Usage Page(0x01)、两个 Usage 节点分别对应 Keyboard 和 Mouse。如果只看到一个集合说明第二个集合没写进数组或者描述符长度被截断。抓包确认这一步能省下后面所有“为什么主机不认”的排查时间。6.2 Python hidapi 验证两个 Report ID 的数据分流import hid dev hid.device(0x0483, 0x5750) # VID/PID 改为自己设备的值 dev.open(0x0483, 0x5750) # 发送鼠标报告Report ID2X方向移动5 dev.write([0x02, 0x00, 0x05, 0x00, 0x00])这段脚本验证鼠标报告是否真正从复合 HID 接口发出去。write的第一个参数是端点数据实际总线上的 HID 数据流里第一个字节就是 Report ID。如果写进去的 ID 与描述符定义不一致hidapi 会直接报错主机端看不到任何鼠标移动。把脚本里的 Report ID 改成 1再发 8 字节键盘缓冲即可同时验证键盘链路。6.3 加第三个 Report ID 做自定义命令通道描述符里再加一个顶层集合Report ID 用 3集合用途定义为 Vendor Usage Page0xFF00里面放 16 字节输入报告和 16 字节输出报告。这样同一根 USB 线上既能键鼠又能跑自定义调试协议上位机用 hidapi 往该接口写数组时数组第一字节写 3后面就是你私有协议的数据段。复合键鼠从此不只是键鼠而是一个带控制通道的混合 HID 外设。这套验证三板斧我是被坑出来的以前改完描述符直接上机出了事就怀疑硬件、怀疑时钟后来强制自己每一步都过一遍抓包和脚本问题基本五秒定位。从那以后我每改一次 HID 描述符都会先在抓包工具里看枚举再用 Python 发一遍两个 ID 的报告最后才敢把设备接到真实主机上跑业务。希望帮到你。本文还有配套的精品资源点击获取