简介:STM32 RBT6 USB复合设备工程,面向嵌入式开发者和USB协议学习者,基于STM32F103RBT6实现单个USB设备同时模拟HID鼠标与HID键盘两个接口,解决多外设接入时USB端口占用与功能集成问题。工程包含可编译的完整源码,共48个文件,以23个.h头文件和15个.c源文件为主,覆盖USB控制器配置、设备/配置/接口/端点描述符、鼠标与键盘报告描述符、中断事务处理以及双接口软件架构;另附Keil工程文件、烧录固件和初始化脚本,压缩包仅110KB,目录层级清晰,便于直接导入开发环境编译验证。已有866人学习,适合希望深入理解USB复合设备枚举流程、HID报告描述符编写和STM32 USB固件库应用的开发者,能在较短时间内掌握从原理到实际调试的关键路径。 最近在调一颗 STM32F103RBT6 的 USB 复合设备,目标很直接:一根线进电脑,枚举出一个 HID 鼠标和一个 HID 键盘,两个 Interface 挂在同一个 USB 设备下面,系统端完全免驱。这个玩法在宏键盘、自定义控制台、工业触控板、实验室键鼠备份工具里都很常见,做起来却很能检验一个人对 USB 描述符体系的理解程度。
我最初以为这个项目就是把 HID 例程里鼠标的报告描述符换成键盘,最多再开个端点。真动手才发现,复合设备的核心根本不是“上报函数”,而是“配置描述符怎么组织”。鼠标接口占一个 Interface,键盘接口占另一个 Interface,主机识别驱动靠的是接口描述符里的类代码和协议号,任何一个字段写错,轻则只出一个设备,重则直接报代码 43。这篇博文就从硬件准备、描述符设计、代码实现到排查经验完整过一遍,给准备做多接口 HID 的朋友一个可以直接落地的参考。
1. 项目整体设计与思路拆解
1.1 USB 复合设备到底是怎么回事
先理清概念。一个 USB 设备有一个设备描述符,设备描述符下面有配置描述符,配置描述符里可以包含多个接口描述符,每个接口描述符下面还有端点描述符。所谓“复合设备”,就是同一个 USB 物理设备里挂载了多个接口,每个接口可以承载一个独立功能。HID 类设备尤其适合这种做法,因为键鼠是操作系统内置驱动,不需要额外装驱动。
鼠标和键盘虽然都属于 HID 类,但它们是两个独立的 Interface:接口 0 是鼠标,接口 1 是键盘。每个接口各有自己的 HID 描述符和端点描述符,上报数据互不干扰。主机端看到一个 USB 设备,枚举出两个 HID 接口后,会自动加载鼠标、键盘两套驱动,用户感知上等于插了一个同时具备鼠标和键盘功能的“二合一”设备。
理解这个结构的关键在于:设备描述符里的 bDeviceClass 必须设为 0x00,表示设备类由各个接口自行声明。如果把设备描述符直接改成 HID 类,有些系统会把整个设备当成单一的 HID 设备来解析,后面的多接口设计就失效了。这一点是最容易踩的坑。
1.2 为什么选 STM32F103RBT6 而不是其他方案
选型的时候其实也很纠结。STM32F103RBT6 是 Cortex-M3 内核,主频 72MHz,Flash 128KB,SRAM 20KB,内部自带 USB 2.0 Full Speed Device 外设,还带一块专用的 PMA(Packet Memory Area),用于 USB 数据包缓冲。相比之下,C8T6 的 Flash 只有 64KB,做纯粹的键鼠足够了,但 RBT6 同封装、同价格带里能多出一倍 Flash,后续想加字库、加协议栈或者多做一个自定义 HID 控制接口,空间会从容很多。
也有人会问,为什么不用 CH552 或者 ATtiny 这类方案。CH552 内置 USB,支持 HID 也很方便,价格更低,但它的开发资料和 USB 库成熟度相比 ST 要差一些,而且调试手段有限。STM32F103 的 USB 库和例程非常丰富,网上几乎能找到所有踩坑记录,对新手极其友好。RBT6 唯一的短板是没有硬件 OTG,只支持 Device 模式,但这个项目恰恰只需要 Device,浪费不了任何资源。
2. 硬件准备与最小系统设计
2.1 最小电路:别把 USB 引脚接错
硬件电路其实很简单,但有几个细节不能省。STM32F103RBT6 的 USB 引脚是固定的:PA11 对应 USB_DM,PA12 对应 USB_DP,这是硬件设计上写死的,软件改不了。建议在 D+ 和 D- 线上各串联一个 22Ω 的电阻,用于阻抗匹配,D+ 线上还要接一个 1.5kΩ 的上拉电阻到 3.3V,这是 USB 全速设备识别的关键,漏接或接错到 5V,电脑基本认不出设备。
晶振方面,我用的 8MHz 外部晶振,配合 PLL 倍频到 72MHz,再把 APB1 的 USB 时钟分频到 48MHz。USB 全速协议要求 48MHz 时钟,这个不能含糊,选 12MHz 或 25MHz 外部晶振也是可以的,但分频配置要重新算清楚。另外,很多最小系统板把 USB 的 D+ 上拉直接接到 3.3V,不做断开控制,这在调试阶段能用,但后面要实现在线重枚举会麻烦一些。更规范的做法是留一个 GPIO 控制上拉电阻,比如用 PA9 去控制一个三极管或 MOSFET,拉低一段时间再释放,就能让主机重新枚举设备,相当于软件里的“热拔插”。
2.2 供电、走线和常见布局问题
供电方面,USB 枚举阶段主机最多提供 100mA,正常工作时可以到 500mA。鼠标键盘这种低功耗外设,电流需求很小,但为了稳定,还是建议用 AMS1117-3.3 把 USB 的 5V 降到 3.3V,并在电源输入端放一个 10μF 钽电容加一个 100nF 陶瓷电容。不要直接把 5V 接到芯片 VDD,不然芯片大概率烧掉。
PCB 布线时,USB 的 D+ 和 D- 要尽量等长,并且不要穿过大电流路径。如果用的是洞洞板飞线,也尽量把这两根线缩短、靠地线隔离。我踩过一次坑:飞线板的 D+/D- 绕了半块板子,结果设备时而能识别、时而掉线,最后把线剪短重新飞线才稳定。这类“软故障”在 USB 上非常典型,优先级甚至比代码问题还高。
3. 核心细节解析:描述符与报告描述符
3.1 配置描述符是多接口设计的重头戏
USB 枚举过程中,主机会向设备请求各种描述符,而复合设备的区别集中体现在配置描述符的组成上。配置描述符里必须把 bNumInterfaces 改成 2,并且依次放入:接口 0 描述符、接口 0 的 HID 描述符、接口 0 的端点描述符、接口 1 描述符、接口 1 的 HID 描述符、接口 1 的端点描述符。
HID 鼠标和 HID 键盘的接口描述符差别不大,类代码都是 0x03,子类代码建议用 0x01(Boot Interface),这样在 BIOS 和某些无驱动环境下也能正常使用。关键在于协议号:鼠标是 0x02,键盘是 0x01,别写反。端点方面,我习惯给鼠标分配端点 1 IN(0x81),键盘分配端点 2 IN(0x82),最大包长分别是 4 字节和 8 字节,轮询间隔选 1ms 或 10ms 都可以,键鼠对这种延迟不敏感,我常用 1ms 保证响应更跟手。
| 配置项 | 鼠标接口(Interface 0) | 键盘接口(Interface 1) |
|---|---|---|
| bInterfaceClass | 0x03 | 0x03 |
| bInterfaceSubClass | 0x01 | 0x01 |
| bInterfaceProtocol | 0x02 | 0x01 |
| 端点数 | 1 个 IN | 1 个 IN |
| 端点地址 | 0x81 | 0x82 |
| 最大包长 | 4 | 8 |
| 查询间隔 | 1ms | 1ms |
注意每个接口后面的 HID 描述符必须独立存在,因为 HID 描述符里包含了各自的报告描述符长度。主机按照“接口描述符 -> HID 描述符 -> 端点描述符”的顺序解析,任何一个 HID 描述符漏写,后面接口的解析就会错位。
3.2 鼠标和键盘的报告描述符怎么设计
HID 报告描述符是另一块核心内容。鼠标的报告描述符定义了 4 字节报文:第一字节低 3 位是左键、右键、中键状态,第二字节是 X 轴相对位移,第三字节是 Y 轴相对位移,第四字节是滚轮。键盘的报告描述符定义了 8 字节报文:第一字节是修饰键(Ctrl、Shift、Alt、Win),第二字节保留,后面 6 字节是普通按键的按键码。
两个接口使用各自的报告描述符,并且通过各自的 HID 描述符告诉主机报告描述符的地址和长度,因此这里不需要使用 Report ID。只有在同一个接口里混用多种报告类型时,Report ID 才是必须的。比如有些宏键盘,一个接口既上报普通按键又上报多媒体键,就会给两种报告各分配一个 ID,这个项目里用不上。
写报告描述符时,最容易错的是 Logical Minimum 和 Logical Maximum。鼠标的 X/Y 位移是相对量,逻辑范围要设置成 -127 到 127,对应十六进制就是 0x81 到 0x7F。键盘按键是绝对量,逻辑范围是 0 到 101,对应键盘键码表的上限。如果这两个值声明得不对,主机对报文的解析也会出问题。
4. 实操过程与核心代码实现
4.1 工程搭建的两种路线
我分别用 ST 标准外设库和 CubeMX/HAL 库做过这个项目,两条路线都可以,但改动点略有差别。
如果用标准外设库加 ST 官方的 USB-FS-Device Development Kit,主要改四个文件:usb_desc.c 里面定义设备描述符和配置描述符,usb_prop.c 里处理标准请求和厂商请求,usb_endp.c 里做端点数据收发,usb_conf.c 或 usb_conf.h 里配置端点数量和缓冲区。这个库看着老,但结构透明,适合深入理解 USB 枚举流程。
如果用 CubeMX 生成工程,默认只生成单 HID 设备,需要在 usbd_hid.c 里把报告描述符换掉,在 usbd_desc.c 里改设备描述符的 VID/PID,并在 usbd_conf.c 里确认端点配置足够。CubeMX 的好处是初始化代码自动生成,但复合设备描述符需要手动维护,没有标准库来得直观。
4.2 修改描述符实现双 Interface
以标准外设库为例,我直接把配置描述符数组改成两段结构,整体长度 59 字节。数组核心部分长这样:
/* USB 配置描述符 */ uint8_t ConfigDescriptor[] = { /* 配置描述符 */ 0x09, /* bLength */ 0x02, /* bDescriptorType */ 0x3B, 0x00, /* wTotalLength: 59 */ 0x02, /* bNumInterfaces: 2 */ 0x01, /* bConfigurationValue */ 0x00, /* iConfiguration */ 0xC0, /* bmAttributes: Self Powered */ 0x32, /* bMaxPower: 100mA */ /* Interface 0 描述符(鼠标) */ 0x09, /* bLength */ 0x04, /* bDescriptorType */ 0x00, /* bInterfaceNumber: 0 */ 0x00, /* bAlternateSetting */ 0x01, /* bNumEndpoints: 1 */ 0x03, /* bInterfaceClass: HID */ 0x01, /* bInterfaceSubClass: Boot */ 0x02, /* bInterfaceProtocol: Mouse */ 0x00, /* iInterface */ /* HID 描述符(鼠标) */ 0x09, 0x21, 0x10, 0x01, 0x00, 0x01, 0x22, 0x32, 0x00, /* 鼠标端点 IN */ 0x07, 0x05, 0x81, 0x03, 0x04, 0x00, 0x01, /* Interface 1 描述符(键盘) */ 0x09, /* bLength */ 0x04, /* bDescriptorType */ 0x01, /* bInterfaceNumber: 1 */ 0x00, /* bAlternateSetting */ 0x01, /* bNumEndpoints: 1 */ 0x03, /* bInterfaceClass: HID */ 0x01, /* bInterfaceSubClass: Boot */ 0x01, /* bInterfaceProtocol: Keyboard */ 0x00, /* iInterface */ /* HID 描述符(键盘) */ 0x09, 0x21, 0x10, 0x01, 0x00, 0x01, 0x22, 0x3A, 0x00, /* 键盘端点 IN */ 0x07, 0x05, 0x82, 0x03, 0x08, 0x00, 0x01, };注意 HID 描述符中最后两个字节是报告描述符的长度,鼠标是 0x0032(50 字节),键盘是 0x003A(58 字节),必须和你实际定义的报告描述符数组长度严格一致。配置描述符的 wTotalLength 写了 0x003B,也就是 59 字节,主机就是靠这个长度遍历整个配置描述符集合的,长度算错,后面接口基本解析不出来。
4.3 鼠标和键盘的收发函数怎么写
描述符改完之后,收发逻辑就相对套路化了。鼠标上报在端点 1 IN,每次发 4 字节;键盘上报在端点 2 IN,每次发 8 字节。下面是我常用的两个发送函数:
/* 鼠标上报:buttons, dx, dy, wheel */ void HID_Mouse_Send(uint8_t buttons, int8_t dx, int8_t dy, int8_t wheel) { uint8_t report[4] = { buttons, (uint8_t)dx, (uint8_t)dy, (uint8_t)wheel }; UserToPMABufferCopy((uint8_t *)report, ENDP1_TXADDR, 4); SetEPTxCount(ENDP1, 4); SetEPTxValid(ENDP1); } /* 键盘上报:modifier + keycode[6] */ void HID_Keyboard_Send(uint8_t modifier, uint8_t *keycodes) { uint8_t report[8] = { modifier, 0x00, keycodes[0], keycodes[1], keycodes[2], keycodes[3], keycodes[4], keycodes[5] }; UserToPMABufferCopy((uint8_t *)report, ENDP2_TXADDR, 8); SetEPTxCount(ENDP2, 8); SetEPTxValid(ENDP2); }这段代码的核心是UserToPMABufferCopy,它把数据写到指定的端点缓冲区地址,然后通过SetEPTxCount设置发送字节数,最后用SetEPTxValid把端点状态切到有效,让 USB 外设在下一个帧周期把数据发出去。有时候漏了 SetEPTxCount,或者端点地址写错,主机会一直收不到数据,现象就是设备正常枚举,但鼠标键盘都没反应。
4.4 PMA 缓冲区分配
STM32F103 的 USB 外设使用一块独立的 PMA 区域,用来存放各个端点的数据。默认的 usb_conf.h 里会给 EP0 分配收发缓冲区,我们要在此基础上追加 EP1 和 EP2 的缓冲区。比如我这样分配:
/* PMA 地址分配示例 */ #define ENDP0_RXADDR (0x40) #define ENDP0_TXADDR (0x80) #define ENDP1_TXADDR (0x88) #define ENDP2_TXADDR (0x90)注意分配规则是地址必须按字对齐,并且各个端点的缓冲区不能重叠。EP0 收发各用了 64 字节,EP1 占 4 字节,EP2 占 8 字节,这样分配刚好不冲突。如果后面再增加第三个接口,需要继续往后推,比如 0x98 开始给第三个端点分配空间。缓冲区重叠的典型症状是鼠标和键盘互相干扰,发一组数据,另一个设备也会动一下,排查起来很痛苦。
5. 常见问题与排查技巧实录
5.1 枚举失败:代码 43 和代码 12
最常见的故障就是插上电脑没反应,设备管理器里出现黄色感叹号。代码 43 通常是枚举中途失败,设备描述符或配置描述符返回有问题,比如 wTotalLength 算错、HID 描述符长度字段错误、端点描述符的轮询间隔为 0 等。代码 12 表示“该设备找不到足够资源可以使用”,在 HID 复合设备上不常出现,一旦出现,先换一个 USB 口,再拔掉其他 USB 设备,因为 PC 的 USB 控制器资源被占满时也会报这个错。
排查这类问题,我强烈建议用 USB 抓包工具。Windows 下可以用 Wireshark 加 USBPcap,或者直接用 USBlyzer,看主机请求到哪个描述符时设备没有正确响应。枚举过程是“主机发请求 -> 设备响应”,只要响应包里数据异常,基本就能定位到是描述符数组的问题,而不是硬件问题。
5.2 只枚举出鼠标,没有键盘
这个问题十有八九是 bNumInterfaces 没有改成 2,或者配置描述符里第二个接口描述符的位置不对。主机遍历配置描述符时,会按固定格式解析,如果第一个接口的 HID 描述符长度写错,主机解析到一半就停止,第二个接口自然看不到。
还有一种情况是,主机识别出了两个 HID 接口,但都是按鼠标加载的。检查一下接口描述符里的 bInterfaceProtocol,鼠标 0x02、键盘 0x01,如果两个接口都写成 0x02,系统会当成双鼠标处理,键盘接口的功能就无法正常工作。
5.3 枚举正常但按键无响应
设备管理器里两个 HID 设备都正常,但按按键没反应,这种问题集中在发送逻辑。先确认键盘上报用的是端点 2 IN,如果误发到端点 1,数据会被鼠标接口接收,主机自然不会处理。再确认键盘报文是否包含了“释放”状态:普通键盘按下某个键时,按键码放进 keycode 数组,松开时必须发送一个全 0 的 8 字节报文。如果一直重复发送按下状态,操作系统只会识别一次按键,表现为按键不连续、像失灵。
鼠标的位移也要注意,X 和 Y 是相对偏移值,转动多少发多少,不要一直发固定值。滚轮状态同样需要在使用后清零,否则系统会认为滚轮一直在滚动。
5.4 排查思路总结
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 设备管理器代码 43 | 描述符长度/内容错误 | USB 抓包,对照标准描述符规范修改 |
| 设备管理器代码 12 | USB 控制器资源不足 | 换 USB 口,移除其他 USB 设备,更新驱动 |
| 只枚举出一个 HID 接口 | bNumInterfaces 未修改或描述符顺序错 | 检查配置描述符集合结构 |
| 两个接口都变成鼠标 | 接口协议号写错 | 鼠标 0x02,键盘 0x01 |
| 枚举正常但键鼠无反应 | 端点地址错、未发送释放报文 | 核对端点地址,确保发送空报告 |
| 键鼠互相干扰 | PMA 缓冲区重叠 | 重新分配端点缓冲区地址 |
我在实际项目里调试这个复合设备,大概花了一天半,其中半天都在跟“只出鼠标不出键盘”较劲,最后发现问题是我在配置描述符里把 bNumInterfaces 写成 1 了,这种低级错误用抓包工具一眼就能看出来。USB 调试没有太多玄学,描述符就是设备向主机自报家门的“身份表”,跟着抓包数据逐字节核对才是最快路径。
这个项目的价值在于,把两个 HID 接口的套路吃透之后,后面再加第三个接口、第四个接口,甚至混合 CDC 串口,思路都是一样的:描述符里加一个接口块,中断处理里加一个端点,收发逻辑复制一份。RBT6 的 USB 资源足够支持 4 个以上的端点,性能完全不是瓶颈。最后再分享一个小技巧,把鼠标和键盘的报告描述符分别放在独立数组里,并在 HID 描述符里用宏方式引用,后面想改键位、增加自定义控制功能,只动数组即可,不必碰配置描述符的布局。
本文还有配套的精品资源,点击获取