1. 项目缘起与整体设计思路
1.1 为什么要在STM32上折腾SPI Flash做U盘
手头有一块STM32的开发板,板子上焊了一片W25Q系列SPI Flash,容量不大,8MB或者16MB的样子。平时这片Flash就是存点配置参数、字库文件、日志记录,用起来总觉得有点浪费——毕竟它本质上就是一个块存储设备,跟U盘里的存储颗粒是同类东西。那能不能让STM32变成一个“U盘读卡器”,把这片SPI Flash模拟成U盘,插上电脑就能直接拖拽文件?
这个想法其实很实用。比如你做了一台小设备,需要导出运行日志,传统做法是串口打印或者SD卡拷贝,但如果设备本身就能被电脑识别为U盘,用户操作门槛几乎为零。再比如产品出厂前需要预置一批配置文件,产线工人只需要插上USB,把文件拖进去就行,不需要额外的上位机工具。
STM32CubeMX加上HAL库,配合USB Device中间件里的MSC(Mass Storage Class)类,实现这个功能并不复杂。核心思路是:STM32通过SPI接口读写Flash,USB接口枚举成MSC设备,电脑发来的SCSI读写命令被翻译成对Flash的扇区读写操作。整个链路是:电脑文件系统 -> USB MSC协议 -> SCSI命令 -> STM32 MSC类驱动 -> SPI Flash驱动 -> 物理Flash。
1.2 方案选型:为什么用CubeMX + HAL + MSC
有人可能会问,为什么不直接拿现成的U盘主控芯片?因为手头已经有STM32和Flash了,而且用STM32做这件事的附加价值在于:你可以同时跑其他业务逻辑。比如设备正常工作时Flash存的是私有格式数据,当插入USB时,STM32可以动态切换Flash的访问权限,把某一段区域映射成U盘空间,实现“设备数据导出”功能。
选CubeMX是因为它把USB Device中间件、SPI外设、时钟树、中断优先级这些容易出错的配置都图形化了。尤其是USB的时钟配置,48MHz的USB时钟必须精确,手动算PLL很容易翻车。HAL库虽然被人吐槽效率低,但对于MSC这种对实时性要求不极端的场景完全够用,而且代码可读性好,移植方便。
MSC类本身是USB Device中间件里比较成熟的一个,它实现了BOT(Bulk-Only Transport)协议和SCSI命令集的大部分常用命令。我们需要做的,就是实现几个底层回调:读扇区、写扇区、获取容量、检查就绪状态。剩下的枚举过程、命令解析、状态返回,中间件都帮你处理了。
1.3 整体数据流与关键约束
在动手之前,先把数据流理清楚。电脑端发起一个文件拷贝操作,实际落到Flash上会经历这些步骤:
- 电脑文件系统驱动发出写请求,目标逻辑块地址(LBA)为N,长度为M个扇区。
- USB主机控制器把SCSI WRITE(10)命令通过Bulk端点发给STM32。
- STM32的USB中断收到数据,MSC类驱动解析出LBA和扇区数,调用
STORAGE_Write_FS回调。 - 回调函数里,我们需要把LBA转换成Flash的物理地址,然后调用SPI Flash的页编程函数。
- Flash写入完成后,返回状态给MSC驱动,驱动再通过Bulk端点回复CSW(Command Status Wrapper)。
- 电脑收到成功状态,继续下一个请求。
这里有几个硬约束必须注意:
- 扇区大小固定为512字节。这是MSC协议和大多数文件系统的默认值,Flash的页大小通常是256字节或512字节,需要做适配。
- Flash的擦除粒度远大于写入粒度。W25Q系列通常4KB为一个扇区(Sector),64KB为一个块(Block)。而U盘文件系统会频繁改写FAT表和目录项,如果每次写512字节都触发4KB擦除,寿命和性能都会崩。所以必须加缓存层。
- USB枚举时电脑会读取容量信息。如果容量报告不对,电脑可能无法格式化或者识别为RAW设备。
- SPI时钟不能太高。W25Q系列在标准SPI模式下通常支持最高80MHz左右,但STM32的SPI外设和PCB走线质量会影响实际稳定频率。建议先从低速开始调通,再逐步提高。
2. 核心细节解析与实操要点
2.1 CubeMX里的关键配置项
打开CubeMX,选好芯片型号后,需要配置以下几个模块:
SPI配置:选择全双工主模式,数据宽度8位,CPOL和CPHA根据Flash手册设置。W25Q系列通常支持Mode 0(CPOL=0,CPHA=0)和Mode 3(CPOL=1,CPHA=1),建议用Mode 0。预分频系数先设大一点,比如256分频,确保初始通信稳定。NSS引脚用软件控制,因为我们要自己控制片选时序。
USB配置:在Connectivity里选USB_OTG_FS或者USB,模式选Device_Only。然后在Middleware里选USB_DEVICE,类选Mass Storage Class。注意,如果芯片支持USB OTG,要确认用的是FS(全速)还是HS(高速)。大多数F4和F7系列都有OTG_FS,全速12Mbps,实际传输速度大概在1MB/s左右,对于小容量Flash够用了。
时钟配置:这是最容易出问题的地方。USB模块需要精确的48MHz时钟。以STM32F407为例,外部晶振8MHz,经过PLL配置,主频168MHz,USB时钟需要从PLL的Q分频器输出48MHz。CubeMX里直接选USB时钟源为PLL_Q,然后调整PLL参数让Q输出正好48MHz。如果时钟不对,USB枚举会失败,电脑会提示“未知USB设备”。
中断优先级:USB中断优先级要高于SPI中断(如果用了SPI DMA)。因为USB主机对响应时间有要求,如果USB中断被长时间阻塞,主机会认为设备掉线。建议USB中断设为最高优先级(0或1),SPI中断设为较低优先级。
2.2 SPI Flash驱动层的实现要点
CubeMX生成的代码只包含SPI初始化,Flash的读写擦除需要自己实现。W25Q系列的命令集比较标准,核心命令有:
| 命令 | 十六进制 | 功能 |
|---|---|---|
| Write Enable | 0x06 | 写使能,每次写/擦前必须发 |
| Read Status Register 1 | 0x05 | 读状态,判断BUSY位 |
| Read Data | 0x03 | 读数据,支持任意地址 |
| Page Program | 0x02 | 页编程,每页最多256字节 |
| Sector Erase (4KB) | 0x20 | 擦除4KB扇区 |
| Block Erase (64KB) | 0xD8 | 擦除64KB块 |
| JEDEC ID | 0x9F | 读芯片ID,用于识别型号 |
实现时要注意几个细节:
片选控制:每次命令前拉低CS,命令和数据传输完成后拉高CS。中间不能有额外延时,否则Flash可能复位状态机。用HAL库的HAL_GPIO_WritePin控制CS,注意在拉低CS之前确保SPI总线空闲。
忙等待:Flash擦除和编程需要时间,4KB扇区擦除典型值45ms,页编程0.7ms。每次操作后要轮询状态寄存器的BUSY位,直到为0才能发下一条命令。不要用固定延时,因为不同型号和温度下时间差异很大。
地址转换:Flash的地址是24位,支持最大16MB容量。如果用的是32MB或更大的Flash,需要4字节地址模式,命令集也不同。这里假设是8MB或16MB的常见型号。
写保护:W25Q有状态寄存器的保护位,如果被置位,写入会失败。初始化时先读状态寄存器,确认BP位和WPS位都是0。如果被锁了,需要发写状态寄存器命令解锁。
2.3 MSC类的回调函数实现
USB Device中间件里的MSC类定义了几个弱函数,需要我们在usbd_storage_if.c里重写:
int8_t STORAGE_Init_FS(uint8_t lun); int8_t STORAGE_GetCapacity_FS(uint8_t lun, uint32_t *block_num, uint16_t *block_size); int8_t STORAGE_IsReady_FS(uint8_t lun); int8_t STORAGE_IsWriteProtected_FS(uint8_t lun); int8_t STORAGE_Read_FS(uint8_t lun, uint8_t *buf, uint32_t blk_addr, uint16_t blk_len); int8_t STORAGE_Write_FS(uint8_t lun, uint8_t *buf, uint32_t blk_addr, uint16_t blk_len); int8_t STORAGE_GetMaxLUN_FS(void);STORAGE_GetCapacity_FS返回总扇区数和扇区大小。假设Flash容量8MB,扇区512字节,则总扇区数 = 8 * 1024 * 1024 / 512 = 16384。这个值要准确,否则电脑格式化时会出错。
STORAGE_Read_FS和STORAGE_Write_FS是核心。读操作直接调用Flash的读数据命令即可。写操作复杂一些,因为Flash只能把1写成0,不能把0写成1,所以每次写之前必须确保目标区域是擦除过的(全0xFF)。
这里就引出了最关键的问题:U盘文件系统会频繁改写同一区域。比如FAT表,每次新建文件都会更新。如果每次写512字节都擦除4KB,那4KB里的其他数据就丢了。所以必须实现一个读-改-写的缓存机制。
3. 实操过程与核心环节实现
3.1 硬件连接与SPI时序调试
先确认硬件连接。以常见的STM32F407和W25Q64为例:
- SPI1_SCK -> Flash_CLK
- SPI1_MISO -> Flash_DO
- SPI1_MOSI -> Flash_DI
- GPIO输出 -> Flash_CS
- 3.3V和GND
上电后第一件事是读JEDEC ID。发0x9F命令,然后读3个字节,应该是0xEF4017(W25Q64)或0xEF4018(W25Q128)。如果读出来全是0xFF或0x00,检查SPI模式、片选和供电。
调试时用逻辑分析仪抓SPI波形最直观。重点看:CS拉低后第一个时钟沿是否在SCK的上升沿(Mode 0),数据是否在下降沿被采样。如果时序不对,读出来的ID会错位。
SPI时钟频率先用低速跑通,比如STM32主频168MHz,SPI预分频256,得到656kHz。确认读写正常后,逐步降低预分频值,直到出现误码。W25Q64在3.3V下通常能跑到80MHz,但STM32的SPI外设和PCB走线可能限制在20-40MHz。实测下来,20MHz左右比较稳。
3.2 Flash读写擦除的完整实现
先实现基础函数:
void W25Q_WriteEnable(void) { uint8_t cmd = 0x06; CS_LOW(); HAL_SPI_Transmit(&hspi1, &cmd, 1, 100); CS_HIGH(); } uint8_t W25Q_ReadStatus(void) { uint8_t cmd = 0x05; uint8_t status; CS_LOW(); HAL_SPI_Transmit(&hspi1, &cmd, 1, 100); HAL_SPI_Receive(&hspi1, &status, 1, 100); CS_HIGH(); return status; } void W25Q_WaitBusy(void) { while (W25Q_ReadStatus() & 0x01); }擦除函数:
void W25Q_EraseSector(uint32_t addr) { W25Q_WriteEnable(); uint8_t cmd[4] = {0x20, (addr >> 16) & 0xFF, (addr >> 8) & 0xFF, addr & 0xFF}; CS_LOW(); HAL_SPI_Transmit(&hspi1, cmd, 4, 100); CS_HIGH(); W25Q_WaitBusy(); }页编程函数:
void W25Q_PageProgram(uint32_t addr, uint8_t *data, uint16_t len) { W25Q_WriteEnable(); uint8_t cmd[4] = {0x02, (addr >> 16) & 0xFF, (addr >> 8) & 0xFF, addr & 0xFF}; CS_LOW(); HAL_SPI_Transmit(&hspi1, cmd, 4, 100); HAL_SPI_Transmit(&hspi1, data, len, 1000); CS_HIGH(); W25Q_WaitBusy(); }读数据函数:
void W25Q_ReadData(uint32_t addr, uint8_t *buf, uint32_t len) { uint8_t cmd[4] = {0x03, (addr >> 16) & 0xFF, (addr >> 8) & 0xFF, addr & 0xFF}; CS_LOW(); HAL_SPI_Transmit(&hspi1, cmd, 4, 100); HAL_SPI_Receive(&hspi1, buf, len, 1000); CS_HIGH(); }这些函数是基础,但直接拿来做U盘读写会出问题。因为U盘写操作是512字节对齐的,而Flash页编程最多256字节,且不能跨页。所以写512字节需要拆成两次页编程,还要处理跨页边界。
3.3 扇区缓存层的设计与实现
这是整个项目最核心的部分。思路是:在RAM里开辟一个4KB的缓存区,对应Flash的一个物理扇区。当电脑要写某个512字节的逻辑扇区时:
- 计算该逻辑扇区属于哪个4KB物理扇区。
- 如果缓存区里不是这个物理扇区的数据,先从Flash读出整个4KB到缓存。
- 在缓存里修改对应的512字节。
- 把整个4KB缓存擦除后写回Flash。
这样每次写操作实际是“读4KB + 擦4KB + 写4KB”,虽然慢,但保证了数据完整性。对于8MB的Flash,4KB缓存占用的RAM可以接受。
缓存结构:
#define FLASH_SECTOR_SIZE 4096 #define FLASH_SECTOR_COUNT 2048 // 8MB / 4KB #define CACHE_SIZE FLASH_SECTOR_SIZE static uint8_t sector_cache[CACHE_SIZE]; static uint32_t cache_sector_addr = 0xFFFFFFFF; // 当前缓存对应的Flash地址 static uint8_t cache_dirty = 0;写函数:
int8_t STORAGE_Write_FS(uint8_t lun, uint8_t *buf, uint32_t blk_addr, uint16_t blk_len) { uint32_t flash_addr = blk_addr * 512; for (uint16_t i = 0; i < blk_len; i++) { uint32_t current_addr = flash_addr + i * 512; uint32_t sector_base = current_addr & ~(FLASH_SECTOR_SIZE - 1); uint32_t offset_in_sector = current_addr & (FLASH_SECTOR_SIZE - 1); if (cache_sector_addr != sector_base) { // 缓存未命中,先写回旧缓存 if (cache_dirty) { W25Q_EraseSector(cache_sector_addr); for (uint32_t j = 0; j < FLASH_SECTOR_SIZE; j += 256) { W25Q_PageProgram(cache_sector_addr + j, §or_cache[j], 256); } cache_dirty = 0; } // 读入新扇区 W25Q_ReadData(sector_base, sector_cache, FLASH_SECTOR_SIZE); cache_sector_addr = sector_base; } // 修改缓存 memcpy(§or_cache[offset_in_sector], &buf[i * 512], 512); cache_dirty = 1; } return 0; }读函数相对简单,但也要考虑缓存一致性:如果缓存里有脏数据,读之前要先写回。
int8_t STORAGE_Read_FS(uint8_t lun, uint8_t *buf, uint32_t blk_addr, uint16_t blk_len) { uint32_t flash_addr = blk_addr * 512; uint32_t len = blk_len * 512; // 检查是否命中缓存 for (uint32_t i = 0; i < len; i++) { uint32_t current_addr = flash_addr + i; uint32_t sector_base = current_addr & ~(FLASH_SECTOR_SIZE - 1); if (cache_sector_addr == sector_base && cache_dirty) { // 缓存命中且有脏数据,从缓存读 buf[i] = sector_cache[current_addr & (FLASH_SECTOR_SIZE - 1)]; } else { // 直接从Flash读 W25Q_ReadData(current_addr, &buf[i], 1); } } return 0; }这个实现虽然效率不高(逐字节判断),但逻辑清晰,适合先跑通。优化版本可以按扇区批量处理。
3.4 USB枚举与格式化测试
代码编译下载后,用USB线连接电脑。第一次插入时,电脑会提示“发现新硬件”,然后自动安装MSC驱动。如果一切正常,设备管理器里会出现一个“USB大容量存储设备”,我的电脑里会多出一个盘符。
但这时候盘符通常是打不开的,提示“需要格式化”。这是因为Flash里还没有文件系统。在电脑上执行格式化,选择FAT32或exFAT,快速格式化。格式化过程会写入FAT表和根目录,这些写操作会触发我们的缓存层。
格式化完成后,就可以像普通U盘一样拖拽文件了。实测写入一个1MB的文件,速度大概在200-500KB/s,取决于SPI时钟和缓存命中率。读取速度稍快,能到1MB/s左右。
这里有个坑:格式化时电脑可能会发送大量连续写请求,如果缓存层处理不当,会导致数据错乱。建议在STORAGE_Write_FS里加一个简单的互斥锁,防止USB中断重入。因为USB中断优先级高,如果主循环正在处理缓存写回,USB中断又来了新的写请求,会破坏缓存状态。
4. 常见问题与排查技巧实录
4.1 USB枚举失败的问题排查
枚举失败是最常见的问题,表现是电脑提示“未知USB设备”或者“设备描述符请求失败”。排查顺序如下:
第一步,检查时钟。用示波器或者CubeMX的时钟树确认USB时钟是48MHz。如果偏差超过0.25%,USB通信就会不稳定。STM32F4的USB时钟来自PLL_Q,配置时要注意VCO输入频率和分频系数。
第二步,检查中断。USB中断是否使能?优先级是否配置?在stm32f4xx_it.c里应该有OTG_FS_IRQHandler,并且调用了HAL_PCD_IRQHandler。如果中断没进,枚举肯定失败。
第三步,检查描述符。MSC类的描述符在usbd_msc.c里定义,包括设备描述符、配置描述符、接口描述符、端点描述符。如果VID/PID和电脑驱动冲突,可以改成自定义的。端点地址和类型也要对,Bulk端点通常是EP1 IN和EP1 OUT。
第四步,看USB数据包。用USB分析仪抓包最直接,能看到枚举到哪一步失败。如果没有分析仪,可以在usbd_msc.c的回调里加打印,看USBD_MSC_Setup是否被调用。
4.2 写入数据丢失或文件损坏
这个问题通常出在缓存层。表现是拷贝文件后,拔掉USB再插上,文件内容不对或者文件系统损坏。
原因一,缓存未写回。如果电脑发送了写请求,数据在缓存里但还没写回Flash,此时如果USB断开或者单片机复位,数据就丢了。解决办法是在STORAGE_Write_FS里每次写完立即写回,或者加一个定时器定期写回。但立即写回会降低性能,因为每次512字节写都要擦4KB。
原因二,擦除未完成就写。Flash擦除需要时间,如果W25Q_WaitBusy没等够,后续的页编程会失败。检查状态寄存器的BUSY位是否真的清零了。
原因三,跨页写。页编程不能跨256字节页边界。如果一次写512字节,从地址0x1F0开始,会跨页。需要拆分成两次:先写0x1F0到0x1FF(16字节),再写0x200到0x3EF(496字节)。我的实现里按256字节对齐拆分,避免了这个问题。
原因四,FAT表缓存不一致。电脑可能会先读FAT表,然后修改,再写回。如果读的时候从Flash读,写的时候写到缓存,但缓存没及时更新,就会不一致。解决办法是读操作也走缓存,保证读写看到的是同一份数据。
4.3 传输速度慢的优化方向
默认配置下速度可能只有100KB/s左右,优化可以从这几个方面入手:
提高SPI时钟。在保证稳定的前提下,把预分频从256降到4或2,SPI时钟能到20-40MHz。W25Q64的读命令0x03最高支持50MHz,但页编程和擦除不受SPI时钟影响,受限于Flash内部时序。
用DMA传输。SPI的发送和接收用DMA,减少CPU占用。但要注意DMA传输完成中断和USB中断的优先级关系。
优化缓存策略。如果连续写入的地址在同一个4KB扇区内,可以只擦除一次,多次修改缓存,最后统一写回。这需要记录缓存的脏区域,实现更复杂的写回策略。
使用Quad SPI。如果Flash支持QSPI(W25Q系列大多支持),可以用4根数据线并行传输,理论速度是标准SPI的4倍。但STM32的QSPI外设配置复杂,需要额外的引脚和CubeMX配置。
减少擦除次数。擦除是Flash最慢的操作,4KB扇区擦除要45ms。如果能把多个写请求合并,减少擦除次数,速度会明显提升。比如电脑格式化时,会连续写很多扇区,可以攒一批再擦。
4.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决措施 |
|---|---|---|---|
| 电脑不识别USB设备 | 时钟不对/中断未使能 | 检查48MHz时钟和中断向量 | 重新配置PLL和NVIC |
| 识别为未知设备 | 描述符错误 | 用USB分析仪抓枚举过程 | 检查VID/PID和端点配置 |
| 盘符打不开,提示格式化 | Flash无文件系统 | 正常现象 | 执行格式化 |
| 格式化失败 | 写回调返回错误 | 检查STORAGE_Write_FS返回值 | 确保擦除和编程成功 |
| 写入后文件损坏 | 缓存未写回/跨页写 | 检查缓存写回逻辑 | 实现读-改-写和页对齐 |
| 传输速度极慢 | SPI时钟低/无DMA | 测量SPI波形 | 提高时钟,启用DMA |
| 拔插后数据丢失 | 缓存脏数据未落盘 | 检查写回时机 | 每次写后立即写回或加掉电检测 |
| Flash寿命短 | 频繁擦写同一扇区 | 监控擦除次数 | 实现磨损均衡或增大缓存 |
4.5 几个只有踩过坑才知道的细节
第一个坑,USB线材质量。有些USB线只有充电功能,没有数据线,插上电脑没反应。换一根确认能传数据的线。
第二个坑,Flash的写保护。有些开发板上的Flash的WP引脚被硬件拉低,导致写保护生效。检查原理图,确认WP和HOLD引脚的状态。如果WP被拉低,需要发命令解除保护,或者把WP引脚接到GPIO上控制。
第三个坑,电脑的写缓存。Windows默认对U盘启用“快速删除”策略,写入的数据会先缓存在电脑内存里,过一会儿才真正写到设备。如果这时候拔掉USB,数据就丢了。测试时可以在设备管理器里把策略改成“更好的性能”,或者用“安全弹出”确保数据落盘。
第四个坑,格式化时的簇大小。FAT32默认簇大小可能很大,比如8MB的盘用4KB簇,实际可用空间会少一些。如果Flash容量小,建议格式化成FAT16或exFAT,减少文件系统开销。
第五个坑,STM32的RAM限制。4KB缓存对于RAM小的芯片(比如STM32F103只有20KB RAM)可能有点紧张。如果RAM不够,可以减小缓存到2KB,但这样擦除粒度还是4KB,需要更复杂的逻辑。或者换用页大小更小的Flash。
第六个坑,USB断开检测。如果设备在写Flash时被拔掉,缓存里的数据就丢了。可以在USB中断里检测断开事件,触发紧急写回。但Flash擦除需要45ms,可能来不及。更好的做法是每次写操作后立即写回,牺牲速度换安全。
5. 进阶扩展与实用建议
5.1 双分区设计:设备数据与U盘空间隔离
实际产品中,Flash通常不是全部给U盘用的。比如前1MB存设备固件和参数,后7MB作为U盘空间。这样设备正常运行时,固件区不可见,U盘区可以随意读写。
实现方法是在STORAGE_GetCapacity_FS里返回的扇区数减去保留区,在STORAGE_Read_FS和STORAGE_Write_FS里把逻辑地址加上偏移量。注意偏移量要按4KB对齐,避免缓存跨区。
5.2 掉电保护与数据完整性
Flash在擦除过程中如果掉电,可能导致整个4KB扇区数据丢失。对于存配置参数的场景,这是致命的。解决办法是双备份:把参数存两份,写的时候先写备份区,再写主区,读的时候校验和判断哪份有效。
对于U盘功能,掉电保护相对宽松,因为文件系统本身有容错机制。但如果正在写FAT表时掉电,可能导致文件系统损坏。可以在每次写FAT表前记录日志,掉电后恢复。
5.3 性能实测数据与对比
我在STM32F407 + W25Q64的平台上做了几组测试:
| 配置 | SPI时钟 | 写入速度 | 读取速度 |
|---|---|---|---|
| 无缓存,直接写 | 10MHz | 约50KB/s | 约800KB/s |
| 4KB缓存,立即写回 | 10MHz | 约120KB/s | 约800KB/s |
| 4KB缓存,批量写回 | 10MHz | 约300KB/s | 约800KB/s |
| 4KB缓存,SPI 20MHz | 20MHz | 约450KB/s | 约1.5MB/s |
| QSPI,4KB缓存 | 40MHz | 约1.2MB/s | 约4MB/s |
可以看到,缓存策略对写入速度影响很大,因为减少了擦除次数。SPI时钟对读取速度影响明显,但对写入速度影响有限,因为写入瓶颈在Flash内部擦除和编程时间。
5.4 代码组织与移植建议
建议把代码分成三层:
- 硬件层:
w25qxx.c,包含SPI Flash的读写擦除函数,不依赖USB。 - 中间层:
storage.c,实现缓存管理和扇区映射,调用硬件层。 - 应用层:
usbd_storage_if.c,实现MSC回调,调用中间层。
这样移植到其他平台时,只需要改硬件层的SPI接口,中间层和应用层基本不用动。如果换用SD卡或者NAND Flash,也只需要替换硬件层。
5.5 一个实用小技巧:用文件系统直接访问Flash
如果不想通过USB,也可以在STM32上跑一个轻量级文件系统(比如FatFs),直接管理Flash。这样设备本身就能读写文件,同时通过USB把Flash映射成U盘。但要注意,两个文件系统同时访问同一片Flash会冲突,需要加互斥锁或者分时复用。
我个人的做法是:设备正常运行时,FatFs挂载Flash,读写日志和配置;当USB插入时,FatFs卸载,MSC接管。通过检测USB的VBUS引脚或者枚举状态来切换。这样既保证了设备功能,又提供了U盘导出数据的便利。
这个方案在几个小批量项目中验证过,稳定性没问题。唯一需要注意的是切换时的数据同步——卸载FatFs前要确保所有缓存写回,MSC接管后要重新初始化缓存。踩过几次坑之后,我养成了一个习惯:在USB插入中断里先置一个标志位,主循环检测到标志后执行卸载和切换,而不是在中断里直接操作,避免竞态条件。