STM32外部Flash模拟U盘:从USB MSC到W25Q32读改写实践
2026/9/13 6:27:15 网站建设 项目流程

简介:这套 STM32 与 W25Q32 结合的外部 Flash 模拟 U 盘方案,演示了如何通过 SPI 接口将 4MB 存储芯片映射为 USB Mass Storage 设备,适合嵌入式开发者、电子竞赛选手以及想深入理解 USB 协议栈的读者。压缩包共 147 个文件、约 931KB,以 61 个 C 源码和 66 个 H 头文件为主,另有汇编启动文件、Keil 工程文件、编译好的 hex 固件以及 FatFs 相关代码,可直接打开工程编译或烧录到开发板验证。资源已有 849 人学习,项目已经验证通过,可用于需要便携数据转存或扩展嵌入式设备存储能力的场景。代码完整覆盖 USB 控制器初始化、设备枚举、Bulk-Only Transport 协议处理、SPI 读写、中断与错误恢复等关键环节,并附有清理脚本和说明文档,能帮助读者从底层理解 U 盘模拟的完整链路,也为二次开发提供了可复用的工程框架。

1. STM32 外部 Flash 模拟 U 盘:容量为什么少一半

给产品加“USB 直连电脑导出数据”的功能时,最省硬件成本的做法不是加 SD 卡座,而是复用板子上已有的 STM32 和一颗 SPI NOR Flash。W25Q32 只有 32Mbit 也就是 4MB,却能通过 USB MSC 类协议被 Windows 和 Linux 认成一个标准 U 盘,前提是你要把 FAT 文件系统完全交给主机来格式化,设备端只负责把 SCSI 块读写命令翻译成 W25Q32 的页写与扇区擦除。真正有门槛的不是 SPI 驱动,而是 512 字节逻辑扇区和 4KB 擦除粒度之间的对齐问题。格式化完成后可用容量永远小于 4MB,若 READ CAPACITY 和擦除流程处理不对,Windows 会直接报“无法完成格式化”。这篇文章把 USB 枚举、CBW/CSW 状态机、W25Q32 读改写这三层拆开讲,适合正在做数据记录仪、Bootloader 升级仓或便携配置导出工具的嵌入式工程师。

2. USB MSC 与 BOT 协议:把 CBW/CSW 翻译成 W25Q32 读写

2.1 MSC 设备与 Bulk-Only Transport 传输模型

USB 的 Mass Storage Class 不像 HID 那样靠中断端点轮询,也不像 CDC 那样建虚拟串口。它走的是 Bulk-Only Transport,也就是 BOT。主机发送 31 字节的 CBW(Command Block Wrapper)给设备,里面带着一条 SCSI 命令,设备执行完以后回 13 字节的 CSW(Command Status Wrapper)告诉主机命令执行结果。数据阶段可以是从设备到主机的读方向,也可以是从主机到设备的写方向,顺序由 CBW 的 bmCBWFlags 位决定。

STM32 的 USB FS 外设只有 12Mbps,单端点每包 64 字节,但 MSC 本身不依赖实时性,数据吞吐靠连续 Bulk 事务完成。对于 4MB 的 W25Q32 来说,这个带宽瓶颈在 Flash 擦除上而不在 USB 总线上,所以 F103 这类没有 USB HS 的芯片完全够用。

BOT 状态机通常分成四步:等待 CBW、解析命令、数据阶段、回发 CSW。如果命令本身不允许有数据阶段,比如 TEST UNIT READY,就直接跳去发 CSW。命令执行出错时不是靠 CSW 的错误位单方面解决,而需要把对应端点 STALL 掉,让主机重新发 REQUEST SENSE 来取错误码。

2.2 SCSI 最小命令集:没有它 U 盘就识别不出来

USB 存储设备只要实现 SCSI 命令的子集即可。这里列一张我常用的缩略表,下面的代码和调试都用这张表对着查:

