STM32F103C8T6实现USB自定义HID+MSC复合设备详解
2026/9/7 9:09:25 网站建设 项目流程

简介:一套面向STM32开发者的USB复合设备工程包,基于F103C8T6芯片将定制HID与大容量存储类设备复合在一起。端点零负责枚举,端点一、二分别用作键盘与鼠标,鼠标支持绝对和相对两种模式,端点三挂载存储设备,并给出官方示例与FAT16文件系统两个版本,默认使用FAT16。针对IBM AIX7等主机在启动引导阶段不请求报告描述符、导致共用接口的键鼠无法使用的问题,工程将键鼠分配到不同接口,既增强了兼容性,也为F1、F4、F0等系列实现HID与MSC、CDC与MSC、HID与CDC乃至三重复合提供参考。压缩包共1420个文件,以C源码和头文件为主,另有汇编、调试配置、链接脚本、IAR与Keil工程文件及hex固件,整体约22.53MB,已有833人学习下载。借助这份完整工程,可对照学习端点分配、报告描述符组织、枚举流程和多接口协同,适合有USB基础、想进阶复合设备开发的工程师。 如果你手头有一块 STM32F103C8T6 的最小系统板,又正好在做需要“既能像 U 盘一样存取文件,又能通过自定义协议实时通信”的 USB 设备,那么这个F103C8T6_USB_CUSTOMHID+MSC.rar工程基本就是为你准备的。它把一个 USB 口同时做成自定义 HID 设备和 MSC 大容量存储设备,插到电脑上之后,设备管理器里会同时出现一个 HID 设备和一个磁盘。这种“一个 USB 口干两件事”的做法,在数据记录仪、加密存储、设备配置工具里非常常见。

我用这个工程跑通了不少项目,也踩过不少坑。下面这篇文章不是简单的资源介绍,而是把这个工程背后的硬件要求、描述符组织、HID/MSC 协议栈实现,以及抓包排查的方法都拆开讲清楚。无论你是想把代码移植到自己的板子上,还是想彻底搞懂复合设备怎么工作,这篇都能给你省下不少时间。

1. 为什么要把 CUSTOMHID 和 MSC 合体

1.1 单一接口的局限

先想清楚一个问题:为什么非要把两个东西合在一起?如果只用 CUSTOMHID,免装驱动这一点确实很香,上位机用 hidapi 或者 Windows 的 HID API 就能收发数据,通信延迟也可控。但 HID 只是“通道”,它没有文件系统的概念,PC 端用户想把设备里的数据导出、把配置参数拖进去,体验非常差。想一想,数据记录仪要给客户导出几百 KB 的日志,总不能让人用一个串口助手去收十六进制数组吧。

MSC 正好补上这一环。MSC 一枚举出来,Windows 就把它当 U 盘,资源管理器里能看到盘符,可以直接拖文件、批量拷贝,甚至可以自动挂载。但 MSC 只是一个块设备,它不是为“实时命令”设计的,你想让它立刻执行“开始采集”这种控制指令,用 U 盘协议根本无从下手。

所以复合设备的思路就是:一个 USB 物理连接,多个接口并存。HID 接口负责实时命令和状态上报,MSC 接口负责文件级存储。上位机一边通过 HID 通道下指令,一边通过资源管理器操作盘符,各取所长。

1.2 这类设备最常见的落地场景

我实际接触过的项目里,这种复合结构集中在这几类:

  • 数据记录仪:传感器数据存在 SPI Flash 或 SD 卡里,HID 通道下发启动采集、停止、校时指令,MSC 通道让用户像 U 盘一样拖出 CSV 或 BIN 日志文件。
  • 加密/授权 U 盘:MSC 暴露出来的盘符只是密文,用户拿不到明文;HID 通道承担密钥协商、权限认证,软件确认身份后才允许导出明文或者格式化。
  • 产线配置工具:HID 通道做固件升级握手和命令交互,MSC 通道批量放置配置文件,生产调试极大提速。
  • 小型数据采集卡:实时波形用 HID 快速上报,离线缓存用 MSC 目录读走。

