STM32F103虚拟U盘方案:W25Q64+FatFs实现8MB移动存储
发布时间:2026/9/28 20:22:09来源:尧图网络
前阵子把手上一台小设备的存储方案改成U盘模式过程比预想中顺利但中间也有几个坑。简单说就是STM32F103C8T6配合一颗W25Q648MB SPI NOR Flash和FatFs文件系统通过STM32自带的USB Device控制器让电脑把整颗Flash当成一个8MB的U盘。设备端自己也能通过FatFs读写文件拔下来插到电脑上就是普通移动存储不装上位机、不跑串口协议文件直接就能拿走。这个方案对数据记录仪、配置参数存储、离线固件升级这类场景非常友好如果你也正想在STM32上做虚拟U盘这篇可以照抄。1. 方案选型与整体架构1.1 为什么不做“裸盘”而要把FATFS加进来先想清楚一个问题如果只是想让电脑识别一个U盘MSCMass Storage Class协议本身并不关心磁盘里存的是什么。USB中间件只需要在收到SCSI命令时把W25Q64的某个扇区数据返回给电脑就行。很多教程就是这么演示的插上电脑后系统提示“需要格式化”格式化完也能当U盘用。但这样有个致命短板MCU自己不认识文件系统。你辛苦采集的传感器数据想在Flash里追加一条记录、想按天生成一个LOG文件没法直接操作。要么你在裸盘上自己维护一套索引要么占用某个固定扇区写固定格式的数据到了PC端还得写专用解析工具非常不划算。加上FatFs之后板端和PC端看到的是同一套FAT文件系统。MCU写一个data_20250601.csv拔下U盘插电脑直接用Excel打开PC在U盘里放一个config.ini设备重启后通过FatFs读进来做参数配置。这套闭环才是虚拟U盘最实用的形态。1.2 两条数据路径最终在disk层汇合整个系统的数据流可以从两个方向看PC方向 PC应用 - USB总线 - STM32 USB MSC中间件 - STORAGE_Read/STORAGE_Write回调 - disk_read/disk_write - W25Q64底层驱动 - SPI Flash 设备本地方向 MCU应用 - FatFs APIf_open/f_write/f_read... - disk_read/disk_write - W25Q64底层驱动 - SPI Flash两条路径在disk_read和disk_write这一层汇合。USB的STORAGE回调函数和FatFs的底层接口最终调用的都是同一套W25Q64读写函数。所以整个工程的关键就是把这层disk接口写对、写稳同时处理好“同一时刻只能有一方持有Flash”的互斥问题。1.3 这套方案适合什么场景采集类设备设备端周期性追加数据到文件用户插上电脑直接拷贝不需要SD卡槽和读卡器芯片。参数配置类电脑更新配置文件设备启动时读取适合产品调试、批量烧写参数。离线升级把固件包拷入U盘设备上电后在BootLoader里读取升级文件。数据备份与导出代替串口、网口传输U盘模式对用户来说最没有学习成本。如果再往产品化方向走16MB的W25Q128、32MB/64MB的国产SPI NOR也可以无缝替换主控和代码逻辑基本不用动改改容量宏就行。2. 硬件准备与CubeMX工程配置2.1 物料与接线我用的是一块常见的STM32F103C8T6核心板淘宝十来块钱那种USB引脚已经引出来了。Flash是W25Q64模块同样很常见。如果手头只有裸芯片按SPI接法飞线也行但模块带稳压和滤波电容调试更省心。W25Q64引脚STM32F103引脚说明CSPA4软件控制片选推挽输出SCKPA5SPI1时钟MISOPA6SPI1主机输入MOSIPA7SPI1主机输出VCC3.3VFlash供电不要接5VGNDGND共地如果用的是带WP和HOLD引脚的模块按模块说明把WP和HOLD接到VCC或者确认模块已经内部上拉。这两个脚一旦被拉低写操作和正常读时序都会异常是我经常见到的“SPI为什么没反应”的来源之一。2.2 CubeMX配置步骤STM32CubeMX版本无所谓F1系列支持都很成熟具体步骤如下新建工程选择STM32F103C8T6。RCC配置HSE选Crystal/Ceramic Resonator外部8MHz晶振。USB的48MHz时钟强烈依赖外部晶振不要省这一步。SYS配置Debug选Serial Wire方便后面用ST-Link调试。SPI1配置Full-Duplex MasterMode 0CPOL0CPHA0数据位8-bit预分频根据系统时钟来最后把SCK调到18MHz左右就行。F1的SPI1挂在APB2上极限可以到36MHz但我建议先从18MHz跑稳定性优先。PA4配置为GPIO_Output作为W25Q64的CS引脚。CubeMX里SPI的NSS可以不管我们用软件控制。USB配置左侧Connectivity里选USB然后选DeviceFS。F103没有OTG只能做设备。中间件配置Middleware and Software Packs里选USB_DEVICEClass选Mass Storage Class。时钟树确认USB Clock为48MHz系统主频72MHz。如果这里配错USB设备会枚举失败电脑根本识别不到。NVIC里把USB中断勾上。生成工程之后可以在usbd_storage_if.c里看到STORAGE_BLK_SIZ和STORAGE_BLK_NBR的宏。STORAGE_BLK_SIZ是512STORAGE_BLK_NBR默认值是0这里必须改成16384因为8MB / 512 16384。2.3 USB电气与时钟上的三个坑第一个坑USB 48MHz时钟。F103的USB外设必须精确输入48MHz不是72MHz也不是36MHz。一定要在RCC配置里检查否则USB描述符传输全乱电脑一直报“无法识别的USB设备”。第二个坑D上拉电阻。F103的USB D引脚需要外部上拉到3.3V主机才能检测到设备插入。很多核心板已经把1.5k上拉电阻做好了USB座子引出来直接能用。如果自己画板千万别漏掉这个电阻不然设备永远“静默”。第三个坑VBUS检测。CubeMX里有个选项是USB VBUS sensing如果勾选了需要把VBUS电平引到指定引脚一般是PA9选了又不接线枚举也会失败。我的做法是不勾选靠D上拉来触发设备插入检测简单可靠。供电方面也别忽视。USB插入瞬间需要48MHz时钟跑起来如果整个板子供电质量太差核心板电压跌到3V以下枚举就会失败。用USB口供电时尽量选质量好一点的线杜邦线也不要拉太长。3. W25Q64驱动与FATFS底层适配3.1 SPI Flash基础操作W25Q64是SPI NOR Flash容量8MB读写有自己的一套规则概括起来就是读可以随便读写之前必须先擦除擦除是按4KB扇区、32KB块或64KB块为单位。本文只用4KB扇区擦除因为粒度小擦写更灵活配合FATFS的512字节逻辑扇区能做到读改写开销最小。初始化时先读JEDEC ID确认SPI接线和Flash型号都正常。W25Q64的ID是0xEF4017W25Q128是0xEF4018。读出来不对先别往下走检查接线、供电和SPI配置。uint32_t W25Q64_ReadJEDECID(void) { uint8_t mid, mtp, cap; W25Q64_CS_LOW(); SPI_TransferByte(0x9F); // JEDEC ID指令 mid SPI_TransferByte(0xFF); mtp SPI_TransferByte(0xFF); cap SPI_TransferByte(0xFF); W25Q64_CS_HIGH(); return (mid 16) | (mtp 8) | cap; }等待Flash空闲是每次擦写后必须做的事情通过读取状态寄存器的bit0判断。Flash在做内部擦除或编程时这个位为1。可以简单用阻塞等待反正一次4KB擦除大约几十毫秒对于U盘场景完全可接受。void W25Q64_WaitBusy(void) { uint8_t status; W25Q64_CS_LOW(); SPI_TransferByte(0x05); // Read Status Register 1 do { status SPI_TransferByte(0xFF); } while (status 0x01); // bit0: BUSY W25Q64_CS_HIGH(); }写使能指令0x06要在每次擦除和编程前发送一次这是NOR Flash的安全机制防止意外写操作。漏掉写使能擦写指令会被忽略数据写不进去还查不到原因。void W25Q64_EraseSector(uint32_t addr) { W25Q64_WriteEnable(); W25Q64_CS_LOW(); SPI_TransferByte(0x20); // Sector Erase 4KB SPI_TransferByte((addr 16) 0xFF); SPI_TransferByte((addr 8) 0xFF); SPI_TransferByte(addr 0xFF); W25Q64_CS_HIGH(); W25Q64_WaitBusy(); }页编程指令0x02一次最多写256字节而且不能跨越256字节边界。换句话说如果当前地址位于某页的200字节处想写100字节必须拆成两次第一次写56字节补满本页第二次从新页偏移0开始写剩下数据。这个限制在写缓存刷新时尤其要注意。void W25Q64_WritePage(uint32_t addr, const uint8_t *buf, uint16_t len) { W25Q64_WriteEnable(); W25Q64_CS_LOW(); SPI_TransferByte(0x02); // Page Program SPI_TransferByte((addr 16) 0xFF); SPI_TransferByte((addr 8) 0xFF); SPI_TransferByte(addr 0xFF); for (uint16_t i 0; i len; i) { SPI_TransferByte(buf[i]); } W25Q64_CS_HIGH(); W25Q64_WaitBusy(); }3.2 4KB擦除块与缓存优化设计这是整个项目最关键的部分没有之一。如果直接每次写512字节就去读改写4KB性能会非常可怜而且会频繁擦除缩短Flash寿命。原因很简单FATFS和USB主机都是以512字节为单位访问磁盘扇区但W25Q64最小擦除单位是4KB等于8个逻辑扇区。我的做法是定义一页4KB的RAM缓存Flash的4KB物理扇区为单位做缓存。写操作先改缓存并标记dirty只有当缓存切换到另一个物理扇区、或者收到CTRL_SYNC时才真正执行“擦除整扇区 - 批量写回”的动作。相当于把8次零散的512字节写入合并成一次4KB整体写入擦除次数大幅降低。#define FLASH_SECTOR_SIZE 4096 #define FLASH_BLOCK_SIZE 512 #define FLASH_TOTAL_SECTORS 16384 typedef struct { uint32_t phy_sector; // 当前缓存的W25Q64 4KB扇区索引 uint8_t valid; uint8_t dirty; uint8_t buf[FLASH_SECTOR_SIZE]; } FlashCache; static FlashCache s_cache; static void FlashCache_Flush(void) { if (!s_cache.valid || !s_cache.dirty) return; uint32_t base s_cache.phy_sector * FLASH_SECTOR_SIZE; W25Q64_EraseSector(base); for (uint16_t off 0; off FLASH_SECTOR_SIZE; off 256) { W25Q64_WritePage(base off, s_cache.buf[off], 256); } s_cache.dirty 0; }这个缓存函数在USB和FATFS两个场景下都适用。PC往U盘里拷贝大文件时数据按顺序写入每一页4KB只擦除一次、写十六次256字节页编程FATFS写目录项和FAT表时频繁改写同一个物理扇区缓存也能大大降低擦写次数。3.3 diskio接口代码FatFs和底层Flash之间是一层标准的disk I/O接口需要实现disk_initialize、disk_status、disk_read、disk_write、disk_ioctl这几个函数。DSTATUS disk_status(BYTE pdrv) { return RES_OK; } DSTATUS disk_initialize(BYTE pdrv) { uint32_t id W25Q64_ReadJEDECID(); if ((id 0xFFFFFF) 0xEF4017) { return RES_OK; } return STA_NOINIT; }这里我偷了个懒在disk_status里直接返回OK。如果真的想严谨可以在Flash异常时返回STA_NOINIT让FATFS知道磁盘不可用。实际项目中我更建议做一次ID校验防止Flash没焊好导致后面各种诡异行为。读取接口比较简单直接按逻辑地址读Flash即可。唯一要注意的是如果当前dirty缓存正好是目标物理扇区数据还在RAM里没写回这时直接读Flash会读到旧数据所以要先查缓存。DRESULT disk_read(BYTE pdrv, BYTE *buff, LBA_t sector, UINT count) { for (UINT i 0; i count; i) { uint32_t lba sector i; uint32_t pg lba / 8; // 8个512字节逻辑块组成一个4KB物理扇区 uint32_t inner (lba % 8) * 512; if (s_cache.valid s_cache.dirty s_cache.phy_sector pg) { memcpy(buff i * 512, s_cache.buf[inner], 512); } else { W25Q64_ReadData(lba * 512, buff i * 512, 512); } } return RES_OK; }写入接口也比较直接但要先加载对应物理扇区到缓存修改后标记dirty。static void FlashCache_Load(uint32_t phy_sector) { if (s_cache.valid s_cache.phy_sector phy_sector) return; FlashCache_Flush(); W25Q64_ReadData(phy_sector * 4096, s_cache.buf, 4096); s_cache.phy_sector phy_sector; s_cache.valid 1; s_cache.dirty 0; } DRESULT disk_write(BYTE pdrv, const BYTE *buff, LBA_t sector, UINT count) { for (UINT i 0; i count; i) { uint32_t lba sector i; uint32_t pg lba / 8; uint32_t inner (lba % 8) * 512; FlashCache_Load(pg); memcpy(s_cache.buf[inner], buff i * 512, 512); s_cache.dirty 1; } return RES_OK; }disk_ioctl主要给FatFs提供扇区大小、总扇区数、擦除块大小等参数。FATFS和USB MSC都遵守512字节逻辑扇区这里千万不能返回4KB否则两边容量对不上。DRESULT disk_ioctl(BYTE pdrv, BYTE cmd, void *buff) { switch (cmd) { case GET_SECTOR_COUNT: *(DWORD *)buff FLASH_TOTAL_SECTORS; return RES_OK; case GET_SECTOR_SIZE: *(WORD *)buff 512; return RES_OK; case GET_BLOCK_SIZE: *(DWORD *)buff 8; // 4KB擦除块按512字节扇区计是8 return RES_OK; case CTRL_SYNC: FlashCache_Flush(); return RES_OK; default: return RES_PARERR; } }4. USB MSC中间件与虚拟U盘衔接4.1 填好STORAGE回调CubeMX生成工程后usbd_storage_if.c里已经预留了STORAGE_Init、STORAGE_GetCapacity、STORAGE_IsReady、STORAGE_IsWriteProtected、STORAGE_Read、STORAGE_Write、STORAGE_GetMaxLun这些函数。真正需要动的只有四个。int8_t STORAGE_GetCapacity(uint8_t lun, uint32_t *block_num, uint16_t *block_size) { *block_num 16384; *block_size 512; return 0; } int8_t STORAGE_Read(uint8_t lun, uint8_t *buf, uint32_t blk_addr, uint16_t blk_len) { disk_read(0, buf, blk_addr, blk_len); return 0; } int8_t STORAGE_Write(uint8_t lun, uint8_t *buf, uint32_t blk_addr, uint16_t blk_len) { disk_write(0, buf, blk_addr, blk_len); return 0; } int8_t STORAGE_IsWriteProtected(uint8_t lun) { return 0; }这里disk_read和disk_write的pdrv参数只要传0就行因为我们只有一颗Flash、一个卷。STORAGE_IsWriteProtected返回0表示可写。如果返回非0电脑会拒绝往U盘里写任何文件。4.2 FATFS与USB的“互斥切换”前面说过FatFs挂载后会在RAM里维护FAT表缓存win[]。如果MCU本地用FatFs写了一堆文件缓存里是最新的FAT表这时候电脑通过USB直接改写Flash扇区MCU手里的缓存就变成了旧数据。下次MCU再写文件时一旦用到旧缓存就可能把新的FAT表覆盖掉文件系统直接损坏。解决办法是USB被主机配置成功时先把FatFs卸载让缓存全部落盘然后把Flash完全交给USB MSCUSB断开后重新挂载FatFs。卸载动作本身会把内部缓存的FAT表数据通过disk_write刷到Flash相当于做了一次安全切换。#define APP_MODE_FS 0 #define APP_MODE_USB 1 static uint8_t s_mode APP_MODE_FS; static FATFS s_fs; static uint8_t s_fs_mounted 0; void AppMode_Poll(void) { USBD_StateTypedef st USBD_GetState(hUsbDeviceFS); uint8_t usb_active (st USBD_STATE_CONFIGURED); static uint8_t last_usb_active 0; if (usb_active !last_usb_active) { if (s_fs_mounted) { f_mount(NULL, , 1); // 卸载并刷缓存 s_fs_mounted 0; } FlashCache_Flush(); s_mode APP_MODE_USB; } else if (!usb_active last_usb_active) { FlashCache_Flush(); s_cache.valid 0; // 清除缓存避免读过期数据 FRESULT res f_mount(s_fs, , 1); s_fs_mounted (res FR_OK || res FR_NO_FILESYSTEM); s_mode APP_MODE_FS; } last_usb_active usb_active; }这段逻辑放在主循环里轮询即可。USB进入CONFIGURED状态说明电脑已经完成枚举并发起SCSI访问此时先把文件系统交出去拔掉USB线后状态从CONFIGURED掉下来重新挂载FatFs恢复本地操作权限。如果你的USB状态轮询不准确也可以把切换动作放到USB中间件的USBD_MSC_Init回调里但我觉得主循环轮询最直观排查问题也方便。实际使用有个细节如果本地程序打开着某个文件切换USB模式时最好先f_close。上面代码里f_mount(NULL)会卸载卷但打开的文件如果没有正常关闭FATFS内部状态是乱的。所以应用层要在主状态机里保证USB插入期间不执行本地文件任务本地文件任务里也不处理USB事件。4.3 首次格式化PC格式化还是f_mkfsW25Q64刚焊上去是空的整片全是0xFF没有文件系统。首次插入电脑系统会提示“使用驱动器前需要将其格式化”。两个方案任选方案一是直接在电脑上格式化。格式化界面文件系统选FAT或FAT168MB的盘Windows默认就是FAT16。格式化完成后设备本地FatFs也能正常挂载读取兼容性最好推荐大家第一次就直接用电脑格式化。方案二是设备端用f_mkfs自动格式化。需要在FatFs配置里打开FF_USE_MKFS然后执行void FormatFlash(void) { BYTE work[4096]; FRESULT res; f_mount(s_fs, , 1); res f_mkfs(, FM_FAT | FM_SFD, 0, work, sizeof(work)); if (res FR_OK) { f_mount(NULL, , 1); } }FM_FAT让FatFs根据容量自动选择FAT12还是FAT16FM_SFD表示整盘做成一个分区不要MBR。簇大小参数填0由FatFs自动计算。8MB的盘不要强行格式化成FAT32容量不够会报错。设备端格式化的好处是不依赖电脑但兼容性不如电脑格式化某些系统可能不认所以量产阶段我自己还是偏向PC格式化一次之后系统就稳定了。5. 设备端文件读写示例5.1 开机自动追加日志这个例子能验证整条链路是否通畅。设备每次开机在Flash里追加一条上电记录然后USB插入电脑打开LOG.TXT能看到多次记录。void AppendBootLog(void) { FIL file; FRESULT res; UINT bw; if (s_mode ! APP_MODE_FS) return; // U盘模式下不要动文件系统 res f_open(file, LOG.TXT, FA_OPEN_ALWAYS | FA_WRITE); if (res ! FR_OK) return; f_lseek(file, f_size(file)); // 移到文件末尾 f_printf(file, boot count: %lu\r\n, g_boot_count); f_close(file); }要使用f_printfFatFs配置里必须打开FF_USE_STRFUNC。另外我习惯在f_open后立刻检查返回值FatFs的报错信息能帮我们快速定位问题。FA_OPEN_ALWAYS在文件不存在时自动创建存在时打开。如果你没有RTC又想文件时间显示得正常配置FF_FS_NORTC0并实现时间回调函数DWORD get_fattime(void) { return ((DWORD)(2025 - 1980) 25) | ((DWORD)1 21) | ((DWORD)1 16) | 0; }这个返回值格式是FatFs规定的年从1980年开始算月、日、时、分、秒按位填充。上面的例子固定返回2025年1月1日0点0分。5.2 串口命令浏览文件日常调试时我不想每次插拔U盘去看文件内容于是在串口里加了一个简易文件浏览器。原理就是遍历目录、打开文件、读取内容打印到串口逻辑不复杂对调试帮助极大。void CmdListFiles(void) { DIR dir; FILINFO fno; if (f_opendir(dir, ) ! FR_OK) return; for (;;) { if (f_readdir(dir, fno) ! FR_OK || fno.fname[0] 0) break; printf(%s %d bytes\r\n, fno.fname, fno.fsize); } f_closedir(dir); } void CmdDumpFile(const char *path) { FIL f; uint8_t buf[64]; UINT br; if (f_open(f, path, FA_READ) ! FR_OK) { printf(open fail\r\n); return; } while (f_read(f, buf, sizeof(buf), br) FR_OK br 0) { for (UINT i 0; i br; i) { printf(%c, buf[i]); } } f_close(f); }这个命令可以和USB模式状态机联动。插上USB时本地串口命令直接提示“U盘模式不可操作”防止开发者边插U盘边敲命令把文件系统搞坏。6. 常见问题与调试笔记6.1 问题速查表现象可能原因排查方法电脑提示无法识别的USB设备USB时钟不是48MHzD上拉缺失供电不足检查CubeMX时钟树和D上拉电路换个USB口/线识别到磁盘但提示格式化Flash全空无文件系统PC右键格式化选FAT或用f_mkfs格式化格式化时卡死或特别慢disk_write没有缓存优化频繁擦除4KB扇区确认disk_write走的是单页缓存路径U盘模式下Flash容量显示0STORAGE_BLK_NBR没改usbd_storage_if.c里改成16384FatFs返回FR_NOT_READYdisk_initialize失败或SPI接线不对读JEDEC ID确认是0xEF4017本地写文件后插U盘文件损坏切换时没有卸载FatFs用s_mode状态机USB激活前先f_mount(NULL)文件能读不能写STORAGE_IsWriteProtected返回非0确认该函数返回0写入速度只有几十KB/s每次写512字节都触发整扇区擦除检查缓存是否生效看disk_write里是否只在跨页时才flush6.2 性能实测与优化方向我这边实测下来SPI配置在18MHz时从电脑向虚拟U盘拷贝一个1MB的文本文件读速度大约1.1MB/s写速度大约75KB/s到100KB/s左右。读速度基本受限于USB全速的12Mbps和SPI时钟写速度主要被4KB擦除时间卡住属于正常水平。如果对速度不满意可以从几个方向优化把SPI时钟提高到36MHz。F1的SPI1最高可以跑到36MHz读性能会有明显改善。把单页缓存扩展成双页或多页缓存减少切换时刷新的频率但需要更大RAM。STM32F103C8T6只有20KB RAM4KB缓存已经占了不小的空间再扩展要谨慎。W25Q64支持Quad SPI如果换用Quad模式读速度能再上一个台阶但驱动复杂度也会增加USB全速下收益有限。优化文件系统参数簇大小默认自动选择就行不要为了“看起来整齐”强行用大簇否则小文件会浪费空间。在我自己的项目里如果日志数据量特别大我一般让设备写FATFS时先写到一个临时文件写完再sleep/关机前批量转存到最终文件尽量减少Flash擦写次数。还有一个细节容易被忽略FATFS的disk_ioctl里GET_BLOCK_SIZE返回8这个参数对f_mkfs的扇区对齐和部分系统识别都很重要不要随手返回1。虽然很多例程只返回1也能用但擦除块信息准确的话FATFS在格式化时会把FAT表、根目录这些元数据放在更合理的位置对长期使用和性能都有帮助。最后分享一个调试经验第一次跑通之前先不要直接插电脑。先写一个最简单的板载测试让STM32本地创建文件、写入、关闭再通过串口打印文件列表确认FatFs正常。然后插上USB电脑识别并格式化后再试着拷贝文件。两段功能独立验证完了最后再把自动切换状态机加进去。一旦出了问题你才知道是FatFs的问题还是USB的问题不然两边搅在一起排查起来非常痛苦。
网站建设高端定制企业官网