命令Opcode数据方向设备端动作
INQUIRY0x12设备→主机返回厂商、产品名、版本
TEST UNIT READY0x00返回就绪状态
REQUEST SENSE0x03设备→主机返回上次错误码
READ CAPACITY(10)0x25设备→主机返回最大 LBA 与块长
MODE SENSE(6)0x1A设备→主机返回写保护与介质参数
READ(10)0x28设备→主机读 W25Q32
WRITE(10)0x2A主机→设备写 W25Q32
PREVENT ALLOW MEDIUM REMOVAL0x1E安全忽略,回成功
START STOP UNIT0x1B安全忽略,回成功
VERIFY(10)0x2F通常无数据校验逻辑,通常直接回成功
SYNCHRONIZE CACHE0x35刷缓存,没有缓存就回成功

Windows 枚举 U 盘时的顺序大致是:INQUIRY、TEST UNIT READY、READ CAPACITY(10)、MODE SENSE(6)、然后开始读 0 号扇区判断有没有文件系统。任何一个环节返回错误或者 STALL 得不对,设备就会在“磁盘管理”里显示为无法初始化。

2.3 命令分派代码与 CBW/CSW 结构体实现

设备端收到的每一包 CBW 都填在同一个结构体里,我用__attribute__((packed))保证 1 字节对齐,避免编译器填充字段导致解析错位:

typedef struct __attribute__((packed)) { uint32_t dCBWSignature; /* 固定 0x43425355,ASCII 为 'USBC' */ uint32_t dCBWTag; /* 主机传来的事务标签,要原样回填到 CSW */ uint32_t dCBWDataTransferLength; /* 数据阶段期望传输的字节数 */ uint8_t bmCBWFlags; /* 0x80 设备→主机,0x00 主机→设备 */ uint8_t bCBWLUN; /* 逻辑单元号,单 LUN 时为 0 */ uint8_t bCBWCBLength; /* 有效 SCSI 命令字节数,0~16 */ uint8_t CBWCB[16]; /* SCSI 命令块 */ } CBW_t; typedef struct __attribute__((packed)) { uint32_t dCSWSignature; /* 固定 0x53425355,ASCII 为 'USBS' */ uint32_t dCSWTag; /* 必须等于 CBW 里的 dCBWTag */ uint32_t dCSWDataResidue; /* 未传输完的字节数,成功为 0 */ uint8_t bCSWStatus; /* 0=成功,1=失败,2=阶段错误 */ } CSW_t;

命令分派在一个 switch 里完成,真实产品中我就是这么写的:

static void msc_process_cbw(CBW_t *cbw) { uint8_t cmd = cbw->CBWCB[0]; uint16_t len; switch (cmd) { case SCSI_INQUIRY: msc_reply_data((uint8_t *)inquiry_resp, sizeof(inquiry_resp)); break; case SCSI_TEST_UNIT_READY: msc_send_csw(CSW_STATUS_OK, 0); break; case SCSI_READ_CAPACITY_10: msc_reply_data((uint8_t *)capacity_resp, sizeof(capacity_resp)); break; case SCSI_READ_10: lba = get_be32(&cbw->CBWCB[2]); blk = get_be16(&cbw->CBWCB[7]); if (lba + blk > DISK_BLOCK_COUNT) { msc_stall_ep(); /* 超容量必须 STALL */ break; } flash_read_multi(lba, blk); /* 从 W25Q32 读出并回传 */ break; case SCSI_WRITE_10: lba = get_be32(&cbw->CBWCB[2]); blk = get_be16(&cbw->CBWCB[7]); if (lba + blk > DISK_BLOCK_COUNT) { msc_stall_ep(); break; } msc_enter_write_mode(lba, blk); /* 准备收数据,数据阶段结束后写 Flash */ break; default: msc_stall_ep(); break; } }

这里有几个参数需要留意。lba是逻辑块地址,Windows 会从 0 开始连续递增访问;blk是本次请求覆盖的块数量,一次最多可以请求 128 到 256 个块,也就是 64KB 到 128KB,设备端不能假设主机永远按 512 字节发单块请求。msc_reply_data内部要做的是先把响应数据搬进端点缓冲,然后启动 IN 传输,传输完成后回 CSW。对于数据阶段的判断,我一般在收到 CBW 后先看dCBWDataTransferLength,为 0 就表示无数据阶段,执行完命令直接回 CSW。

