
简介面向需要同时实现人机交互与串口通信功能的STM32嵌入式开发者本资源以STM32F107为例系统讲解USB组合设备中HID与CDC两个类别的配置与实现过程。内容覆盖CubeMX图形化配置、HAL库初始化代码生成、HID设备报告定义、CDC虚拟串口读写以及Keil MDK-ARM下的编译调试与中断服务例程多类数据区分处理适合有基础USB开发经验、希望进阶组合设备的软硬件工程师也可为物联网、数据采集等应用提供直接参考。资源共1023个文件压缩包大小28.72MB包含570个C源文件、255个头文件、51个汇编文件以及编译生成的hex、axf、map等工程产物并保留uvprojx、ioc等工程配置文件整体结构完整便于直接打开研究。已有3118人浏览学习对理解STM32 USB协议栈、复合设备端点分配与实际项目落地具有较高参考价值。 做USB设备开发的朋友应该都遇到过这种情况手里只有一个USB口但既想用免驱的HID收发简单指令又想用虚拟串口CDC传日志、传数据。最常见的选择是直接做两个设备或者买一个USB HUB来硬分但这两种方案都折腾两个设备意味着两套固件、两个USB地址、上位机还得分别处理加HUB成本高不说在嵌入式场景里还占空间。STM32的组合设备Composite Device就是来解决这个问题的一个USB口、一个设备地址里面同时挂HID和CDC两个功能。主机枚举一次设备管理器里会出现一个HID设备加一个虚拟串口两边互不干扰。我最早在STM32F103C8T6上做通这个方案之后在F401、F407上也跑过整套东西在Windows、Linux下面都正常识别。这篇博文就从方案选型、CubeMX配置、描述符合并到驱动异常排查完整讲一遍我怎么把HIDCDC组合设备跑起来的给正在做USB复合设备、或者准备把调试串口和上位机指令通道合并到一个USB口上的朋友做个参考。1. 为什么要把HID和CDC做进一个设备1.1 HID和CDC各自擅长什么先明确一个前提USB设备类之间不是竞争关系它们各自解决不同的问题。HIDHuman Interface Device类设备类代码0x03常见的键盘、鼠标、游戏手柄、触摸屏都属于它。它的特点是免驱、系统原生支持而且数据走中断传输Interrupt Transfer延迟低、时序可控每8毫秒全速1ms帧中断端点最小间隔可设1ms就能有一次交互。缺点是单次传输的包长限制很死全速下中断端点一次最多64字节实际很多设备只用到8字节或16字节。HID适合传按键状态、控制指令、小包状态信息这种低延迟数据。CDCCommunications Device Class类虚拟串口用的就是它。它的核心优势是传输模型是流式的没有固定包长概念底层走批量传输Bulk Transfer带宽比中断传输大得多全速下理论带宽能到1MB/s左右适合日志输出、固件升级、大块数据上下行。代价是它需要安装驱动Windows下通常是usbser.sys或厂商提供的VCP驱动而且驱动配置不当容易出现设备管理器感叹号。其实两者正好互补HID负责低延迟小包控制CDC负责大数据流通信。一个做实时交互一个做数据搬运放在同一个设备里可以省一个USB口。1.2 组合设备的本质多个接口集合共用一个地址USB协议里设备描述符是唯一的一个设备地址下可以有多个配置描述符Configuration Descriptor但同一时刻只能激活一个配置。组合设备的本质就是在一个配置描述符里放多个接口集合每个集合实现一个设备类。拿HIDCDC来说CDC本身特点特殊它至少需要两个接口通信接口Communication Interface类代码0x02和数据接口Data Interface类代码0x0A前者负责管理控制信息后者负责传输数据。HID一般只需要一个接口类代码0x03。所以一个HIDCDC组合设备的配置描述符里至少要包含三个接口描述符0号接口给CDC控制口、1号接口给CDC数据口、2号接口给HID。这个接口编号不是随便定的描述符里的bInterfaceNumber字段决定了操作系统如何分配驱动端点地址也必须全局唯一、不能和别的接口冲突。我在后面第4节会具体讲。1.3 什么时候确实需要复合设备不需要复合设备的场景很简单只是做个USB转串口或者只想模拟个键盘用现成的例程改改就够。需要组合设备的是下面这些场景一个产品要同时支持上位机控制指令和调试日志输出比如机器人控制器HID收遥控按键、CDC发运行状态。设备要模拟键盘鼠标做自动化测试同时又要通过虚拟串口回传测试结果。带USB接口的仪器仪表HID做实时状态指示灯和控制面板CDC做数据采样上传。这类需求如果拆成两个设备主机端要装两套驱动、两个设备地址写驱动的人得多写一堆逻辑。组合设备一个地址搞定主机端枚举顺序固定上位机处理起来省心。2. 方案选型与CubeMX配置的前期准备2.1 硬件选型F103够用但时钟必须顶得住STM32系列里F103、F401、F407都有全速USB外设USB 2.0 Full Speed时钟要求是固定的48MHz。不是随便一套时钟就能跑USB外设的时钟源必须精确否则设备枚举都可能失败。F103的USB时钟有两个来源一个是PLL分频常用72MHz主频除以1.5得到48MHz另一个是内部的HSI48但HSI精度不高很多USB设备会枚举失败。我第一次调组合设备就是图省事用了HSI48结果插到电脑上一直是Unknown Device后来换回外部8MHz晶振锁相环倍频到72MHz再二分频成48MHz立刻好了。所以要做USB板子上必须有外部晶振时钟树里USB的48MHz来源必须确认是PLL不是HSI。F103C8T6这类64KB Flash的设备跑完整的USB协议栈加上组合设备描述符、两个功能类的处理代码Flash剩余空间大概还剩40%左右够用。如果还要跑RTOS或者做大数据缓冲建议上F405/407毕竟组合设备的数据收发是并发的RAM占用会高一些。2.2 CubeMX版本与配置思路STM32CubeMX现在6.x版本已经提供了Composite Device的图形化配置选项New Project里配置USB_DEVICE时Class for FS IP下拉菜单里有Communication Device Class (Virtual Port COM)、Custom HID还有Composite Device具体名称不同版本略有差异比如叫Composite Device (CDC HID MSC)之类。如果你用的版本有这个选项直接选它就能生成组合设备的框架。但注意一个事实即便CubeMX帮你生成了框架描述符依然需要手动检查修改。我对比过不同版本的生成结果有的版本生成的配置描述符里bNumInterfaces不对、wTotalLength算错有的是报告描述符和代码里的Report ID不匹配。图形化配置只是一个起点真正决定组合设备能不能识别的是描述符数组的内容。如果用的CubeMX版本比较老没有Composite Device选项那就先选CDC生成一套代码再手动把HID的描述符、端点定义合并进去。这个手工合并的方法我会在第4节展开其实理解了原理手工改也不难。3. CubeMX配置与代码生成完整流程3.1 时钟树配置先把48MHz搞对以F103C8T6为例板载8MHz晶振。CubeMX里依次打开RCC设置选择HSECrystal/Ceramic Resonator然后时钟树配置关键参数PLL Source: HSEPLL Multiplier: x98MHz x 9 72MHzSystem Clock Mux: PLLCLKAHB Prescaler: /1得到72MHzAPB1 Prescaler: /2得到36MHzAPB2 Prescaler: /1得到72MHzUSB Prescaler: /1.5得到48MHz配置完如果正确时钟树界面里USB那一栏会显示48MHz。如果显示的不是48检查PLL或分频系数。这个步骤扎实了后续枚举失败的概率会小很多。F407的话主频可到168MHzUSB时钟来源是PLLQ输出配到48MHz即可原理一样。3.2 中间件选择Composite Device还是手工合并从CubeMX 6.5左右的版本开始Middleware and Software Packs - USB_DEVICE - Class for FS IP里有Composite Device选项。选它之后会生成一个叫usbd_composite.c的文件里面默认包含CDC和HID的前缀代码省去了一大部分描述符合并工作。但即便有现成框架也建议把生成的usbd_desc.c、usbd_composite.c通读一遍。我遇到过生成的复合设备描述符里HID的端点地址是0x82CDC数据端点也是0x81/0x01看起来没冲突但实际枚举却报错检查发现描述符数组里HID的接口号和CDC的接口号出现了重叠。这类问题只能靠肉眼逐字节检查描述符解决图形化工具也保证不了。如果用的旧版本或者想彻底弄懂原理推荐手工合并法先新建一个USB_Device - Communication Device Class (Virtual Port COM)工程生成了CDC完整代码然后从另一个HID工程里复制usbd_hid.c和usbd_hid_desc.c旧版本文件名可能有差异再加到当前工程最后把两个类的配置描述符合并到一个数组里。下文按手工步骤讲解理解后无论CubeMX生成什么框架都能随便改。3.3 生成工程后的目录与关键文件生成工程后以STM32CubeIDE或Keil为例关键文件在USB_DEVICE目录下usbd_desc.c设备描述符、字符串描述符、串号。这里主要改产品ID/厂商ID、字符串。usbd_cdc.cCDC类请求处理、收发回调。usbd_cdc_desc.cCDC配置描述符相关定义结构化定义。注意里面对端点、接口的描述。usbd_hid.cHID类请求处理、报告发送。usbd_hid_desc.cHID的描述符配置描述符 报告描述符。usbd_composite.c新版本组合设备合并的描述符。手工合并的目标是把usbd_cdc_desc.c和usbd_hid_desc.c描述的内容放在一个配置描述符数组里明确接口编号、端点分配然后让USBD库在GetDeviceDescriptor回调时返回合并后的数组。4. 组合设备描述符合并核心难点4.1 描述符层级一个配置里挂三个接口USB标准描述符的层级关系大概是设备描述符(Device Descriptor)下面有配置描述符(Configuration Descriptor)配置描述符下面有接口描述符(Interface Descriptor)接口描述符下面有端点描述符(Endpoint Descriptor)和类特定描述符比如HID Descriptor、CDC功能描述符。主机枚举时先拿设备描述符再拿配置描述符配置描述符里的信息决定了操作系统为这个设备分配哪些驱动。对于组合设备配置描述符的大结构如下以全速为例描述符类型长度数量说明配置描述符9字节1个wTotalLength记录整个配置总长bNumInterfaces3CDC通信接口描述符9字节1个bInterfaceClass0x02带中断端点0x81CDC功能描述符若干1-2个含Header、ACM、Union功能描述符CDC数据接口描述符9字节1个bInterfaceClass0x0A带批量端点0x01和0x82HID接口描述符9字节1个bInterfaceClass0x03带HID描述符HID报告描述符若干1个描述HID数据的Report FormatHID端点描述符7字节1个中断端点0x83关键字段就几个必须确认明白wTotalLength整个配置描述符从它自己开始到最后一个字节的总长度。这个值如果比实际数组短主机枚举会截断比实际数组长主机等不到足够数据会超时。我见过最多的问题就是这个字段忘了同步修改。bNumInterfaces接口总数。HID一个、CDC两个所以是3。bInterfaceNumber每个接口的序号按0、1、2顺序排。端点地址的D7位表示方向1指IN0指OUT所以0x81是端点1 IN0x01是端点1 OUT0x02是端点2 OUT0x82是端点2 IN0x83是端点3 IN。全速下CDC用端点1做收发端点2做通知NotificationHID用端点3这样互不冲突。接口关联描述符IADCDC类使用了两个接口有些操作系统尤其是Windows需要知道接口0和接口1属于同一个功能设备不然它会尝试给两个接口装两个独立的驱动。正规做法是给CDC的两个接口前面加一个Interface Association Descriptor接口关联描述符长度8字节指明bFirstInterface0、bInterfaceCount2、bFunctionClass0x02。这个是很多手工合并教程漏掉的漏掉之后Windows下会识别成两个独立的未知设备。4.2 手工合并配置描述符的具体修改法用CubeMX生成一套标准CDC代码后usbd_cdc_desc.c里通常是用结构体定义配置描述符老版本是数组大概长这样示意#define CDC_CMD_EP 0x81 #define CDC_DATA_EP 0x02 #define CDC_DATA_IN_EP 0x82 #define CDC_DATA_OUT_EP 0x02 #define HID_EP_IN 0x83 #define HID_EP_SIZE 0x40注意老版本CubeMX生成CDC描述符时默认使用的是端点1作为命令端点0x81端点2作为数据端点IN 0x82OUT 0x02。我们保留这些端点HID新增一个端点3 IN不会和前两者冲突。现在生成一套HID工程把usbd_hid_desc.c里的配置描述符提取出来。HID的配置描述符结构一般比较短配置描述符9字节 接口描述符9字节 HID描述符9字节 端点描述符7字节。手工合并时我习惯直接在usbd_desc.c的配置描述符数组里有些版本叫USBD_CFG_HSDesc/FSDesc把两个类的内容拼接起来顺序是标准配置描述符9字节。CDC的接口关联描述符8字节可选但推荐。CDC通信接口描述符9字节里面带端点描述符0x81和一些CDC功能描述符。CDC数据接口描述符9字节带端点描述符0x01OUT、0x82IN。HID接口描述符9字节带HID描述符接着是HID报告描述符可能是独立的数组引用也可能是内嵌的数据最后带一个端点描述符0x83。同时把配置描述符第一字节的wTotalLength修改为整个数组的实际长度bNumInterfaces修改为3。修改后写一个小工具或者在代码里打印出来检查确保每个字节符合预期。我自己常做的事是分别把HID标准配置描述符和CDC标准配置描述符用hex工具导出来对比看两端有没有重叠字节。4.3 HID报告描述符的构造HID枚举时操作系统会向设备请求Report Descriptor报告描述符这个描述符定义了HID设备上报给主机时的数据格式。它独立于配置描述符但也是HID能被正确识别的前提。对于组合设备HID部分依然要维护自己的Report Descriptor。最简单的做法是做一个自定义用途的HID比如把8个字节当成自定义输入报告发给主机报告描述符大致长这样static const uint8_t HID_ReportDesc[] { 0x06, 0x00, 0xFF, /* Usage Page (Vendor Defined) */ 0x09, 0x01, /* Usage (Vendor Usage 1) */ 0xA1, 0x01, /* Collection (Application) */ 0x09, 0x01, /* Usage (Vendor Usage 1) */ 0x15, 0x00, /* Logical Minimum (0) */ 0x26, 0xFF, 0x00, /* Logical Maximum (255) */ 0x75, 0x08, /* Report Size (8 bits) */ 0x95, 0x40, /* Report Count (64) */ 0x81, 0x02, /* Input (Data, Variable, Absolute) */ 0xC0 /* End Collection */ };这个描述符定义了64字节的输入报告主机就按64字节接收来自设备的输入事件。如果同时需要设备接收主机下发的命令还需要加Output报告节点0x91开头同时保证代码端的USBD_HID_Receive也按相应长度接收。4.4 代码端的双回调分发描述符合并之后关键是代码里两个类的事务要独立运行。CDC用USBD_CDC_Receive或新版本API CDC_Receive_FS接收主机数据用CDC_Transmit_FS发数据HID用USBD_HID_Receive接收主机下发的报告用USBD_HID_SendReport或HID_Transmit_FS上传数据。中断回调里要注意缓冲管理。我最开始把CDC接收缓冲设成64字节HID接收也设64字节结果同时上电时CDC日志压力一大HID报告就丢。后来把两个缓冲都翻倍接收回调里处理完马上调用接收函数继续等待下一次接收才稳定下来。/* USB中断处理入口由USB中断服务程序调用 */ void HAL_PCD_SetupStageCallback(PCD_HandleTypeDef *hpcd) { USBD_LL_SetupStage(hpcd-pData, (uint8_t *)hpcd-Setup); } /* CDC接收回调 */ static int8_t CDC_Receive_FS(uint8_t *Buf, uint32_t *Len) { /* 拷贝数据到应用缓冲处理完成后必须再次调用接收 */ USBD_CDC_ReceivePacket(hUsbDeviceFS); return USBD_OK; } /* HID接收回调 */ static int8_t HID_OutEvent(uint8_t *Buf, uint32_t Len) { /* 处理主机下发指令 */ USBD_HID_ReceivePacket(hUsbDeviceFS); return USBD_OK; }注意USBD底层协议栈运行在USB中断上下文回调里不要做耗时操作只做数据复制和置标志位实际业务逻辑放到主循环或RTOS任务里处理。5. 常见问题与排查实录5.1 设备管理器显示“Unknown Device”或“USB Composite Device”带感叹号很大概率是配置描述符字段有误。先别急着改驱动用USB树状视图工具比如USB Tree Viewer查看系统枚举出来的描述符内容。如果设备根本没被识别优先检查wTotalLength是否等于实际配置描述符总长度。bNumInterfaces是否等于3。接口描述符里的类代码是否正确CDC通信类0x02CDC数据类0x0AHID0x03。所有端点地址是否全局唯一。设备描述符里的bDeviceClass必须是0或0xEF混合设备用0xEF配合IAD使用如果默认生成了其他值Windows有可能不按复合设备处理。我遇到最经典的一次新生成的工程没有配置描述符里添加设备的字符串索引iConfiguration0设备管理器显示Unknown Device后来补上iConfiguration字符串描述符指向一个字符串就正常了。5.2 HID枚举成功但上位机收不到数据HID类请求有Get_Report、Set_Report等STM32的HID库默认实现了部分但如果你使用的是自定义报告描述符要检查USBD_HID_GetReport/SetReport回调里是否对所有Report ID做了处理。如果不处理很多上位机工具比如HID调试助手下发Set_Report时会返回USB错误。另外HID端点中断的轮询间隔bInterval默认是10ms如果希望更新更快可以在端点描述符里把bInterval改成1ms。全速USB的1ms帧结构里中断端点最小间隔就是1ms不能再小了。最直观的检查方式是用Bus Hound抓一次USB总线事务看主机有没有周期性的IN令牌请求如果没有说明报告描述符或者接口配置有问题。5.3 CDC虚拟串口打不开或数据乱码CDC虚拟串口的波特率由主机端设置设备整体并不真正做波特率匹配但CDC规范的SET_LINE_CODING回调如果处理不对就会导致打开失败或者数据错位。STM32的CDC库默认提供了CDC_Control_FS回调可以在这里保存参数并返回USBD_OK。有的第三方库没实现这个回调Windows打开串口会报“参数错误”。数据乱码通常不是波特率问题虚拟串口不关心波特率而是发送/接收缓冲处理有竞争。尤其是HID和CDC同时收发的时候要确认USB中断优先级高于业务任务避免一边在USB中断里拷贝数据一边主循环又在改写同一个缓冲区。5.4 插上后没反应拔下重插偶尔能被识别此类问题通常指向硬件而不是描述符。检查EVK板子的D和D-走线是否等长、D是否有上拉电阻全速设备必须在D上拉1.5k欧到3.3VD-不需要、供电滤波电容是否足够。STM32CubeMX生成的GPIO配置里PA11、PA12如果被复用为USB功能注意不要重叠映射到其他外设。时钟树里确认USB时钟就是48MHz。5.5 常见问题速查表症状可能原因排查方法Unknown Device配置描述符错误或时钟不稳用USB Tree Viewer抓描述符核对wTotalLength和接口号设备管理器感叹号 Code 10CDC的IAD缺失或类代码错误确认接口关联描述符已添加类代码0x02/0x0A/0x03HID设备正常串口不出现CDC接口描述符未合并或总线枚举被跳过检查bNumInterfaces3及端点是否重叠串口打开后马上关闭SET_LINE_CODING回调未正确处理检查CDC_Control_FS是否返回USBD_OK数据收发丢包缓冲区竞争或缓冲溢出加大缓冲接收回调内快速复制并重新使能接收插上时必须拔插几次才识别硬件上拉/时钟抖动确认D上拉电阻改用外部晶振6. 实测体会与后续扩展这套组合设备方案我在F103C8T6和F407VE上都验证过Windows 10、Windows 11、Ubuntu 22.04都能正常识别CDC虚拟串口在Linux下是/dev/ttyACM0HID则被接入/dev/input/event或者libusb看应用层怎么用。实际调试中最耽误时间的其实是描述符。后来我习惯多备一台电脑用Bus Hound抓枚举包每次改动描述符都对照Hex反查一遍能解决90%的奇怪问题。再就是建议把USB的描述符定义放到独立文件里方便以后扩展比如升级成CDC HID MSC三重复合或者做双CDC一路日志、一路数据本质还是同一套原理改配置描述符并加类回调。这次先把HID CDC这套跑通后面遇到三重复合或者HIDCDCMSC组合时你会发现流程完全一样核心还是把描述符组织和端点分配搞透彻。本文还有配套的精品资源点击获取