☰
STM32 SPI Flash模拟U盘:Mass Storage协议与Flash读写实战
2026/10/13 1:06:05 网站建设 项目流程

1. 项目缘起与整体设计思路

把一块 SPI Flash 做成电脑能识别的 U 盘,这个需求我第一次听到的时候觉得挺绕——明明有现成的 SD 卡槽、现成的 USB 接口,为什么还要折腾 SPI Flash?后来在实际项目里踩过几次坑才明白,很多小板子为了省成本、省 PCB 面积,压根不放 SD 卡座,但板载一颗 W25Q 系列的 SPI Flash 几乎是标配。这颗 Flash 平时用来存字库、存配置、存固件备份,容量从 2MB 到 16MB 不等,闲着也是闲着,如果能把它模拟成一个 U 盘,插上电脑就能直接拖文件进去,那调试和量产烧录的效率能提升一大截。

这个项目的核心逻辑其实就一句话:用 STM32 的 USB 外设跑 Mass Storage 协议,把 SPI Flash 的读写封装成块设备接口,让主机以为自己在操作一个真实的 U 盘。听起来简单,但中间涉及三个技术栈的咬合——USB 协议栈、SPI 驱动、Flash 存储管理,任何一层出问题都会导致电脑识别不到盘符或者写入数据损坏。

我选择用 STM32CubeMX 来做这个项目,原因很直接:它能把 USB Device 中间件和 SPI 外设的初始化代码一键生成,省去大量底层寄存器配置的时间。但 CubeMX 生成的代码只是骨架,真正要让 U 盘跑起来,还需要自己实现STORAGE_Read_FS、STORAGE_Write_FS这几个回调函数,并且处理好 Flash 的擦除粒度、扇区对齐、写入缓冲这些细节。这些才是这个项目真正的难点所在。

适合谁来参考这篇内容?如果你已经会用 CubeMX 配置 GPIO 和串口,对 SPI 通信有基本概念,知道 Flash 需要先擦后写,那这篇内容能帮你少走至少两天的弯路。如果你连 HAL 库都没碰过,建议先把 SPI 读写 Flash 的例程跑通再来看 USB 部分,否则问题排查会非常痛苦。

提示:这个方案不依赖任何外部存储芯片的专用驱动库,所有 Flash 操作都基于标准 SPI 指令集,通用性很强,换任何一款支持 25 系列指令的 Flash 都能直接迁移。

2. 核心细节解析与实操要点

2.1 为什么选 Mass Storage 而不是 HID 或 CDC

USB 设备类有很多种,HID 适合传小数据量的人机交互,CDC 适合串口通信,但要让电脑弹出一个盘符、支持文件系统的拖拽操作,只有 Mass Storage Class(MSC)能做到。MSC 协议的本质是主机通过 SCSI 指令集来读写设备的逻辑块,设备端只需要实现几个标准的 SCSI 命令响应——INQUIRY、READ_CAPACITY、READ_10、WRITE_10、REQUEST_SENSE等。

CubeMX 的 USB Device 中间件里已经内置了 MSC 的协议栈,我们不需要自己解析 SCSI 命令,只需要填充一个USBD_STORAGE_cb_TypeDef结构体,把底层的读写函数挂上去就行。这个结构体里最关键的是三个函数指针:

  • STORAGE_Read_FS:主机读数据时调用,参数是逻辑块地址和要读的块数
  • STORAGE_Write_FS:主机写数据时调用,参数类似
  • STORAGE_GetCapacity_FS:主机查询容量时调用,返回块数和块大小

块大小通常设为 512 字节,这是 U 盘的标准扇区大小,也是 FAT 文件系统的基本单位。Flash 的物理扇区通常是 4KB 擦除粒度,所以逻辑块和物理扇区之间需要一个映射层来处理。

2.2 SPI Flash 的物理特性与约束

W25Q 系列 Flash 有几个硬性约束必须提前搞清楚,否则写出来的 U 盘会频繁丢数据:

擦除粒度:最小擦除单位是 4KB(一个扇区),有些型号支持 32KB 和 64KB 块擦除,但 4KB 是最细的。这意味着你不能像写 RAM 一样直接改一个字节,必须先把整个 4KB 扇区读到缓冲区,修改后再擦除整个扇区,最后写回去。

