基于TinyUSB的STM32 USB U盘实现与调试指南
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 USB12Mbps要求时钟精度在±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复用配置好。如果你是STM32F103PA11是USB_DMPA12是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卡。我的第一个版本用的是W25Q648MB容量拿来当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 128block_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和DMACPU不参与逐个字节搬运。如果数据量不大直接把整个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这条路绝对是我踩完各种坑之后仍然愿意推荐给你的最快路径。试着动手做一次你会发现自己对“驱动开发”的恐惧其实只是对未知的恐惧。