STM32实现USB MSC U盘:外部SPI Flash模拟大容量存储设备全解析
1. 项目概述与核心价值最近在做一个需要数据离线导出的小设备主控是STM32F103板载了一片16MB的SPI FLASH。客户提了个需求能不能像插U盘一样直接把设备接到电脑上把里面的数据文件拖出来这可比用串口一点点传或者额外做个SD卡槽方便太多了。这个需求的核心就是利用STM32自带的USB接口把片外FLASH模拟成一个USB大容量存储设备也就是我们常说的U盘。这个项目我称之为“STM32使用片内FLASH制作小U盘”。这里的“片内”是个广义说法对于STM32这类MCU其芯片内部自带的Flash容量通常有限几十KB到几MB主要用来存程序代码。而我们项目里用来存数据的16MB Flash是通过SPI、QSPI等接口挂在MCU外部的从系统角度看它也是“片内”资源的一部分区别于需要通过USB Host读取的“U盘”或“移动硬盘”。整个方案的技术栈很清晰STM32 外部SPI FLASH USB MSCMass Storage Class协议 FatFs文件系统。它的价值非常直接为嵌入式设备提供一种零成本、极简的PC交互方式。你不再需要编写复杂的上位机软件也不用担心用户不会使用串口调试工具。用户只需要一根USB线就能像操作普通U盘一样访问设备里的数据。这对于数据记录仪、配置文件更新、日志导出、媒体播放器等场景来说用户体验是质的飞跃。下面我就把这次从零搭建的过程、遇到的坑和爬坑经验详细拆解一遍。2. 整体方案设计与核心思路拆解2.1 为什么选择USB MSC FatFs方案当决定要把FLASH做成U盘时摆在面前的有几条路USB MSC大容量存储、USB CDC虚拟串口自定义协议、或者像MTP媒体传输协议这类。我选择MSC原因很简单兼容性无敌无需驱动。只要是Windows XP SP2以上、macOS、主流Linux发行版插上就能识别资源管理器直接操作。CDC虽然灵活但需要上位机配合MTP在嵌入式端实现又稍显复杂。MSC是达到“即插即用”目标的最短路径。而FatFs是一个完全独立于底层介质和硬件平台的开源FAT文件系统模块它用纯C语言编写资源占用小特别适合单片机。它的层次结构很清晰最上层是应用层APIf_open f_write等中间是FatFs核心最下层需要用户实现磁盘I/O接口disk_read disk_write。这个设计正好把USB MSC和FLASH驱动桥接起来。所以整个系统的数据流是这样的用户层面电脑向“U盘”写入一个文件。协议层面电脑的USB总线驱动通过MSC协议向STM32的USB设备端点发送SCSI命令如WRITE_10。传输层面STM32的USB设备库如STM32CubeMX生成的HAL库代码收到数据将其放入缓冲区并调用我们实现好的MSC_Write回调函数。文件系统层面在MSC_Write函数里我们调用FatFs的f_write函数。FatFs会处理FAT表、目录项等文件系统结构并将要写入的数据和地址转换成对底层存储设备的“扇区”操作。硬件驱动层面FatFs调用我们实现的disk_write函数。在这个函数里我们再将“扇区”地址和大小转换成对SPI FLASH芯片的页编程Page Program或扇区擦除Sector Erase操作。物理存储SPI驱动最终通过GPIO模拟或硬件SPI接口将数据写入FLASH芯片的相应物理地址。逆向的读取流程也类似。可以看到我们最主要的工作就是实现“USB MSC回调函数”和“FatFs的磁盘I/O层”这两个桥梁。2.2 硬件选型与工程框架搭建MCU选型必须选择带有USB Device功能的STM32型号。比如STM32F103系列USB全速设备、STM32F4/F7/H7系列支持USB高速。我手头项目对成本敏感选择了最经典的STM32F103C8T6它有一个USB全速12Mbps接口足够应付小文件频繁读写的场景。外部FLASH选型这是关键。不能随便选个SPI Flash就上。首先要确认芯片支持扇区擦除通常为4KB这是FatFs以扇区为单位管理的基础。其次最好选择带有写保护和独-立扇区擦除功能的型号比如Winbond的W25Q系列、GD的GD25Q系列。我选用的是W25Q128JVSIQ容量16MB128Mbit扇区大小4KB页大小256字节。一定要仔细看数据手册的“写时序”和“擦除时序”部分。工程框架我强烈推荐使用STM32CubeMX进行初始化配置。它能图形化配置USB、SPI、时钟树并生成基于HAL库的工程骨架省去大量底层寄存器配置时间。在CubeMX中关键步骤是在Middleware中间件中启用USB_DEVICE并选择Class For FS IP为Mass Storage Class (MSC)。配置好USB对应的引脚PA11 PA12。配置连接FLASH的SPI外设例如SPI1注意速率不要超过FLASH芯片支持的最大值初期调试可先设低点如10Mbps。生成代码时注意选择MDK-ARMKeil或STM32CubeIDE等你熟悉的工具链。生成代码后你会得到一个大致的框架其中包含了USB设备初始化和MSC相关的源文件如usbd_storage_if.c但里面的读写函数都是空的模板需要我们自己填充。3. 核心模块驱动实现详解3.1 SPI FLASH底层驱动实现这是所有存储操作的基础必须稳定可靠。我们通常需要实现以下几个基本函数FLASH_Init(): 初始化SPI接口发送释放深度休眠、使能写等命令。FLASH_Read(uint32_t addr, uint8_t *pData, uint32_t size): 从指定地址读取数据。FLASH_Write(uint32_t addr, uint8_t *pData, uint32_t size): 向指定地址写入数据。这里有大坑FLASH写入前必须先擦除且擦除单位扇区比写入单位页大。FLASH_Erase_Sector(uint32_t sector_addr): 擦除一个扇区如4KB。FLASH_Read_ID(): 读取芯片ID用于驱动自检。关键技巧与避坑指南写使能Write Enable任何擦除或写入操作前必须先发送WRITE_ENABLE命令0x06操作完成后状态寄存器中的WEL位会置1。最好在每次擦/写前都发送一次养成好习惯。等待忙状态Wait For Busy擦除和写入操作需要时间毫秒级。操作指令发出后必须循环读取状态寄存器READ_STATUS_REG_1 0x05直到BUSY位为0才能进行下一步操作。绝对不能在忙的时候进行下一次擦写否则会导致数据丢失或芯片锁死。擦除后才能写FLASH存储单元的物理特性决定了它只能从1变成0。出厂时所有位都是10xFF。写入操作是将某些位从1变为0。如果想将已经变为0的位改回1必须通过擦除操作将整个扇区恢复为全1。所以我们的FLASH_Write函数内部逻辑必须是int FLASH_Write(uint32_t addr, uint8_t *pData, uint32_t size) { // 1. 检查写入范围是否跨越扇区边界需要处理这里简化 uint32_t sec_start addr / FLASH_SECTOR_SIZE; uint32_t sec_end (addr size - 1) / FLASH_SECTOR_SIZE; // 2. 对涉及的所有扇区进行擦除 for(uint32_t i sec_start; i sec_end; i) { FLASH_Erase_Sector(i * FLASH_SECTOR_SIZE); } // 3. 以页为单位循环写入数据 while(size 0) { // 计算当前页内剩余空间处理非页对齐写入... FLASH_Page_Program(cur_addr, pData, write_this_time); // 更新地址、数据指针和剩余大小... } return SUCCESS; }SPI时序与速率确保SPI的时钟极性CPOL和相位CPHA与FLASH芯片要求一致通常是Mode 0或Mode 3。初期调试可降低SPI速率如1Mbps待读写稳定后再提升至芯片标称最大值。3.2 FatFs磁盘I/O层实现FatFs要求我们实现diskio.c中的几个函数。这是连接FatFs和物理驱动的桥梁。DSTATUS disk_initialize (BYTE pdrv): 初始化磁盘驱动。在这里调用我们的FLASH_Init()并返回初始化状态。DSTATUS disk_status (BYTE pdrv): 返回磁盘状态。通常直接返回RES_OK。DRESULT disk_read (BYTE pdrv, BYTE* buff, LBA_t sector, UINT count):核心读函数。FatFs告诉我们要从第sector个逻辑扇区开始读取count个扇区的数据到buff。我们的任务是将逻辑扇区号转换为FLASH物理地址并调用FLASH_Read。DRESULT disk_read(BYTE pdrv, BYTE* buff, LBA_t sector, UINT count) { uint32_t addr sector * FLASH_SECTOR_SIZE; // 关键转换 for(UINT i 0; i count; i) { if(FLASH_Read(addr i * FLASH_SECTOR_SIZE, buff i * FLASH_SECTOR_SIZE, FLASH_SECTOR_SIZE) ! SUCCESS) { return RES_ERROR; } } return RES_OK; }DRESULT disk_write (BYTE pdrv, const BYTE* buff, LBA_t sector, UINT count):核心写函数。这是最复杂的地方。我们需要将逻辑扇区转换为物理地址并调用我们封装好的FLASH_Write函数。注意FLASH_Write内部已经包含了擦除逻辑。DRESULT disk_ioctl (BYTE pdrv, BYTE cmd, void* buff): 获取磁盘信息。FatFs会通过这个命令询问磁盘的属性我们必须正确响应GET_SECTOR_SIZE: 返回扇区大小如4096。GET_SECTOR_COUNT: 返回总扇区数如FLASH_TOTAL_SIZE / FLASH_SECTOR_SIZE。GET_BLOCK_SIZE: 返回擦除块大小单位是扇区。对于W25Q128一个扇区就是一个擦除块所以返回1。重要经验disk_write函数必须保证原子性。即一次disk_write调用可能包含多个扇区应该要么全部成功要么全部失败不能只写一半。因为FatFs在写文件时可能会先写FAT表再写数据。如果中间断电只写了一部分会导致文件系统损坏。虽然我们在单片机层面很难做到真正的原子性需要备份电源或电容但至少要保证软件逻辑上的完整写入。3.3 USB MSC接口回调函数实现STM32CubeMX生成的USB MSC代码中关键文件是usbd_storage_if.c。它定义了一个USBD_StorageTypeDef结构体变量里面有两个最重要的函数指针STORAGE_Read和STORAGE_Write。我们的工作就是实现它们。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)这里的lun是逻辑单元号对于我们单FLASH就是0blk_addr是逻辑块地址LBAblk_len是块的数量。注意这里的“块”是USB MSC协议层面的概念通常一个块是512字节这是硬盘的标准扇区大小。而我们的FLASH物理扇区是4096字节。所以这里存在一个重要的转换我们需要在usbd_storage_if.c中定义一个转换因子#define STORAGE_BLK_SIZ 512 // USB MSC标准块大小 #define FLASH_BLK_SIZ 4096 // 我们Flash的物理扇区大小 #define SECTORS_PER_BLK (FLASH_BLK_SIZ / STORAGE_BLK_SIZ) // 8那么在回调函数中int8_t STORAGE_Read_FS(uint8_t lun, uint8_t *buf, uint32_t blk_addr, uint16_t blk_len) { // 将USB的LBA地址和长度转换成FatFs的扇区地址和数量 uint32_t sector blk_addr / SECTORS_PER_BLK; uint32_t offset (blk_addr % SECTORS_PER_BLK) * STORAGE_BLK_SIZ; uint16_t count (blk_len SECTORS_PER_BLK -1) / SECTORS_PER_BLK; // 向上取整 // 情况1读取对齐到物理扇区 if(offset 0 (blk_len * STORAGE_BLK_SIZ) % FLASH_BLK_SIZ 0) { // 直接调用disk_read disk_read(0, buf, sector, count); } else { // 情况2读取未对齐需要先读到临时缓冲区再拷贝出所需部分 // 这是性能瓶颈点也是容易出错的地方需要仔细处理边界 uint8_t temp_buf[FLASH_BLK_SIZ]; // ... 处理非对齐读取逻辑 } return USBD_OK; }STORAGE_Write_FS同理但更复杂因为涉及非对齐写入时的“读-修改-写”操作Read-Modify-Write。这是整个项目最大的难点之一。简单来说如果主机要写入的512字节块没有对齐到我们4096字节的物理扇区边界我们不能直接擦除整个4096字节扇区否则会丢失该扇区内其他未修改的数据。正确的做法是将目标4096字节物理扇区数据全部读入RAM缓冲区。在RAM缓冲区中用新的数据替换掉要修改的那部分一个或多个512字节块。擦除整个4096字节的物理扇区。将整个RAM缓冲区写回FLASH。这个过程效率较低且对RAM有要求至少一个扇区大小的缓冲区。因此在软件设计上我们应尽量让FatFs格式化时的“分配单元”簇大小与物理扇区大小匹配以减少非对齐操作。4. 文件系统格式化与初始化流程4.1 第一次运行的格式化操作全新的FLASH里面是空白的没有文件系统。因此在第一次使用前或者文件系统损坏时我们需要进行格式化。格式化不能在USB连接状态下由电脑完成吗理论上可以但更可靠的做法是由单片机自己主动完成。我们可以在disk_initialize函数中或者在主循环的初始化阶段添加一个“首次使用检测”。// 在disk_initialize或主函数中 FATFS fs; FRESULT res; BYTE work[_MAX_SS]; // 格式化用的工作区 // 尝试挂载Mount文件系统 res f_mount(fs, 0:, 1); // “0:”对应pdrv0的磁盘 if(res FR_NO_FILESYSTEM) { // 没有文件系统需要格式化 printf(No filesystem found. Formatting...\r\n); // 格式化参数设置 MKFS_PARM opt; opt.fmt FM_FAT32; // 或FM_FAT 根据容量决定。16MB用FAT12/16也行。 opt.n_fat 1; // 1个FAT表为安全可用2 opt.align 0; // 自动对齐 opt.n_root 512; // 根目录项数FAT32此参数无效 opt.au_size 4096; // **关键分配单元簇大小设为与物理扇区一致4096** res f_mkfs(0:, opt, work, sizeof(work)); if(res FR_OK) { printf(Format succeeded.\r\n); // 重新挂载 f_mount(fs, 0:, 1); } else { printf(Format error: %d\r\n, res); } } else if(res ! FR_OK) { printf(Mount error: %d\r\n, res); }将au_size设置为物理扇区大小4096可以确保每次磁盘读写都以整个扇区为单位完美避开前述的非对齐读写问题极大提升性能和可靠性。4.2 文件系统挂载与读写测试格式化并挂载成功后就可以使用标准的FatFs API进行文件操作了这和你在PC上编程几乎一样。// 写入一个文件 FIL fil; UINT bw; res f_open(fil, 0:/test.txt, FA_CREATE_ALWAYS | FA_WRITE); if(res FR_OK) { f_write(fil, Hello, USB MSC U盘!\r\n, 23, bw); f_close(fil); printf(Write %u bytes.\r\n, bw); } // 读取文件内容 res f_open(fil, 0:/test.txt, FA_READ); if(res FR_OK) { char buf[100]; f_read(fil, buf, sizeof(buf), bw); f_close(fil); buf[bw] \0; printf(Read: %s, buf); }在主循环中可以定期执行这些操作或者由外部事件如按键触发。务必注意文件操作是相对耗时的尤其是写操作涉及擦除。要避免在中断服务程序ISR中直接调用FatFs函数最好通过标志位在主循环中处理。5. 调试技巧与常见问题实录5.1 电脑无法识别或提示“需要格式化”这是最常见的问题。排查思路如下USB硬件连接检查DPPA12和DMPA11线是否接反、虚焊。USB端口是否有5V供电。可以用USB分析仪如Beagle USB抓包看设备枚举过程是否成功。USB描述符在usbd_desc.c中确保产品IDPID、厂商IDVID、产品字符串、配置描述符等正确无误。特别是USBD_MSC_StorageInfo这个结构体它向主机报告磁盘的容量、块大小等信息。如果这里报告的容量为0或块大小不对电脑肯定会提示格式化或无法识别。int8_t STORAGE_GetCapacity_FS(uint8_t lun, uint32_t *block_num, uint16_t *block_size) { *block_size STORAGE_BLK_SIZ; // 必须是512 *block_num FLASH_TOTAL_SIZE / STORAGE_BLK_SIZ; // 总块数 return USBD_OK; }计算block_num时务必准确且确保FLASH_TOTAL_SIZE是STORAGE_BLK_SIZ的整数倍。MSC回调函数返回值确保STORAGE_Read_FS和STORAGE_Write_FS在任何情况下都返回USBD_OK成功或明确的错误码。如果返回USBD_FAIL主机会认为读写失败可能导致盘符消失。磁盘状态在STORAGE_IsReady_FS和STORAGE_IsWriteProtected_FS函数中确保返回正确的状态就绪、非写保护。5.2 文件读写慢、复制大文件容易失败非对齐读写如前所述这是性能杀手。确保格式化时au_size与物理扇区对齐。可以在STORAGE_Read_FS/Write_FS函数中加入打印观察blk_addr和blk_len看看是否经常出现非8倍数的地址。FLASH擦写时间W25Q128擦除一个4KB扇区典型时间约50ms页编程256字节约0.7ms。写入大量数据时这个延迟是累积的。不要在USB中断回调函数中同步等待擦写完成这会导致USB无响应而断开。正确的做法是在回调函数中将数据拷贝到RAM缓冲区并设置一个“写请求”标志。在主循环中检测到这个标志后在后台执行实际的FLASH擦写操作。这需要实现一个简单的写缓存队列。但注意USB主机期望写入操作在一定时间内完成超时时间通常几秒所以后台处理也不能太慢。SPI速率在确保稳定的前提下尽量提高SPI时钟频率。W25Q128在Fast Read模式下可以支持到104MHz但受限于STM32F103的SPI主频和PCB布线达到50-60Mbps是比较稳妥的。USB缓冲区检查usbd_conf.h中定义的MSC_MEDIA_PACKET大小。默认可能是64字节全速USB的最大包长。可以尝试增大到512一个块的大小以减少传输事务数量提升吞吐量。5.3 意外断电后文件系统损坏这是基于FLASH的存储设备的通病。FatFs本身不是为掉电安全设计的日志型文件系统。改善措施定期同步在完成关键文件操作后调用f_sync(fil)强制将缓存写入磁盘。但注意这只能保证文件数据写入不能保证FAT表等元数据完全安全。使用多个FAT表在格式化时设置opt.n_fat 2。这样有两份FAT表一份损坏后可以用另一份恢复。FatFs在挂载时会自动选择有效的FAT表。设计应用层协议对于非常重要的数据不要完全依赖文件系统。可以在FLASH中划出一块“安全区”用简单的“头数据CRC”的格式直接存储。或者采用“双备份”机制每次写入两个不同位置读取时校验CRC并选择有效的一份。5.4 Keil调试中关于Flash算法的报错在开发过程中你可能遇到类似“No algorithm found for: 00008000h”或“Flash Download Failed”的错误。这通常与本项目无关而是调试器在尝试向STM32的内部程序Flash下载代码时找不到对应地址的编程算法。原因你的工程可能将某些数据如常量数组通过const定义在了默认的Flash地址0x08000000开始但你的调试配置比如下载算法没有包含整个Flash空间的算法。解决在Keil的Options for Target - Debug - Settings - Flash Download中确保添加了正确型号和完整容量范围的Flash编程算法。对于STM32F103C8T6应添加“STM32F10x Med-density Flash”算法并确认Start和Size正确0x08000000 64KB。与本项目关系这个错误是针对MCU内部Flash的。我们项目操作的是外部SPI Flash其地址空间通过内存映射或直接读写完全不在这个范围内因此不受此影响。编程外部Flash的代码是作为应用程序的一部分由MCU通过SPI指令执行的。6. 性能优化与进阶思考当基本功能跑通后可以考虑以下优化方向启用4线SPIQuad SPI如果MCU和FLASH都支持QSPI如STM32F4/F7/H7的QUADSPI外设和W25Q的QPI模式可以将数据线从1位提升到4位理论传输速率翻四倍。这需要修改底层驱动使用QSPI的命令来读写。实现内存映射Memory-Mapped模式部分支持QSPI的STM32型号可以将外部QSPI Flash映射到MCU的地址空间如0x90000000。这样读取Flash数据就像读取常量数组一样直接用指针访问无需调用FLASH_Read函数。但注意写操作仍然需要通过标准的编程命令。增加写缓存Write Cache在RAM中开辟一个或多个扇区大小的缓存。写入操作先到缓存缓存满或收到同步命令时再一次性写入Flash。这可以将多次小写操作合并为一次大写操作减少擦写次数提升寿命和速度。但同样增加了掉电丢失数据的风险。磨损均衡Wear LevelingFlash扇区有擦写次数限制通常10万次。如果频繁更新同一个文件如日志会导致该文件占用的扇区快速损坏。简单的均衡策略可以在磁盘I/O层实现一个逻辑扇区到物理扇区的映射表每次写操作都写到不同的物理位置。这个项目麻雀虽小五脏俱全涵盖了嵌入式开发中硬件驱动、协议栈、文件系统、系统集成等多个层面。把它调通的过程就是对STM32和嵌入式存储系统一次非常深刻的理解。最后一个小建议在调试USB MSC时可以先用一个简单的RAM磁盘在内部SRAM中划出一块空间模拟磁盘来验证USB和FatFs的代码逻辑排除了文件系统和Flash驱动的干扰后再接入真正的Flash驱动这样问题定位会快很多。