简介:这套基于STM32CubeMX生成、面向STM32F103C8T6的USB复合键盘鼠标工程资源,适合需要实现HID多接口设备的嵌入式工程师与电子竞赛选手,用于解决单个USB设备同时模拟键盘和鼠标时的配置与编码难题。压缩包共626个文件,体量仅7.58MB,其中包含345个C源码、114个头文件、HID报告描述符相关汇编文件、Keil工程配置文件以及编译生成的axf镜像和map映射文件,从工程源码到固件产物均可直接查阅。资源已有3414人下载学习,工程内完整保留了USB初始化、HID报告描述符、端点中断处理等关键代码,同时附带STM32CubeMX的.ioc工程文件,便于逆向理解配置流程或基于HAL库进行二次修改。对于不熟悉USB协议栈的开发者,通过对照源码可以快速掌握复合设备枚举、报告描述符编写和键盘鼠标事件上报的实现套路,避免从零查阅手册。这套工程尤其适合大学生嵌入式实验、产品原型验证以及各类电子设计竞赛的备赛参考,可显著缩短开发周期。
1. STM32CubeMX 下 STM32F103C8T6 的 USB 复合键盘鼠标:别被“两个 Interface”卡住
用 STM32CubeMX 生成 USB HID 键盘或鼠标,大多数人都能跑通,但如果你想做的是一个 USB 接口同时识别成键盘+鼠标的复合设备,问题就来了:CubeMX 默认只生成一个 HID 接口,哪怕你在配置里把“支持 HID”勾上,出来的也是单设备。真正要让主机枚举出两个 Interface,得自己动手改描述符和驱动代码。这篇笔记是我在某跨平台系统项目的输入设备模块里实测过一遍的完整过程,围绕 STM32F103C8T6 的 USB 全速外设,讲清楚怎么从 CubeMX 生成工程,到改出复合 HID 设备,再按下发送键让键盘和鼠标同时工作。整个过程包含时钟配置、描述符修改、端点处理逻辑,以及几个特别隐蔽的坑。适合刚接触 STM32 USB 开发、被枚举失败折腾过一整天、或者想在低成本芯片上做 HID 复合外设的嵌入式开发者,拿去做自动化输入模拟、产线测试、多媒体控制器都行得通。
2. CubeMX 初始化:把默认的 USB HID 工程搭成能改的版本
2.1 时钟树与 USB 外设的开启细节
STM32F103C8T6 的 USB 外设要求 48MHz 的时钟输入,但这个芯片没有高速 PLL 直出 48MHz,你得从 PLL 里分出来。打开 CubeMX,先把 RCC 的 HSE 设为 Crystal/Ceramic Resonator,然后在 Clock Configuration 页面把 PLL Source 选为 HSE,PLLM 根据外部晶振频率设置——如果你板子上的晶振是 8MHz,PLLM=1(不分频),PLLN=9,PLLP=4,这样主频跑到 72MHz,USB 时钟源选 PLLCLK 再除以 1.5 得到 48MHz。这里有个常见做法是直接在 CubeMX 里选 USB 外设,软件会自动把 USB Prescaler 配成 1.5 分频,但如果你用的是无源晶振,还得确认 USB 外设的 DP 上拉是否由芯片内部完成。
很多人在这里忽略一个关键点:STM32F103 的 USB 连接是靠 DP 引脚上的 1.5kΩ 上拉电阻实现的,CubeMX 生成的代码里,USB 初始化函数会调用HAL_PCD_Start()来触发上拉,从而让主机检测到设备连接。如果你在硬件上加过外部上拉电阻,代码里这步往往会造成重复上拉,现象就是设备能枚举但容易掉线。我一般直接把外部上拉去掉,全交给芯片内部逻辑。
配置完成后生成工程,注意 Toolchain 选 MDK-ARM 或者 EWARM 都行,但优先选 MDK,因为调试 USB 时你多半要开 RTT 或者串口打印,MDK 的调试器配合度更好。生成后先编译一次确保基础工程能过,再动手改代码。
2.2 开启 USB 设备 HID 类并理解生成的目录结构
在 CubeMX 的 USB_DEVICE 配置里,Class for FS IP 选 Communication Device Class (CDC) 还是 Human Interface Device (HID)?我们这里选 HID。默认生成的代码里,usbd_hid.c会带一份标准的键盘报告描述符,那是单设备的,后面我们要替换掉。
生成工程的目录结构里,关键是这几个文件:
| 文件 | 作用 |
|---|---|
usbd_desc.c | 设备描述符、字符串描述符 |
usbd_hid.c | HID 类驱动,包含报告描述符和端点回调 |
usbd_hid.h | 接口定义和报告描述符大小宏 |
usbd_conf.c | USB 中断优先级、内存池配置 |
默认生成的 HID 报告描述符里,USBD_HID_ReportDesc数组里面只有键盘的报表,端点用的是 0x81(IN 端点,中断传输,64 字节或者 8 字节)。我们要做的复合设备,需要在同一个配置里塞进去两个 HID 接口,每个接口有各自独立的报告描述符和端点地址。Stm32 的 USB 设备库对多接口的支持方式是:配置描述符里追加第二个接口描述符和对应的 HID 描述符,同时端点都要重新规划。
这里有个容易误解的地方,复合 HID 设备其实可以在一个配置里放两个 interface,也可以放一个 interface 下两个 alternate setting,前者才叫复合设备,后者只能算一个逻辑设备。键盘+鼠标这种场景标准做法是前者。后面修改描述符时,你要清楚自己改的是配置描述符,不是设备描述符。
3. 描述符修改:让主机枚举出两个独立的 HID 接口
3.1 配置描述符的结构重组
先看 CubeMX 生成的配置描述符,在usbd_hid.c里有一份USBD_HID_CfgDesc数组。默认只有一个接口(bNumInterfaces=1)、一个端点。我们要把它改成 bNumInterfaces=2,同时接口 0 做键盘,接口 1 做鼠标。具体可以参考下面这种描述符布局:
__ALIGN_BEGIN static uint8_t USBD_HID_CfgDesc[] __ALIGN_END = { 0x09, // bLength 0x02, // bDescriptorType: Configuration 0x29, 0x00, // wTotalLength: 41 bytes 0x02, // bNumInterfaces: 2 0x01, // bConfigurationValue 0x00, // iConfiguration 0xC0, // bmAttributes: Self-powered 0x32, // MaxPower: 100mA // Interface 0: Keyboard 0x09, // bLength 0x04, // bDescriptorType: Interface 0x00, // bInterfaceNumber: 0 0x00, // bAlternateSetting 0x01, // bNumEndpoints: 1 0x03, // bInterfaceClass: HID 0x01, // bInterfaceSubClass: Boot 0x01, // bInterfaceProtocol: Keyboard 0x00, // iInterface 0x09, // HID descriptor 0x21, 0x11, 0x01, // HID 1.11 0x00, 0x01, // NumDescriptors 0x22, // Report descriptor type 0x3F, 0x00, // Report descriptor length: 63 bytes 0x07, // Endpoint descriptor 0x05, 0x81, // EP1 IN 0x03, // Interrupt 0x08, 0x00, // MaxPacketSize: 8 0x0A, // Interval: 10ms // Interface 1: Mouse 0x09, // bLength 0x04, // bDescriptorType: Interface 0x01, // bInterfaceNumber: 1 0x00, // bAlternateSetting 0x01, // bNumEndpoints: 1 0x03, // bInterfaceClass: HID 0x01, // bInterfaceSubClass: Boot 0x02, // bInterfaceProtocol: Mouse 0x00, // iInterface 0x09, // HID descriptor 0x21, 0x11, 0x01, // HID 1.11 0x00, 0x01, // NumDescriptors 0x22, // Report descriptor type 0x34, 0x00, // Report descriptor length: 52 bytes 0x07, // Endpoint descriptor 0x05, 0x82, // EP2 IN 0x03, // Interrupt 0x08, 0x00, // MaxPacketSize: 8 0x0A, // Interval: 10ms };代码逻辑上,这里最关键的是wTotalLength必须跟实际数组长度一致,否则主机会认为描述符截断。另一个关键点是每个接口的 HID 描述符里,Report descriptor 长度要跟你后面塞进去的键盘/鼠标报告描述符实际长度一致,这个经常会漏改。默认库里面所有接口共用一份USBD_HID_ReportDesc,你要拆成两份分别定义,并且接口 0 和接口 1 的 bInterfaceProtocol 分别设为 1(键盘)和 2(鼠标),这样主机才能正确识别并加载驱动。
常见的做法是保留原USBD_HID_ReportDesc给键盘用,另外新建一个USBD_MOUSE_ReportDesc给鼠标用。然后在配置描述符里,把接口 0 的 Report Descriptor 长度改成键盘那份数组的 sizeof,接口 1 的长度改成鼠标那份的 sizeof。修改描述符后的第一个坑就是:主机枚举时如果发现描述符长度不符,会直接卡在“USB Device 无法识别”,没有任何提示,你只能靠 Bus Hound 抓枚举失败时中断在哪里。
3.2 键盘和鼠标的报告描述符怎么拆
报告描述符决定了你在应用层发数据的格式。键盘标准报告也就是 8 字节,第一位是修饰键(Ctrl、Shift、Alt、Win),第二位是保留,后面六位是普通按键。鼠标标准报告是 4 字节:按钮、X 偏移、Y 偏移、滚轮。给复合设备定义两份报告描述符时,建议加上 Report ID 来区分数据来源,这样应用层在一个 IN 端点里同时传键盘和鼠标数据时,主机不会混淆。
__ALIGN_BEGIN static uint8_t USBD_HID_ReportDesc[] __ALIGN_END = { 0x05, 0x01, // Usage Page: Generic Desktop 0x09, 0x06, // Usage: Keyboard 0xA1, 0x01, // Collection: Application 0x85, 0x01, // Report ID: 1 0x05, 0x07, // Usage Page: Keyboard 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 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, 0x06, // Report Count: 6 0x75, 0x08, // Report Size: 8 0x05, 0x07, // Usage Page: Keyboard 0x19, 0x00, // Usage Minimum: 0 0x29, 0x65, // Usage Maximum: 101 0x81, 0x00, // Input: Data, Array 0xC0 // End Collection }; __ALIGN_BEGIN static uint8_t USBD_MOUSE_ReportDesc[] __ALIGN_END = { 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: Button 0x19, 0x01, // Usage Minimum: Button 1 0x29, 0x03, // Usage Maximum: Button 3 0x15, 0x00, // Logical Minimum: 0 0x25, 0x01, // Logical Maximum: 1 0x95, 0x03, // Report Count: 3 0x75, 0x01, // Report Size: 1 0x81, 0x02, // Input: Data, Variable, Absolute 0x95, 0x01, // Report Count: 1 0x75, 0x05, // Report Size: 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,键盘数据第一个字节固定填 0x01,鼠标数据第一个字节固定填 0x02。这样即便协议栈把两份描述符合并成一个入口,你在发送时也能明确区分。代码上,宏USBD_HID_ReportDescSize对应的值要改成键盘那份的大小,另外鼠标描述符没有对应的宏,我一般把两份数组的长度宏分别命名为KEYBOARD_REPORT_DESC_SIZE和MOUSE_REPORT_DESC_SIZE,在描述符数组或者头文件里直接写死。
4. 驱动层改造:让 EP1 和 EP2 各走各的数据通道
4.1 端点地址和发送接口的对应关系
改完描述符后,你会发现自己面对的问题变成了:协议栈的USBD_HID_DataOut和USBD_HID_DataIn回调只认一个端点。STM32 USB 设备库默认把hUSBD_HID_Device的所有收发操作都绑定到第一个 HID 接口的端点上,第二个接口的数据通道压根没有建立。
好在 STM32F103 的 USB 外设本身支持多端点,我们需要在usbd_hid.c里把第二个接口的端点也加入端点描述的查询表。这里有一个很隐蔽的坑:usbd_hid.c里的端点地址是通过PCD_EP_TYPE_INTR类型逐个注册的,你需要在HID_Init函数里手动增加第二个端点的初始化,否则就算描述符里写了 0x82,主机枚举后也发不了中断传输。
static uint8_t HID_Init(USBD_HandleTypeDef *pdev, uint8_t cfgidx) { // 第一个接口的端点0x81 USBD_LL_OpenEP(pdev, HID_EPIN_ADDR, USBD_EP_TYPE_INTR, HID_EPIN_SIZE); pdev->pData = &hid_cb; // 第二个接口的端点0x82 if(((USBD_HID_HandleTypeDef*)pdev->pData)->Init != NULL) { USBD_LL_OpenEP(pdev, HID_EPIN2_ADDR, USBD_EP_TYPE_INTR, HID_EPIN_SIZE); } return USBD_OK; }上面这段逻辑里,HID_EPIN2_ADDR要在头文件里定义为0x82,HID_EPIN_SIZE保持 0x08。不是说你只把这个函数加上就完事,HID_DataIn回调里得判断是哪个端点触发的,再决定把hUSBD_HID_Device->RxBuffer里的数据往哪里搬。如果不管端点直接发送,你就会发现主机收到了错位的数据——比如鼠标动一下,键盘跟着打字。
4.2 发送函数与 Report ID 的分派逻辑
我在自己的实现里,把发送键盘数据和鼠标数据的函数拆成两个,但底层共用同一个协议栈发送接口。习惯做法是先把带 Report ID 的数据组好,再调用USBD_HID_SendReport。这里USBD_HID_SendReport原型是uint8_t USBD_HID_SendReport(USBD_HandleTypeDef *pdev, uint8_t *report, uint16_t len),它内部只认第一个端点,所以鼠标的数据要自己映射到第二个端点去。
uint8_t USBD_HID_Mouse_SendReport(USBD_HandleTypeDef *pdev, uint8_t *report, uint16_t len) { if(pdev->dev_state == USBD_STATE_CONFIGURED) { if(HID_EP2_BUSY == 0) { USBD_LL_Transmit(pdev, HID_EPIN2_ADDR, report, len); return USBD_OK; } } return USBD_FAIL; }这里有个习惯:如果上一个鼠标报告还没发送完成,这时又来一个报告,直接丢弃会丢操作手感,所以我一般把上一次的 Report ID 暂存起来,USBD_HID_GetState返回失败就等待下一个 1ms 定时器周期重发。键盘那边可以直接用协议栈自带的USBD_HID_SendReport,因为它内部绑定了 0x81 端点。
对应地,HID_DataOut回调中对端点做分流处理时,中断状态寄存器读出来的端点号要和 0x82 比较。实际调试中,我经常发现两个端点同时在传数据时,如果发送间隔太短,会出现第二个端点的数据吞掉第二个端点的中断状态,表现为主机鼠标指针抽搐。
5. 避坑记录:枚举失败、错包和供电玄学
5.1 现象:设备插入后主机一直显示“未知设备”
原因:最常见的是配置描述符里wTotalLength和实际长度不一致。我最初在修改描述符时,把接口数量从 1 改成了 2,但长度只加了接口描述符的长度,忘了补上 HID 描述符和端点描述符的增量,导致主机解析出截断的描述符。
解决:用 USB 分析工具抓枚举包,看主机请求配置描述符时返回的字节数是多少,再和数组sizeof(USBD_HID_CfgDesc)对比。注意 HID 描述符每个接口要多出 9 字节,端点描述符每个要多出 7 字节,接口描述符本身 9 字节。两个接口比一个接口总共多出 9+9+7=25 字节,如果只加了 9 字节,肯定会出问题。
5.2 现象:枚举成功,但键盘和鼠标在系统里只有一个能用
原因:报告描述符里没有加 Report ID,导致主机把两个 interface 的输入报告混在一起解析。比如先把 mouse 的按钮字节当成了 keyboard 的 modifier,结果按键完全乱套。
解决:键盘和鼠标的 Report Descriptor 都加上Report ID项,前面给的示例代码里已经包含了。这是标准做法,USB 规范里多个 HID 接口共用一个配置时,必须通过 Report ID 区分输入报告。
5.3 现象:高频率发送鼠标数据时,设备响应越来越迟钝
原因:STM32F103 的 USB 发送是单缓冲的,上一个 IN 事务还没完成,下一个USBD_LL_Transmit就把数据覆盖了。键盘用 10ms 间隔没事,鼠标如果用 1ms 轮询,很容易撞车。
解决:发送前检查上一个端点的 busy 状态,或者用双缓冲。在 HID_Mouse_SendReport 里我直接判断 busy 标志,如果没发完就返回失败,上层定时器下个周期再补发。另外把鼠标端点描述符里的 Interval 改成 1(对应 1ms)比较合理。
5.4 现象:把代码下载到板子后 USB 完全无反应,供电不良?
原因:最玄学但也最常发生的是板子供电不足。STM32F103C8T6 的 USB 外设启动瞬间电流需求比预期大,劣质 USB 线或者直接从 3.3V LDO 取电时,会出现芯片反复上电复位,现象就是插入时没有任何枚举动作。
解决:USB 插座到芯片的 VBUS 和 GND 之间要加 10μF 的胆电容,并且 VBUS 分压电阻网络必须稳定。我用某开发板实测时,换成粗一点的 USB 线后问题消失,说明确实是线阻的问题。这个坑不是代码问题,但排错成本最高,你检查顺序应该是供电、线缆、Debug 口、代码。
5.5 现象:使用无晶振时钟(HIS/RC 振荡)时枚举时好时坏
原因:STM32F103 的 USB 需要精准 48MHz,如果用内部 RC 振荡器,精度达不到 USB 规范要求的 ±0.25%,主机可能误码率飙升。我一开始为了让 PCB 省一个晶振位置,用内部 RC 跑,结果十个板子里有两三个无法稳定枚举。
解决:这个场景下毫无办法,只能上外部晶振。我在头文件里直接把HSE_VALUE改成8000000U并且不能走 PLL 直连。另外启动时可以判断时钟是否稳定,HAL_RCC_GetFlagStatus(RCC_FLAG_HSERDY)返回 SET 才真正进入 USB 初始化,否则等待超时。
6. 一个调试技巧:在枚举回调里埋串口标记,5 分钟定位枚举阶段
6.1 协议栈里加串口打印
很多开发者遇到枚举失败时,只会不断插拔线缆,浪费时间。我习惯在usbd_desc.c的USBD_LL_SetupStage回调里加一个串口打印,把收到的 bmRequestType 和 bRequest 打出来,这样就能精确看到主机走到哪一步挂掉的。
void USBD_LL_SetupStage(USBD_HandleTypeDef *pdev, uint8_t *psetup) { // psetup[0]=bmRequestType, psetup[1]=bRequest printf("SETUP: 0x%02X 0x%02X\r\n", psetup[0], psetup[1]); // 只打印控制传输请求,不影响后续处理 }打印出的内容包括 GET_DESCRIPTOR、SET_CONFIGURATION、SET_INTERFACE 等。如果你发现打印停在 GET_DESCRIPTOR(Configuration) 之后就没了,说明配置描述符有问题。正常枚举应该一路看到 SET_CONFIGURATION,接着就是 HID 设备的 Set_Report 或 Idle 请求,这时设备才真正处于工作状态。我一般还会把每个USBD_LL_DataInStage的端点号打出来,用于确认 0x81 和 0x82 的 IN 事务确实都在发生。
6.2 把一次关键 SET_REPORT 请求抓出来分析
当键盘和鼠标同时工作时,主机可能先发一个 Set_Report 请求给设备,用来设置 LED 状态(键盘的 CapsLock 灯),这个请求的目标接口号可能为 0,也可能为 1,完全取决于你的配置描述符。如果你在实现里没有针对第二个接口做处理,主机会返回 STALL,但是很多应用层的键盘指示灯功能仍然能工作,因为主机把 STALL 直接忽略了。真正的麻烦在于某些 USB 分析工具会把这个 STALL 当成错误,导致后续 IN 事务被挂起。
所以我的习惯是,在HID_Setup里对HID_REQ_SET_REPORT这一分支做兼容,不管是接口 0 还是接口 1 的请求,都回 ACK,但不处理具体数据。这样既不会让主机进入错误流程,也不会影响你的键盘灯。从那以后我每次枚举调试都强制走一遍这个流程:先看 SetupStage 打印,再确认 DataInStage 有没有持续输出,最后才去查描述符数组的长度。希望这个流程帮你在复合 HID 项目里少走几天弯路。
本文还有配套的精品资源,点击获取