这款工程提供的正是这些场景共用的底层骨架:一个配置描述符下挂两个接口,一个走自定义 HID,一个走 MSC,代码能直接改改 VID/PID 就用。

1.3 从 USB 主机的视角看复合设备

从主机角度,一个复合设备仍然只是一个“设备”,它的设备描述符只有一份,但配置描述符里包含多个接口描述符。总线枚举时,主机读到配置描述符后会逐个检查接口,看到接口的类代码为 0x03 就加载 HID 驱动,看到接口类代码为 0x08 就加载大容量存储驱动,于是设备管理器里就出现了两个“叶子节点”。

需要留意的是 USB 的接口关联描述符(IAD)。IAD 是用来把多个接口绑成一个功能的,比如音频设备的控制接口和音频流接口需要成组匹配驱动。但这里 HID 和 MSC 是两个独立功能,通常不需要 IAD,直接把两个接口依次写在配置描述符里就行。工程里如果没有出现 IAD 相关结构体,那是对的。

2. F103C8T6 的 USB 硬件与时钟配置,枚举失败先从这查

2.1 F103 内部 USB 外设的能力边界

F103C8T6 的 USB 是 device-only 的 USB 2.0 Full Speed 从设备,PA11 固定是 USB_DM,PA12 固定是 USB_DP。它不是 OTG,不能当主机去接 U 盘或键鼠。很多刚接触的人会误以为 STM32F103 都带 OTG,拿到 F103C8T6 想去做 Host,结果片上 USB 根本没法切主机模式。这个工程只做从设备,F103C8T6 完全够用;如果哪天要做 USB Host,得换 F105/F107 或者接个外部主控芯片。

端点资源方面,F103 的 USB 外设不像某些芯片那样每个端点只能配一个方向,它的端点寄存器同时有 IN 和 OUT 收发缓冲区,所以同一个端点号可以双向使用。我通常这样分配:端点 0 固定控制传输;HID 使用端点 1 IN 和端点 1 OUT;MSC 使用端点 2 OUT 和端点 3 IN。这样端点号不冲突,也方便在标准库里用ENDP1ENDP2ENDP3直接编程。

2.2 48MHz 时钟配置:第一道生死线

USB Full Speed 要求设备提供精确的 48MHz 时钟。STM32F103 的 USB 时钟不是独立的,它来自 PLL 输出再分频,典型配置是外部 8MHz 晶振经 PLL 九倍频到 72MHz,再除以 1.5 得到 48MHz。代码里对应的关键配置是这样:

RCC_PLLConfig(RCC_PLLSource_HSE_Div1, RCC_PLLMul_9); RCC_USBCLKConfig(RCC_USBCLKSource_PLLCLK_1Div5); RCC_APB1PeriphClockCmd(RCC_APB1Periph_USB, ENABLE);

如果你把外部晶振换成了 25MHz,或者干脆没焊晶振、程序用 HSI 运行,USB 时钟就不可能落在 48MHz,主机端最常见的现象就是“无法识别的 USB 设备(设备描述符请求失败)”。所以从别人工程拷代码时,第一个检查点必须放在时钟函数里,确认 HSE 是否启用、PLL 倍频和 USBCLK 分频是否一致。这个问题一旦存在,我们在调试里做再多别的优化都是白费功夫。

2.3 D+/D- 上拉、串联电阻与 Layout 细节

硬件上,全速 USB 设备要求在 D+ 引脚接一个 1.5kΩ 上拉电阻到 3.3V,主机靠检测这个上拉来识别设备插入。很多现成的 F103C8T6 核心板已经把上拉画进去了,但如果你自己做板,记得检查三件事:

  • 上拉电阻是否接到了 3.3V,误接到 5V 会导致电平不匹配,轻则枚举失败,重则损伤引脚。
  • D+ 和 D- 上各串联一个 22Ω 左右的电阻,放在 MCU 和 USB 座子之间,用于阻抗匹配和限制振铃。
  • 热搜里常有人问“USB D+D- 电容大小”,D+/D- 走线上不建议加太大的对地电容。见过有人加 100nF 去“滤波”,结果信号边沿被拉得惨不忍睹。最多放 10pF~22pF 的电容,或者干脆不放。