只要这个状态机是完整的,USB 枚举和驱动加载就没有问题,接下来才会进入真正的 Flash 读写环节。

3. W25Q32 读改写与扇区擦除:512 字节背后的 4KB 对齐

3.1 W25Q32 的写约束:页写 256B、扇区擦除 4KB

W25Q32 的 4MB 空间分成 1024 个 4KB 扇区,每 16 个扇区组成一个 64KB 块。它支持整片读、按任意字节读,但写操作有两个硬约束:写最小单元是 256 字节页,且页写不能跨 256 字节边界;擦除最小单元是 4KB 扇区,不存在只擦 512 字节的指令。也就是说,NOR Flash 只能把 1 写成 0,要把 0 变回 1 必须先擦除,擦除的单位远大于主机读写的最小单位。

常用参数表如下:

操作指令码典型时间最大时间说明
页写 Page Program0x020.7ms3ms一次最多 256B,不能跨页
扇区擦除 Sector Erase0x2050ms400ms擦除 4KB
块擦除 Block Erase0xD8120ms1000ms擦除 64KB
读状态寄存器0x05WIP 位为 1 表示忙
写使能0x06每次写或擦除前必须发

最容易被忽略的是写使能。W25Q32 在上电后默认禁止写,如果写完一个扇区想接着写下一个,必须重新发 0x06。否则写指令被忽略,状态寄存器里的 WEL 位也能帮你排查这类问题。

等待 Flash 空闲的标准做法是轮询状态寄存器最低位,而不是用HAL_Delay(400)死等上限,否则性能会差整整一个数量级:

static void w25q_wait_busy(void) { uint8_t sr; w25q_cs_low(); spi_txrx(0x05); /* 读状态寄存器 */ do { sr = spi_txrx(0xFF); /* 每次发无效字节,同时收状态 */ } while (sr & 0x01); /* WIP=1 表示忙 */ w25q_cs_high(); }

这一段轮询代码是 SPI NOR 驱动的基础。spi_txrx同时完成发送和接收,写 0xFF 只是为了取回数据。CS 脚必须全程拉低直到读完状态寄存器,否则芯片认为 SPI 事务结束了。

3.2 逻辑扇区到物理扇区的映射:一个 LBA 不等于一个扇区

USB 主机把设备看成一块硬盘,读写的最小单位是 512 字节逻辑块。W25Q32 的物理擦除单位是 4KB,两者之间需要做一个除法:flash_sector = lba / 8,因为 4096 / 512 = 8,每个物理扇区容纳 8 个逻辑扇区。offset = (lba % 8) * 512则定位到物理扇区内的字节偏移。

这个换算直接决定写入逻辑:

static uint32_t lba_to_flash_addr(uint32_t lba) { return (lba >> 3) << 12; /* 逻辑块号除以 8 得到物理扇区号,再乘以 4096 */ }

我一般把逻辑块总数DISK_BLOCK_COUNT定义成 8192,这样容量正好是 4MB,而最后一章会介绍为什么实际产品中我喜欢留出一点余量。这个宏会在 READ CAPACITY、READ(10) 和 WRITE(10) 的越界检查里反复用到,所有的检查都要以“可能越界”为前提写,因为 Windows 的快速格式化偶尔会请求超出容量的扇区。

3.3 读改写实现:先读 4KB、改 512B、擦除、再写回

当主机只更新一个 512 字节逻辑块时,绝不能直接擦掉整个 4KB 扇区,因为这次写入只覆盖其中 1/8 的数据。正确流程是把整个 4KB 物理扇区读进内存,在内存中修改对应偏移,擦除物理扇区,再把 4096 字节全部写回。

static int disk_write_lba(uint32_t lba, const uint8_t *data, uint32_t len) { uint32_t flash_addr = lba_to_flash_addr(lba); uint32_t offset = (lba & 7) * 512; uint8_t sector_buf[4096]; if (len > 512) len = 512; w25q_read(flash_addr, sector_buf, 4096); /* 读旧扇区 */ memcpy(sector_buf + offset, data, len); /* 更新目标区域 */ flash_erase_sector(flash_addr); /* 擦除整个 4KB */ w25q_page_program_multi(flash_addr, sector_buf, 4096); return 0; }

