☰
STM32H7虚拟U盘实战:SDMMC到USB的完整链路与避坑指南
2026/10/20 12:57:48 网站建设 项目流程

简介:面向有一定嵌入式基础、希望综合掌握存储接口、文件系统、USB通信与实时操作系统协同开发的开发者,这是一份基于STM32H7微控制器实现SDMMC+FATFS+USBMSC+FreeRTOS四层协作的虚拟U盘方案。工程共203个文件,以119个头文件与68个C源码文件为主,另有IOC配置、链接脚本、文本说明等辅助文档,整体压缩包约1.39MB,目录结构完整,便于按模块阅读与移植到其他H7系列项目。已有1250人学习下载。开发者可从中获取HAL库与LL库配合使用的初始化思路、SDMMC驱动配置细节、FATFS挂载流程、USBMSC底层回调实现,以及FreeRTOS下任务优先级与队列通信的划分范例;通过任务调度、文件读写与USB主机请求的串联,既能快速搭建可用的虚拟U盘原型,也能加深对多组件协作时时序配合与资源共享要点的理解。

1. STM32H7 虚拟 U 盘:SDMMC 到 USB 的整条链路怎么搭

手里有一块 STM32H7 开发板,板载 TF 卡槽和一个 USB 口,想把 TF 卡变成电脑上可见的 U 盘,同时还要在 FreeRTOS 任务里用 FATFS 记录日志——这个组合听起来像是把官方例程缝在一起,真正动起手来才发现,SDMMC、FATFS、USBMSC 三层叠加之后,链路上一共有七八个让人想砸板的坑。这篇文章就是把这条链路拆开讲清楚:先解决底层 SDMMC 的时钟和 DMA 缓存一致性问题,再把 FATFS 挂上并处理多任务互斥,最后用 USBMSC 把整卡暴露给电脑,附带我调试时踩过的五条血泪经验。适合正在做数据记录仪、设备日志导出、板卡调试工具的朋友,照着配置就能少走弯路。

2. SDMMC 底层:总线宽度、时钟分频与 DMA 缓存一致性

2.1 引脚分配与时钟树配置

STM32H7 有两个 SDMMC 外设,我一般默认使用 SDMMC1,原因是它在 CubeMX 的时钟树里和 DMA 请求的映射更充裕,而且给 SD 卡用 4 位总线就够了,没必要把 8 位数据线全接上——那通常是留给 eMMC 的。另一个现实原因是 8 位模式会多占 4 个 GPIO,布线稍差就容易引入串扰,对虚拟 U 盘这种追求稳定的场景并不划算。

时钟树部分是很多人第一处翻车的地方。H7 的 SDMMC 时钟来自内核总线的可调分频,CubeMX 里看到的分频系数不是简单的“除以几”,而是按f = f_core / (2 * ClockDiv)计算。我常用ClockDiv = 2,配一个 168MHz 的内核时钟,实际 SDMMC 时钟就是 42MHz,对绝大多数 C10/A1 级 TF 卡来说已经够稳。

/* MX_SDMMC1_SD_Init 中关键参数 */ hsd1.Instance = SDMMC1; hsd1.Init.ClockEdge = SDMMC_CLOCK_EDGE_RISING; hsd1.Init.ClockBypass = SDMMC_CLOCK_BYPASS_DISABLE; hsd1.Init.ClockDiv = 2; /* 168MHz / (2*2) = 42MHz */ hsd1.Init.BusWidth = SDMMC_BUS_WIDE_4B; /* 4位总线,留出更多GPIO */ hsd1.Init.HardwareFlowControl = SDMMC_HARDWARE_FLOW_CONTROL_ENABLE; HAL_SD_Init(&hsd1); /* 卡识别 + 默认模式 */ HAL_SD_ConfigSpeed(&hsd1, SDMMC_SPEED_HIGH); /* 切到高速传输 */

代码逻辑是先用 ClockDiv 把时钟压到合理区间,再指定 4 位总线,然后初始化 SD 卡并切换到高速模式。HardwareFlowControl_ENABLE让 SDMMC 内部 FIFO 在 DMA 来不及搬运时自动暂停,能省掉不少软件重试。实际调试中如果发现大文件读取偶尔卡死,优先来这里把 ClockDiv 提到 4,也就是 21MHz,然后再查别的。

2.2 DMA 与 Cache 一致性问题