布局上,D+/D- 尽量等长、短距离、少打过孔,USB 座子的外壳地要单独处理。有些板子只是原理图对了,但因为 Layout 太随意,D+/D- 被长走线绕了十几厘米,最后枚举不稳定,插拔频繁出错。

3. 复合设备描述符编排:HID 与 MSC 共存的核心

3.1 描述符的层次结构

复合设备能不能被正确识别,关键在配置描述符的编排。枚举时,主机会先取设备描述符,再以“配置描述符”为入口得到整棵配置树。一个典型结构是:

  • 配置描述符(bNumInterfaces = 2)
    • 接口 0:HID 接口(bInterfaceClass = 0x03)
      • HID 描述符,其中指定报告描述符长度
      • 中断 IN 端点,如 EP1 IN,最大包 64 字节
      • 中断 OUT 端点,如 EP1 OUT,最大包 64 字节
    • 接口 1:MSC 接口(bInterfaceClass = 0x08,bInterfaceSubClass = 0x06,bInterfaceProtocol = 0x50)
      • 批量 OUT 端点,如 EP2 OUT
      • 批量 IN 端点,如 EP3 IN

MSC 走的是 Bulk-Only Transport(BOT)协议,所以不需要额外类描述符,只有接口描述符加两个 Bulk 端点。HID 接口则必须带一个类特殊描述符(HID 描述符),报告描述符是单独通过GET_DESCRIPTOR(Report)获取的。千万注意端点描述符里的bmAttributes,HID 写 0x03(中断),MSC 写 0x02(批量),写错后主机驱动行为会非常怪。

3.2 接口顺序对 Windows 识别的影响

接口的排列顺序在某些系统上会直接影响 MSC 盘符的挂载。按理说 HID 接口在 0、MSC 接口在 1 也能工作,但我确实在某个 Win10 版本上遇到过:MSC 接口放第二位时,磁盘管理器里偶尔不刷新,需要重新插拔才能看到盘符;把 MSC 接口换到第一位后问题消失。这个问题很玄,跟 PnP 驱动的匹配顺序有关。

如果你的设备在部分电脑上“U 盘不显示但 HID 正常”,不用急着怀疑硬件,先尝试把两个接口的顺序换一下。这个工程里如果usb_desc.c的配置描述符数组是“HID 接口在前”,你可以按需把 MSC 接口挪到前面重新编译测试。

3.3 字符串描述符与序列号

复合设备最好为每个子接口提供清晰的字符串描述符,尤其是产品字符串和序列号。Windows 设备管理器里显示的名称来自字符串描述符的iProduct,调试时如果两个接口都叫“STM32”,很容易认错目标。

序列号在复合设备里承担额外作用:Windows 通过序列号判断多个接口是否来自同一个物理设备,也经常用它做盘符绑定的注册表键。所以序列号必须固定,不能让每次枚举都随机变化,否则用户插上设备后盘符会乱漂移。工程里一般在设备描述符的iSerialNumber字段指定一个固定字符串,比如产品型号加批次号。

3.4 HID 报告描述符设计

HID 接口能被识别成“自定义”而不是“键盘鼠标”,完全由报告描述符决定。最常用的做法是把 Usage Page 设置为厂商自定义(0xFF00)。下面是我经常用的 64 字节输入报告加 64 字节输出报告的最小描述符:

const uint8_t CustomHID_ReportDescriptor[] = { 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, Var, Abs) 0x09, 0x02, // USAGE (Vendor Usage 2) 0x75, 0x08, // REPORT_SIZE (8 bits) 0x95, 0x40, // REPORT_COUNT (64) 0x91, 0x02, // OUTPUT (Data, Var, Abs) 0xC0 // END_COLLECTION };

这里用的 64 字节报告大小与全速中断端点最大包长一致,上位机读写时不会遇到半包问题。如果报告长度比端点包长还大,HID 协议会用多包拼接,你就得自己处理分包,没必要。

4. 代码侧的收发与存储:HID 消息流和 MSC 块设备

4.1 HID 收发的几个关键函数

不管使用 ST 标准外设库还是 HAL 库,CUSTOMHID 都要把报告描述符注册进 USB 配置,并处理两类传输:控制传输的SET_REPORT/GET_REPORT和中断端点的 IN/OUT 传输。