写入粒度:页编程指令一次最多写 256 字节,超过这个长度会回卷到页首覆盖之前的数据。所以写操作必须按页对齐,跨页写入要拆分成多次。

寿命限制:每个扇区的擦写次数大约 10 万次,虽然听起来很多,但如果文件系统频繁更新 FAT 表,某些扇区会被反复擦写。不过对于调试用途来说,这个寿命完全够用,不用过度担心。

SPI 速率:W25Q 系列在标准 SPI 模式下最高支持 104MHz 时钟,但 STM32 的 SPI 外设和 PCB 走线质量会限制实际速率。我实测下来,用 18MHz 左右的时钟比较稳,再高就容易出现数据错误,尤其是杜邦线连接的时候。

2.3 CubeMX 配置的关键参数

在 CubeMX 里配置这个项目,有几个地方容易踩坑:

USB 时钟配置:STM32 的 USB 外设要求 48MHz 时钟,这个时钟通常来自 PLL。如果你的主频是 72MHz,PLL 分频后要确保 USB 预分频器输出正好 48MHz。CubeMX 的时钟树界面里会实时显示 USB 时钟频率,如果显示红色或者不是 48MHz,USB 根本不会工作。

SPI 参数:CPOL 和 CPHA 要设为 Low 和 1Edge(模式 0),这是 W25Q 的标准模式。数据宽度 8 位,MSB 先出,这些默认值就行。关键是预分频系数,如果系统时钟 72MHz,分频 4 得到 18MHz,这个值比较稳妥。

USB Device 中间件:在 Middleware 里选 USB_DEVICE,Class 选 Mass Storage Class。CubeMX 会自动生成usbd_storage_if.c文件,里面就是我们要填充的骨架。

堆栈大小:USB 协议栈和 Flash 读写缓冲会占用不少 RAM,建议把 Heap 和 Stack 都调到 0x400 以上,否则容易出现莫名其妙的死机。

注意:CubeMX 生成的代码里,STORAGE_Read_FS和STORAGE_Write_FS默认是空实现,直接返回 USBD_FAIL。如果你不填这两个函数,电脑会识别到设备但无法访问磁盘,提示"请插入磁盘"。

3. 实操过程与核心环节实现

3.1 硬件连接与 SPI 初始化

先确认硬件连线。以常见的 W25Q64 为例,8 个引脚里我们只需要关注 6 个:

Flash 引脚功能STM32 连接
CS片选任意 GPIO,推挽输出
CLK时钟SPI_SCK
DO数据输出SPI_MISO
DI数据输入SPI_MOSI
VCC电源3.3V
GND地GND

WP 和 HOLD 引脚如果不用硬件写保护,直接拉高到 3.3V 就行。CS 引脚我习惯用 GPIO 手动控制而不是 SPI 硬件 NSS,因为 Flash 的指令序列里经常需要在发送指令和读取数据之间保持 CS 拉低,硬件 NSS 有时候会在字节间隙自动拉高,导致时序错乱。

SPI 初始化代码由 CubeMX 生成,核心参数如下:

hspi1.Instance = SPI1; hspi1.Init.Mode = SPI_MODE_MASTER; hspi1.Init.Direction = SPI_DIRECTION_2LINES; hspi1.Init.DataSize = SPI_DATASIZE_8BIT; hspi1.Init.CLKPolarity = SPI_POLARITY_LOW; hspi1.Init.CLKPhase = SPI_PHASE_1EDGE; hspi1.Init.NSS = SPI_NSS_SOFT; hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_4; hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB;

分频系数 4 对应 18MHz,这个速率在杜邦线连接下实测稳定。如果你用 PCB 走线且线长控制得好,可以尝试分频 2 得到 36MHz,但建议先用低速调通再提速。

3.2 Flash 底层驱动实现

在填充 USB 回调之前,先要有一套可靠的 Flash 读写函数。我封装了四个基本操作:

void W25Q_Read(uint32_t addr, uint8_t *buf, uint32_t len); void W25Q_WritePage(uint32_t addr, uint8_t *buf, uint16_t len); void W25Q_EraseSector(uint32_t addr); void W25Q_WaitBusy(void);