H7 的 D-Cache 默认是关闭的,一旦在 CubeMX 里打开 DCache,DMA 和 CPU 看到的内存就会不一致,这是虚拟 U 盘最典型的“玄学”故障源。CPU 写了数据到内存,DMA 未必能立刻看到;DMA 把数据搬进内存,CPU 读到的可能是旧 cache line。

解决思路是分清楚方向:写 SD 卡之前要 Clean,把 cache 里的脏数据刷到物理内存;读 SD 卡之后要 Invalidate,把 cache 里可能存在的旧数据作废,让 CPU 下次访问时从物理内存重新加载。

/* 缓冲区必须按 32 字节对齐,和 cache line 对齐一致 */ static uint8_t sd_dma_buf[512] __attribute__((aligned(32))); /* 写之前:Clean */ SCB_CleanDCache_by_Addr((uint32_t *)sd_dma_buf, 512); HAL_SD_WriteBlocks(&hsd1, sd_dma_buf, sector, count, HAL_MAX_DELAY); /* 读之后:Invalidate */ HAL_SD_ReadBlocks(&hsd1, sd_dma_buf, sector, count, HAL_MAX_DELAY); SCB_InvalidateDCache_by_Addr((uint32_t *)sd_dma_buf, 512);

这里的参数要点是:地址必须 32 字节对齐,长度也按 32 的整数倍处理,否则SCB_InvalidateDCache_by_Addr会为了对齐 cache line 而丢掉邻近数据,反而引入新bug。另外,Invalidate 是“作废整个 cache line”,所以必须在 DMA 完全结束后调用,不能一启动 DMA 就立刻 invalidate。我习惯把这块缓冲和操作封装成一个sd_read_dma函数,专门负责“对齐检查→DMA 启动→等待完成→cache 维护”四步,避免在业务代码里散落 cache 操作。

2.3 底层块读写的完整实现

底层 block read/write 是 SDMMC 最核心的部分,FATFS 和 USBMSC 最终都会调到这里。在 FreeRTOS 场景下,这个层还要顺手解决“多任务同时访问 SDMMC”的互斥问题,我在 3.2 节会专门讲,先看函数主体:

DRESULT sd_disk_read(BYTE *buff, LBA_t sector, UINT count) { /* 转换扇区号为字节地址,SD卡按 512 字节一个扇区 */ uint32_t addr = sector * 512; /* 读前把 CPU 可能残留的缓存清干净 */ SCB_InvalidateDCache_by_Addr((uint32_t *)buff, count * 512); if (HAL_SD_ReadBlocks(&hsd1, buff, addr, count, HAL_MAX_DELAY) != HAL_OK) { return RES_ERROR; } /* 等待 SDMMC 状态机回到传输态 */ while (HAL_SD_GetCardState(&hsd1) != HAL_SD_CARD_TRANSFER); return RES_OK; }

HAL_SD_ReadBlocks的第三个参数是字节地址而不是扇区号,这是 H7 的 HAL 库和 F1/F4 的差异点,容易写错。HAL_MAX_DELAY表示一直等,但在真实产品里我建议换成带超时的等待,比如 500ms,一旦超时就返回RES_TIMEOUT,避免 SD 卡硬件异常时把任务卡死。上述函数是给 USBMSC 用的路径,FATFS 的 diskio 底层也走同一套操作,只是额外包一层互斥锁。

3. FATFS 桥接:文件系统与 USB 之间的互斥设计

3.1 FATFS 配置:关键宏的选择

FATFS 是一套独立于硬件的文件系统层,它通过 diskio 接口访问底层块设备。在这个项目里,底层块设备就是 SDMMC 的sd_disk_read/sd_disk_write。编译前需要把ffconf.h里几个宏调好,我用关键配置如下:

宏值说明
FF_USE_LFN3启用长文件名,Unicode 编码,PC 端拷入中文名文件不出乱码
FF_FS_EXFAT1打开 exFAT 支持,PC 格式化默认会用 exFAT 格式大容量卡
FF_VOLUMES1只挂一张 SD 卡,减少内存占用
FF_FS_LOCK4最多同时打开 4 个文件,配合多任务使用
FF_USE_MALLOC0全部用静态分配,避免 FreeRTOS 堆碎片化导致f_open失败

这里最容易被忽略的是FF_USE_MALLOC = 0。如果打开,FATFS 的目录项和文件对象会走 C 运行时堆,而 FreeRTOS 自己的堆往往是独立实现的,两者互相不可见,很容易出现“FreeRTOS 没耗尽内存,但f_open返回FR_NOT_ENOUGH_CORE”的怪问题。我一般直接把FIL、DIR、FATFS对象定义为全局静态变量,每个任务只碰自己那一个文件对象。

