基于STM32F103的USB复合设备:同口实现免驱HID与U盘

发布时间:2026/10/19 12:32:45
基于STM32F103的USB复合设备:同口实现免驱HID与U盘
简介面向嵌入式固件工程师和USB协议学习者压缩包内是一份完整的STM32 USB复合设备工程源码。项目在STM32F103C8T6上实现CustomHID与MSC双重设备EP0负责枚举控制EP1作为键盘接口EP2作为鼠标接口并支持绝对和相对两种模式EP3作为MSC存储接口同时附带官方demo版本与默认的FAT16版本。核心解决当主机以boot启动模式枚举时复合设备若不单独分配接口键鼠会因无法获取报告描述符而全部失效的问题通过将键鼠拆分为不同接口提高主机兼容性尤其针对AIX7等特殊系统。除本方案外设计思路还可扩展到F1、F4、F0等系列实现HIDMSC、CDCMSC乃至三重复合。资源包共1420个文件以C源码854个和头文件274个为主体辅以汇编源文件、编译静态库、链接脚本、Keil/IAR工程配置文件及hex固件等便于直接编译、烧录与二次开发压缩包整体22.53MB工程结构完整分类清晰。目前已有839人学习下载。整套源码的价值在于不仅给出可直接烧录的复合设备实现还通过端点分配与接口拆分应对特殊主机的boot枚举问题为扩展多USB功能组合提供了可复用的设计范式。1. 这个项目是什么一个USB口同时做U盘和自定义HID做嵌入式设备经常遇到这种拧巴的需求要实时上传数据又想让用户插上电脑直接把日志文件拷走。纯串口要装驱动纯U盘又不能双向通信。这个F103C8T6工程把USB口做成了复合设备——同一根USB线枚举出一个免驱的自定义HID端口和一个系统自带驱动的U盘。一颗F103C8T6没有外加芯片插上电脑就能一边收发指令一边当U盘用。适合数据采集器、工控面板、需要掉电导数据的传感器设备。这篇文章把它的复合设备实现、存储接口、HID收发链路拆开讲给出一套能直接跑的USB复合设备参考工程。2. 整体架构与描述符设计两个类如何挤在一条USB总线上2.1 USB复合设备的概念和F103的端点资源先理清一个概念。USB设备在物理上可以有两种组合方式一种是在一颗芯片内部做HUB下面挂多个功能单元另一种是单芯片单USB总线但配置描述符里声明多个接口每个接口实现不同功能。前者对主机来说是一个HUB加多个设备后者在主机眼里就是“一个设备多个接口”也就是真正的Composite Device复合设备。这个项目用的是后者代码里只有一个设备地址但逻辑上同时存在一个HID功能和一个MSC功能。F103C8T6的USB外设是全速设备控制器不是OTG没有内置HUB所以只能走Composite路线。它的硬件资源是8个端点加一个共享的PMA缓冲区看起来不多但算一下就知道够用MSC需要一对Bulk端点EP IN和EP OUTHID需要一对中断端点IN负责上报OUT负责接收上位机数据再加上固定的EP0控制端点一共5个端点还剩下3个余量。真正麻烦的不是端点数量而是描述符怎么排布让主机的类驱动能同时识别HID和MSC并且不会把它们的配置搞混。2.2 工程目录结构一套标准的USB Device Library布局解压这个资源后里面的工程结构非常标准是芯片厂商USB设备库的经典布局。我刚拿到的时候先看目录再找描述符文件基本就能猜到改哪里。我按项目里的实际结构列一份清单目录/文件作用Core/Src/main.c主循环初始化USB外设启动心跳逻辑Core/Src/usb_desc.c设备描述符、配置描述符、字符串描述符复合设备关键修改点USB_DEVICE/usbd_core.cUSB协议核心处理标准请求和枚举流程USB_DEVICE/usbd_usr.c用户回调封装枚举完成、断开等事件USB_DEVICE/usbd_hid.cHID类实现包括报表描述符、数据收发USB_DEVICE/usbd_msc.cMSC类实现处理SCSI命令USB_DEVICE/usbd_storage_if.c存储介质驱动接口对接SPI Flash或内部FlashUSB_DEVICE/usbd_conf.c端点内存分配、中断优先级、USB时钟配置这套结构里usbd_core.c基本不用改它负责解析USB标准请求然后根据描述符注册好的类回调把请求分发给HID或MSC模块。你要改的集中在三处usb_desc.c里描述符怎么拼、usbd_storage_if.c里存储介质怎么读写、usbd_hid.c里数据收发的触发逻辑。很多第一次做复合设备的人会去翻usbd_core.c其实方向搞反了。2.3 配置描述符把HID接口和MSC接口拼进同一个描述符集合复合设备的核心是配置描述符。设备描述符里通常把bDeviceClass设成0x00意思是类信息只在接口描述符里声明这样主机就不会试图用单一的类驱动去加载整个设备。配置描述符里依次放HID接口、MSC接口每个接口自带端点描述符。以下是工程里精简过的配置描述符代码可以看清拼接结构static uint8_t usbd_hid_msc_CfgDesc[] { /* 配置描述符 */ 0x09, /* bLength */ 0x02, /* bDescriptorType */ USB_LOWBYTE(CONFIG_DESC_SIZE), USB_HIGHBYTE(CONFIG_DESC_SIZE), 0x02, /* bNumInterfaces: 两个接口 */ 0x01, /* bConfigurationValue */ 0x00, /* iConfiguration */ 0xC0, /* bmAttributes: 自供电 */ 0x32, /* bMaxPower: 100mA */ /* 接口0HID */ 0x09, /* bLength */ 0x04, /* bDescriptorType: Interface */ 0x00, /* bInterfaceNumber */ 0x00, /* bAlternateSetting */ 0x02, /* bNumEndpoints: IN和OUT */ 0x03, /* bInterfaceClass: HID */ 0x00, /* bInterfaceSubClass */ 0x00, /* bInterfaceProtocol */ 0x00, /* iInterface */ 0x09, /* HID描述符长度 */ 0x21, /* HID描述符类型 */ 0x11, 0x01, 0x00, /* 国家码 */ 0x01, /* NumDescriptors */ 0x22, /* ReportDesc */ 0x3B, 0x00, /* 报表描述符长度 */ /* HID端点 IN */ 0x07, 0x05, 0x81, 0x03, 0x40, 0x00, 0x0A, /* HID端点 OUT */ 0x07, 0x05, 0x01, 0x03, 0x40, 0x00, 0x0A, /* 接口1MSC */ 0x09, 0x04, 0x01, 0x00, 0x02, 0x08, 0x06, 0x50, 0x00, /* MSC端点 IN */ 0x07, 0x05, 0x82, 0x02, 0x40, 0x00, 0x00, /* MSC端点 OUT */ 0x07, 0x05, 0x02, 0x02, 0x40, 0x00, 0x00, };注意看几点接口0的bInterfaceClass是0x03对应HID类接口1是0x08对应MSC类。两个接口的bNumEndpoints都是2因为要同时支持双向数据。端点地址上HID用了0x81和0x01MSC用了0x82和0x02两条通道的IN/OUT端点各自独立不会冲突。配置描述符的总长度必须和CONFIG_DESC_SIZE一致差一个字节枚举都会失败这是我最开始写复合设备时最容易翻车的地方。2.4 数据流链路从USB中断到HID和MSC的处理函数当主机向OUT端点发送数据时USB外设会把数据放进PMA缓冲区然后触发中断。usbd_conf.c里的中断回调会调用USBD_LL_DataOutusbd_core.c根据收到的端点号找到对应的类回调。例如端点0x01的数据交给USBD_HID_DataOut端点0x02的数据交给MSC_DataOut。IN方向则反过来HID调用USBD_HID_SendReport往端点0x81写数据MSC通过存储接口从介质读数据后再由Transmit送出去。这条链路决定了调试思路如果HID和MSC同时插上但只有一个功能能用问题往往不在核心协议而在端点地址是否和描述符、usbd_conf.c里的端点初始化一致。比如描述符写了HID OUT是端点1但代码里设置的发送缓冲区对应端点1必须完全对应。USB库没有“智能映射”它完全按你给的地址走错一个地址数据就进错门。3. 核心模块拆解报表描述符、存储接口、收发链路3.1 HID报表描述符定义64字节的双向数据通道HID设备的“灵魂”是报表描述符。它告诉主机这个设备有哪些用途、数据包多大、是输入还是输出。工程里用了一个Vendor-Defined Usage Page这样操作系统不会把数据解释成鼠标键盘而是当成原始字节流。代码不长但每个字节都影响通信格式static const uint8_t HID_ReportDesc[] { 0x06, 0x00, 0xFF, /* USAGE_PAGE (Vendor Defined) */ 0x09, 0x01, /* USAGE (Data) */ 0xA1, 0x01, /* COLLECTION (Application) */ 0x09, 0x02, /* USAGE (Data) */ 0x75, 0x08, /* REPORT_SIZE (8 bits) */ 0x95, 0x40, /* REPORT_COUNT (64) */ 0x81, 0x02, /* INPUT (Data,Var,Abs) */ 0x09, 0x03, /* USAGE (Data) */ 0x95, 0x40, /* REPORT_COUNT (64) */ 0x91, 0x02, /* OUTPUT (Data,Var,Abs) */ 0xC0 /* END_COLLECTION */ };这里的REPORT_COUNT是64配合REPORT_SIZE8表示输入报表和输出报表都是64字节。主机端的HID API读写时每次最多传64字节。如果你要改成128字节除修改这里的0x95参数外还需要同步修改端点描述符里的wMaxPacketSize以及usbd_conf.c里端点缓冲区的大小。只改报表不改进程上位机收到的包会不对齐。发送数据的核心函数和状态机有关我一般这样判断USBD_StatusTypeDef USBD_HID_SendReport(USBD_HandleTypeDef *pdev, uint8_t *report, uint16_t len) { USBD_HID_HandleTypeDef *hhid pdev-pClassData; if (hhid-state USBD_HID_IDLE) { hhid-state USBD_HID_BUSY; USBD_LL_Transmit(pdev, HID_EPIN_ADDR, report, len); } return USBD_OK; }state字段是抗重入的关键。如果上次发送还没完成再次调用这个函数数据会被直接丢弃。很多“发第二次就死机”的问题就是这个原因后面避坑章节会专门讲。接收方向的回调类似数据到达后会触发USBD_HID_DataOut里面把PMA缓冲区数据拷贝到用户数组并置一个标志位主循环检测到后处理业务逻辑。3.2 MSC存储接口把底层介质抽象成512字节扇区的U盘MSC类不关心底层是什么存储介质它只通过一组回调函数操作“块设备”。在usbd_storage_if.c里最重要的三个回调是GetCapacity拿容量、Read读块、Write写块。代码片段如下int8_t STORAGE_GetCapacity(uint8_t lun, uint32_t *block_num, uint16_t *block_size) { *block_num FLASH_SECTOR_COUNT; *block_size 512; return 0; } int8_t STORAGE_Read(uint8_t lun, uint8_t *buf, uint32_t blk_addr, uint16_t blk_len) { for (uint16_t i 0; i blk_len; i) { spi_flash_read(buf i * 512, (blk_addr i) * 512, 512); } return 0; }参数lun是逻辑单元号复合设备里通常只有0但保留这个参数方便以后扩展多分区。blk_addr是逻辑块地址blk_len是操作块数量。要注意buf指向的缓冲区必须4字节对齐因为底层DMA传输有对齐要求我在调试时吃过一次亏传输大文件偶发数据错位后来改成强制对齐的全局数组就稳定了。MSC协议在上层还会发SCSI命令比如格式化、读容量、读门状态。usbd_msc.c已经处理了这些命令存储接口只需要专注“给一个块号把数据读出来或写进去”。整个设计是典型的接口分离模式换一种存储介质只改usbd_storage_if.c的底层函数不用动MSC类。3.3 端点初始化与PMA缓冲区分配USB设备库在启动时会根据设定好的端点地址分配PMA缓冲区。这个缓冲区在F103内部是一块专门的SRAM每个端点需要至少2字节描述符加数据缓冲。工程里一般用宏定义#define HID_EPIN_ADDR 0x81 #define HID_EPOUT_ADDR 0x01 #define MSC_EPIN_ADDR 0x82 #define MSC_EPOUT_ADDR 0x02usbd_conf.c里的HAL_PCDConfigEndpoint会为这些端点分配内存。这里有个容易被忽略的细节PMA大小只有512字节如果HID和MSC每个端点都要64字节缓冲再加上控制端点总量会逼近上限。所以工程里把MSC的端点缓冲设置成64字节以内HID也是64字节这样刚好放下。如果要改成128字节的CDC就要小心分配溢出枚举会失败。4. 编译烧录与首次运行三步让复合设备现身4.1 用CubeMX生成基础工程并确认时钟拿到源码后我习惯先用CubeMX重新生成一遍外设初始化代码因为不同板子的晶振和启动配置可能有差异。选芯片型号为STM32F103C8T6设置主时钟HCLK为72MHz注意把USB的预分频配成48MHz。在“Connectivity”里勾选USB模式选Device Only。如果CubeMX版本支持直接在Middleware里勾选HID和MSC如果不支持就手动把压缩包里的usbd_hid.c、usbd_msc.c等文件加入工程再把main.c里的MX_USB_DEVICE_Init()调用放好。4.2 检查描述符大小和设备ID生成后先别急着下载打开usb_desc.c确认CONFIG_DESC_SIZE与配置描述符数组的实际长度一致。我最近一次调试就是只改了数组没改宏结果USB枚举反复Request Error。还要确认idVendor、idProduct不能是0否则主机驱动会拒绝加载。工程里默认用的是一组参考值正式做产品时需要换成自己在USB-IF申请或使用非认证厂商ID。4.3 烧录、连接和验证三件套用ST-Link通过SWD口烧录后把板子的USB口插到电脑。第一次插上系统会识别到两个设备一个“USB输入设备”HID一个“大容量存储设备”MSC。验证我的习惯是三步走打开设备管理器确认没有感叹号设备名称正确。右键U盘盘符选择格式化文件系统选FAT分配单元大小默认格式化后往U盘拷几个文件再拔出重新插上确认文件还在。用PC端HID调试工具发一段64字节数据给HID端点看设备是否返回。如果HID没有OUT端点这一步需要换成读取HID设备的信息但工程里已经做了双向所以可以直接收发。这套流程跑下来大约十分钟。如果卡在中间某一步优先看描述符长度和端点地址这是我踩坑最多的地方。5. 避坑与常见问题USB复合设备最容易翻车的五个细节5.1 枚举失败设备描述符请求失败现象插入USB后主机提示“无法识别的USB设备”设备管理器里是一个未知设备没有VID/PID。原因最常见的是D/D-接线反了或者USB FS模式下D的上拉电阻没接。另一个高频原因是芯片时钟不对USB要求48MHz如果PLL配置错误枚举时序会不对。解决先用示波器量D/D-的波形确认上拉电阻正常。然后用CubeMX重新配置时钟树确保USBCLK48MHz。如果自己焊的板子检查D/D-是否接反以及是否错把D当成D-。5.2 U盘出现了但是未格式化现象设备管理器里能看到大容量存储设备盘符也出现但双击提示“使用驱动器之前需要格式化”。原因STORAGE_GetCapacity返回的块大小与主机期望不一致主机读到总块数和介质实际大小不符或者存储介质里本来就是空的没有文件系统。解决先格式化一遍文件系统选FAT16或FAT32。如果格式化失败检查GetCapacity确认block_size返回512block_num和底层Flash容量吻合。同时检查STORAGE_Read能不能正确读扇区0这个扇区包含引导记录读不回来主机就会认为介质不可用。5.3 HID发送一次之后不再发送现象上位机第一个包能收到第二个包发出后设备无响应或者程序跑飞。原因USBD_HID_SendReport里的state没有从BUSY恢复。传输完成的回调USBD_HID_DataIn没有被正确调用或者回调里没有把状态改回IDLE。解决在usbd_hid.c里找到USBD_HID_DataIn在里面加一行hhid-state USBD_HID_IDLE;同时确认没有在多个地方混乱修改state。HID的状态机很简单只有IDLE和BUSY两种记住“发送前检查完成后清状态”就不会再卡死。5.4 大文件拷贝时U盘消失现象往U盘拷几百MB的文件拷到一半盘符消失需要重新拔插。原因F103的RAM只有20KBMSC的写缓冲如果不能一次性容纳一大块数据HAL库的传输就会pending。另一个原因是底层Flash擦写时间太长超过了主机超时机。解决在usbd_storage_if.c的写函数中避免在中断回调里做长时间的擦写操作把擦写放在临时缓冲区中然后由主循环轮询完成。另外检查usbd_conf.c里端点缓冲是不是64字节PMA有没有溢出。5.5 换了一块板子HID识别为鼠标现象HID设备被主机识别成鼠标或键盘导致光标乱跳。原因报表描述符的Usage Page没有设置成Vendor Defined主机使用通用桌面页解析把你的数据误当成鼠标移动。解决严格按照上面3.1节的报表描述符把USAGE_PAGE设为0xFF00USAGE设为自定义值不要用Generic Desktop页。这是HID自定义设备最容易忽视的坑。6. 二次开发改造从“跑通”到“自己的复合设备”6.1 改造HID端点从64字节变成128字节透传如果需求是每次传输128字节首先把报表描述符里的REPORT_COUNT改成128即0x95, 0x80然后把HID IN/OUT端点的wMaxPacketSize改成128并将usbd_conf.c里对应的端点缓冲区至少设为128。这里有个限制F103全速端点最大包长是64字节如果要超过64字节需要HID库实现“多包传输”或者改用CDC类。我一般建议保持64字节端点如果数据长就分多次传这样简单可靠。6.2 更换存储介质从SPI Flash换成SD卡工程默认存储驱动是SPI Flash。要换成SD卡只需改usbd_storage_if.c里的底层读写函数。常见的做法是使用FATFS库把SD卡包装成块设备然后在STORAGE_Read和STORAGE_Write里调用SD驱动函数。注意SD卡初始化要尽早做否则MSC获取容量时会超时。同时需要处理SD卡不能带电拔插的问题硬件上做好保护。6.3 用Python快速联调上位机HID设备最大的好处是免驱上位机用现成的hidapi库就能通信。下面是一个简单的Python脚本演示打开设备、发送64字节并读取返回import hid device hid.device() device.open(0x1234, 0x5678) # VID, PID # 发送数据hidapi会自动在前面加一个Report ID字节没有ID时填0x00 data [0x00] [0x01] * 64 device.write(data) # 读取64字节数据 ret device.read(64) print(ret) device.close()注意read是阻塞的超时参数在open之后设置。Windows下第一次调用open可能会失败需要确认VID/PID与描述符里的值一致以及驱动已经正常加载。改到这一步这个复合设备就从“别人的工程”变成了“自己的基建”。从那以后我每次改USB工程都会先整理一份描述符和端点对应表把设备描述符、配置描述符、报表描述符、缓冲区分配四份数据放在一起核对格式化完再测一轮双向收发这已经成了肌肉记忆。希望这篇解析能帮你少走一点弯路祝你可以顺利跑起来。本文还有配套的精品资源点击获取