1. 为什么“普通工程师”一碰USB就卡壳:项目背景与目标
先说个真实场景。我见过不少同事做产品原型,硬件画板、驱动移植、RTOS任务调度都玩得风生水起,一提到USB就头皮发麻。不是他们不会写代码,而是USB协议栈这东西太抽象——什么描述符、端点、控制传输、批量传输,光是名词就能把人绕晕。更别提以前要做一个简单的U盘功能,还得去啃官方那一大坨USB库,改配置改到头秃,稍不留神枚举就失败,PC上弹个“无法识别的USB设备”就直接心态爆炸。
其实这里有个误区:大部分嵌入式场景里的“USB开发”,压根不是做电脑端的驱动开发,而是把MCU模拟成一个USB设备,比如U盘、HID键盘、虚拟串口。你不需要去碰Windows底层驱动,也不需要懂WDF、KMDF,你要做的是把设备的描述符和端点配置写对,让主机把我们识别成“U盘”。这套逻辑只要想通了,事情瞬间从地狱难度降到新手村。
TinyUSB就是在这样的需求背景下火起来的。它是一个开源的USB协议栈,专门为嵌入式设备设计,支持设备模式(Device)和主机模式(Host),代码结构清晰,对STM32的支持特别友好。我用它把一颗普通的STM32F103芯片做成了U盘,从零开始到插上电脑被正确识别,整个过程用不到五分钟。这篇博客就记录下我折腾这个小项目的完整路径、中间碰到的坑和我觉得值得反复琢磨的细节,希望能帮那些被“USB驱动开发”这个词吓退的朋友迈过这道坎。
这篇文章适合谁?如果你手中有一块STM32开发板,想快速实现一个U盘功能;或者你做的产品需要一个“虚拟磁盘”用来存配置、做固件升级;再或者你只是对USB协议好奇,想知道“从MCU变成U盘”到底经历了什么——这篇文章都能给你一个可以直接落地的答案。
2. 环境准备与硬件选型:别在起点就埋雷
2.1 最小硬件清单:不要迷信开发板
先说清一件事:不是所有STM32都能轻松跑TinyUSB的USB设备功能。理论上只要芯片带有USB外设(比如F1系列带USB Device的型号、F4系列大部分型号、F103C8T6这类),都能跑。但如果你手头是一颗只带USB OTG但没有外部晶振的芯片,后面的调试会让你多折腾很久。
我这次用的是最常见的STM32F103C8T6,俗称“蓝丸”的板子,主频72MHz,片内Flash 64KB(实际可用是128KB,这个是另外一个话题),RAM 20KB。这个配置跑TinyUSB做U盘刚刚好,Flash里要同时装下固件和文件系统镜像,RAM里要放数据缓冲区,别把工程开太大就行。
除了MCU板子,你还需要:
- 一根数据线,不是充电线。很多“U盘识别不了”的新手问题,一半出在这根线上。
- 一个Micro-USB或者USB-C转接座,方便把板子的USB口引到电脑。
- 如果板子没有板载USB口,需要自己飞线的话,注意D+、D-的走线尽量短。
我用的是最普通的“蓝丸最小系统板”,板载一个Micro-USB接口。注意它的D+和D-并不是直接连到电脑,中间通常有RC滤波电路,这个不影响使用,但如果你拿示波器去看波形,会注意到信号边沿没那么陡,这是正常的。
2.2 软件工具链:版本不匹配是最隐蔽的坑
工具链这块列一下我用的组合:
- STM32CubeMX:用来生成工程框架和时钟树配置。
- Keil MDK或者STM32CubeIDE:编译调试,我用的是Keil MDK 5.38。
- TinyUSB源码:直接去仓库拉最新的release,不要拉master分支的最新commit,别问我是怎么知道的。
- 烧录工具:ST-Link V2,便宜好用,SWD四根线(SWDIO、SWCLK、GND、3V3)接好就能下载。
这里必须提醒一个经典坑:CubeMX生成的USB配置不要用它的PCD中间件。我们后面要做的,是让CubeMX只负责底层时钟和GPIO初始化,USB协议栈的部分全部交给TinyUSB。如果你在CubeMX里把“USB Device”下方的“PCD”中间件勾上,它会生成一堆HAL库的USB处理代码,这部分代码和TinyUSB是会打架的,最好不加,后面就是一堆重复定义、库函数冲突。
同理,如果你是用的HAL库版本和TinyUSB自带的示例代码版本不一致,就不建议直接把示例工程拿过来覆盖编译,而是要按我下面说的思路把TinyUSB作为第三方库嵌入你的工程。
2.3 时钟配置:主频、USB分频、波特率的三角关系
USB设备端对时钟的要求比串口严格得多。Full-Speed USB(12Mbps)要求时钟精度在±0.25%以内,STM32F1内部RC振荡器精度根本达不到这个指标,所以必须用外部晶振(HSE)。如果你的开发板上没有8MHz晶振,那这项目基本没法正常做。
标准的配置路径是:HSE 8MHz -> PLL倍频到72MHz(也就是系统主频SYSCLK)-> APB1预分频得到36MHz -> USB预分频器(PLLCLK/1.5)得到48MHz的USB时钟(USBCLK)。
这一步是让USB工作起来的基石。具体到CubeMX里的设置,我一般在“Clock Configuration”页面里,把HCLK设为72MHz,然后看右侧的USBCLK这一栏,确保它是48.0MHz,如果显示不是48,换一下PLL的M、N、P参数就行。这里没有太多花活,值对了就稳定。
3. 从CubeMX到TinyUSB移植:核心工程搭建实操
3.1 CubeMX生成的工程要做哪三件事
CubeMX生成的工程是一个很好的“空壳子”,它把时钟、GPIO、调试口都初始化好了。我们的任务是三件事:
第一,把USB相关的GPIO复用配置好。如果你是STM32F103,PA11是USB_DM,PA12是USB_DP。在CubeMX里只需要把这两个引脚配置为“USB”外设功能即可,不用手动配置成GPIO模式。
第二,加上USB的中断处理。USB设备需要中断服务函数才能及时响应主机请求。在STM32F1的HAL库里,这个中断函数叫USB_LP_CAN1_RX0_IRQHandler。我们会在后面把TinyUSB的事件轮询逻辑放进去,或者直接处理中断回调。
第三,关闭我们不用的中间件。如果你在CubeMX里勾了USB PCD中间件,这里删掉,或者不勾选PCD,只让CubeMX生成底层的USB外设初始化代码(MX_USB_PCD_Init()有时候也会被生成,但TinyUSB自己会再次初始化这个外设,一般没冲突;真正冲突的是PCD中间件的回调函数)。
我在实际搭建的时候,最干净的方式是:CubeMX里完全不开启USB Device相关配置,只打开USB外设。然后在main.c的while(1)循环里调用tusb_task(),剩下的一切交给TinyUSB接管。
3.2 把TinyUSB源码搬进工程:三种方式对比
把TinyUSB源码加入工程有三种常规方式,我逐个用过,说下差异:
- 源码整体加入:把
tinyusb/src整个目录加入Keil工程,编译选项里添加包含路径。好处是灵活,可以随意修改协议栈版本;坏处是文件多,编译速度稍微慢,新手容易漏掉某些源文件。 - 预编译库加入:TinyUSB官方不提供预编译库,这种方式基本不用考虑。
- 作为子模块拉取:如果你用Git管理工程,推荐用
git submodule或者直接vendor目录放一份固定版本。这种方式方便后续同步,但要注意版本锁定,升级要谨慎。
我最终采用的是“源码整体加入,但只添加需要的源文件”。TinyUSB的源码目录结构很清楚:
tinyusb/ src/ device/ // 设备协议栈核心 class/msc/ // MSC类实现(U盘就靠它) common/ portable/ // 芯片底层移植 osal/ // 操作系统抽象层你的工程里至少需要:
tusb.c/tusb.hdevice/usbd.c/usbd.hclass/msc/msc_device.c/msc_device.hportable/st_stm32_fsdev/dcd_stm32_fsdev.c(这是F1系列的Device Controller Driver)- 对应的一些通用头文件和配置头文件
在Keil里把这些文件添加进工程,然后把tinyusb/src这一层加进C/C++ Include Paths里,编译一下,如果遇到找不到头文件的报错,基本就是路径没加全。
3.3 TusbConfig.h:定制协议栈的“开关面板”
TinyUSB有一个全局配置文件tusb_config.h,放工程目录下,用来裁剪功能、分配缓冲区。这个文件里面全是宏定义,类似开关面板。我第一次做U盘时,这里踩了不少坑,逐个说明:
#ifndef TUSB_CONFIG_H_ #define TUSB_CONFIG_H_ #define CFG_TUSB_MCU OPT_MCU_STM32F103 #define BOARD_TUD_RHPORT 0 #define CFG_TUSB_OS 0 // 不使用操作系统 #define CFG_TUSB_DEBUG 0 // 调试日志开关 #define CFG_TUD_ENABLED 1 #define CFG_TUD_MAX_SPEED OPT_MODE_FULL_SPEED // MSC类配置 #define CFG_TUD_MSC_BUFSIZE 512 #endifCFG_TUD_MSC_BUFSIZE是MSC类进行数据传输时的缓冲区大小,对Full-Speed USB来说,一包数据最多64字节,但底层通常会用更大的缓冲来提升效率,我设成512字节,这是最常见的值。
注意BOARD_TUD_RHPORT这个宏,它表示当前使用第几个USB口。STM32F103只有一个USB外设,所以设为0。如果你用的是带双USB的高端芯片,这里就要按实际情况改。
3.4 中间层适配:把TinyUSB挂到USB外设上
很多人卡在这一步:协议栈源码加进来了,但MCU怎么知道何时去查看主机发来的数据?答案在中断里。
TinyUSB底层会注册一个中断回调,当USB外设检测到主机发送的数据包或事件时,硬件触发中断,中断函数里调用dcd_int_handler(),这个函数再分发到协议栈各个模块。
对于STM32F1,中断服务函数的写法是:
extern void dcd_int_handler(void); void USB_LP_CAN1_RX0_IRQHandler(void) { dcd_int_handler(); }放在任意一个C文件里就可以。然后主循环里还要周期调用tud_task(),它负责处理一些软件状态机轮询,比如配置完成事件、断开事件等。两者缺一不可。
如果你使用RTOS,也可以把tud_task()放到一个独立线程里,这里我们裸机跑,直接在主循环调用即可。
4. 把U盘“跑”起来:描述符、回调与底层读写
4.1 MSC描述符:把设备“伪装”成U盘的关键
所谓的“握手”,第一步就是设备向主机描述自己。主机发一个“请把你的描述符给我看看”的请求,设备回复一串结构化的数据,这串数据的格式由USB规范定义,里面包含了设备类型、厂商ID、产品ID、端点大小等信息。
TinyUSB里使用描述符数组来定义这些内容。我做的U盘项目,关键描述符是这样的:
enum { ITF_NUM_MSC = 0, ITF_NUM_TOTAL }; #define CONFIG_TOTAL_LEN (TUD_CONFIG_DESC_LEN + TUD_MSC_DESC_LEN) uint8_t const desc_configuration[] = { TUD_CONFIG_DESCRIPTOR(1, ITF_NUM_TOTAL, 0, CONFIG_TOTAL_LEN, 0x00, 100), TUD_MSC_DESCRIPTOR(ITF_NUM_MSC, 0, EP_MSC_OUT, EP_MSC_IN, 64), };这个数组定义看起来和“寄存器配置”完全不像,但对PC来说,它就是设备的“身份证”。TUD_MSC_DESCRIPTOR这个宏会展开成接口描述符、端点描述符,它告诉主机:我暴露了一个MSC类接口,输出端点是EP_MSC_OUT,输入端点是EP_MSC_IN,端点最大包长是64字节。
在主机的设备管理器里看到的“USB大容量存储设备”就是这么来的。如果你想给U盘起个自定义的名字,那是SCSI查询响应(Inquiry Response)里的厂商字符串做的事,后面会说。
4.2 SCSI命令处理:U盘里的“语言”
MSC类本质上是跑在USB总线上的SCSI命令。你在电脑上格式化U盘、拷贝文件、弹出U盘,最终都会变成一长串SCSI命令发给设备,设备端必须逐条响应。
比如电脑想知道U盘有多大,会发READ CAPACITY(10)命令;要读数据,会发READ(10)命令。TinyUSB已经把SCSI命令解析好了,自动调用你在msc_callbacks.c里实现的几个回调函数,我们要干的事是回答几个核心问题。
// 磁盘有多少个逻辑块?每块多大?返回给主机 int32_t tud_msc_capacity_cb(uint8_t lun, uint32_t* block_count, uint32_t* block_size) { *block_count = 128; // 128个块 *block_size = 512; // 每块512字节,总共64KB return 0; }有一个点必须强调:SCSI READ/WRITE回调返回值的含义和很多人直觉相反。tud_msc_read_cb返回0表示“正在忙”,返回正数表示“成功读取了多少字节”。总线上的每次读写请求,主机都可能分包发送,所以回调里要做“继续读完”的判断。
// 读扇区 bool tud_msc_read_cb(uint8_t lun, uint32_t lba, uint32_t offset, void* buffer, uint32_t bufsize) { // F103内部Flash不够大时,我直接读取外部SPI Flash uint8_t sector[512]; spi_flash_read(lba * 512, sector, 512); memcpy((uint8_t*)buffer + offset, sector + offset, bufsize); return true; }4.3 存储介质选型:Flash、SD卡还是内置Flash
做U盘,绕不开“数据存在哪”这个问题。STM32F103C8T6的片内Flash才64KB,写坏了还影响代码存储,所以我强烈建议用一个外部SPI Flash(比如W25Q64)或者SD卡。我的第一个版本用的是W25Q64,8MB容量,拿来当U盘刚刚好,测试阶段也不心疼。
如果你用片内Flash做测试,有个噩梦级的坑:写Flash前必须先擦除,而且擦除最小单位是扇区(比如1KB或4KB)。SCSI的WRITE命令是按逻辑块(512B)发的,一旦你把写操作直接映射到Flash硬件,一个只写512字节的请求就能毁掉相邻的扇区数据。解决办法是使用Flash磨损均衡库(比如LittleFS)或者把物理扇区映射关系放到RAM里模拟,但这对F103的SRAM压力不小。
我的方案是外挂W25Q64,操作简单,读写接口统一,也不用担心擦写寿命。如果你要做的是“功能演示”而不是“量产产品”,这一步最省心。
4.4 字符串描述符:让U盘在电脑上有个名字
设备管理器里看到的“USB大容量存储设备”是类名,资源管理器里显示的盘符名称才是用户最能感知的地方。这个名称来自SCSI的Inquiry Response,在TinyUSB里通过tud_msc_inquiry_cb回调返回。
void tud_msc_inquiry_cb(uint8_t lun, uint8_t vendor_id[8], uint8_t product_id[16], uint8_t product_rev[4]) { const char vid[] = "TINY"; const char pid[] = "MY_USB_DISK"; const char rev[] = "1.0"; memcpy(vendor_id, vid, strlen(vid)); memcpy(product_id, pid, strlen(pid)); memcpy(product_rev, rev, strlen(rev)); }注意这里返回的字符串不是Unicode,而是ASCII数组,直接按字节拷贝就行。做完这一步,插上电脑,资源管理器里就会显示一个名为“MY_USB_DISK”的可移动磁盘。
5. 实测结果与掉坑记录:三次让人抓狂的“不识别”
5.1 枚举失败:电脑完全没反应
第一次上电,插上USB,电脑一点反应都没有,设备管理器也不刷新。我当时第一个怀疑就是硬件连接问题,于是量了D+和D-的静态电平——D+应该被设备上拉到3.3V,但这个板上既没有上拉电阻也没有下拉配置。
这里要补一个概念:Full-Speed USB设备是靠D+线上的上拉电阻来通知主机“我来了”的。STM32F103内部虽然有上拉,但TinyUSB的device驱动会在初始化时配置USB_DP引脚,打开内置的上拉功能。如果上拉没生效,主机完全感知不到设备。
排查步骤我整理成一张表:
| 现象 | 可能原因 | 验证方式 |
|---|---|---|
| 电脑完全没反应 | D+上拉未开启 | 用万用表量D+对地电压,正常约3.3V |
| 电脑弹“无法识别的USB设备” | 枚举过程出错 | 查看TinyUSB调试日志,重点看Setup包处理 |
| 插拔后经常失效 | 电源不稳定 | 检查板子供电,F103的USB最好用独立LDO供电 |
我那次的问题其实就是代码里dcd_int_handler没有正确链接到中断函数,主机发的第一个Setup包没有回,枚举直接超时。加上中断处理函数后,再插就正常了。
5.2 容量显示128MB,但格式化就失败
第一次让U盘成功识别时,我开心了十分钟,右键格式化,系统提示“Windows无法完成格式化”。这个问题的根子在逻辑块数量上。
我最初设置的block_count = 128,block_size = 512,总共才64KB。Windows在格式化一个64KB的磁盘时,FAT文件系统都没法正确创建,自然报错。解决办法是扩大容量,我直接把SPI Flash当整盘暴露,block_count设为W25Q64的实际扇区数(16384个块,每块512B,总容量8MB),格式化就顺利通过了。
这里有个经验:容量太小不仅体验差,还会引发一连串文件系统层面的麻烦。至少分配1MB以上空间再去做U盘演示,省得格式化都过不去。
5.3 数据写入后拔插丢失
跑通读写后,我试着往U盘里拷贝一个文档,拷贝过程没有报错,拔下来再插上,文件不见了。
这个问题差点让我怀疑人生。后来才意识到,TinyUSB的MSC回调里我写的是“只要收到WRITE命令就直接往Flash写”,但FAT文件系统往U盘写数据时,并不会立刻把文件数据落盘,而是先写入缓存和FAT表,等主机主动发“SYNCHRONIZE CACHE”命令(也就是安全弹出时的操作)才真正要求设备刷新缓存。
如果你在回调里直接忽略了SYNCHRONIZE CACHE,也就是把数据删了但没刷盘,就会丢。我的修复方法是:在tud_msc_sync_cache_cb回调里把RAM缓冲区里未写完的数据强制刷到SPI Flash。同时,设置一个“写脏标记”,每次收到WRITE命令只标记,等收到同步命令或者主机断开前再批量写入。
简单来说,设备端不能把每一个SCSI WRITE都理解成“立刻落盘”,它在很多情况下只是“写入缓存”的意思。理解了这就理解了USB MSC的数据流。
5.4 多字节读取错位:offset参数的陷阱
还有一个后来才注意到的问题:tud_msc_read_cb里的offset参数不是文件系统的偏移,而是当前SCSI请求内已经读取了多少字节的偏移。主机每次发来的bufsize不一定是512的整数倍,它可能分包读取,设备要按offset搬运数据。
我最初直接spi_flash_read(lba * 512 + offset, buffer, bufsize),看起来逻辑没错,但有时候文件拷出后打不开。后来查TinyUSB源码和邮件列表才搞明白,同一个LBA的读取,可能被拆成多次调用,每次bufsize不同,而lba是保持不变的。正确的处理方式应该是:
uint32_t const sector_addr = lba * block_size; memcpy(buffer, flash_data + sector_addr + offset, bufsize);关键是“块地址+块内偏移”,而不是“把每次回调的offset当成新的地址偏移”。
6. 这个项目还能怎么玩:扩展思路与进一步优化
6.1 把板载Flash文件系统做成“双分区”U盘
跑通最基础的U盘后,我很快不满足了。一个常见的产品需求是:MCU固化一些配置文件在U盘里,用户插上电脑能直接修改,修改完拔掉,MCU再读取这些配置运行。
这个方案超出“纯裸U盘”的范围,需要在设备端同时暴露两个LUN(逻辑单元号),一个LUN映射到配置分区,一个LUN映射到数据分区。TinyUSB的多LUN支持很完善,在tud_msc_capacity_cb等回调里通过lun参数区分不同介质即可。
不过要提醒一点:别让用户直接看到两个裸盘,更好的方案是把块设备做成FAT文件系统,用户插上电脑能看到一个个文件,而不是一个“打不开的盘”。这意味着要在MCU上集成FatFS,然后把TinyUSB的读写回调接到FatFS的底层diskio接口上。看起来工程量大了一倍,但产品价值也大了一倍。
6.2 安全弹窗与自动卸载:优雅的产品体验
U盘设备还有一个普通开发者容易忽略的细节:Windows右下角“安全删除硬件”弹出功能。如果不做特殊处理,Windows会认为设备支持“弹出”特性,从而在通知栏显示图标。对某些产品来说,这个“弹出”功能必须禁用,否则用户点了弹出,设备还在工作,逻辑会混乱。
在MSC的SCSI层,这个操作对应ALLOW MEDIUM REMOVAL和START STOP UNIT命令。在TinyUSB里,可以通过处理tud_msc_test_unit_ready_cb等回调来控制。如果你的产品不允许用户在运行中拔盘,那就主动设置检测位为false,并屏蔽弹出命令。
6.3 性能调优:批量传输和缓冲区的取舍
最后聊聊速度。Full-Speed USB的理论带宽是12Mbps,实际拷贝文件能到1MB/s左右就算不错。很多人优化半天速度上不去,瓶颈常常在SPI Flash的擦写延迟上。
我的实测优化顺序是:
- 确保MSC缓冲区
CFG_TUD_MSC_BUFSIZE开得足够大,至少512字节,有条件就1024;缓冲区小,每包都要等待Flash写入完成,速度直接被拖垮。 - 使用SPI Flash时,开启SPI的硬件发送FIFO和DMA,CPU不参与逐个字节搬运。
- 如果数据量不大,直接把整个U盘内容缓存到SRAM再定期刷入Flash,可以极大减少Flash擦写次数,寿命和速度一起提升。
全套优化下来,我用8MHz SPI时钟的W25Q64做主存储,实际写速度从原来的约200KB/s提升到了850KB/s左右。虽然和真正的高速U盘没法比,但对一个MCU模拟的U盘来说,完全够用了。
7. 写在最后:调试USB设备的一些个人体会
项目做完后回头看,最大的感受是:USB协议栈本身并不难理解,难的是你能不能在崩溃的边缘保持耐心,把协议栈的日志打开,一条命令一条命令地看。TinyUSB的调试信息特别有价值,打开CFG_TUSB_DEBUG,它会在串口打印出主机发来的每个控制传输请求,你只要对照USB规范里的标准请求来看,几乎所有枚举问题都能定位到具体是哪个描述符写错了。
还有一个小习惯值得分享:每次修改描述符或回调代码后,重新插拔USB之前,先把开发板断电再上电,而不是只按复位键。因为很多USB外设状态在上电时才会初始化,复位键有时不会重新触发D+上拉,容易造成“这次改了没生效”的假象。
最后,如果你在做的产品要求高可靠性,不建议直接用我这个“裸TinyUSB+外部Flash”的方案直接量产。至少要做到三件事:一是电源电路上增加TVS管和ESD保护,USB热插拔时的浪涌很容易打坏MCU引脚;二是实现Flash坏块管理和掉电保护,不然用户正拷贝文件时突然断电,U盘可能直接变成“RAW格式”;三是把固件升级通道也集成进去,免得以后想升级协议栈还要拆机连ST-Link。
但如果你只是想快速做一个U盘验证功能,或者正在学习USB协议,那么TinyUSB加STM32这条路,绝对是我踩完各种坑之后仍然愿意推荐给你的最快路径。试着动手做一次,你会发现自己对“驱动开发”的恐惧,其实只是对未知的恐惧。