
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 Speed12MbpsUSB_OTG_HS在High Speed480Mbps模式下需要外接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_DMPA11USB差分数据负线OTG_FS_DPPA12USB差分数据正线OTG_FS_VBUSPA9VBUS电压检测接5VOTG_FS_IDPA10OTG模式识别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外设需要精确的48MHzF407的USB_OTG_FS对时钟要求非常严格必须输入48MHz。如果PLL配置不对枚举时设备会反复复位或者USB主机发出去的SETUP包根本没有回应。我当时在CubeMX里的时钟树是这样的HSE使用8MHz外部晶振主PLL配置为168MHzSYSCLKPLL48CLK必须配置为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 FATFSCubeMX里的配置路径是这样的Connectivity → USB_OTG_FS → ModeHost_OnlyMiddleware → USB_HOST → ClassMass Storage Host ClassMiddleware → 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.hUSB主机初始化入口usbh_msc.cMass Storage设备类处理fatfs.c / diskio.cFatFs的HAL层封装app_usb_host.cUSB 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设备上拉DLow 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协议的核心是三个数据对象CBWCommand Block Wrapper主机发给设备的命令包31字节包含命令块通常是SCSI命令CSWCommand Status Wrapper设备返回的状态包13字节告诉主机刚才的命令是否成功Data数据阶段可能是主机发数据也可能是设备返回数据在STM32 USB Host库中USBH_MSC类会在类请求阶段发送以下SCSI命令SCSI命令作用INQUIRY获取设备基本信息厂商、型号TEST UNIT READY检查设备是否就绪U盘未挂载时返回Not ReadyREAD 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_ReadDRESULT 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_LFN2支持长文件名0关闭1静态缓冲区2动态缓冲区推荐_MAX_SS4096最大扇区大小U盘通常512但一些4K扇区U盘存在_FS_READONLY00可写1只读只读固件升级场景可设1以节省RAM_CODE_PAGE936936简体中文GBK支持中文文件名_VOLUMES1卷数量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_LFN2、_CODE_PAGE936之后问题解决。还要注意如果你用SD卡调试正常但U盘上同样名字的文件打不开很可能是U盘上的文件系统不是标准FAT32。比如U盘被格式化成了exFATFatFs老版本不支持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 HostFATFS这条链路上跑得很稳定。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.xUSB Host库的API也变了不少但整个架构逻辑几乎没变USB OTG外设做主机MSC类处理U盘FATFS管文件系统。把这三层理解透了不管是换芯片还是换库你都能很快迁移。如果非要给一条最实在的建议我会说先不要急着在开发板上调代码先把VBUS供电、ID引脚、DP/DM差分线画对再看软件。电源和硬件链路没问题软件调试通常一天就能跑通。祝你一次点亮。