STM32模拟U盘:W25Q64 SPI Flash存储实现与USB MSC协议详解

发布时间:2026/9/3 21:18:05
STM32模拟U盘:W25Q64 SPI Flash存储实现与USB MSC协议详解 简介一套基于STM32F103RC的USB与W25Q64闪存模拟U盘完整工程面向嵌入式开发、单片机及USB协议学习者解决将SPI Flash映射为U盘的关键技术问题。压缩包共287个文件约7.17MB以67个C源文件和59个头文件为开发核心涵盖USB设备配置、MSC类实现、SPI驱动、FAT文件系统以及SCSI命令处理等模块同时包含uvprojx工程文件、hex烧录文件、axf与map调试产物、o与crf编译中间文件以及png截图和htm说明文档便于直接编译、烧录和对照学习。目前已有1509人学习下载覆盖从入门到进阶的不同阶段开发者。资源内容覆盖USB枚举、Bulk传输、SCSI命令解释、FAT文件系统挂载、SPI读写W25Q64及中断处理等完整实现并结合工程截图与说明文档帮助梳理开发流程还能从中理解中断优先级配置、端点缓冲区管理和SCSI命令回调机制等细节。通过分析源码可以理解STM32的USB设备控制器配置、MSC协议与Flash存储的联动机制适合作为课程设计、毕业设计或产品原型验证的参考工程。 手头有一块 STM32F103RC 最小系统板还有一片 W25Q64 SPI Flash这俩东西放一起能干嘛最常见的玩法是当数据存储用比如存日志、存字库但如果把 STM32 的 USB 外设利用起来把 W25Q64 模拟成 U 盘插在电脑上就能直接看到 8MB 的磁盘拖文件进去跟真 U 盘一样这件事就变得很有意思了。这篇文章就是我做这个项目时留下的完整笔记从硬件连线到 USB MSC 协议处理再到 W25Q64 的擦写策略全过程拆开讲中间还穿插了好几个我踩过的坑希望给正在折腾 USB 外设或想做简易文件存储设备的朋友一些参考。这个项目适合两类人一是想弄懂 USB Mass Storage 协议到底是怎么回事的嵌入式开发者二是想把低成本 SPI Flash 变成电脑可访问存储介质的量产方案预研者。不管你是刚会用 STM32CubeMX 的新手还是对 SCSI 命令一知半解的老手只要照着下面这套思路走大概率一次跑通。1. 项目整体设计USB MSC 设备是怎么“变成”U 盘的1.1 系统架构拆解整个系统的核心就是靠 STM32F103RC 自带的 USB 全速Full Speed外设配合官方 USB 设备库里的 Mass Storage Class大容量存储类简称 MSC在电脑看来这颗芯片就是一个标准的 USB 移动存储设备。用户层看到的行为和真实 U 盘完全一致插入电脑后系统识别为磁盘可以分区、格式化、拷贝文件。实际的数据通路是这样走的电脑通过 USB 总线发出 SCSI 命令比如“读取第 100 块”“写入第 2000 块”STM32 的 USB 外设收下命令之后由 USB 设备库解析再调用用户编写的存储回调函数最终由 SPI 接口去操作 W25Q64 芯片把数据读出来或写进去。整个过程相当于把一片简单 SPI Flash 包装成了一个块存储设备。这个方案里有个关键点USB MSC 设备对外暴露的“块大小”通常是 512 字节而 W25Q64 的擦除单位是 4KB 扇区物理编程单位是 256 字节页。所以中间必须做一层“逻辑块到物理扇区”的映射这也是这个项目里最容易翻车的地方后面我会单独用一节讲清楚。1.2 为什么用 STM32F103RC 而不是别的方案选择 STM32F103RC 有几个很现实的原因第一这颗芯片内置 USB 全速设备控制器不需要外加 USB 转接芯片硬件成本几乎为零第二它有 64KB SRAM能够轻松放下 4KB 的扇区缓存同时还能跑 USB 协议栈第三STM32CubeMX 对 F1 系列的支持非常成熟生成代码后只需要改少量底层的 Flash 读写函数就能完成移植。相比之下如果你用 STM32F103C8T6只有 20KB RAM虽然也可能跑起来但 RAM 会比较紧张缓存、协议栈、堆栈加起来容易撑爆。F103RC 的 64KB RAM 让这个方案的余量大了很多调试起来心里不慌。1.3 USB MSC 类协议的基本数据流再花几十秒梳理一下 MSC 类协议的底层机制。USB MSC 采用 Bulk-Only TransportBOT协议整个交互分成三个环节主机向设备的批量输出端点发送一个 31 字节的 CBWCommand Block Wrapper里面包含了 SCSI 命令、传输方向、传输长度等信息根据 CBW 的指示主机和设备之间传输数据读盘时设备发数据给主机写盘时主机发数据给设备设备向主机返回一个 13 字节的 CSWCommand Status Wrapper告知这次命令是否执行成功。所以从用户视角看USB MSC 其实就是在 USB 总线上跑 SCSI 命令。SCSI 命令又分很多种比如 INQUIRY查询设备信息、READ CAPACITY查询容量、READ(10)读数据和 WRITE(10)写数据。好在 ST 的 USB 设备库把这些协议细节基本都处理完了我们需要做的就是向库提供存储介质的相关函数。2. 硬件连接最小系统板加五根杜邦线就能起步2.1 硬件清单做这个项目所需的东西极其简单清单如下STM32F103RC 最小系统板一块最好带 USB 接口板载 D、D- 已经引出且 DP 上接好了 1.5k 上拉电阻没接的话自己得补这个电阻否则电脑识别不到设备W25Q64 芯片一片SOIC-8 封装可以直接买带转接板的模块省得飞线杜邦线或者飞线若干一台电脑Windows / Linux / macOS 都行主要用来看识别效果。另外强调一下STM32F103 的 USB 外设需要 48MHz 时钟推荐用 8MHz 晶振作为 HSE走 PLL 倍频到 72MHz 系统时钟再由内部把 72MHz 经过分频给到 USB 外设。板载 8MHz 晶振是必需的千万别用内部 HSI 凑合HSI 的精度不够USB 枚举很容易失败我一开始在这上面浪费了不少时间。2.2 W25Q64 与 STM32 的接线表我的板子上 SPI1 对应的引脚是 PA5SCK、PA6MISO、PA7MOSI片选我选了 PA4。接线如下W25Q64 引脚STM32F103RC 引脚说明CSPA4 (GPIO 输出)片选低电平有效CLKPA5 (SPI1_SCK)时钟线MOSIPA7 (SPI1_MOSI)主机输出从机输入MISOPA6 (SPI1_MISO)主机输入从机输出VCC3.3V供电必须 3.3VGNDGND共地WP3.3V写保护拉高解除保护HOLD3.3V保持引脚拉高才能正常操作这里最容易忽略的是 WP 和 HOLD 两个引脚。W25Q64 的 WP 拉低会进入写保护状态HOLD 拉低则会让 SPI 通信暂停这两个脚如果不接芯片可能“不干活”或者“只读不写”。我见过不少案例是 SPI 读取正常、写入死活没反应最后排查发现是 HOLD 悬空导致编程命令无法执行。2.3 USB 接口部分的注意事项如果核心板已经带了 USB 座子那直接把 USB 线插上就行。如果是裸板需要把 PA11USB_DM、PA12USB_DP和数据线连接并在 DP 线上接 1.5k 上拉电阻到 3.3V。STM32F103 的 USB 是设备模式D/D- 交叉接法别弄错接线图网上到处都是核对一下即可。上电前检查一下供电W25Q64 和 STM32 必须共用同一个 3.3V避免电平不匹配导致通信异常。如果你的开发板有多个 3.3V 引脚选一个离 MCU 近的走线尽量短。3. W25Q64 SPI 驱动基础操作与两个关键坑3.1 芯片的基本操作流程W25Q64 是一款 64Mbit 的 NOR Flash也就是 8MB 存储空间。对它来说读操作最随意随时可以按字节读写操作却要分两步先擦除把 1 变成 0擦除后全为 0xFF再编程把某些 0 变成 1实际上是把 0xFF 按位改写。所以你看擦除是“置 1”的过程编程是“清 0”的过程这决定了写入前必须先擦除。最常用的命令我列在下面命令操作码功能写使能0x06编程/擦除前必须发送读状态寄存器0x05查询 BUSY 位读数据0x03传 24 位地址后连续读出页编程0x02最多 256 字节不能跨页扇区擦除0x20擦除地址所在的 4KB 块块擦除0xD8擦除 64KB 块本项目中可不用代码上SPI 初始化配置为模式 0CPOL0, CPHA0主模式8 位数据帧时钟分频可以先设 16 或 32跑稳定后再提速率。注意 STM32F103 的 SPI1 挂在 APB2 总线上最大 18MHz 或 36MHz而 W25Q64 的极限时钟有 104MHz瓶颈在 MCU 这边所以基本不用担心分频问题。3.2 页编程“跨页回绕”这个坑这是所有 SPI Flash 新手都会踩的坑。W25Q64 的页大小是 256 字节页编程命令虽然一次可以写最多 256 字节但不能跨越页边界。如果你在地址 0x00FF 处要写 4 个字节数据会被回绕到页首地址 0x0000把前面已经写好的数据覆盖掉。我最初做顺序写入日志时就踩过这个坑后来养成了习惯任何写操作之前先判断“当前地址所在页剩余空间是否足够”如果不够就拆成两段写。在模拟 U 盘的高速写场景里这个检查更是必做。3.3 等待忙死等还是超时擦除和编程命令执行期间芯片内部 BUSY 位会被置 1此时不能发送新的状态寄存器读取命令以外的东西。惯用写法是循环读状态寄存器直到 bit0 变 0。但强烈建议加一个超时计数比如 100ms 或 1s避免芯片异常时程序卡死在死循环里。下面是一段带超时的等待函数void Flash_WaitBusy(void) { uint32_t timeout 0xFFFFF; GPIOA-BRR GPIO_PIN_4; // CS 拉低 SPI1_SendByte(0x05); // 发读状态寄存器命令 while (SPI1_SendByte(0xFF) 0x01) { // 检查 BUSY 位 if (--timeout 0) break; } GPIOA-BSRR GPIO_PIN_4; // CS 拉高 }4. USB MSC 回调实现从 CubeMX 配置到扇区映射4.1 CubeMX 里把 USB 配成 Mass Storage Class用 STM32CubeMX 建工程时选择 STM32F103RC配置完时钟树HSE8MHzPLL 锁到 72MHzUSB 时钟选择 48MHz之后在左侧 Connectivity 里找到 USB使能 DeviceFS然后在 Middleware 里把 USB_DEVICE 的 Class for FS IP 选为 Mass Storage Class。生成工程后会得到一个usbd_storage_if.c文件这个文件就是我们要“填空”的地方。但有个前提我把 SPI 初始化、W25Q64 的底层驱动写在一个单独的w25q64.c文件里然后在usbd_storage_if.c里包含头文件再实现回调函数。这样代码结构最清晰后续如果要换存储介质只需改底层驱动。4.2 必须实现的四个核心回调函数STM32 USB 设备库的 MSC 层已经帮你处理了 CBW/CSW 和大部分 SCSI 命令你只需要把存储介质的操作映射到这些回调上回调函数作用我的实现要点STORAGE_GetCapacity返回块数和块大小块数16384块大小512STORAGE_Read从指定 LBA 读若干块结合扇区缓存读取STORAGE_Write向指定 LBA 写若干块先读后改再擦写STORAGE_Inquiry返回设备厂商、产品信息返回 36 字节固定数组STORAGE_Init、STORAGE_IsReady、STORAGE_IsWriteProtected这些按套路返回即可STORAGE_GetMaxLun返回 0单 LUN。STORAGE_GetCapacity的写法很直接int8_t STORAGE_GetCapacity(uint8_t lun, uint32_t *block_num, uint16_t *block_size) { *block_num 16384; // 8MB / 512B 16384 块 *block_size 512; return USBD_OK; }STORAGE_Inquiry返回一个 36 字节的数组内容可以自定义uint8_t *STORAGE_Inquiry(uint8_t lun) { static uint8_t inquiry_data[] { 0x00, 0x80, 0x02, 0x02, 0x1F, 0x00, 0x00, 0x00, S, T, M, 3, 2, , , , W, 2, 5, Q, 6, 4, , , 1, ., 0, 0, , , , }; return inquiry_data; }这里字节 8~15 是产品名我填了 W25Q64电脑里磁盘属性会显示这些字符串。4.3 扇区映射把 512 字节块翻译成 4KB 物理扇区这是整个项目最核心的设计。W25Q64 的擦除单位是 4KB也就是 4096 字节这正好等于 8 个 512 字节的逻辑块。所以逻辑块号和物理地址的换算关系是物理地址 LBA × 512所属 4KB 扇区地址 (LBA × 512) / 4096 × 4096 (LBA / 8) × 4096扇区内偏移 (LBA % 8) × 512由于每写入一个逻辑块可能都需要擦除整个 4KB 物理扇区如果每次写都执行“读整扇区→改 512 字节→擦除→重写”性能会非常难受。我的做法是维护一个 4KB 的 RAM 缓冲当主机连续写同一物理扇区内的多个块时只在内存里改等切换到别的物理扇区时才一次性落盘。这个“延迟写”策略能显著减少擦除次数。下面的代码示意了扇区缓存的读写核心逻辑static uint8_t sector_cache[4096]; static int32_t cache_sector -1; // 当前缓存的是哪个物理扇区-1 表示无效 static uint8_t cache_dirty 0; // 是否有修改 static void Flash_FlushCache(void) { if (!cache_dirty) return; Flash_EraseSector(cache_sector); for (uint16_t off 0; off 4096; off 256) { Flash_PageProgram(cache_sector off, sector_cache off, 256); } cache_dirty 0; } int8_t STORAGE_Read(uint8_t lun, uint8_t *buf, uint32_t blk_addr, uint16_t blk_len) { for (uint16_t i 0; i blk_len; i) { uint32_t lba blk_addr i; uint32_t sector (lba / 8) * 4096; uint16_t offset (lba % 8) * 512; if (cache_sector ! sector || !cache_dirty) { Flash_ReadBytes(sector, sector_cache, 4096); cache_sector sector; } memcpy(buf i * 512, sector_cache offset, 512); } return USBD_OK; } int8_t STORAGE_Write(uint8_t lun, uint8_t *buf, uint32_t blk_addr, uint16_t blk_len) { for (uint16_t i 0; i blk_len; i) { uint32_t lba blk_addr i; uint32_t sector (lba / 8) * 4096; uint16_t offset (lba % 8) * 512; if (cache_sector ! sector) { Flash_FlushCache(); Flash_ReadBytes(sector, sector_cache, 4096); cache_sector sector; } memcpy(sector_cache offset, buf i * 512, 512); cache_dirty 1; } return USBD_OK; }这里有个细节读操作如果命中脏缓存也应该读缓存而不是直接读 Flash否则会出现“文件拷进去再读出来对不上”的怪现象。我上面的代码里cache_dirty和cache_sector同时判定就是为了保证读写一致。4.4 什么时候把缓存刷到 Flash上面的实现里缓存只在“下一次写另一个物理扇区”时才刷出。如果主机写完数据后直接拔掉 U 盘理论上可能存在数据仍留在 RAM 中没落盘的风险。USB MSC 规范里有个 SYNCHRONIZE CACHE 命令Windows 和 Linux 在安全弹出设备或卸载文件系统时通常会发这个命令但为了保险我建议在 STORAGE_Init设备重新初始化时和 STORAGE_Write 的收尾阶段都调用一次 Flash_FlushCache确保数据尽量落盘。不过说实话在调试阶段最直接的验证方法就是从电脑拷贝一个文件到模拟 U 盘里然后安全弹出再把 U 盘重新插上看看文件是否正常存在。如果文件没了或者内容不对优先排查缓存没刷出或者页编程跨页这类问题。5. 常见问题与调试实录插上没反应、容量不对、格式化失败全排查5.1 电脑完全没反应设备管理器里没有新设备先把供电和数据线问题排除。然后重点检查 DP 引脚的 1.5k 上拉电阻如果没有上拉主机根本不会开始枚举。另一个常见原因是 USB 时钟不对STM32F103 的 USB 需要精确的 48MHz否则枚举过程会卡在“Unknown Device”这时候检查时钟树配置里有没有把 USB 时钟来源设对。还有一个容易忽略的点HSE 晶振是否起振。如果板子上的 8MHz 晶振没起振或者电容没焊系统会退回 HSI 运行USB 时序自然不稳定。用示波器看 XO 引脚有没有稳定波形或者直接在代码里读取 RCC 时钟标志确认 HSE 就绪。5.2 设备出现了但容量显示 0 字节或明显不对容量不对十有八九是STORAGE_GetCapacity的返回值有问题。W25Q64 是 8MB块大小是 512所以块数等于 16384。注意块数的有效范围是 0x00000000 ~ 0xFFFFFFFF但如果块数传成 16383主机也会少显示 512 字节不影响使用。如果返回 0那说明回调根本没被调用或者STORAGE_Init里出了问题。还有一种情况主机显示的容量是 7.2GB 之类明显来自真实磁盘的数值说明枚举时设备描述符中的 VID/PID 或其他字段被识别为某个真实 U 盘型号多半是设备描述符里的字符串信息配置得过于“真实”或者 host 端驱动缓存。改一改usbd_desc.c里的字符串描述符重新插拔即可。5.3 格式化时卡住或提示“Windows 无法完成格式化”这个场景我遇到过原因通常有两个一是STORAGE_Write在收到跨扇区的多块写请求时缓存切换逻辑写死了导致特定 LBA 写失败二是 FAT 表区域被反复改写但你的扇区缓存策略里每个 512 字节逻辑块都触发了一次“整扇区读改写”而且每次切换扇区都做一次完整擦写速度极慢主机格式化超时。解决思路不要把缓存策略做得太简单至少要保证一个 4KB 物理扇区内的 8 个逻辑块写操作在同一轮里能重复命中缓存。格式化过程中 FAT 表会被反复写命中率越高速度越快。我实测优化后格式化 8MB 分区在 Windows 下大约 10 秒内完成虽然比真实 U 盘慢但完全可以接受。另外如果你的代码里没实现STORAGE_IsWriteProtected或者返回了 1Windows 会认为磁盘写保护格式化会直接报错。这个回调必须返回 0。5.4 写入文件后拔掉再插文件损坏或打不开大概率是页编程跨页写入导致数据错位。排查方法是在STORAGE_Write里加个调试计数器把写入的 LBA 范围打印到串口然后跟主机发出的写请求比对。如果在某个地址正好落在 256 字节边界时数据开始错乱就确认是页编程回绕问题。修复方法就是前面说的任何写操作前先判断是否跨页跨页就拆成两半。还有一种情况是没有正确处理“脏缓存”状态。比如写操作完成后缓存还是脏的读操作却绕过了缓存直接读 Flash导致读到旧数据。确保读写都走同一套缓存判断逻辑就能避免这种不一致。5.5 想深入调试用 USB 抓包工具看 SCSI 命令如果上述排查都不能解决问题那就要上升到协议层了。Wireshark 配合 USBPcapWindows或 usbmonLinux可以抓取 USB 总线上的 CBW、CSW 和 SCSI 命令。我调试这个项目时就是靠抓包确认了 Windows 在格式化时发送了一连串的 TEST UNIT READY 和 WRITE(10)以及偶尔出现的 MODE SENSE(10)。对照抓到的请求和自己的实现能快速定位是哪一条 SCSI 命令没处理好。这里可以分享一个小经验看到设备枚举成功但读写没反应时抓包几乎必中。基本都能看到主机反复发送 TEST UNIT READY设备却迟迟不回 CSW。这种情况通常不是协议栈的 bug而是 STORAGE_Read/STORAGE_Write 回调里死循环了常见于 Flash 等待 BUSY 没有超时。给所有等待循环加超时问题立刻暴露出来。6. 实测体验与进一步扩展思路这个项目跑通之后 USB 枚举过程、SCSI 命令回调、以及 Flash 擦写时序这些东西你会有一个非常直观的理解。实测下来在 Windows 下把 8MB 的文件系统格式化成 FAT32显示容量 7.44MB读写文件都正常。速度方面读取大约能到 400~600KB/s写入会慢不少因为受限于擦除和页编程的时间这个结果对全速 USB 加 SPI Flash 的组合来说很正常。如果还想继续深入有几条很值得尝试的路线一是把 W25Q64 换成 W25Q25632MB容量更大玩法更多二是在扇区缓存的基础上做一个小型磨损均衡算法提升 Flash 寿命这能让方案更接近工业级应用三是把设备枚举信息改成自定义厂商名甚至做成“加密 U 盘”的雏形主机向特定 LBA 写数据时触发额外逻辑。最后再分享一个实际项目中的小技巧在usbd_storage_if.c里加一个全局计数器记录主机发起的读写扇区总数通过串口输出。别小看这个计数器它既能帮你估测磨损速度又能在调试性能瓶颈时提供最直观的数据。等哪天你觉得这个项目“太简单了”说明你已经可以从容应对这套协议栈了。本文还有配套的精品资源点击获取