页写函数内部会把 4096 字节拆分到 16 个页事务里,每一页独立发写使能与页写指令。这里不能偷懒把 4096 字节一次发完,W25Q32 的页边界约束会造成卷绕写,后 256 字节会跑到这个页的起始位置。

void w25q_page_program_multi(uint32_t addr, const uint8_t *buf, uint32_t len) { uint32_t page_remain; while (len > 0) { page_remain = 256 - (addr & 0xFF); /* 到当前页末端的剩余空间 */ if (page_remain > len) page_remain = len; w25q_write_enable(); /* 先发 0x06 */ w25q_cs_low(); spi_txrx(0x02); spi_txrx((addr >> 16) & 0xFF); spi_txrx((addr >> 8) & 0xFF); spi_txrx(addr & 0xFF); while (page_remain--) spi_txrx(*buf++); w25q_cs_high(); w25q_wait_busy(); /* 等 WIP 清 0 */ addr += 256; len -= 256; } }

每次调用w25q_write_enable是因为 WEL 位在一条写或者擦除指令完成后自动清零。如果你把 16 个页的写使能合并成一次,从第二个页开始数据会被静默丢弃,格式化到一半报错,排查起来非常隐蔽。

读改写导致的性能损失是客观存在的:即使只写一个 512B 扇区,也要付出 4096B 读、一次 4KB 擦除、4096B 写的成本。如果主机连续写同一物理扇区内的多个逻辑块,这种逐块读改写就会造成大量重复擦除,后面第 5 章的脏扇区缓存就是专门解决这个问题的。

3.4 全 0xFF 跳过与磨损集中的处理

新出厂的 Flash 全片是 0xFF,擦除完成后也是 0xFF。如果主机第一次格式化时对整盘写 0,而我们对每个扇区都做“读旧值—擦除—写新值”,前 4MB 要执行 1024 次擦除,按 50ms 一次算就是 51 秒,用户会以为设备卡死了。

常见做法是在写之前先检查目标物理扇区是否全为 0xFF,如果是就直接页写,跳过擦除。这在第一次格式化、擦除后重新写入的场景里能省掉大量时间:

static int w25q_sector_is_blank(uint32_t addr) { uint8_t buf[16]; uint32_t i; for (i = 0; i < 4096; i += 16) { w25q_read(addr + i, buf, 16); for (int j = 0; j < 16; j++) { if (buf[j] != 0xFF) return 0; } } return 1; }

磨损集中是另一个需要提前知道的问题。Windows 或者 Linux 格式化后,FAT 表、根目录区都集中在磁盘起始位置,每次新建或修改文件都会改写这些区域,导致低地址扇区擦写次数远高于高地址。4MB 小盘按 10 万次擦写寿命算,如果某个扇区每秒被擦写一次,三天就会耗尽。这就要用到最后一章提到的缓存合并和磨损均衡思路。

4. 端点配置与容量上报:让 PC 正确识别这个 STM32 U 盘

4.1 MSC 接口描述符与端点分配

设备要跑 U 盘,必须配置至少一个 Bulk IN 端点加一个 Bulk OUT 端点。STM32 的 USB 外设里,每个端点都要在端点描述符里声明传输类型,MSC 的接口描述符必须带两个端点。下面这段是配置描述符的关键字节:

static const uint8_t msc_config_desc[] = { 0x09, 0x02, 0x20, 0x00, 0x01, 0x01, 0x00, 0x80, 0x32, 0x09, 0x04, 0x00, 0x00, 0x02, 0x08, 0x06, 0x50, 0x00, 0x07, 0x05, 0x81, 0x02, 0x40, 0x00, 0x00, 0x07, 0x05, 0x01, 0x02, 0x40, 0x00, 0x00, };