3.2 多任务互斥:递归互斥量锁住 diskio

FATFS 本身不是线程安全的。比如一个任务正在写日志,另一个任务恰好在 USB 线程里执行f_open,两个f_read交叉执行,文件系统的 FAT 表会被写乱。标准做法是在 diskio 的disk_read/disk_write里加互斥锁,这样文件系统所有底层操作都变成了临界区。

static SemaphoreHandle_t sd_mutex; /* 挂载 FATFS 前,在 FreeRTOS 初始化任务中创建锁 */ sd_mutex = xSemaphoreCreateRecursiveMutex(); DRESULT disk_read(BYTE pdrv, BYTE *buff, LBA_t sector, UINT count) { DRESULT res = RES_ERROR; if (pdTRUE == xSemaphoreTakeRecursive(sd_mutex, portMAX_DELAY)) { res = sd_disk_read(buff, sector, count); xSemaphoreGiveRecursive(sd_mutex); } return res; }

选择递归互斥量是因为 FATFS 某些路径会在一次 API 调用里重复进入 diskio,比如f_mount内部可能先读引导扇区又读 FAT 表,如果用普通互斥量会死锁。递归锁允许同一个任务重入,同时仍然阻止其他任务插入。这里有个细节:disk_read的buff如果是栈上数组,DMA 会直接写栈地址,H7 的栈默认不在 cache 管理范围内,所以务必要在函数外定义对齐的静态缓冲,或者把任务栈地址做 MPU 配置。

3.3 挂载与模式切换:USB 占用时卸载 FATFS

项目标题同时包含 FATFS 和 USBMSC,这意味着同一个 SD 卡会被两个系统访问:MCU 侧用 FATFS 写日志,PC 侧通过 USB MSC 读文件。问题在于 FATFS 有缓存,PC 修改文件后 MCU 这边还握着旧目录项,两者必然冲突。

我的做法是把整卡当成一个可切换的设备:默认状态 MCU 用 FATFS 访问卡;检测到 USB 主机枚举后,先执行f_mount(NULL, "", 1)卸载 FATFS,确保缓存落盘,然后把 SDMMC 控制权完全交给 USBMSC。USB 断开后再反向操作。这个模式切换在系统里用一个全局状态变量控制,USB 回调里修改状态,FATFS 任务轮询该状态决定是否继续写日志。虽然会丢失“PC 在线的同时 MCU 后台写文件”的能力,但这恰恰是稳定性的取舍,至少不会出现两边争数据。

4. USBMSC 部分:SCSI 命令与扇区映射

4.1 端点和描述符的选择

STM32H7 的 USB OTG 内核有 FS 和 HS 之分。做虚拟 U 盘建议用 HS 内核,即使只跑 Full Speed,代码稍后也能平滑升级。默认 H7 的内置 PHY 能支持 Full Speed,想要 480Mbps High Speed 得外接 ULPI PHY。大多数调试场景,Full Speed 的读速度约 1MB/s,够把日志文件拉出来。

MSC 类设备只需要两对端点:Bulk IN 和 Bulk OUT,配置在 CubeMX 的 USB Device 里选 Mass Storage Class,它会自动生成:

/* 端点缓冲区描述符,usbd_conf.h 中 */ #define MSC_DATA_MAX_PACKET_SIZE 512U /* HS 下实际 512,FS 下 64 */ #define MSC_FS_BULK_MAX_PACKET_SIZE 64 #define MSC_HS_BULK_MAX_PACKET_SIZE 512

端点大小直接影响传输效率。Full Speed 的 Bulk 端点一次最多 64 字节,High Speed 是 512 字节,PC 驱动会按端点大小分片。如果你想提升速度,优先保证硬件支持 HS 并把端点设为 512。实际上很多 STM32H7 板子默认只引出 FS 引脚,这也是功能正常但速度只有 1MB/s 的原因。

4.2 SCSI 命令回调:READ_CAPACITY 与 READ10/WRITE10

MSC 的工作方式是 PC 下发 CBW 命令块,设备返回 CSW 状态。最核心的是 SCSI 命令分发函数,每条命令都要正确处理:

