
简介一套基于STM32CubeMX与STM32F103C8T6的USB复合设备开发资源将HID键盘与鼠标整合为同一个设备、两个独立接口面向嵌入式入门及中级开发者解决复合设备配置与固件实现中的关键难点。压缩包内共626个文件以345个C源码、114个头文件为主体同时包含MDK工程配置、链接脚本及编译产物ioc文件保存CubeMX完整配置uvprojx和uvoptx可直接用Keil打开整体约7.58MB目录结构清晰便于按模块对照学习。该资源已有3380人学习下载。读者可获得完整的MDK工程与CubeMX配置直接查看HID报告描述符、USB枚举流程、端点中断处理等核心代码理解如何在一个USB设备上同时实现键盘与鼠标两个HID接口并据此扩展自定义按键、组合键或复合功能。 STM32F103C8T6 做 USB 复合键盘鼠标这个需求在群里被问过很多次。标题里的“个interface两个设备”实际想表达的就是一个 USB 口插上去主机识别出两个设备——一个键盘、一个鼠标。严格说这应该叫“一个配置描述符里挂两个 HID 接口”每个接口对应一个功能设备。先说明一点这个改造 CanvasMX 默认生成代码是做不到一步到位的默认 HID 例程只有一个 Interface、一个 IN 端点。要把键盘鼠标都塞进去必须改描述符、增加端点还要动一部分类驱动的上报逻辑。这篇文章就把我实测通过的一套做法完整写出来适合手里有最小系统板、想自己折腾 USB 复合设备的开发者参考。1. F103C8T6 的 USB 到底能不能做复合 HID先说结论能做而且资源完全够。F103 系列内置的 USB 2.0 全速设备外设虽然不如 STM32F4 的 OTG 那么灵活但端点寄存器有 8 个控制端点 EP0 之外完全可以再给键盘分一个 IN 端点、给鼠标分一个 IN 端点。C8T6 的 Flash 虽然只有 64KB 左右RAM 也只有 20KBHID 报告的负载非常小8 字节键盘报告加 4 字节鼠标报告端点缓冲区占不了多少。很多人把“复合设备”想复杂了以为必须用外部 HUB 接两个独立 USB 设备。其实 USB 协议里有标准做法一个物理设备在主机看来还是同一个地址但配置描述符里可以包含多个 Interface每个 Interface 自带一组端点。主机枚举的时候会为每个 Interface 创建独立的设备节点于是你在设备管理器里就看到了“HID Keyboard Device”和“HID-compliant mouse”两个设备。所以这个项目的本质就变成三件事配置描述符里声明两个 HID Interface。每个 Interface 各自带一个中断 IN 端点。应用层把键盘报告发到 EP1把鼠标报告发到 EP2。这里要特别提醒键盘和鼠标除了端点地址不同Interface 描述符里的 bInterfaceProtocol 也要区分。键盘是 0x01鼠标是 0x02。有了这个字段主机在 Boot 协议模式下才知道该把哪个 Interface 当键盘、哪个当鼠标。这一点很容易被漏掉漏掉之后设备管理器里能看到两个 HID 设备但不一定都正常识别成键盘/鼠标。1.1 端点规划端点方向用途包大小EP0双向控制传输枚举和类请求64 字节EP1 IN主机方向键盘中断报告8 字节EP2 IN主机方向鼠标中断报告8 字节最大包大小这里统一用 8 字节其实鼠标用 8 字节也没问题全速 USB 中断端点允许 1~64 字节只要描述符里写清楚就行。键盘 Boot 协议的固定报告长度就是 8 字节鼠标我习惯按 4 字节上报按钮 1 字节X/Y/Wheel 各 1 字节。2. CubeMX 工程时钟、USB 外设和中间件的正确打开方式这个项目必须先保证 USB 工作时钟是 48MHz。F103C8T6 内部虽然有 HSI但 HSI 精度和稳定性不适合直接给 USB 用我强烈建议用外部 8MHz 晶振做 HSE然后 PLL 倍频到 72MHzUSB 预分频器再除以 1.5 得到 48MHz。CubeMX 的时钟树页面会自动帮你算你只要确保 HCLK 是 72MHzUSB 时钟那里显示 48MHz 就行。具体在 CubeMX 里操作RCC 勾选 HSE 为 Crystal/Ceramic Resonator。时钟树里 System Clock Mux 选 PLLCLKHCLK 填 72。Connectivity 标签页里找到 USB勾选 Device (FS)。Middleware and Software Packs 里打开 USB_DEVICEClass for FS IP 选择 HID。如果你还要用串口打印日志顺手把 USART1 也打开调试会方便很多。这里选 HID 而不是 Custom HID原因只有一个HID 类生成代码的目录结构简单默认就有usbd_hid.c / usbd_hid.h我们只要修改里面的描述符数组和上报函数就行。Custom HID 类需要处理自定义请求多一层学习成本不是必须的。生成工程后打开usbd_hid.c你会看到几个核心数组USBD_HID_DeviceDesc[]设备描述符。USBD_HID_CfgDesc[]配置描述符。USBD_HID_ReportDesc[]HID 报告描述符。这三个数组就是接下来动刀的地方。还有一个硬件细节必须提F103 的 USB D 上拉电阻虽然芯片内部有控制逻辑但典型的 USB 设备接口电路还是需要外部 1.5kΩ 电阻把 D 拉到 3.3V。绝大多数 STM32F103C8T6 最小系统板已经把 USB 接口电路画好了插上就能用。如果是自己画板子一定要加上这个电阻不然主机永远枚举不到设备或者枚举到一半就掉线。我见过不少人在这里卡了一整天最后拿示波器一量D 电平不对。3. 描述符改造先让主机认识“两个 HID 接口”这是整个项目最核心的部分也是最容易出错的地方。描述符不是随便拼几个字节就能用每一个长度字段都要和实际数组长度严格对上否则主机直接拒绝枚举。3.1 设备描述符只改一个字节USBD_HID_DeviceDesc[]里有一个bNumInterfaces字段表示设备一共有多少个 Interface。默认值是 0x01改成 0x02 即可。__ALIGN_BEGIN static uint8_t USBD_HID_DeviceDesc[] __ALIGN_END { 0x12, /* bLength */ USB_DESC_TYPE_DEVICE, /* bDescriptorType */ 0x00, 0x02, /* bcdUSB */ 0x00, /* bDeviceClass */ 0x00, /* bDeviceSubClass */ 0x00, /* bDeviceProtocol */ 0x40, /* bMaxPacketSize0 */ 0x83, 0x04, /* idVendor */ 0x00, 0x00, /* idProduct */ 0x00, 0x01, /* bcdDevice */ 0x01, /* iManufacturer */ 0x02, /* iProduct */ 0x03, /* iSerialNumber */ 0x01 /* bNumInterfaces原来是 0x01改成 0x02 */ };设备描述符里的 bDeviceClass 保持 0x00意思是每个 Interface 自己声明设备类HID 设备的类代码放在 Interface Descriptor 里这本身就是复合 HID 的标准做法。3.2 配置描述符重写默认USBD_HID_CfgDesc[]的结构是配置描述符 一个接口描述符 一个 HID 描述符 一个端点描述符总长度 0x19 也就是 25 字节。我们要改成两个接口所以需要再复制一组接口/HID/端点描述符并把总长度改成 59 字节也就是 0x3B。数组如下可以直接替换__ALIGN_BEGIN static uint8_t USBD_HID_CfgDesc[] __ALIGN_END { 0x09, /* bLength: Configuration Descriptor */ USB_DESC_TYPE_CONFIGURATION, 0x3B, 0x00, /* wTotalLength 59 */ 0x02, /* bNumInterfaces 2 */ 0x01, /* bConfigurationValue */ 0x00, /* iConfiguration */ 0x80, /* bmAttributes: Bus Powered */ 0x32, /* bMaxPower: 100mA */ /* ---------- Interface 0: Keyboard ---------- */ 0x09, /* bLength: Interface Descriptor */ USB_DESC_TYPE_INTERFACE, 0x00, /* bInterfaceNumber 0 */ 0x00, /* bAlternateSetting */ 0x01, /* bNumEndpoints */ 0x03, /* bInterfaceClass: HID */ 0x01, /* bInterfaceSubClass: Boot Interface */ 0x01, /* bInterfaceProtocol: Keyboard */ 0x00, /* iInterface */ 0x09, /* bLength: HID Descriptor */ 0x21, /* bDescriptorType: HID */ 0x11, 0x01, /* bcdHID: 1.11 */ 0x00, /* bCountryCode */ 0x01, /* bNumDescriptors */ 0x22, /* bDescriptorType: Report */ 0x79, 0x00, /* wDescriptorLength 121 */ 0x07, /* bLength: Endpoint Descriptor */ USB_DESC_TYPE_ENDPOINT, 0x81, /* bEndpointAddress: EP1 IN */ 0x03, /* bmAttributes: Interrupt */ 0x08, 0x00, /* wMaxPacketSize: 8 */ 0x0A, /* bInterval: 10ms */ /* ---------- Interface 1: Mouse ---------- */ 0x09, /* bLength: Interface Descriptor */ USB_DESC_TYPE_INTERFACE, 0x01, /* bInterfaceNumber 1 */ 0x00, /* bAlternateSetting */ 0x01, /* bNumEndpoints */ 0x03, /* bInterfaceClass: HID */ 0x01, /* bInterfaceSubClass: Boot Interface */ 0x02, /* bInterfaceProtocol: Mouse */ 0x00, /* iInterface */ 0x09, /* bLength: HID Descriptor */ 0x21, /* bDescriptorType: HID */ 0x11, 0x01, /* bcdHID: 1.11 */ 0x00, /* bCountryCode */ 0x01, /* bNumDescriptors */ 0x22, /* bDescriptorType: Report */ 0x79, 0x00, /* wDescriptorLength 121 */ 0x07, /* bLength: Endpoint Descriptor */ USB_DESC_TYPE_ENDPOINT, 0x82, /* bEndpointAddress: EP2 IN */ 0x03, /* bmAttributes: Interrupt */ 0x08, 0x00, /* wMaxPacketSize: 8 */ 0x0A /* bInterval: 10ms */ };两个 HID 描述符里 wDescriptorLength 都写成 121因为我把键盘报告描述符和鼠标报告描述符合并成了同一个USBD_HID_ReportDesc[]数组主机拿到的是完整的一份报告描述符。这是当前最简单、改动最少的做法。如果想做得更规范每个接口各指各的报告描述符也不是不行但需要改类驱动的 GetReportDescriptor 回调根据接口号返回不同偏移复杂度高一些新手不建议一上来就走这条路。3.3 报告描述符合并USBD_HID_ReportDesc[]原来长度只有 34 左右现在我们直接把它替换成一个“键盘集合 鼠标集合”的组合描述符总长度就是 121 字节。键盘报告描述符沿用标准的 Boot Keyboard 描述符鼠标报告描述符加了滚轮支持。__ALIGN_BEGIN static uint8_t USBD_HID_ReportDesc[] __ALIGN_END { /* 键盘集合 */ 0x05, 0x01, /* Usage Page (Generic Desktop) */ 0x09, 0x06, /* Usage (Keyboard) */ 0xA1, 0x01, /* Collection (Application) */ 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) */ 0x95, 0x06, /* Report Count (6) */ 0x75, 0x08, /* Report Size (8) */ 0x15, 0x00, /* Logical Minimum (0) */ 0x25, 0x65, /* Logical Maximum (101) */ 0x05, 0x07, /* Usage Page (Keyboard) */ 0x19, 0x00, /* Usage Minimum (0) */ 0x29, 0x65, /* Usage Maximum (101) */ 0x81, 0x00, /* Input (Data, Array) */ 0xC0, /* End Collection */ /* 鼠标集合 */ 0x05, 0x01, /* Usage Page (Generic Desktop) */ 0x09, 0x02, /* Usage (Mouse) */ 0xA1, 0x01, /* Collection (Application) */ 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, 0x05, /* Report Size (5) */ 0x95, 0x01, /* Report Count (1) */ 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 */ };对应的长度宏也要改。不同版本的 CubeMX 生成的 HID 头文件里这个宏可能在usbd_hid.h也可能在usbd_hid.c顶部名字一般是USBD_HID_ReportDesc_SIZE或USBD_HID_REPORT_DESC_SIZE。找到后把值改成 121。这步漏了的话主机读取报告描述符时长度不对枚举会失败或者功能异常。4. 双端点上报键盘走 EP1鼠标走 EP2描述符改完USB 枚举层面已经没问题了但应用层还有个坑等着你。默认生成的USBD_HID_SendReport函数内部写死用的是HID_EPIN_ADDR一般就是 0x81也就是 EP1。鼠标要走的 EP2默认根本没有对应的发送函数。网上很多教程到了这一步直接说“把鼠标报告也通过 USBD_HID_SendReport 发出去”那是骗人的因为那样发出去的还是键盘端点主机从鼠标 Interface 的 EP2 读不到任何数据。我的做法是在usbd_hid.c里加一个USBD_HID_SendReport2专门往 EP2 发数据。同时要给USBD_HID_HandleTypeDef结构体增加一个state2状态字段否则鼠标发送和键盘发送会共用同一个 busy 标志主循环里连续调用两次上报第二次会被当成“端点忙”丢掉。大致修改思路#define HID_EPIN_ADDR 0x81U #define HID_EPIN2_ADDR 0x82U USBD_StatusTypeDef USBD_HID_SendReport2(USBD_HandleTypeDef *pdev, uint8_t *report, uint16_t len) { USBD_HID_HandleTypeDef *hid pdev-pClassData; if (pdev-dev_state USBD_STATE_CONFIGURED) { if (hid-state2 USBD_HID_IDLE) { hid-state2 USBD_HID_BUSY; USBD_LL_Transmit(pdev, HID_EPIN2_ADDR, report, len); } } return USBD_OK; }然后在完成中断回调里原来只把hid-state清回 IDLE现在要加一段判断如果完成的端点是 0x82就把hid-state2也清回 IDLE。具体回调函数名字在生成的usbd_hid.c里不同版本可能叫HID_EPIN_Callback或HID_DataIn找到后对照着处理即可。应用层主循环我这样写uint8_t kbdReport[8] {0}; uint8_t mouseReport[4] {0}; while (1) { /* 键盘Ctrl A */ kbdReport[0] 0x04; /* Ctrl modifier */ kbdReport[2] 0x04; /* A */ USBD_HID_SendReport(hUsbDeviceFS, kbdReport, 8); /* 鼠标按下左键 向右移动 10 */ mouseReport[0] 0x01; /* Left button */ mouseReport[1] 10; /* X 10 */ mouseReport[2] 0; /* Y */ mouseReport[3] 0; /* Wheel */ USBD_HID_SendReport2(hUsbDeviceFS, mouseReport, 4); HAL_Delay(10); /* 记得清零增量不然鼠标会一直往右飘 */ kbdReport[0] 0; kbdReport[2] 0; mouseReport[0] 0; mouseReport[1] 0; }这里有个细节鼠标的 X/Y 是相对位移不是绝对坐标所以上报一次之后必须清零否则下一次继续加 10鼠标就会匀速往右飞。键盘则要注意松开按键时也要上报一次全 0 报告很多组合键盘失灵的问题就是只上报按下、没上报释放。5. 枚举失败时怎么查我的三步排查清单我调试 USB 设备最怕的就是改完描述符插上电脑“滴”一声然后设备管理器里一个黄色感叹号或者干脆连“滴”声都没有。后来把排查流程固化成三步效率高了很多。5.1 第一步先退回单 HID 验证链路如果第一次接触这个项目强烈建议先用 CubeMX 默认生成的单 HID 例程烧进去设备管理器能看到一个 HID 设备说明 USB 物理链路、48MHz 时钟、CubeMX 中间件都没问题。这种“最小可用验证”能帮你把硬件问题和软件问题分开。5.2 第二步用 USBTreeView 或 Bus Hound 看描述符是否完整描述符改成双接口之后插上设备用 USBTreeView 打开能看到完整的配置描述符树。重点检查两点bNumInterfaces 是不是 2。两个 Interface 下面是不是各有自己的 HID Descriptor 和 Endpoint Descriptor。如果在设备管理器里连设备都看不到多半是描述符数组长度写错尤其是wTotalLength。这个值必须精确等于所有描述符字节数的总和。我上面的配置是 59 字节如果你自己增删了字段一定要重新数。5.3 第三步看主机返回的错误码USB 枚举失败的原因五花八门但最常见的就是下面几种现象大概率原因主机不识别无任何反应没有 1.5kΩ D 上拉电阻或 USB 时钟不是 48MHz设备管理器黄色感叹号设备描述符请求失败HSE 没起振或 PLL 配置不对枚举成功但只有一个 HID 设备bNumInterfaces 没改成 2或配置描述符没编译进新数组键盘正常鼠标没反应鼠标报告发到了 EP1或者 EP2 未使用 state2 独立管理两个设备都有但发数据不生效报告描述符长度宏没改成 121主机读到的是截断描述符这里再分享一个排查技巧很多 USB 枚举问题不在代码而在时钟。F103 的 USB 外设是 48MHz 才能跑如果外部晶振用的是 12MHzPLL 配置又照抄了 8MHz 的USB 直接罢工。修改时钟树后先确认 CubeMX 里 USB Clock 显示 48.000 MHz再往下查。6. 实测后的几个扩展想法项目跑通之后我再分享几个实际使用中会踩到的点和可以继续玩的方向。首先是报告描述符里 Include 两个集合的做法Windows 和 Linux 都能认但如果你想让代码更“标准”可以给键盘集合加 Report ID(1)鼠标集合加 Report ID(2)。这样主机端多个集合的区分更明确代价是报告长度要相应加一个字节。其次是键盘的 LED 状态。默认 Boot Keyboard 报告描述符里那段 Output 集合是用来接收 NumLock/CapsLock 状态的但 CubeMX 生成的 HID 类没有 OUT 端点主机发下来的 LED 状态会通过控制传输的 Set_Report 请求到达而不是中断 OUT。如果你要做键盘背光跟随大小写灯需要自己在 HID 类的控制请求处理里把 Set_Report 的数据取出来。这个问题我当初查了很久以为是描述符写错了其实是类驱动只处理了控制请求的一部分。最后是 C8T6 的 RAM 占用。整个双 HID 工程编译出来Flash 占用大概十几 KBRAM 也很宽裕不需要担心资源不够。想再加一个自定义 HID 报告、加一个串口打印辅助调试都能塞得下。后续如果想升级成“键盘 鼠标 多媒体控制”三合一复合设备原理完全一样只需在配置描述符里再加一个 Interface再分一个端点出去思路不会变。本文还有配套的精品资源点击获取