☰
STM32复合键鼠实战:一个HID接口同时实现键盘和鼠标
2026/10/16 7:21:14 网站建设 项目流程

简介:基于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 时钟树上必须抠出 48MHz:PLL 72MHz 再 1.5 分频的默认链路

STM32F103C8T6 这个芯片的 USB 是全速设备,主机端要求设备时钟是 48MHz±0.25%,这个精度直接决定枚举能不能成功。很多人第一步就翻车,是因为在 CubeMx 里只勾了 USB Device 没配时钟,生成代码后插上电脑没有任何反应,或者提示“未知 USB 设备”。

正确做法是先在 RCC 里打开 HSE(外部高速时钟),选 Crystal/Ceramic Resonator,也就是常见的 8MHz 无源晶振。时钟树配置顺序我一般这样走:HSE 选外部晶振 8MHz,PLL Source 选 HSE,PLLMUL 设为 x9,这样 SYSCLK 是 72MHz。然后 USB Prescaler 设为 1.5,USB 时钟 = 72MHz / 1.5 = 48MHz。这里有个容易误解的地方:不是直接把系统时钟设成 48MHz,而是让 PLLCLK 输出 72MHz,再由 USB 分频器砍到 48MHz。

时钟项典型值说明
HSE8MHz外部无源晶振
PLL SourceHSE走 PLL 倍频链路
PLLMULx9SYSCLK = 8 x 9 = 72MHz
SYSCLK72MHzF103 满主频
APB1 Prescaler/236MHz,注意别把外设总线压太高
USB Prescaler/1.5得到精确 48MHz USB 时钟

有开发者图省事直接用 HSI 内部时钟,如果 HSI 经过校准,短时间能枚举成功,但内部 RC 振荡器温漂大,连续跑十几分钟就可能出现随机掉线、重启后不复位的怪毛病。我一般不建议拿 HSI 做 USB,除非你的板子没法放晶振,这种情况宁可先校准再上机,也别裸跑。

2.2 中间件选型:HID 类而不是 CDC,端点大小和轮询间隔

时钟配好后,在 CubeMx 左侧 Connectivity 里勾选 USB,Mode 选 Device,也就是让 F103 作为设备端工作。然后在 Middleware 里打开 USB_DEVICE,Class 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 引脚固定是 PA11(USB_DM)和 PA12(USB_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 规范的解法是引入报告 ID(Report 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 配成 72MHz,USB 分频设为 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]是否固定是 1,mouseBuf[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 ID=2,X方向移动+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 Page(0xFF00),里面放 16 字节输入报告和 16 字节输出报告。这样同一根 USB 线上既能键鼠,又能跑自定义调试协议,上位机用 hidapi 往该接口写数组时,数组第一字节写 3,后面就是你私有协议的数据段。复合键鼠从此不只是键鼠,而是一个带控制通道的混合 HID 外设。

这套验证三板斧我是被坑出来的:以前改完描述符直接上机,出了事就怀疑硬件、怀疑时钟,后来强制自己每一步都过一遍抓包和脚本,问题基本五秒定位。从那以后我每改一次 HID 描述符,都会先在抓包工具里看枚举,再用 Python 发一遍两个 ID 的报告,最后才敢把设备接到真实主机上跑业务。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询