
简介这套 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 字节的 CBWCommand Block Wrapper给设备里面带着一条 SCSI 命令设备执行完以后回 13 字节的 CSWCommand 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设备→主机读 W25Q32WRITE(10)0x2A主机→设备写 W25Q32PREVENT 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; /* 固定 0x43425355ASCII 为 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; /* 固定 0x53425355ASCII 为 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、扇区擦除 4KBW25Q32 的 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读状态寄存器0x05——WIP 位为 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); /* WIP1 表示忙 */ 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 Storage0x06是接口子类SCSI Transparent Command Set0x50是接口协议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 使用。它要返回介质类型和写保护状态我们直接回一份零初始化的响应写保护位为 0static const uint8_t mode_sense6_resp[] { 0x03, /* Mode Data Length后续还有 3 字节 */ 0x00, /* Medium Type0 表示默认可移动介质 */ 0x00, /* Device Specific ParameterWP 位为 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 最小卷大小要求约 32MB4MB 盘强行格式化成 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 bs1024 count100 convfsync 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 的话题了先从这层脏扇区缓存开始收益最直接。本文还有配套的精品资源点击获取