最近在做一个大容量数据记录仪的项目,主控选的是STM32H723ZGT6,需要把设备采集的数据以U盘形式导出来。就是那种“插上USB线,电脑上直接多出一个移动磁盘,拖文件出来就行”的效果。折腾了小两周,把USB Mass Storage类设备从零调通,中间踩了不少坑。这篇文章就把整个实现过程、原理、以及调试中遇到的问题完完整整写出来,给准备在H7系列上做USB disk的同行们一个参考。
这个项目不算复杂,但涉及的知识点很杂:USB协议栈的移植、MSC类协议的状态机、Cortex-M7的Cache一致性、QSPI Flash的读写策略、FATFS文件系统的对接。任何一个环节出问题,现象都是“电脑认不到盘”或者“认到了但拷文件报错”,排查起来特别费劲。我会把每一步的关键细节都讲清楚,包括CubeMX里怎么配、代码哪些地方必须改、以及实测的性能数据。
1. 项目概述与方案选型
1.1 为什么让单片机直接扮演U盘
先说说应用场景。设备本身是个数据采集器,采集到的数据存放在板载的16MB QSPI NOR Flash里。客户要求的数据导出方式非常简单粗暴:插上USB线,电脑上出现一个磁盘,像操作普通U盘一样把数据文件拷出来。
这个需求有几个天然优势,也是我最终选择MSC方案的原因:
- 免驱动:Windows、Linux、macOS都原生支持USB Mass Storage类设备,插上就能识别,不需要装任何驱动软件。
- 操作零门槛:最终用户不需要学习任何上位机软件,打开文件管理器拖文件就行。
- 兼容性好:不管用户的电脑是Win7还是Win11,还是Linux工控机,行为都是一致的。
对比另一条路线——USB虚拟串口(VCP)加自定义协议,需要写配套的上位机软件,用户在部署和使用上都多一道门槛。虽然H723的USB虚拟串口我也在别的项目里用过,但从交付体验来说,MSC方案在这个场景下完胜。
1.2 H723的USB硬件资源盘点
STM32H723这颗芯片比较有意思,它的USB资源其实是“一强一弱”:
- USB OTG HS:高速接口,内置了Full-Speed PHY,可以直接以12Mbps的全速模式运行。如果想跑480Mbps的高速模式,需要外接ULPI接口的高速PHY芯片(比如USB3300)。
- 芯片上没有独立的Full-Speed OTG(也就是没有传统STM32F1/F4上那种OTG_FS专用接口),USB功能全靠OTG HS这一个外设。
这里有个很多人容易忽略的点:H723的OTG HS内置PHY只支持Full-Speed(12Mbps),但它内部仍然以HS核心在工作。在CubeMX里配置时,如果选择“Internal PHY + Full-Speed”,实际就是一个全速USB设备。这个速率跑MSC够不够?读速度大概1MB/s左右,写速度取决于存储介质,对于几MB到几十MB的数据导出来说,完全够用。
存储介质我选了W25Q128JV,16MB容量,单线SPI模式。为什么不用SD卡?因为设备有震动和温度要求,SD卡的可靠性不如贴片NOR Flash。为什么不用片内Flash?H723内置Flash最大1MB,而且MSC读写会频繁擦写,对代码存储区有风险。16MB的NOR Flash做数据记录和导出,空间也够。
2. 核心原理:USB MSC设备的工作机制
2.1 必须搞懂的BOT传输协议
USB Mass Storage类设备最常见的传输方式叫Bulk-Only Transport(BOT),整个通信过程围绕三个东西展开:CBW、数据阶段、CSW。
- CBW(Command Block Wrapper):主机发给设备的31字节命令块,里面最关键的是CBWCB,也就是包装在USB传输里的SCSI命令。比如READ(10)、WRITE(10)、INQUIRY这些。
- 数据阶段:根据命令方向,主机和设备之间传输数据。读命令是设备发数据给主机,写命令是主机发数据给设备。
- CSW(Command Status Wrapper):设备在命令执行完毕后返回的13字节状态块,告诉主机这个命令成功还是失败。
理解这个协议的关键在于:USB MSC本质上是在USB Bulk端点上做SCSI命令的搬运。STM32的USB中间件(usbd_msc.c)已经把CBW/CSW的状态机处理好了,你不需要自己解析CBW结构,只需要在中间件留出的接口里实现具体的存储操作函数。这个设计很好,能让你把精力集中在存储介质驱动上。
2.2 描述符:设备能不能被识别,全看它
USB设备枚举的过程就是主机不停发送各种标准请求(GET_DESCRIPTOR、SET_ADDRESS等),设备不断返回描述符的过程。MSC设备最关键的三层描述符:
- 设备描述符(Device Descriptor):包含VID/PID、bcdUSB版本号、设备类代码。MSC设备通常把bDeviceClass设为0xFF,由接口描述符来定义实际功能。
- 配置描述符(Configuration Descriptor):包含接口描述符和端点描述符。MSC设备会声明一个接口,接口类为0x08(Mass Storage),子类0x06(SCSI Transparent Command Set),协议0x50(Bulk-Only Transport)。
- 端点描述符:两个Bulk端点,一个IN一个OUT。全速设备要求Bulk端点最大包长64字节。
STM32CubeMX生成的代码默认带了一套MSC描述符,VID=0x1155,PID=0x1234,字符串描述符是“STMicroelectronics”。如果批量交付设备,这些信息最好改掉,不然客户电脑上所有这个芯片的设备都显示同一个名字,排查问题的时候特别晕。
2.3 存储介质与扇区的映射关系
U盘给主机呈现的是一个“块设备”:主机以扇区(逻辑块)为单位访问,每个扇区512字节。主机发的READ(10)命令会带逻辑块地址(LBA)和传输块数量,设备要把LBA映射到物理存储介质的实际地址。
对于W25Q128这样的SPI NOR Flash,映射方式很直接:
物理地址 = LBA × 512操作流程是:
- 收到READ(10),把LBA换算成Flash地址,调用SPI读函数把数据读出来。
- 收到WRITE(10),把LBA换算成Flash地址,写入数据。
听起来简单,但NOR Flash有个致命特性:写入前必须擦除,擦除的最小单位是扇区(W25Q128是4KB),而写入的最小单位是页(256字节)。这意味着写一个512字节的扇区,如果目标地址所在的4KB区域没有擦除过,你得先把这4KB读出来,在RAM里修改,然后整块擦除,再重新写入。
换句话说说,每次写入都可能伴随一次完整的擦写周期,实际写速度被拉得很低。我在后面的实操部分会讲怎么优化这个问题。
3. 实操:从CubeMX配置到第一个枚举成功的U盘
3.1 CubeMX配置步骤
我用的是STM32CubeMX 6.10配合HAL库,芯片选STM32H723ZGT6,主频跑550MHz。USB相关配置如下:
- 在Connectivity里使能USB_OTG_HS,Mode选择Device_Only。
- USB_OTG_HS的Parameter Settings里,把Speed选为Full-Speed(Internal PHY)。
- 在Middleware and Software Packs里选择USB_DEVICE,Class for FS IP选择Mass Storage Class。
- 如果存储介质数据要经过DMA传输,注意要在USB_OTG_HS配置里使能DMA(实际上H7的USB驱动在无DMA模式下也能工作,但效率差一些,后面会细说)。
有个细节容易被忽略:H723的USB_OTG_HS有个**共享FIFO(RX/TX FIFO)**配置,在CubeMX的USB_OTG_HS参数里,需要根据端点数量和传输大小调整FIFO大小。默认值在MSC场景下够用,但如果后面发现枚举后数据传输不稳定,可以回来调大TX_FIFO_HS的深度。
生成代码后,工程里会多出这些关键文件:
USB_DEVICE/usbd_core.c、usbd_ctlreq.c:USB核心和标准请求处理。USB_DEVICE/usbd_msc.c:MSC类状态机。USB_DEVICE/usbd_storage_if.c:这个文件就是要你实现存储回调函数的地方。USB_DEVICE/usbd_desc.c:描述符定义。
3.2 描述符与VID/PID修改
生成的usbd_desc.c里有几个数组需要重点看:
__ALIGN_BEGIN static const uint8_t USBD_VID[] = __ALIGN_END { (LPSTR)USBD_VID_STR_DESC, }; __ALIGN_BEGIN static const uint8_t USBD_PID[] = __ALIGN_END { (LPSTR)USBD_PID_STR_DESC, };修改str_vid、str_pid对应的字符串描述符即可。注意字符串描述符是Unicode格式,格式为:第一个字节是描述符长度,第二个字节是0x03(字符串描述符类型),后面是UTF-16编码的字符。比如VID改成"1234",实际上要写0x34, 0x12, 0x33, 0x12...这种形式。
我这边直接把VID改成自家产品的编号,PID改成0x0001,制造商字符串改成自定义名称。同时把设备序列号字符串(USBD_SERIAL_NUMBER)也改了,这玩意儿在Windows下用来区分同一VID/PID的不同设备,如果量产设备,建议用芯片唯一ID生成序列号。
3.3 存储回调函数的实现
usbd_storage_if.c是核心。文件里定义了一组函数指针结构体:
#define STORAGE_LUN_NBR 1 #define STORAGE_BLK_NBR 0x2000 #define STORAGE_BLK_SIZ 0x200 int8_t STORAGE_Init(uint8_t lun); int8_t STORAGE_GetCapacity(uint8_t lun, uint32_t *block_num, uint16_t *block_size); int8_t STORAGE_IsReady(uint8_t lun); int8_t STORAGE_IsWriteProtected(uint8_t lun); int8_t STORAGE_Read(uint8_t lun, uint8_t *buf, uint32_t blk_addr, uint16_t blk_len); int8_t STORAGE_Write(uint8_t lun, uint8_t *buf, uint32_t blk_addr, uint16_t blk_len); int8_t STORAGE_GetMaxLun(uint8_t *lun);逐个说下实现要点:
STORAGE_Init:初始化QSPI Flash和FATFS。如果Flash里还没有文件系统,这里要做一次格式化。但要注意:不能在枚举过程中做耗时操作,否则主机会因为设备响应超时而枚举失败。我是在main函数初始化阶段就完成Flash和文件系统准备,STORAGE_Init只做状态标记。
STORAGE_GetCapacity:返回总扇区数和扇区大小。W25Q128是16MB,换算成扇区是16 * 1024 * 1024 / 512 = 32768个。要确保返回值正确,这个值错了Windows会报“磁盘大小不正确”。
STORAGE_IsReady:返回0表示就绪。如果你的Flash正在做擦除等其他操作,可以返回1让主机稍后再试,但如果长时间不就绪,Windows会报错。
STORAGE_IsWriteProtected:返回0表示可写。这里可以做成读取某个GPIO或者配置位,实现“写保护开关”功能。
STORAGE_Read和STORAGE_Write是核心函数。参数含义:blk_addr是起始LBA,blk_len是要读写的扇区数量,buf是数据缓冲区。注意:
int8_t STORAGE_Read(uint8_t lun, uint8_t *buf, uint32_t blk_addr, uint16_t blk_len) { // 把LBA换算成字节地址 uint32_t addr = blk_addr * STORAGE_BLK_SIZ; // 读取数据到buf if (QSPI_Read(addr, buf, (uint32_t)blk_len * STORAGE_BLK_SIZ) != 0) return -1; return 0; }这里有个H7特有的坑:Cortex-M7的Cache。如果USB的DMA把数据通过AHB总线写入内存,而你的CPU或者QSPI控制器从Cache里读数据,就会读到脏数据。有两种解决方式:
- 把USB和QSPI的DMA缓冲区所在内存区域配置为不缓存(通过MPU配置)。
- 在每次DMA传输前后手动调用
SCB_CleanDCache()和SCB_InvalidateDCache()。
我用的方案是2,因为H723在550MHz下跑,直接关闭Cache性能损失太大。在STORAGE_Read返回前,如果QSPI是通过DMA读数据的,需要先SCB_InvalidateDCache()再让USB把数据发出去;在STORAGE_Write里,USB DMA已经把数据写进缓冲区了,写Flash之前要SCB_CleanDCache()确保数据落回内存。
3.4 让Windows认识FATFS文件系统
MSC设备只是“块设备”,要让Windows显示一个可以拖文件的盘符,介质上还得有FAT文件系统。最简单的方式是第一次插上时让Windows自己格式化,但这样用户体验差。我的做法是用FATFS(elm-chan那个)在Flash上直接格式化并创建好目录结构。
#include "ff.h" FATFS fs; DIR dir; void MX_FATFS_Init(void) { // 挂载文件系统,如果失败则格式化 if (f_mount(&fs, "", 1) != FR_OK) { // 格式化后重新挂载 f_mkfs("", FM_FAT, 0, work_buf, sizeof(work_buf)); f_mount(&fs, "", 1); } // 创建数据目录 f_chdrive(""); f_mkdir("DATA"); }注意f_mkfs需要一个足够大的work buffer,我用的是uint8_t work_buf[4096]。FATFS的配置宏_MAX_SS要设为4096(如果Flash页大小是4096),并且_USE_MKFS要打开,这些在ffconf.h里改。
还有个经验:格式化后的簇大小会影响导出性能。16MB的Flash建议用4KB一簇(也就是4096字节),FAT表和目录项的损耗比较低,文件系统碎片也少。FATFS的f_mkfs默认会选一个合适的簇大小,如果不改的话通常也是合理的,但用大扇区Flash时最好显式指定一下。
4. 性能优化与稳定性处理
4.1 QSPI Flash的写入性能瓶颈
上面提到的“读-改-擦-写”策略如果不做优化,实际写速度会非常难看。我实测过,在W25Q128上,Windows向U盘写入一个10MB的文件,如果每次都做完整的读-改-擦-写,速度只有30~50KB/s,拷个文件等好几分钟,用户体验很差。
优化思路有几个层次:
第一层:利用Flash的页写特性。W25Q128支持256字节页编程,如果写入的数据正好是页对齐的,可以免去擦除操作(前提是该区域已经是擦除状态)。但文件系统会把数据分散在多个扇区,很难做到完美对齐,收益有限。
第二层:做脏扇区标记和整块擦除。把Flash划分成“块”,每个块(比如4KB)对应8个扇区。当收到写扇区请求时,不立即写Flash,而是把数据放进内存中的一个“影子缓冲区”,标记该块为脏。定期或断电前把脏块整块擦除并写回。这样可以把多次扇区写合并成一次块擦写。FATFS在写FAT表时经常连续操作多个扇区,这种合并效果非常明显。
第三层:使用内存文件系统配合后台落盘。更激进的方案是直接在RAM里维护一个完整的FAT文件系统,USB的读写全部打到内存缓冲区,然后后台任务周期性刷到Flash。这个方案速度最快(可以跑满USB全速带宽),但实现复杂,对断电可靠性要求高,一般设备项目慎用。
我最终用的是第二层方案,把写速度提升到了150~200KB/s,10MB文件一分钟左右拷完,产品上可接受。
4.2 USB全速模式下传输性能实测
这个项目USB跑在Full-Speed,理论带宽12Mbps,实际有效数据吞吐约1.1MB/s。考虑到MSC协议本身的CBW/CSW开销和Bulk传输的NAK机制,纯读速度实测大约900KB/s~1.0MB/s。
读性能基本没瓶颈,因为NOR Flash读速度快,USB成了瓶颈。写性能则完全看存储策略。如果你用的是QSPI Flash开启Memory-Mapped模式,读取可以直接映射到地址空间,连SPI读命令都省了,代码更简洁。但注意Memory-Mapped模式下写还是要走普通寄存器操作,不能直接写映射地址。
4.3 USB DMA和中断优先级的斟酌
H7的USB可以用内部DMA搬运FIFO数据,这需要正确配置中断优先级。我用的是裸机+主循环架构,USB中断优先级设为最高(抢占优先级0),SPI/QSPI的DMA完成中断设为次高(抢占优先级1)。实测这个组合在200次连续读写循环中没有丢包。
如果你的工程跑RTOS(比如FreeRTOS),USB中断里调用HAL_PCD_IRQHandler时要小心:中间件里可能会有较长的处理流程,最好把USB中断优先级调到高于所有用户任务的中断,但低于系统节拍中断,避免RTOS调度器在中断处理中出问题。
4.4 掉电保护和同步问题
USB U盘最大的隐患之一是缓存数据丢失。Windows写文件时有缓存,它会认为数据已经写入磁盘,但实际数据可能还在USB设备的缓冲区里,或者还没落回Flash。客户如果拔出USB线后马上断电,文件系统可能损坏。
我的做法:
- 在USB的
STORAGE_Write函数里,每次写入都同步落盘到Flash,不做异步延迟(牺牲一点性能保证数据安全)。 - 在产品的电源管理里,检测到USB拔出事件(
PCD_EVT_DISCONNECT)后,立刻调用f_sync()和f_unmount(),确保FATFS的FAT表和目录项都刷到Flash。 - 在硬件上,Flash的WP引脚接一个上拉电阻并受MCU控制,在掉电瞬间(检测到电源跌落)锁死Flash写入,防止电压不稳时擦写损坏。
这些措施对工业级设备尤其重要,数据记录仪最怕的就是导出的文件打不开,等于之前的数据全白采了。
5. 常见问题与排查技巧实录
5.1 设备枚举失败,Windows提示Unknown Device
这个现象我调试初期遇到最多,原因五花八门,按照我总结的排查顺序来:
- 先确认USB D+/D-信号线。H723的USB_OTG_HS在Full-Speed模式下,需要DP引脚通过1.5kΩ上拉到3.3V。部分参考设计直接把DP接了上拉电阻,但有的H7板子需要软件控制上拉。检查原理图,确认这块没错。
- 确认USB电源。很多最小系统板给USB的3.3V是LDO出来的,如果负载大电压会被拉低,枚举就会失败。用示波器量一下VBUS和3.3V在插入瞬间的跌落。
- 确认时钟配置。USB全速模式需要48MHz时钟,H723通常是PLL1Q或者PLL2P产生。在CubeMX里检查Clock Configuration,确保USB_OTG_HS的时钟确实是48MHz。这个错了,设备100%枚举不起来。
- 用USB分析仪抓包。强烈建议备一个逻辑分析仪或者USB分析仪。如果设备在GET_DESCRIPTOR之后没有再返回任何数据,通常是描述符长度不对或者端点配置有问题。STM32中间件生成的代码一般不会有这种低级错误,但如果你手工改过描述符就要重点检查长度字段。
- 检查USB_OTG_HS的全局中断是否在NVIC中使能。CubeMX会自动配置,但如果你用了别的启动文件,可能把中断屏蔽了。
5.2 枚举成功,但Windows提示“需要格式化”
这个现象说明USB枚举和MSC类握手都过了,但Windows读不到文件系统结构。可能的原因:
- LBA总数不对。比如Flash实际是16MB,但你给Windows报了32MB的容量,Windows读后面的扇区发现全是0xFF,就认为分区表损坏。检查
STORAGE_GetCapacity返回的block_num。 - FATFS格式化失败或格式不对。用
f_mkfs格式化的参数不对,导致FAT表位置不对。建议先读回Flash前几个扇区的数据,确认MBR/DBR是否有效。 - 存储介质的首地址偏移。如果你在Flash前面预留了一段区域做别的用途(比如存储设备配置),FATFS的起始扇区要避开这部分。如果没处理好,Windows读到的分区起始位置和你实际写入文件系统的位置错位,也会报需要格式化。
调试技巧:用一个16进制编辑器(比如WinHex)直接查看U盘的首扇区内容。正常的FAT32引导扇区开头应该是EB 58 90,FAT表会有特定模式。如果看到全FF或者全00,说明文件系统没写进去;如果看到内容但Windows不认,就是引导扇区参数不对。
5.3 读文件正常,写文件报“磁盘被写保护”
这个问题很经典。Windows在写入前会发送一个MODE SENSE(6)命令,查询设备是否写保护。如果你的STORAGE_IsWriteProtected函数返回了非0值,Windows就认为磁盘被写保护,拒绝写入。
检查你的实现:
int8_t STORAGE_IsWriteProtected(uint8_t lun) { (void)lun; // 返回0表示允许写入 return 0; }另外要检查MODE SENSE的返回数据。usbd_msc.c里对MODE SENSE的响应是中间件内部处理的,如果你的存储回调函数没有正确设置,它可能返回了带有写保护位的模式页。这个一般不会出错,除非你改过中间件源码。
5.4 文件拷贝中断,设备掉线
这个现象通常是USB传输的时序问题:
- DMA缓冲区的对齐问题。USB中间件要求数据缓冲区4字节对齐,如果传入的buf不是4字节对齐,DMA传输会出错。FATFS的
f_read/f_write使用的缓冲区通常是malloc分配的,可能不对齐。解决办法是使用__ALIGN_BEGIN定义全局缓冲区,或者在中间件里做内存拷贝。 - Cache一致性问题。上面说的Cortex-M7 Cache,在大量数据传输时,如果漏了某个Cache操作,会出现偶发数据错误,表现为拷贝大文件时中途报错。解决方式:彻底检查每个DMA传输方向的Clean/Invalidate操作。
- VBUS供电不足。有些电脑USB口输出电流只有500mA,如果你的板子功耗高,插上后电压跌落会导致USB PHY工作异常。用带外部供电的USB Hub或者优化板子功耗。
5.5 常见问题速查表
| 现象 | 原因 | 解决方向 |
|---|---|---|
| Unknown Device | 上拉电阻、时钟、电源 | 检查DP上拉、48MHz时钟、USB供电 |
| 枚举成功但需格式化 | 容量报错、文件系统缺失 | 检查GetCapacity、重新格式化 |
| 磁盘写保护 | IsWriteProtected返回值错误 | 返回0并检查MODE SENSE |
| 拷贝中途掉线 | 缓存一致性、缓冲区对齐 | 检查Cache操作、对齐缓冲区 |
| 写入速度极慢 | NOR Flash擦写策略不佳 | 合并扇区写入、脏块标记 |
| 拔出后文件损坏 | 未同步文件系统 | 在拔出事件中f_sync/f_unmount |
5.6 我的调试环境与工具建议
这套开发我用的工具有:
- STM32CubeProgrammer:查看和修改内部Flash、烧录固件。
- USB协议分析仪:这个花了大价钱,但绝对是MSC调试的神器。能看到主机发的每一条SCSI命令和设备返回的CSW状态码,枚举失败时一眼就能定位问题出在哪个环节。
- 逻辑分析仪:排查SPI/QSPI时序问题,特别是Flash擦写失败,用逻辑分析仪抓SPI波形比看代码更直观。
- WinHex:直接查看U盘扇区数据,验证文件系统布局。
如果没有USB分析仪,一个替代方案是在STM32的MSC回调函数里加调试打印,把收到的CBW中的OP_CODE和LBA打出来,也能大致判断主机执行到哪一步。全速设备用这种方式调试是可行的,因为打印函数本身不会占用USB资源。
6. 项目经验总结与扩展思路
这个项目让我对STM32的USB协议栈有了更深的理解。做MSC设备,核心工作其实有四块:USB枚举(中间件帮你做了大部分)、SCSI命令的存储映射(就是那几个回调函数)、文件系统的正确格式化与维护(FATFS)、以及Cortex-M7这种高性能内核特有的缓存一致性处理。四块里面任何一块出问题,都会表现为一个让人摸不着头脑的现象。
最后再分享几个我实际踩过坑之后总结的小经验:
第一,量产固件里序列号一定要用芯片唯一ID生成。Windows会记录每个USB设备的序列号,如果所有设备都是同一个序列号,客户在两台设备之间切换使用时,Windows会缓存错误的设备状态,出现各种古怪问题。用UID生成序列号是标准做法。
第二,给设备加一个“安全弹出”提示。U盘最怕的事就是用户在数据还没写完时直接拔线。我在产品文档里明确要求用户“先在电脑上弹出设备再拔线”,同时软件层也尽量做到拔出事件后立即同步文件系统,双保险。
第三,如果后续要做高速模式,外接USB3300 PHY后,H723的USB可以跑480Mbps。但这不仅仅是改个PHY的问题,中间件的端点配置、FIFO大小、DMA缓冲区都要跟着调整,建议提前在原理图设计时就把USB3300的接口留出来,后面想升级不需要改板。
目前这套方案在客户现场已经稳定运行了三个多月,数据导出功能没有再出过问题。下一步我准备在固件里增加一个通过U盘升级固件的功能,把设备固件文件(.bin)直接拷进U盘,设备检测到文件后自动完成Bootloader更新,这样现场维护就能彻底告别拆机、用烧录器了。这个功能涉及USB MSC和Bootloader联动,等做完再写一篇详细拆解。
如果你也在做STM32H7系列的USB相关项目,或者在MSC调试中遇到了上面提到的问题,欢迎留言交流。这块调试起来确实磨人,但啃下来之后,你会发现USB协议栈的设计其实相当优雅。