逐个字段解释:0x08是接口类,Mass Storage;0x06是接口子类,SCSI Transparent Command Set;0x50是接口协议,Bulk-Only Transport。MSC 必须用 0x50 而不是 0x00,否则 Windows 会安装不上驱动。后面两个端点描述符分别是 0x81 的 Bulk IN 和 0x01 的 Bulk OUT,每包 0x40,也就是 64 字节。

端点数看起来是双端点,在 USB 协议层很干净;不过在 STM32 内部可以进一步把这两个端点配置成双向的端点 1,配合双缓冲来减少 CPU 中断频率。F103 的 USBD MSC 例程里常见做法是使用单一端点号 1,方向靠传输方向位区分。

4.2 READ CAPACITY、MODE SENSE 的响应细节

READ CAPACITY(10) 返回 8 字节,决定主机把这块存储识别成多大。主机只认最后一块 LBA 与块长度,而不是 4MB 这个十进制数字:

static uint8_t capacity_resp[8]; static void build_capacity(uint32_t block_count) { capacity_resp[0] = (block_count - 1) >> 24; capacity_resp[1] = (block_count - 1) >> 16; capacity_resp[2] = (block_count - 1) >> 8; capacity_resp[3] = (block_count - 1); capacity_resp[4] = 0x00; capacity_resp[5] = 0x00; capacity_resp[6] = 0x02; /* 512 高字节 */ capacity_resp[7] = 0x00; /* 512 低字节 */ }

这里要注意的是:如果 W25Q32 全片 4MB 都暴露给主机,block_count 就是 8192,最后一块 LBA 是 8191。Windows 分区管理和格式化工具会把这个盘当成容量刚好 4MB 的移动磁盘。由于 FAT 文件系统本身要占用引导扇区、FAT 表、根目录,格式化后可用空间大约 3.8MB 左右,这是正常现象。千万不要把 block_count 上报得超出 Flash 实际容量,写保护时显示器会提示“磁盘容量无效”。

MODE SENSE(6) 在枚举阶段也会被 Windows 使用。它要返回介质类型和写保护状态,我们直接回一份零初始化的响应,写保护位为 0:

static const uint8_t mode_sense6_resp[] = { 0x03, /* Mode Data Length,后续还有 3 字节 */ 0x00, /* Medium Type,0 表示默认可移动介质 */ 0x00, /* Device Specific Parameter,WP 位为 0 */ 0x00, /* Block Descriptor Length */ 0x00, 0x06, 0x00, /* 这 3 字节内容由实际请求决定 */ };

这里的第六字节会在主机发送 MODE SENSE(6) 的页面码后动态填充。有的主机要求页面 0x01 也就是 Read-Write Error Recovery Page,有的要求页面 0x3F 返回所有页面,所以响应不能写死,要留出按页面号组织内容的处理函数。

4.3 格式化兼容:Windows 与 Linux 处理 4MB 小盘的差异

4MB 的盘格式化有个特有的兼容性问题。Windows 的格式化对话框对小容量移动盘通常使用 FAT16,簇大小会自动选 512 字节或 4096 字节;Linux 的mkfs.vfat默认行为则可能是 FAT12。两种文件系统在引导扇区内的 BPB 字段不同,但只要 SCSI 读写在逻辑层实现正确,两种主机都能识别并格式化。

不能碰的是 FAT32。FAT32 最小卷大小要求约 32MB,4MB 盘强行格式化成 FAT32 会被 Windows 拒绝,Linux 下mkfs.vfat -F 32也会因为容量太小直接报错。所以设备端只需要把容量上报正确,由主机在格式化时自选 FAT12 或 FAT16 即可,不需要在 STM32 里内置文件系统代码。

还有一个小细节容易被忽略:Windows 快速格式化会写引导区、两份 FAT 表、根目录,并且会把很多扇区一次性写入。这些写请求经常是连续大块,会激活第 3.3 节的整扇区覆盖路径。如果写时序处理不好,格式化进行到一半报“参数错误”,建议先用逻辑分析仪确认最后一次出错时主机的 LBA 长度是否越界,再确认 Flash 擦除之后有没有重新发写使能。