static int8_t MSC_ProcessCmd(uint8_t lun, uint8_t *cmd) { switch (cmd[0]) { case INQUIRY: /* 0x12 查询设备信息 */ memcpy(msc_data_buf, inquiry_data, 36); break; case READ_CAPACITY: /* 0x25 返回总扇区数 */ cap.last_lba = total_sectors - 1; /* 减 1 防越界 */ cap.block_len = 512; memcpy(msc_data_buf, &cap, 8); break; case READ10: /* 0x28 读数据 */ { uint32_t sector = (cmd[2] << 24) | (cmd[3] << 16) | (cmd[4] << 8) | cmd[5]; uint32_t count = (cmd[7] << 8) | cmd[8]; sd_disk_read(msc_data_buf, sector, count); break; } case WRITE10: /* 0x2A 写数据 */ { uint32_t sector = (cmd[2] << 24) | (cmd[3] << 16) | (cmd[4] << 8) | cmd[5]; uint32_t count = (cmd[7] << 8) | cmd[8]; sd_disk_write(msc_data_buf, sector, count); break; } case SYNCHRONIZE_CACHE: /* 0x35 手动刷新缓存 */ HAL_SD_SyncCache(&hsd1); break; } return USBD_OK; }

这里READ10命令里的扇区地址是 4 字节大端序,数量是 2 字节大端序,直接移位拼出来即可。total_sectors来自HAL_SD_GetCardInfo返回的BlockNbr,一定要在 USB 初始化前获取并且保证不小于 FATFS 卷需要的扇区数。inquiry_data里的厂商和产品字段随便填,但bInterfaceSubClass要设置成 0x50,表示使用 SCSI 透明命令集,否则 Windows 驱动会不认。

4.3 扇区映射:LBA 就是 SD 卡物理扇区

在这个架构下,USBMSC 的 LBA 直接对应 SD 卡物理扇区,没有再做一层偏移。这意味着电脑格式化该 U 盘时,会直接对扇区 0 写入引导区和 FAT 表,也就是把整张卡作为一个无分区表的超级盘。Windows 和 Linux 都能识别,是一个“软盘式”的 U 盘。

如果以后想给 SD 卡做分区表,就得在这里加一个 offset,把所有 LBA 都加上分区起始扇区。我建议初次跑通阶段不要做分区表,直接在 flash 里把卡格式化成 FAT32,减少一层换算和问题点。格式化动作可以在上电时通过按键或特定日志文件名触发f_mkfs,也可以预先在电脑上用读卡器格式化好。

5. 避坑实录:五个从示波器和调试器里看出来的问题

5.1 USB 中断抢占 FreeRTOS 临界区导致枚举死锁

现象:把 FreeRTOS 打开后,USB MSC 枚举偶尔卡死,插上电脑有时识别不到设备,有时识别到但打开文件夹就转圈。

原因:CubeMX 生成代码默认把 USB 中断优先级设成 0,也就是最高优先级。FreeRTOS 的临界区靠屏蔽中断实现,USB 中断一旦在调度器临界区内触发,会反过来等待调度器释放资源,两边互相等,形成死锁。这在裸机例程完全正常,一上 RTOS 就翻车。

解决:把 USB 中断优先级降到可被 FreeRTOS 管理的中断优先级范围,通常是 5 到 15 之间的数值。我在 CubeMX 的 NVIC 页把OTG_HS_IRQn抢占优先级设为 5,同时确认 PendSV 和 SysTick 保持最低优先级的默认配置,此后枚举死锁没有再出现。

5.2 电脑上改了文件,MCU 侧读到的还是旧内容

现象:通过 U 盘模式在电脑上往 SD 卡里丢了一个配置文件,拔掉 USB,切回 FATFS 任务,f_open打开后读出来的还是旧文件。

原因:FATFS 在挂载时把目录项和 FAT 表缓存到了内存,USB 模式切换时没有先把这些缓存释放。PC 侧看到的是物理扇区,改了文件,MCU 这边内存里的目录项还是旧的,自然读不到新文件。

解决:在 USB 枚举成功的回调里,先做一次f_mount(NULL, "", 1)强制卸载 FATFS,确保所有脏缓存写回 SD 卡。USB 断开后重新f_mount。从那以后我每次切换都强制走一遍“卸载→切换→恢复挂载”的顺序,再也没有出现过文件不同步。

5.3 单步调试正常,全速运行写文件丢字节

现象:程序停在断点里看着数据完全正确,一跑起来写入的文件就出校验错误。

原因:这是典型的 DMA Cache 问题。CubeMX 里开启了 DCache,但 DMA 缓冲的地址没有做 cache maintenance。单步调试时 CPU 和 DMA 之间的执行顺序被人为拉长,Cache 里有足够时间自然失效,所以掩盖了问题;全速运行时 write 命令把数据写进 cache 却还没刷进物理内存,DMA 搬走的就是旧数据。