实际开发中,我建议把 OUT 端点收到的报文先放到一个环形缓冲区,再在主循环里解析处理,而不是在 USB 中断回调里直接执行业务逻辑。因为 HID 的中断回调频率不低,一旦在里面执行 Flash 写入或复杂计算,会拖累整个 USB 中断,造成数据丢包。发送侧则要查询前一个 IN 传输是否完成,确认端点空闲之后再写缓冲区,否则会覆盖之前的发送。

HID 中断传输受主机轮询间隔限制,F103 全速下典型 bInterval 是 1ms,每包最多 64 字节,理论带宽大约 64KB/s,实际能稳定跑一半就不错。如果带宽不够,就要考虑换用批量传输的厂商自定义类,这也是选型时就要提前明确的事情。

4.2 MSC 的 Bulk-Only 协议与 SCSI 命令

MSC 侧的协议核心是 BOT(Bulk-Only Transport)。主机通过 Bulk OUT 端点发来 31 字节的 CBW(Command Block Wrapper),里面包含命令块、传输方向和长度;设备执行完 SCSI 命令后,通过 Bulk IN 端点回一个 13 字节的 CSW(Command Status Wrapper),报告执行结果。整个流程是:收 CBW → 传输数据 → 发 CSW。

SCSI 命令不需要全部实现,但基础子集必须正确处理:

命令操作码主要作用
INQUIRY0x12返回厂商、产品名、版本
TEST UNIT READY0x00报告设备是否就绪
READ CAPACITY(10)0x25报告总扇区数和扇区大小
READ(10)0x28读取扇区
WRITE(10)0x2A写入扇区
MODE SENSE(6/10)0x1A/0x5A返回介质参数,固定响应即可
START STOP UNIT0x1B处理停止/弹出,一般直接返回成功

很多“U 盘不识别”的问题,并不是硬件坏了,而是 INQUIRY 或 TEST UNIT READY 回包有误。另外别忽略 CSW 里的dCSWSignature,它必须是0x53425355(即 “USBS”),一旦写错,主机就会视为协议错误,Windows 直接报“无法访问”或者“设备不可用”。

4.3 存储介质选择与缓存的坑

MSC 底层挂什么介质,是这个工程里最影响体验的部分。F103C8T6 内部 Flash 只有 64KB,代码本身还占一部分,如果你只是想临时存少量配置参数,可以用内部 Flash 模拟小容量 U 盘;但要做到能拷日志、存固件,建议外挂 SPI Flash(W25Q64/W25Q128)或者 SD 卡。

SPI Flash 方案电路简单,但要注意扇区擦除比较慢。W25Q 的 4KB 扇区擦除通常要几百毫秒,如果 Windows 连续发来多个写扇区命令,而你的代码还在阻塞等待擦除完成,上位机就会明显卡顿。工程里如果提供 SPI Flash 驱动,通常应该有“扇区缓存”机制:先把 512 字节的扇区读到 RAM,修改后再整块写回,并且在 MSC 状态机里以非阻塞方式管理擦写。

SD 卡方案则需要接 FATFS 或者直接把 SD 卡扇区映射给 MSC,代码量会大不少,但容量、速度和兼容性都更接近真实 U 盘。

5. 用 USB 抓包定位枚举和传输问题

5.1 抓包工具体验

调试复合设备,我强烈建议从最开始就学会抓 USB 包。Windows 下可以用 Wireshark 加 USBPcap,Linux 下直接用 usbmon 模块。抓包能看到主机和设备的每一笔控制交互,比在代码里加串口日志高效得多。

正常枚举流程大概是:主机先发GET_DESCRIPTOR(Device)取前 8 字节;设备回包;主机发SET_ADDRESS;主机再次GET_DESCRIPTOR(Device)取完整 18 字节;然后GET_DESCRIPTOR(Configuration)拿完整配置;设备把配置描述符分段返回;最后SET_CONFIGURATION完成配置。抓到包之后,一眼就能看出卡在哪一步。