读操作最简单,发送0x03指令加 3 字节地址,然后连续读取即可。写操作要先发0x06写使能,再发0x02页编程指令,地址加数据,最后等待忙状态结束。擦除发0x20扇区擦除指令加地址,同样等待忙状态。

W25Q_WaitBusy的实现是循环发送0x05读状态寄存器,检查 bit0 是否为 0。这里有个细节:每次读写操作前都要检查忙状态,因为 Flash 内部擦除需要几十毫秒,如果不等它完成就发下一条指令,数据会丢失。

void W25Q_WaitBusy(void) { uint8_t status; do { CS_LOW(); SPI_TransmitReceive(0x05); status = SPI_TransmitReceive(0xFF); CS_HIGH(); } while (status & 0x01); }

3.3 USB 存储回调的填充

这是整个项目的核心。usbd_storage_if.c里需要实现三个函数,我逐个说明。

GetCapacity返回两个值:块数量和块大小。假设 Flash 容量 8MB,块大小 512 字节,那么块数量就是 810241024/512 = 16384。

int8_t STORAGE_GetCapacity_FS(uint8_t lun, uint32_t *block_num, uint16_t *block_size) { *block_num = 16384; *block_size = 512; return USBD_OK; }

Read_FS把逻辑块地址转换成 Flash 物理地址,然后调用 Flash 读函数。逻辑块地址乘以 512 就是字节偏移,直接映射即可。

int8_t STORAGE_Read_FS(uint8_t lun, uint8_t *buf, uint32_t blk_addr, uint16_t blk_len) { uint32_t addr = blk_addr * 512; uint32_t len = blk_len * 512; W25Q_Read(addr, buf, len); return USBD_OK; }

Write_FS是最复杂的,因为 Flash 不能直接覆盖写。主机的写入请求可能跨越多个 4KB 扇区,每个扇区都要先读出来、修改对应部分、擦除、再写回。我采用了一个 4KB 的缓冲区来处理:

int8_t STORAGE_Write_FS(uint8_t lun, uint8_t *buf, uint32_t blk_addr, uint16_t blk_len) { uint32_t addr = blk_addr * 512; uint32_t len = blk_len * 512; uint32_t sector_start, sector_offset, remaining; while (len > 0) { sector_start = (addr / 4096) * 4096; sector_offset = addr - sector_start; remaining = 4096 - sector_offset; if (remaining > len) remaining = len; W25Q_Read(sector_start, sector_buf, 4096); memcpy(sector_buf + sector_offset, buf, remaining); W25Q_EraseSector(sector_start); for (int i = 0; i < 4096; i += 256) { W25Q_WritePage(sector_start + i, sector_buf + i, 256); } addr += remaining; buf += remaining; len -= remaining; } return USBD_OK; }

这段代码的逻辑是:每次处理一个 4KB 扇区,把要写的数据合并到扇区缓冲里,擦除后整扇区写回。虽然效率不高,但胜在可靠。如果你追求写入速度,可以加一个缓存机制,把连续的写请求攒起来批量处理,但调试阶段不建议这么做,容易引入难以排查的 bug。

3.4 文件系统的格式化与挂载

代码烧录后,把 USB 线插到电脑上,设备管理器里会出现一个 USB 大容量存储设备。但这时候打开"此电脑",可能会提示需要格式化。这是正常的,因为 Flash 里还没有文件系统。

在 Windows 下直接右键格式化,选 FAT32 或者 exFAT 都行。FAT32 兼容性最好,但单文件不能超过 4GB;exFAT 支持大文件,但有些老设备不认。对于调试用途,FAT32 足够了。

格式化完成后,你就可以像操作普通 U 盘一样拖拽文件了。我实测写入一个 2MB 的文件大约需要 8 到 10 秒,读取只要 1 秒左右,瓶颈主要在 Flash 的擦除和页编程速度上。

提示:第一次格式化后,建议拔插一次 USB 线再重新挂载,让主机重新读取容量信息。有时候 Windows 会缓存之前的容量数据,导致显示异常。

4. 常见问题与排查技巧实录

4.1 电脑识别不到设备

这是最常见的问题,排查顺序如下:

先看设备管理器。如果连"未知设备"都没有,说明 USB 枚举都没完成。检查三个地方:USB 时钟是不是 48MHz、DP 引脚有没有上拉电阻(有些板子需要外部 1.5K 上拉)、CubeMX 里 USB 中断有没有使能。

如果显示"未知设备",说明枚举过程中出错了。用 USB 分析仪抓包是最快的,但没有分析仪的话,可以在usbd_conf.c的HAL_PCD_SetupStageCallback里打断点,看主机发的第一个 Setup 包是什么。通常是GET_DESCRIPTOR,如果设备返回的描述符长度不对,主机就会放弃。

如果显示设备但提示"请插入磁盘",说明 MSC 协议栈跑起来了,但GetCapacity返回的数据有问题。检查块数量和块大小是不是合理值,块大小必须是 512,块数量不能为 0。

4.2 写入数据后文件损坏

这个问题我踩过好几次,原因通常有三个:

擦除不完整。Flash 擦除后所有位都是 1,如果擦除指令没发成功或者没等忙状态结束就写数据,写入的内容会和残留数据混在一起。解决办法是在W25Q_EraseSector后面加一个验证步骤,读回来检查是不是全 0xFF。

跨页写入。前面说过页编程最多 256 字节,如果一次写超过 256 字节,地址会回卷。我的W25Q_WritePage函数里加了断言,如果len > 256就直接返回错误,避免这种问题。

缓冲区溢出。USB 回调里的buf指针是主机提供的,长度由blk_len决定。如果blk_len是 1,但代码里按 512 字节处理,就会读越界。我习惯在函数入口加一个检查:if (blk_len == 0 || blk_len > 64) return USBD_FAIL;,限制单次传输的最大块数。

4.3 写入速度慢得离谱

如果写入一个几百 KB 的文件要等半分钟,通常是擦除策略的问题。我的实现是每次写请求都擦除整个 4KB 扇区,即使只改了一个字节。优化方法是加一个扇区缓存:如果连续多次写入落在同一个扇区,只在第一次擦除,后续直接改缓存,最后统一写回。

但这个优化有个风险:如果主机在缓存未写回时突然断电,数据就丢了。对于调试用途,我宁愿慢一点也要保证可靠性。如果你确实需要速度,可以在Write_FS里判断blk_len,如果大于 8 个块(4KB),说明主机在连续写入,这时候用缓存策略;如果小于 8 个块,说明是零散更新,直接擦写更安全。

4.4 常见问题速查表

现象可能原因排查方法
设备管理器无反应USB 时钟不对检查时钟树 USB 是否为 48MHz
未知设备描述符错误抓包看 GET_DESCRIPTOR 响应
提示插入磁盘容量返回错误检查 GetCapacity 返回值
文件损坏擦除不完整擦除后读回验证全 0xFF
写入极慢频繁擦除加扇区缓存或批量写入
拔插后数据丢失缓存未写回禁用缓存或加掉电检测
只能读不能写写保护引脚检查 WP 引脚是否拉高

4.5 几个容易被忽略的细节

USB 线材质量。我遇到过用劣质 USB 线导致枚举失败的情况,换一根好线就正常了。调试阶段尽量用短而粗的线,避免用延长线。

供电问题。有些开发板的 3.3V LDO 电流不够,USB 枚举瞬间的电流波动会导致 Flash 复位。如果遇到随机性识别失败,可以在 Flash 的 VCC 和 GND 之间并一个 100uF 的电解电容。

Flash 的 ID 读取。在初始化时读一下 Flash 的 JEDEC ID(指令 0x9F),确认 SPI 通信正常。如果读回来全是 0xFF 或者 0x00,说明 SPI 接线有问题,不用往下调了。

文件系统兼容性。Windows 格式化的 FAT32 和 Linux 下的 mkfs.vfat 生成的参数略有不同,如果发现 Linux 下能识别但 Windows 下不行,试试用 Windows 重新格式化。反过来也一样。

这个项目我从开始做到稳定运行大概花了三天时间,其中两天都在排查写入数据损坏的问题。最后发现是W25Q_WritePage里没有等忙状态结束就返回了,导致下一次擦除指令在页编程还没完成时就发了出去。这个教训告诉我,Flash 的时序要求必须严格遵守,任何"应该差不多了"的侥幸心理都会导致数据丢失。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询