简介:面向 STM32 嵌入式开发者的 USB 复合设备工程资源,以 F103C8T6 为平台实现 CUSTOMHID 与 MSC 复合。端点分配合理:EP0 负责枚举,EP1 作键盘,EP2 作鼠标并支持绝对与相对模式,EP3 作 MSC,还提供官方 demo 和 FAT16 两个版本,结构为双端口。针对部分主机在 boot 启动模式下不请求报告描述符、导致键鼠失效的情况,作者优先采用键鼠分接口设计,使兼容性明显提升;整套方案可扩展到 HID+MSC、CDC+MSC、HID+CDC 甚至三复合,对 STM32F1/F4/F0 均有参考意义。资源包共含 1420 个文件,以 854 个 C 源码、274 个头文件为主,另有汇编、链接脚本、IAR/Keil 工程配置、文本资料与 PDF 文档,整体 22.53MB,目录组织便于学习和移植。已有 833 人学习。通过该工程可以掌握复合设备枚举流程、多接口配置、FAT16 MSC 存储实现,并获取移植与排错经验,适合中高级嵌入式工程师和 USB 协议学习者。 拿到这个F103C8T6_USB_CUSTOMHID+MSC.rar的工程,我的第一反应是:这可比那些烂大街的USB鼠标键盘示例有价值多了。F103C8T6这颗芯片在USB开发里被用得够多,但能把自定义HID和**MSC(Mass Storage Class)**复合到一根USB线上,让设备既能当私有通信通道,又能被上位机当成U盘直接读写,很多刚入门的朋友在这里卡了不止一两天。我当初调这个复合设备时,从描述符长度算错到端点分配撞车,再到Windows下驱动加载异常,前前后后折腾了一整个周末。这篇文章就把整套方案从头拆一遍,告诉你该怎么做、为什么这么做,以及出了问题怎么排查。
1. 项目概述:一根USB线上同时跑HID和U盘
1.1 这个RAR压缩包里到底是什么
先澄清一个词:这里的MSC不是Windows那个“Microsoft管理控制台”,而是USB协议里的大容量存储设备类。自定义HID呢,也不是键盘鼠标那种标准HID——标准HID会被系统当成输入设备劫持,而自定义HID走的是厂商标识用法页(Vendor-Defined Usage Page),数据完全由你的上位机软件解释,Windows不会乱动。
一个典型的复合设备工程,解压后会看到大致这样几块内容:MDK-ARM工程文件、CubeMX生成的初始化代码、USB设备库(HID类、MSC类)、描述符文件,以及一个充当U盘存储介质的RAM盘实现。编译烧录进F103C8T6之后,插上USB线,电脑端会枚举出两个独立节点:一个是“HID兼容设备”,另一个是“大容量存储设备”。两个节点共用同一根USB线、同一个芯片,但互不干扰。
这种方案的实战价值很明显。比如一个工控采集盒,上位机可以通过HID通道下发采集指令、读取实时状态,同时MSC通道暴露一个U盘,用户拔插USB就能拷走日志文件,或者把配置文件拖进来完成参数更新。一个USB口解决了通信和存储两件事,省掉了额外的串口线和读卡器。
1.2 为什么都在拿F103C8T6做USB设备
STM32F103C8T6算是USB从设备开发里性价比最高的选择之一。它内置一个USB 2.0 Full Speed设备外设,不支持OTG,只能做从机,但做HID和MSC完全够用。外围电路简单,资料多到泛滥,最小系统板几十块钱,坏了也不心疼。
把它和另外两颗常见芯片放在一起对比会更清楚:
| 芯片 | USB能力 | 时钟要求 | 上手成本 | 适合场景 |
|---|---|---|---|---|
| F103C8T6 | Full Speed Device,无内置PHY | 外部晶振+48MHz | 低,资料最多 | HID/MSC/CDC复合设备 |
| F042/F072 | Full Speed Device/OTG,内置USB PHY | 部分型号内置48MHz振荡器 | 中,文档偏少 | 需要精简时钟电路的小批量产品 |
| ESP32-S3 | USB OTG/FS,可做Host或Device | 内部时钟即可 | 中,SDK风格不同 | 同时需要Wi-Fi和USB的应用 |
F103这颗芯片的坑也有,最典型的就是USB必须使用48MHz时钟,少一步配置枚举必然失败。另外一个限制是它只有一个USB设备控制器,所以复合设备不是“两个USB设备插在一起”,而是“一个配置描述符里声明多个接口”。这个逻辑在后面章节会重点展开。
2. 描述符与协议:复合设备的关键拼图
2.1 枚举阶段,USB设备怎么“自我介绍”
USB主机(你的电脑)在设备插入后做的第一件事叫枚举。设备通过一组结构化的描述符告诉主机“我是谁、有几种功能、要用哪些端点”。复合设备的难点全在这个“自我介绍”环节。
整个枚举过程看起来有点像面试:主机先发一个Get_Descriptor(Device),设备返回18字节的设备描述符,里面包含VID、PID、bcdUSB版本和设备类别。主机确认设备基本靠谱后,会分配一个地址,再请求配置描述符。配置描述符是复合设备的重点——它不是一个独立结构,而是把“配置描述符+接口描述符+类描述符+端点描述符”一整套数据打包返回。HID+MSC这种双接口设备,需要在配置描述符里按顺序排列两个接口,主机拿到后就会按接口分别加载驱动。
关键字段可以这样理解:
| 描述符 | 关键字段 | 作用 |
|---|---|---|
| 设备描述符 | idVendor、idProduct | 厂家识别 |
| 配置描述符 | bNumInterfaces | 接口数量,复合设备这里必须填2 |
| HID接口描述符 | bInterfaceClass=0x03 | 声明这是HID类 |
| MSC接口描述符 | bInterfaceClass=0x08, bInterfaceSubClass=0x06, bInterfaceProtocol=0x50 | 声明这是Mass Storage Bulk-Only |
| 端点描述符 | bEndpointAddress、wMaxPacketSize | 数据传输通道 |
这里最容易翻车的点是配置描述符的总长度。一旦你往配置里加了第二个接口,总长度就要重新算,少一个字节、多一个字节,主机都可能直接报“设备描述符请求失败”。而且这种问题用肉眼看很难看出来,后面我会说怎么用抓包工具定位。
2.2 端点的分配方案
F103的USB设备控制器提供1个控制端点EP0加7组普通端点,但每组只能单向使用。也就是说EP1 IN和EP1 OUT在物理上是两个方向,但不能在一个方向上同时注册两个端点。复合设备为了让两个接口互不干扰,必须做好端点规划。
我个人验证下来比较稳的分配方案是这样的:
| 端点 | 方向 | 用途 | 传输类型 | 最大包长 |
|---|---|---|---|---|
| EP0 | 双向 | 控制传输、枚举 | Control | 64 |
| EP1 IN (0x81) | 设备→主机 | HID上报数据 | Interrupt | 64 |
| EP1 OUT (0x01) | 主机→设备 | HID下发数据 | Interrupt | 64 |
| EP2 IN (0x82) | 设备→主机 | MSC读取(U盘读) | Bulk | 64 |
| EP2 OUT (0x02) | 主机→设备 | MSC写入(U盘写) | Bulk | 64 |
HID用中断端点,因为需要保证低延迟,F103全速模式下中断端点轮询间隔最小可以做到1ms,实际代码里经常填10ms,够用。MSC用批量端点,不在乎实时性,但要求数据吞吐,批量传输全速下单方向最大理论带宽约1.2MB/s,做小文件交换没问题。
还有一点必须注意:F103的USB DMA缓冲区(PMA)有对齐要求,端点缓冲地址通常要4字节对齐。很多朋友把缓冲数组定义得很随意,结果高负载传输时偶尔丢数据,就是这个原因。
3. 从CubeMX到代码:如何跑起来
3.1 先把时钟和工程底子打好
我用CubeMX搭建最基础工程时,第一步永远是确认时钟树。F103C8T6的USB模块需要48MHz,但系统时钟通常是72MHz,所以必须经过分频器处理。假设外部晶振是8MHz,配置为 PLL×9 得到72MHz,USB时钟预分频选择 /1.5,正好得到48MHz。
CubeMX中需要勾选USB外设并选择Device模式,然后手动核对Clock Configuration页面里USB时钟是否为48MHz。这一步没做对,后面全是白费——设备会表现为插上没反应,或者偶尔识别一次就掉线。
提示:很多最小系统板不会把USB_DISCONNECT引脚引出来单独处理,但F103芯片内部的DP上拉控制是依赖这个机制的。自制板子时建议参考ST参考设计,不要在DP/DM线上额外并联1.5k上拉电阻,否则上拉电流过大会导致信号畸变。
工程生成后,把USB设备类代码加入工程。如果你用的CubeMX版本支持直接在中间件里选择Composite Device,那会省事很多;不支持就手动把HID和MSC两个类的源文件都加进工程,然后修改描述符文件,把它们拼进同一个配置描述符。我第二次做的时候,反而更习惯手动拼——毕竟自动生成的描述符往往带着一堆用不到的内容,手动拼能清楚知道每段在干什么。
3.2 自定义HID的报告描述符
HID类和其他USB类最大的区别,是它多了一个报告描述符。报告描述符不是给主机看的配置信息,而是“数据格式说明书”——告诉系统你的报告里有多少个字节、每个字节怎么解释。自定义HID要做的就是声明一个Vendor Defined的用法页,这样Windows就不会把你的设备误认成鼠标或键盘。
这里有一个比较标准的64字节输入+64字节输出的报告描述符示例,可以直接用在工程里:
0x06, 0x00, 0xFF, // Usage Page (Vendor Defined 0xFF00) 0x09, 0x01, // Usage (0x01) 0xA1, 0x01, // Collection (Application) 0x09, 0x02, // Usage (Input data) 0x15, 0x00, // Logical Minimum (0) 0x26, 0xFF, 0x00, // Logical Maximum (255) 0x75, 0x08, // Report Size (8) 0x95, 0x40, // Report Count (64) 0x81, 0x02, // Input (Data, Var, Abs) 0x09, 0x03, // Usage (Output data) 0x15, 0x00, // Logical Minimum (0) 0x26, 0xFF, 0x00, // Logical Maximum (255) 0x75, 0x08, // Report Size (8) 0x95, 0x40, // Report Count (64) 0x91, 0x02, // Output (Data, Var, Abs) 0xC0 // End Collection这个报告描述符可以配合专门的HID Descriptor Tool生成,工具会把每一个字节的含义列出来,方便检查。注意Usage Page必须是0xFF00或0xFFxx,如果填成标准用法页,Windows大概率会尝试用鼠标或键盘驱动去解析,结果就是设备行为莫名其妙。
还有一个容易踩的坑:报告长度要和端点描述符的wMaxPacketSize一致。如果端点配置成64字节,报告描述符里却声明128字节,主机很可能会拒绝加载驱动。
3.3 MSC的Bulk-Only协议与RAM盘
MSC类走的是Bulk-Only Transport,简单说就是命令块(CBW)、数据、状态块(CSW)三阶段的循环。主机下发一个31字节的CBW,里面包含SCSI命令,设备执行完返回数据,最后回一个13字节的CSW告诉主机“我执行成功/失败”。整个SCSI命令集合里,要让Windows把设备识别成可用的U盘,至少要实现下面几条:
switch (cbw->CBWCB[0]) { case 0x00: // TEST UNIT READY break; case 0x12: // INQUIRY memcpy(data, "F103 MSC\0\0\0\0\0\0\0\0", 36); break; case 0x23: // READ FORMAT CAPACITIES break; case 0x25: // READ CAPACITY // 返回扇区数-1 + 扇区大小512 break; case 0x28: // READ(10) memcpy(data, ramdisk + lba * 512, length); break; case 0x2A: // WRITE(10) memcpy(ramdisk + lba * 512, data, length); break; }这个框架看起来不复杂,但真正写起来有大量细节:CBW和CSW里的dCBWSignature分别是USBC和USBS的ASCII码,Tag要认真回填,Residue要正确计算剩余未传输字节数。任何一项错了,主机都可能认为设备异常。
最快跑通MSC的方式是使用RAM盘——在内存里定义一个全局数组当作U盘存储介质。建议容量不要太小,我习惯用512字节×1024块,也就是512KB,Windows基本都能正常识别和格式化。注意F103C8T6只有20KB RAM,所以你不可能真的在RAM里放一个512KB数组。实际存在内部Flash或外部SPI Flash更合理。这里RAM盘方案主要用于验证协议逻辑,真做产品就得往Flash上搬了。
注意:如果用内部Flash当U盘存储,要考虑擦写寿命和页大小。内部Flash按页擦除,一页通常是1KB或2KB,每次写入前要做擦除和读改写处理,不能直接按512字节扇区写。这也是为什么量产方案更倾向于外挂SPI Flash或TF卡。
4. 踩坑与排查:问题往往藏在这些细节里
4.1 用USB抓包看枚举过程
调USB复合设备,最应该学会的就是抓包。Windows下常用的工具是Bus Hound和USBlyzer,免费方案可以用Wireshark加USBPcap。抓包不需要多高深,关键是会看枚举阶段的Setup包和描述符返回。
具体操作是:打开抓包工具,选择USB控制器,点击开始,然后插入设备。抓到的第一个完整流程就是枚举过程。重点看这几个地方:
GET_DESCRIPTOR(Device)请求,返回的bLength是不是18,bMaxPacketSize0是不是0x40。SET_ADDRESS之后,主机会重新请求设备描述符,再请求配置描述符。GET_DESCRIPTOR(Configuration)返回的总长度,是否和你代码里写的配置描述符总长度一致。- HID类接口的
GET_DESCRIPTOR(HID Report)是否能正常返回报告描述符。
我遇到过一次很典型的情况:设备始终显示为“未知设备”,代码翻来覆去查不出问题,抓包一看,配置描述符总长度比实际少了1个字节,主机提前结束读取,后面所有数据都乱了。这种问题靠编译器和调试器根本看不出来,但抓包一眼就能定位。
4.2 常见故障速查表
把我在实际调试中遇到的高频问题整理成一张表,按这个顺序排查能省很多时间:
| 现象 | 可能原因 | 排查/解决思路 |
|---|---|---|
| 插上后完全没反应,或“未知USB设备(设备描述符请求失败)” | 时钟不是48MHz;DP/DM接线有问题;芯片供电不足 | 先确认时钟树USB时钟为48MHz;测量DP/DM引脚电压;换一根带屏蔽的USB线;外接3.3V供电 |
| 枚举成功,但只出现U盘,没有HID节点 | 配置描述符中接口数量写成了1;HID接口描述符的类字段不是0x03 | 抓包看配置描述符内容;核对bNumInterfaces;检查HID接口描述符位置 |
| 只出现HID,没有U盘节点 | MSC接口描述符的SubClass和Protocol不对;SCSI命令回复错误 | 确认MSC接口的SubClass=0x06、Protocol=0x50;用Bus Hound看MSC端点是否有数据交互 |
| U盘出现,但提示“请插入磁盘”或无法格式化 | READ CAPACITY返回值异常;块大小不对;RAM盘容量太小 | 检查READ CAPACITY返回的最后LBA和块大小;容量尽量不小于512KB |
| 偶发掉线、写入数据损坏 | 端点PMA缓冲区未对齐;DP/DM走线过长 | 检查PMA缓冲地址4字节对齐;DP/DM线上串联22Ω电阻,对地电容控制在10pF以内 |
| ST-Link烧录时提示USB communication error | 调试器与电脑连接不稳定;驱动冲突 | 换USB口、换线;重装ST-Link驱动;和调试器无关,先单独测试调试器 |
还有一个经验之谈:不要在产品开发初期就追求“一次复合成功”。我的习惯是先单独调HID,再单独调MSC,最后拼到一起。这样出了问题能确定是哪个环节引入的,不会因为两个类互相干扰而无从下手。
5. 后续扩展:把复合设备做得更实用
5.1 RAM盘换成SPI Flash,做成真正的板载U盘
RAM盘用来验证协议可以,但掉电数据就没了,容量也受RAM限制。更实际的做法是外挂一颗W25Q64(8MB)或W25Q128(16MB) SPI Flash,通过SPI接口接在F103上,再把MSC的读写回调从memcpy改成Flash读写。
这个过程中最麻烦的是写保护和擦写方式。SPI Flash写入前必须擦除,擦除粒度是4KB扇区。主机下发一个512字节的写请求,你可能要先读出一个4KB扇区,把512字节合并进去,再整块擦除重写。这类操作建议用FatFS处理,把MSC暴露的逻辑块和Flash物理扇区之间的映射关系理顺。
另外,SPI Flash上电后第一次插入电脑,Windows会提示“需要格式化磁盘”,这是正常的。你可以在MCU侧预置一个FAT12/FAT16镜像,也可以直接让Windows格式化。后者更省事——只要SCSI命令实现正确,Windows格式化后就能正常读写。
5.2 再加一个CDC虚拟串口,变成三合一
HID+MSC已经能覆盖很多场景,但调试时如果还要另接一个USB转串口(比如FT232R),体验就差了。可以在现有工程里再加一个CDC类虚拟串口,变成HID+MSC+CDC三合一设备。
加入CDC类后,配置描述符里就有了三个接口。CDC由“通信接口+数据接口”组成,需要用**接口关联描述符(IAD)**告诉Windows这是一组设备,否则Windows可能把通信接口和数据接口识别成两个独立设备。这也是CDC复合设备最常见的问题:看到两个带感叹号的未知设备,就是缺了IAD。
不过要提醒一句:F103C8T6只有64KB Flash、20KB RAM,代码空间和内存都很紧张。三个USB类同时启用后,Flash可能占到接近一半,再叠加应用逻辑和协议栈,就要考虑换F103RCT6或者F105了。我做完三合一之后,明显感觉到编译镜像变大,RAM盘和缓冲区都要省着用。
这个项目最值钱的地方不在代码本身,而在于“把链路打通”的过程。我刚做复合设备时,HID单跑、MSC单跑都好好的,一拼到一起就只剩下U盘,HID节点死活不出来。后来抓包才发现,配置描述符总长度算错了,导致主机在读取接口描述符时提前结束。从那以后我养成了一个习惯:遇到USB识别异常,先抓包,再看代码。代码里的逻辑问题可以靠Debug,但协议层面的握手异常,只有看“线上数据”才能确认。最后再分享一个小技巧:调试复合设备时,准备一个USB HUB和设备供电分开的测试环境,能显著减少供电不稳导致的假故障。
本文还有配套的精品资源,点击获取