卡在前面设备描述符阶段,基本是硬件和时钟问题;卡在配置描述符阶段,通常是wTotalLength写错、接口描述符长度不对、端点地址冲突;卡在枚举之后的 HID/SCSI 阶段,则是报告描述符或 SCSI 响应的问题。

5.2 “设备描述符请求失败”的高频原因

“未知 USB 设备(设备描述符请求失败)”是讨论区出现频率极高的问题,我碰到的原因主要集中在三种:一是 48MHz 时钟没配好,设备对 SETUP 包没有响应;二是 D+ 上拉电阻缺失或接到了 5V,导致主机根本没检测到全速设备;三是 PA11/PA12 的 GPIO 复用配置错误,比如没有把引脚设置成复用推挽输出。

有一个很好用的检查方法:上电后用万用表量 D+ 引脚对地电压,正常全速设备枚举前应该被上拉到 3.3V 附近。如果 D+ 一直是低电平,主机根本不会尝试枚举,这时候再怎么调软件都是白费。

5.3 枚举成功但 HID/MSC 单独异常

枚举成功不代表万事大吉。HID 设备不出现在“符合 HID 标准的用户控制设备”分类里,多半是报告描述符没有使用厂商自定义 Usage Page,Windows 把它当成标准鼠标键盘去匹配驱动了。MSC 设备插上后提示需要格式化,多数是READ CAPACITY返回的扇区数不对,或者 INQUIRY 响应中的可移动介质位没有置位。

还有一类问题很隐蔽:MSC 接口的 CBW 接收不完整,或者 CSW 返回过早,Windows 会卸载驱动并报告“延迟写入失败”。这类问题需要用抓包确认 CBW/CSW 的时序,单纯盯着代码看很难发现。

6. 移植这个工程时值得注意的几个坑

6.1 先分清库的版本再动手

这种网上的 .rar 工程,很多是基于 ST 早期标准外设库(SPL)写的,目录结构通常长这样:

Libraries/ CMSIS/ STM32F10x_StdPeriph_Driver/ Project/ usb_desc.c usb_prop.c usb_endp.c usb_pwr.c main.c

如果你平时用 HAL 库,这里不要急着把整个工程往 CubeMX 里塞。先确认启动文件是startup_stm32f10x_md.s,F103C8T6 属于中等容量,换成高密度启动文件会导致中断向量错乱。我自己移植的做法是:先建一个空的 SPL 最小工程,把 USB 相关文件逐个加入,编译通过后再加业务逻辑,这样能最快定位问题。

6.2 内部 Flash 和 SPI Flash 的介质切换

如果工程默认用内部 Flash 模拟 U 盘,你的板子却有 SPI Flash,那至少有两层要改。第一层是msc_mem.c里的介质读写函数,要换成 SPI Flash 驱动;第二层是容量和扇区参数,内部 Flash 页大小和 SPI Flash 的扇区大小不一样,但 MSC 向主机暴露的块大小最好固定成 512 字节,具体映射由代码完成。

这里有个常见误区:直接把 MSC 的READ(10)/WRITE(10)按主机给的 LBA 去操作 SPI Flash,但 SPI Flash 需要按扇区擦除,只改 512 字节可能要把整块 4KB 擦掉重写。没有缓存和回写机制的话,Windows 连续写入时迟早出错。

6.3 上下位机联调时的注意细节

联调时,HID 侧的上位机如果用 hidapi,hid_open的 VID/PID 一定要和工程里usb_desc.c的设备描述符完全一致。MSC 侧,先复制小文件、再复制大文件,观察 Windows 是否报“延迟写入失败”。延迟写入失败基本都是写命令处理不及时,或者 CSW 返回时机不对,去检查写命令的状态机。

另外,如果你是做量产设备的,建议在 HID 命令里预留一个“切换 MSC 只读/可写”的控制项。默认插上处于只读状态可以防止误操作,通过 HID 下发授权后才切换为可写。这种小功能在工程骨架上加代码不难,但能在后续使用里省不少事。

我最后想说的是,这类复合设备最容易出问题的其实不是代码能不能编译,而是描述符、端点、时钟这些底层细节没有理顺。你先把这个工程跑通,再对照抓包结果去看每一条描述符,很多原来想不通的问题会一下子清晰起来。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询