5. 验证 U 盘读写并压低写放大:usb 抓包与脏扇区缓存

5.1 主机侧验证流程与 usb 抓包

板子插上电脑后,先在 Linux 下确认枚举结果:

dmesg | tail -20 lsusb lsblk

正常会看到sd 0:0:0:0: [sdb] 8192 512-byte logical blocks: (4.19 MB/4.00 MiB)类似的日志。如果没有出现,先查描述符里的 VID/PID 是否与驱动绑定,再抓 USB 报文看枚举到哪一步失败。用 usbmon 抓包是嵌入式开发者定位 MSC 问题最快的路径:

sudo modprobe usbmon sudo cat /sys/kernel/debug/usb/usbmon/2u | tee /tmp/usb.log

在这个日志里能看到主机发出的 CBW 原始字节和设备回发的 CSW 状态。如果bCSWStatus频繁出现 0x01,说明设备端执行 SCSI 命令时主动报错,问题多半在越界检查或者 Flash 写失败;如果出现 0x02,则是数据阶段剩余长度不匹配,说明 CBW 里的dCBWDataTransferLength和实际传输的字节数不一致,这是协议实现最常见的 bug。

读写验证用一个文件就够:

sudo mkfs.vfat -F 12 /dev/sdb sudo mount /dev/sdb /mnt dd if=/dev/urandom of=/mnt/test.bin bs=1024 count=100 conv=fsync sync sudo umount /mnt

重新插拔后sha256sum前后对比,确认数据没有因擦写逻辑丢字节。

5.2 写放大倍数:512 字节写入背后的真实成本

把写放大系数列出来,能帮你决定要不要做缓存优化:

主机操作W25Q32 实际行为放大倍数
连续写满整 4KB 扇区擦除 1 次 + 写 4096B1x
写单个 512B 扇区读 4096B + 擦除 + 写 4096B8x
写单个 512B 但不缓存多次零散写同上,且每次重复擦除16x 以上

第 3.3 节的读改写路径每写 512B 都要擦除整个 4KB,如果不做任何缓存,FAT 表区域密集写入时写放大非常可观,而且还会拖慢格式化速度。

5.3 脏扇区缓存:把零散写合并成整扇区写

我这里介绍一个在 4MB 小盘上性价比最高的优化手段:在 RAM 里维护若干全 4KB 的脏扇区缓存。主机对同一物理扇区的多次 512B 写入先在缓存里合并,只有缓存被替换或者需要读取时才真正回写到 W25Q32。

#define CACHE_SLOTS 4 typedef struct { uint32_t flash_addr; /* 物理扇区地址 */ uint8_t dirty[8]; /* 8 个逻辑块对应位图 */ uint8_t buf[4096]; /* 缓存内容 */ uint8_t used; } sector_cache_t; static sector_cache_t cache[CACHE_SLOTS];

CACHE_SLOTS取 4 时占用 16KB RAM,对 STM32F103 的 64KB RAM 来说负担可控。写入路径变成:先查缓存,命中就直接改buf并置对应 dirty 位;没命中就找一个空闲槽,把整扇区读进来,修改后标记为脏。回写发生在三个时机:需要替换缓存时、收到 READ(10) 且目标扇区正好命中缓存时、以及收到 SYNCHRONIZE CACHE 命令时。最后一种情况不实现时,Windows 会通过 CSW 状态的 0 来容忍,但主动实现掉电前刷缓存能显著提升可靠性。

有了这个缓存,连续写多个逻辑扇区时只要它们在同一个 4KB 物理扇区内,Flash 擦除次数会大幅下降,格式化速度提升一倍以上。再配合第 3.4 节的全 0xFF 跳过判断,一个 4MB 的 W25Q32 模拟 U 盘方案无论是在 Windows 还是 Linux 下,都能在几秒内完成首次格式化并长期稳定写入。想要进一步延长寿命,还可以在此基础上把 FAT 区域映射到物理扇区轮转,不过那是把 SCSI 层改成 FTL 的话题了,先从这层脏扇区缓存开始收益最直接。

本文还有配套的精品资源,点击获取

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

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

立即咨询