解决:把那块 DMA 缓冲固定成全局 32 字节对齐数组,并且在启动 DMA 前强制SCB_CleanDCache_by_Addr,完成后SCB_InvalidateDCache_by_Addr。这是项目里最典型的“看起来玄学、实际是教科书问题”的故障。

5.4 使用长杜邦线连接 SD 卡座,读取大文件时 SDMMC 超时

现象:TF 卡用杜邦线飞到一个外接卡座,小文件正常,复制一个大文件到一半就报 IO 错误,重插又恢复正常。

原因:42MHz 的 SDMMC 时钟在长导线上信号完整性问题严重,尤其 Data 线和 Clk 线之间串扰,数据线缺乏上拉时更明显。单块读时命令间隙长,还能撑住;多块连续读时每一位都是按 42MHz 节奏采样,稍有点振铃就 CRC 错误。

解决:先把 ClockDiv 改到 4,也就是降速到 21MHz,同时给 Data0-Data3 和 Cmd 引脚全部使能内部上拉,Clk 不加上拉,再在靠近卡座处加 33Ω 串联电阻。这样调整后长时间压力测试没有再超时。产品上还是建议用板载卡座加短走线,飞线只适合验证。

5.5 读取容量大于 32GB 的卡,Windows 提示需要格式化

现象:插上一张 64GB 的 TF 卡,电脑弹出“需要格式化才能使用”,格式化后仍然报错。

原因:FATFS 的FF_FS_EXFAT没有打开,f_mkfs只能格式化成 FAT32,而 FAT32 对 64GB 容量支持需要特殊参数,很多 SD 卡厂商默认出厂就是 exFAT,底层读到的分区表是 exFAT 类型,Windows 直接识别为未知格式。

解决:把FF_FS_EXFAT打开并重新编译,底层驱动无需改动。另一个隐藏点是扇区大小,READ_CAPACITY返回的块长度必须是 512,如果返回 4096,部分 PC 驱动会走高级格式路径,兼容性反而差。

6. 进阶吞吐:多块传输与双缓冲

前面整链路跑通后,你会发现读速度上不去,瓶颈不在 USB,而在 SDMMC 单块读命令的间隙。USB 每次下发 READ10 包含扇区数量字段,如果只把它拆成一个个 512 字节单块HAL_SD_ReadBlocks,每次都要发一次 CMD17,协议开销让吞吐率掉到实际值的六成以下。正确处理是直接把 USB 的count参数透传给 SDMMC 的底层多块读函数:

static void MSC_ReadBlocks(uint32_t sector, uint32_t count) { /* 一次命令传输多块,SD 卡内部自动连续读 */ HAL_SD_ReadBlocks(&hsd1, msc_dma_buf, sector * 512, count, HAL_MAX_DELAY); while (HAL_SD_GetCardState(&hsd1) != HAL_SD_CARD_TRANSFER); SCB_InvalidateDCache_by_Addr((uint32_t *)msc_dma_buf, count * 512); }

多块传输依赖 SDMMC 内部 FIFO 和 DMA 的配合,只要硬件流控打开且缓冲区足够宽,多块读的速度能比单块循环快 30% 到 50%。在我测试的某 32GB 卡上,Full Speed 状态从 0.9MB/s 提升到 1.3MB/s,接近 USB FS 的理论上限。

再进一步就是 DMA 双缓冲:把msc_dma_buf拆成两个交替缓冲,DMA 搬运第二块的同时,USB 外设正在发送第一块,峰值吞吐率由“搬完再发”变成流水线操作。实现上要用 DMA 的 Normal 模式,在传输完成中断里切换缓冲地址,不能开启 DMA 的 Circular 模式,否则地址会自己绕回起点,造成数据错位。

如果做完上面这些速度还是不满意,下一步就是考虑硬件换 High Speed PHY,同时把 SDMMC 时钟跑满 50MHz,配合双缓冲,整体吞吐率能做到 15MB/s 以上。我这次项目就止步在 Full Speed 方案,因为调试器导出日志的场景 1.3MB/s 完全够用。从那以后我每次调这种跨外设项目都强制走一遍“全片顺序写→全片顺序读→文件级校验”的链路测试,确认底层 DMA 健康了才碰 FATFS 和 USB,希望帮到你。

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

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

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

立即咨询