2018年5月那次内部培训,我定的主题就是:让STM32当USB主机,插上U盘,直接把文件系统里的文件读出来。当时团队要做固件升级,客户不想每次都用串口线连电脑,理想状态是把升级文件丢进U盘,设备上电自动识别、自动读取、自动刷写。这个需求放到今天依然非常典型,而且我发现直到现在,仍然有不少人被“STM32 + USB OTG + U盘 + 文件系统”这套组合拳劝退——不是硬件不通,就是卡在枚举,要么就是文件系统挂不上。这篇文章把当年的工程从头到尾拆开,把原理、配置、代码、踩坑都讲清楚,给正准备折腾U盘读取的朋友一条能直接走通的路。
1. 从“读U盘”这个需求说起:先想清楚硬件链路再去碰代码
1.1 不是所有STM32都能当USB主机
那次培训用的板子是STM32F407IGT6,选它不是因为性能有多猛,而是因为F4系列带USB OTG外设。很多人一开始会拿STM32F103C8T6做试验,然后就卡住了——F103的USB模块是Device Only,只能做从机,不能做主机。它在USB协议栈里天然没有Host那一套状态机,硬件层面也没有提供主机模式需要的VBUS控制和ID检测,所以F103是读不了U盘的。
这里的常见误区是:以为“USB OTG”只是软件上的模式切换,实际上引脚和IP都是硬件绑定的。STM32F1/F2/F4/F7/H7系列里的USB OTG外设,是一个独立的IP核。F407上同时有USB_OTG_FS和USB_OTG_HS两个外设。USB_OTG_FS支持Full Speed(12Mbps),USB_OTG_HS在High Speed(480Mbps)模式下需要外接ULPI接口的PHY芯片,否则也只能降级当FS用。对U盘这种设备来说,FS模式完全够用——读一个几MB的固件包,也就一两秒的事。
1.2 主机模式和从机模式,本质上是两套工作逻辑
从机模式(Device)下,MCU是被动响应主机请求,就像服务员等客人点单。主机模式(Host)下,MCU主动发起一切通信,要自己管理总线复位、枚举、地址分配、端点配置、传输调度,相当于你直接当了餐厅老板,服务员、采购、收银全得你管。
OTG模式则是在两者之间动态切换:ID引脚接地时设备充当主机(A设备),ID引脚悬空时设备充当从机(B设备)。如果把CubeMX里的USB_OTG_FS配置成 Host_Only,那么软件直接锁死主机模式,不需要ID引脚判断。当年培训的工程为了稳定,直接选的Host_Only。
从代码层面看,HAL库的Host和Device是两套完全不同的API:主机侧先USBH_Init再USBH_Process,从机侧是HAL_PCD_Start和一堆回调。别想着写一份代码两边通用,OS里你还要自己处理状态机。
1.3 硬件连接:PA11、PA12、PA9,谁也不能错
USB OTG_FS的引脚非常固定:
| 信号 | 引脚 | 说明 |
|---|---|---|
| OTG_FS_DM | PA11 | USB差分数据负线 |
| OTG_FS_DP | PA12 | USB差分数据正线 |
| OTG_FS_VBUS | PA9 | VBUS电压检测,接5V |
| OTG_FS_ID | PA10 | OTG模式识别,Host模式可接地 |
这里有个特别容易翻车的点:主机模式必须自己给U盘供电。PA9虽然叫VBUS,但它只负责检测电平,不负责输出5V电源。你需要在硬件上给USB座的VBUS引脚接上5V供电,这个5V可以由板上的5V网络提供,也可以接一个USB口的VBUS输入。
图我就不画了,直接说经验:当年培训时,有两块板子死活识别不到U盘,排查了半天,最后发现是USB座子的VBUS没接5V。U盘插入后没有任何反应,逻辑分析仪看波形也是一片死寂。把5V接上,USB枚举立刻正常。所以检查顺序永远是:供电 → 引脚 → 软件。
还有一个细节,ID引脚在Host_Only模式下可以接地。如果你用的是带OTG的USB座,ID脚悬空也能工作(因为CubeMX配成Host_Only后代码不读ID)。但建议还是老老实实接地,避免和OTG模式混淆。
2. CubeMX工程配置实录:时钟、OTG和中间件,一个都不能错
2.1 时钟树:USB外设需要精确的48MHz
F407的USB_OTG_FS对时钟要求非常严格,必须输入48MHz。如果PLL配置不对,枚举时设备会反复复位,或者USB主机发出去的SETUP包根本没有回应。
我当时在CubeMX里的时钟树是这样的:
- HSE使用8MHz外部晶振
- 主PLL配置为168MHz(SYSCLK)
- PLL48CLK必须配置为48MHz,供USB OTG FS使用
这一步最隐蔽的坑是:CubeMX不会强制你检查PLL48CLK,你改了一处分频系数,USB时钟可能在不知不觉中被带到46MHz或50MHz。所以生成代码之前,一定要在Clock Configuration页面确认USB那一栏显示的是48MHz。我见过不止一个人,明明CubeMX看着一切正常,代码也生成成功了,U盘就是识别不了,最后发现是PLL配置里Q系数不对,导致USB时钟偏了。
2.2 中间件配置:USB_HOST + Mass Storage Class + FATFS
CubeMX里的配置路径是这样的:
- Connectivity → USB_OTG_FS → Mode:Host_Only
- Middleware → USB_HOST → Class:Mass Storage Host Class
- Middleware → FATFS → 底层驱动选USB(有的版本显示为USB Disk)
注意FATFS这个中间件,设计初衷是支持SD卡和U盘两种介质。如果你用的是STM32CubeMX生成工程,FATFS界面的“Platform”里有SD和USB两个选项。既然我们是USB U盘,就选USB,这样CubeMX会自动把diskio.c底层接口绑定到USBH_MSC_Read/Write。
如果不依赖CubeMX,手动移植FatFs也不复杂,核心就是实现diskio.c里的那几个底层函数,后面会细说。
生成的工程里,你会看到几个关键文件:
| 文件 | 作用 |
|---|---|
| usb_host.c / usb_host.h | USB主机初始化入口 |
| usbh_msc.c | Mass Storage设备类处理 |
| fatfs.c / diskio.c | FatFs的HAL层封装 |
| app_usb_host.c | USB Host应用状态机回调 |
2.3 生成后的启动流程,跑通它才算半只脚踩进门
代码生成后,main()里大约是这样的流程:
int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USB_HOST_Init(); MX_FATFS_Init(); while (1) { MX_USB_HOST_Process(); } }MX_USB_HOST_Process()内部其实就是调用USBH_Process(&hUsbHostFS),这个函数必须被高频调用,它负责USB主机状态机的推进。我当年把它放在while(1)里死循环跑,频率约百万次每秒,没有任何问题。但如果你用了RTOS,一定要保证这个函数所在的Task优先级足够高,或者使用USBH_Process的线程安全版本,否则U盘拔插时会出现卡死或漏检。
3. USB Host协议栈到底在干什么:U盘是怎么被“承认”的
3.1 从物理连接到设备枚举:一次完整的握手过程
U盘插入USB座后,主机会经历一个标准枚举过程。这个过程发生在USB协议层,用大白话讲就是“主机向设备自我介绍,并要求设备介绍自己”。
枚举的简化流程:
- 主机检测到设备上拉DP引脚(U盘内部有1.5k上拉电阻,Full Speed设备上拉D+,Low Speed设备上拉D-)
- 主机对总线复位,设备地址回到0
- 主机发GET_DESCRIPTOR(Device),设备返回设备描述符(包括VID/PID、端点0最大包长等)
- 主机发SET_ADDRESS,给设备分配一个新地址(比如1)
- 主机再发GET_DESCRIPTOR(Device)确认
- 主机发GET_DESCRIPTOR(Config),获取配置描述符、接口描述符、端点描述符
- 主机SET_CONFIGURATION,设备进入配置完成状态
在STM32的USB Host库里,这些步骤被封装到了USBH_HandleEnum里面。代码层面的表现就是HOST_DEV_ATTACHED → HOST_DEV_ENUMERATED → HOST_DEV_CONFIGURED的状态切换。如果你在调试时看到卡在HOST_ENUMERATION,绝大多数情况是设备没有正确响应SET_ADDRESS或GET_DESCRIPTOR,优先查时钟和供电。
3.2 Mass Storage类的传输机制:CBW、CSW和SCSI命令
枚举完成后,USB主机还要进一步和设备通信,确认这个设备是U盘(Mass Storage Class),而不是键盘或鼠标。MSC设备使用Bulk Only Transport协议,也就是BOT协议。
BOT协议的核心是三个数据对象:
- CBW(Command Block Wrapper):主机发给设备的命令包,31字节,包含命令块(通常是SCSI命令)
- CSW(Command Status Wrapper):设备返回的状态包,13字节,告诉主机刚才的命令是否成功
- Data:数据阶段,可能是主机发数据,也可能是设备返回数据
在STM32 USB Host库中,USBH_MSC类会在类请求阶段发送以下SCSI命令:
| SCSI命令 | 作用 |
|---|---|
| INQUIRY | 获取设备基本信息(厂商、型号) |
| TEST UNIT READY | 检查设备是否就绪(U盘未挂载时返回Not Ready) |
| READ CAPACITY(10) | 获取U盘总扇区数和扇区大小 |
| READ(10) | 读取指定扇区数据 |
| WRITE(10) | 写入指定扇区数据 |
一个典型的现象是:U盘插入后,USBH_User_Process回调里会先收到HOST_USER_CLASS_ACTIVE,但这时文件系统还不能马上用,因为U盘内部可能还在初始化(尤其是一些老U盘,上电后要几百毫秒才能响应TEST UNIT READY)。所以底层代码里通常会有重试机制。
3.3 为什么识别不到U盘:我建议你按这个顺序排查
很多人在这一步就卡住了。如果U盘始终无法被识别,我的排查顺序是:
- 先量USB座子VBUS有没有5V
- 用示波器/逻辑分析仪看DP/DM引脚是否有数据波形
- 检查USB_OTG_FS是否配置成Host_Only,不要配成Device_Only
- 检查PLL48CLK,确认是48MHz
- 换一个U盘(有些U盘的SCSI实现不规范,兼容性差)
- 给U盘供电加一个100uF以上的电解电容,防止瞬态压降
还有一个常见坑:开发板的USB座是Device模式的Type-B座,或者Micro-USB座子里的ID引脚接死了。你把它插到电脑上没问题,但让MCU当主机时,需要用USB-A座子或者转接线。我当年培训时,有学员用一根Micro-USB转USB-A的OTG线调试,结果ID引脚悬空,Host_Only模式下倒是没受影响,但个别版本库会读ID来决定角色,导致识别不稳定。保险做法是直接使用Type-A座,ID引脚接地。
4. FatFs文件系统挂载:从裸扇区读写到打开第一个文件
4.1 diskio层:FatFs怎么和USB Host“握手”
FatFs是一个与硬件无关的文件系统层,它只负责管理FAT表、目录项、文件分配等逻辑,真正和硬件打交道的是diskio.c中的底层函数。对于U盘工程,核心就是这几个函数:
DSTATUS disk_initialize(BYTE pdrv); DSTATUS disk_status(BYTE pdrv); DRESULT disk_read(BYTE pdrv, BYTE *buff, LBA_t sector, UINT count); DRESULT disk_write(BYTE pdrv, const BYTE *buff, LBA_t sector, UINT count); DRESULT disk_ioctl(BYTE pdrv, BYTE cmd, void *buff);其中disk_read是最关键的。对于U盘,它应该调用USBH_MSC_Read:
DRESULT disk_read(BYTE pdrv, BYTE *buff, LBA_t sector, UINT count) { if (USBH_MSC_Read(&hUsbHostFS, sector, buff, count) == USBH_OK) { return RES_OK; } return RES_ERROR; }注意这里的sector是LBA逻辑块地址,count是扇区数,每个扇区通常512字节。USBH_MSC_Read内部会构造CBW,发送SCSI READ(10)命令,然后接收数据。如果你在调试时发现f_open返回FR_NOT_READY,可能是disk_read一直没有成功返回RES_OK。
4.2 标准文件读取流程:f_mount、f_open、f_read
挂载U盘文件系统,标准流程是:
FATFS fs; // 文件系统对象 FIL file; // 文件对象 FRESULT res; // 1. 挂载逻辑驱动器,第二个参数是盘符路径,填""表示默认盘符 res = f_mount(&fs, "", 1); if (res != FR_OK) { // 处理挂载失败:FR_NOT_READY(没有介质) / FR_NO_FILESYSTEM(没有文件系统) } // 2. 打开文件 res = f_open(&file, "FIRMWARE.BIN", FA_READ); if (res != FR_OK) { // 处理打开失败:FR_NO_FILE(文件不存在) / FR_DENIED(权限问题) } // 3. 循环读文件 uint8_t buf[512]; UINT bytesRead; while (f_read(&file, buf, sizeof(buf), &bytesRead) == FR_OK && bytesRead > 0) { // 处理读取到的bytesRead字节数据 } // 4. 关闭文件 f_close(&file);f_mount里的第三个参数1表示立即挂载。如果不传1,首次调用f_open时也会自动挂载,但立即挂载能让你更早发现问题。
4.3 FATFS配置的几个关键开关
FATFS在ffconf.h里有几个宏,直接影响功能:
| 宏 | 值 | 说明 |
|---|---|---|
| _USE_LFN | 2 | 支持长文件名,0=关闭,1=静态缓冲区,2=动态缓冲区(推荐) |
| _MAX_SS | 4096 | 最大扇区大小,U盘通常512,但一些4K扇区U盘存在 |
| _FS_READONLY | 0 | 0=可写,1=只读(只读固件升级场景可设1以节省RAM) |
| _CODE_PAGE | 936 | 936=简体中文GBK,支持中文文件名 |
| _VOLUMES | 1 | 卷数量,U盘工程一个就够 |
如果你发现FatFs能挂载U盘但打开中文名文件失败,八成_CODE_PAGE没配置,或者_USE_LFN没打开。需要注意,改_CODE_PAGE为936会引入一个较大的一字节转两字节码表,Flash小的芯片要注意空间占用。
4.4 一个完整可运行的读取示例
把上面几个部分串起来,完整的读取逻辑大概是这样:
#include "ff.h" FATFS fs; FIL file; void UserApp_ReadUPack(void) { FRESULT res; uint8_t readBuffer[1024]; UINT bytesRead; // 确保USB Host处于APPLICATION_READY if (AppState != APPLICATION_READY) return; res = f_mount(&fs, "", 1); if (res != FR_OK) return; res = f_open(&file, "UPGRADE/APP.BIN", FA_READ); if (res != FR_OK) return; do { res = f_read(&file, readBuffer, sizeof(readBuffer), &bytesRead); if (res != FR_OK) break; // 将readBuffer中的bytesRead字节写入Flash或其他目标 // 下面这句是示意,实际升级流程里你可能会做校验、分块写入等 // Flash_Program(bytesRead, readBuffer); } while (bytesRead > 0); f_close(&file); f_mount(NULL, "", 0); // 卸载文件系统 }AppState来自USBH_User_Process回调,需要在USB主机枚举成功后才置为APPLICATION_READY。我不会一插入U盘就去读文件,而是先初始化USB主机,等回调通知“设备就绪”,再挂载文件系统。这个先后顺序很重要。
5. 实测中踩过的坑:5个问题,个个都让我调过至少半天
5.1 供电不足:U盘枚举成功-失败-成功-失败的死循环
第一块板子接上U盘后,USBH_User_Process里一会儿收到HOST_USER_CONNECTION,一会儿收到HOST_USER_DISCONNECTION,疯狂循环。用示波器抓VBUS波形,发现插上U盘瞬间VBUS跌到4.2V左右,然后又跳回5V,如此往复。
原因是U盘内部Flash控制器启动时瞬态电流大,而板上5V网络由一片LDO提供,电流余量不足。解决办法是VBUS直接接外部5V电源适配器供电,或者并联470uF电容。从那以后,我在USB Host相关的电路板上都会预留大容量储能电容的位置,这是血泪经验。
5.2 USBH_Process调用不够频繁:一切正常但数据不对
有的代码把USBH_Process放在一个10ms周期的RTOS消息里调用,结果U盘能枚举成功,但读文件时数据时而正确时而错误。原因就是USB主机状态机被“卡顿”了,BOT协议里的CSW状态超时。
USBH_Process的正确节奏是“尽可能快地持续调用”。放在while(1)里没问题;如果用RTOS,就给它一个高优先级任务,至少保证1ms内调用几十次。USB是时分复用总线,主机必须及时响应设备的中断传输请求,否则协议层会超时。
5.3 U盘兼容性:不是所有U盘都“讲道理”
BOT协议和SCSI命令虽然都有标准,但不同厂商的实现细节千差万别。有的U盘对TEST UNIT READY反应慢,有的U盘在READ CAPACITY之后还需要额外延迟,还有少量U盘支持多LUN(逻辑单元号),这就要求Get Max LUN命令返回正确值。
我整理了一个工程内部用的兼容性记录:
| U盘型号 | 是否正常 | 备注 |
|---|---|---|
| 金士顿 DataTraveler 16GB | 正常 | 标准实现 |
| 闪迪 CZ48 32GB | 正常 | 兼容性最好 |
| 忆捷 4GB(老款) | 枚举成功但读扇区偶发超时 | 需要加大CSW超时时间 |
| 某个杂牌8GB | 完全无法枚举 | SET_ADDRESS无响应 |
所以如果你的产品面向大众用户,U盘兼容性测试一定要做,并且要在协议层预留超时重试机制,比如CSW超时后重发命令,最多重试3次。
5.4 中文文件名和长文件名:f_open永远返回FR_NOT_FOUND
测试的时候用“测试文件.txt”这个文件名,FatFs挂载成功,目录列表也能列出来,但f_open就是找不到。排查后发现是ffconf.h里的_USE_LFN没有开启,同时_CODE_PAGE还是437(英文)。改成_USE_LFN=2、_CODE_PAGE=936之后,问题解决。
还要注意,如果你用SD卡调试正常,但U盘上同样名字的文件打不开,很可能是U盘上的文件系统不是标准FAT32。比如U盘被格式化成了exFAT,FatFs老版本不支持exFAT。解决办法是让U盘保持FAT32格式,或者升级FatFs到支持exFAT的版本(FF_FS_EXFAT开启)。
5.5 缓冲区对齐问题:读出来的数据全是0或每隔一段错位
这个问题是后来做USB传输优化时才遇到的。MSC底层使用DMA传输时,数据缓冲区如果不对齐到4字节,某些DMA配置下会失败或数据错乱。
解决方案很简单:读文件用的buffer定义成4字节对齐。
__ALIGN_BEGIN uint8_t readBuffer[1024] __ALIGN_END;同时在FatFs配置里打开_USE_LBA和_USE_DMA相关的选项(如果版本支持),确保底层USBH_MSC_Read的传入缓冲区地址满足对齐要求。类似问题在F4/F7上比较常见,因为L1-Cache开启后还涉及Cache一致性处理。
6. 从培训工程到量产项目:还差哪几块拼图
6.1 拔插状态检测和自动重试:产品不能只在“姿势正确”时工作
原型工程里,U盘插入后我们手动按一下复位键再读文件。但产品化的时候,用户不会考虑“顺序”。所以你要在USBH_User_Process的HOST_USER_DISCONNECTION回调里清理FATFS状态,f_mount(NULL, "", 0)卸载文件系统,并重置AppState为APPLICATION_IDLE。同时在检测到U盘连接后,不要立即挂载,应延迟500ms左右再执行文件操作,给U盘内部初始化留时间。
这个逻辑很像手机插入充电线时的“先握手再通信”。如果在写文件过程中U盘被拔走,底层USB读返回错误,FatFs可能处于不一致状态,必须做异常恢复处理。
6.2 文件校验和固件升级中的实际使用
读取U盘文件最常见的目的就是固件升级。这时候你不能只把文件读出来就完事,通常需要:
- 在升级文件头放固件版本、长度、校验值等信息
- 每读一个块,累加CRC32或计算SHA256
- 全部读完且校验通过后,才允许跳转到Bootloader的Flash写入流程
- 校验失败时保留旧固件,不能破坏现有系统
我在工程里的做法是自定义一个头结构体:
typedef struct { uint32_t magic; // 0xAA55A5A5 uint32_t version; uint32_t length; // 固件长度 uint32_t crc32; // 固件CRC32 } ProgramHeader;读取U盘文件时先读头部,验证magic和length,再逐块读入并计算CRC,最后和头部里的crc32对比。整个过程在USB Host+FATFS这条链路上跑得很稳定。
6.3 量产注意:低成本MCU移植FATFS的空间和时序
有些量产项目为了成本,想用F103之类的芯片做U盘读取,但正如第一小节所说,F103没有USB Host。如果你的团队坚持用低端芯片,可以考虑外接CH376等USB Host转接芯片。大致思路就是:MCU通过SPI/并口控制CH376,再让CH376负责USB枚举和MSC协议,MCU侧只需操作FATFS。
这种方案的缺点是增加了BOM成本和外设芯片,但好处是软件复杂度大幅降低,且对MCU主频和Flash要求不高。如果你的产品形态是“几十块钱的小家电”,这种方案更现实。
7. 给后来者的一句话
那次培训到现在过去好几年了,STM32CubeMX从4.x一路升级到6.x,USB Host库的API也变了不少,但整个架构逻辑几乎没变:USB OTG外设做主机,MSC类处理U盘,FATFS管文件系统。把这三层理解透了,不管是换芯片还是换库,你都能很快迁移。
如果非要给一条最实在的建议,我会说:先不要急着在开发板上调代码,先把VBUS供电、ID引脚、DP/DM差分线画对,再看软件。电源和硬件链路没问题,软件调试通常一天就能跑通。祝你一次点亮。