1. 问题根源为什么FATFS和USB MSC不能“和平共处”很多朋友在用STM32做数据采集或者设备日志存储的时候都想过一个很“美好”的方案让设备平时用FATFS往SD卡里写日志文件当需要导出数据时插上USB线电脑就能把SD卡识别成一个U盘直接拷贝文件。听起来很顺理成章对吧我一开始也是这么想的觉得CubeMX里把FATFS和USB MSC都勾上生成个代码不就完事了结果一上手就踩坑了这两个功能根本没法同时工作一运行就各种卡死、数据错乱。折腾了好几天我才把这里面的门道搞清楚。核心矛盾就出在SDIODMA这套硬件资源上。你可以把SDIO控制器想象成一条唯一的高速公路而FATFS和USB MSC是两辆都想独占这条路的卡车。FATFS这辆卡车它的任务是按照文件系统的规则比如创建文件、写入数据块来搬运货物数据而USB MSC这辆卡车它的任务是响应电脑的SCSI指令把SD卡的原始扇区数据搬运给电脑。两辆卡车的驾驶规则、目的地完全不同。更麻烦的是CubeMX为了让我们开发方便给这两辆卡车都配了“自动驾驶系统”——也就是基于HAL库和DMA的驱动。当FATFS通过f_write函数写文件时底层sd_diskio.c会启动SDIO的DMA传输然后挂起当前任务安静地等待一个“DMA传输完成”的消息。这个“消息”是放在一个叫消息队列的邮箱里的。等DMA真正干完活触发中断在中断服务函数里就会往这个邮箱投递一封“活儿干完了”的信FATFS的任务收到信才醒来继续执行。问题来了USB MSC的驱动usb_storage_if.c干活流程也一模一样它响应电脑的读写命令也是调用HAL_SD_ReadBlocks_DMA然后等DMA完成。如果两个功能同时启用那么SDIO的DMA完成中断回调函数比如BSP_SD_ReadCpltCallback就会变得“精神分裂”它不知道该给谁发“完工通知”。可能FATFS刚启动一次DMA写日志USB MSC那边电脑正好要读个文件DMA完成的信号就被USB的任务拿走了导致FATFS的任务永远等不到消息直接“睡死”过去。这就是最典型的资源竞争与状态机冲突。所以网上那些只讲单独配置FATFS或USB MSC的文章往往没触及这个深层矛盾。我们的目标不是让它们“同时”跑这几乎不可能而是实现一种安全、稳定的分时复用。就像给那条高速公路加一个智能调度系统让FATFS和USB MSC这两辆卡车分时段通行并且在换班的时候把道路彻底清理干净避免上一辆卡车留下的路障影响下一辆。2. 工程搭建CubeMX的正确配置与关键陷阱明白了问题在哪我们从头开始搭工程。打开CubeMX选好你的STM32型号我用的是F407第一步就是正确配置时钟树确保SDIO的时钟一般是48MHz和USB的时钟必须是48MHz都满足要求。这个基础步骤不能错。接下来是外设配置的关键点SDIO模式选择“SD 4bits Wide bus”这能发挥SD卡的最高速度。一定要勾选上SDIO全局中断这是DMA传输完成触发的基础。DMA在SDIO配置页添加SDIO的RX和TX两个DMA流。优先级可以设为“中”重要的是Memory和Peripheral都配置为增量模式数据宽度都是Word32位。这是保证数据连续搬运不出错的关键。FATFS在Middleware中间件里启用FATFS把“Drive Connect”里的“SD Card”选上。这里有个超级重要的细节注意你上面有没有启用FreeRTOS。CubeMX会根据这个选择生成完全不同逻辑的sd_diskio.c文件这是我们一切方案的起点。USB_OTG_FS模式选择“Device Only”然后在“USB_DEVICE”的Class for FS IP里选择“Mass Storage Class (MSC)”。FreeRTOS我强烈建议启用哪怕你只创建一个默认任务。因为它提供的消息队列、信号量等同步机制是我们实现安全切换的利器。接口就用默认的CMSIS-V1好了。配置完生成代码我们先别急着写应用得先看看CubeMX给我们生成了什么。重点就是FATFS/Target目录下的sd_diskio.c文件。如果你选了FreeRTOS你会看到类似下面的DMA完成回调函数void BSP_SD_WriteCpltCallback(void) { osMessagePut(SDQueueID, WRITE_CPLT_MSG, osWaitForever); } void BSP_SD_ReadCpltCallback(void) { osMessagePut(SDQueueID, READ_CPLT_MSG, osWaitForever); }这里的osMessagePut就是向消息队列SDQueueID发送消息。而没有FreeRTOS的版本则是简单的设置一个状态标志变量。这个差异直接决定了我们后续的解决方案必须围绕消息队列的隔离来做文章。另一个陷阱在freertos.c的默认任务StartDefaultTask里。CubeMX会自动在这里插入MX_USB_DEVICE_Init()。如果你的设计是设备上电先跑FATFS需要时再切换成USB MSC那么一定要把这行初始化注释掉否则一上电USB就开始工作会和FATFS抢SDIO。我的做法是上电只初始化FATFSUSB的初始化完全由我手动控制。3. 核心机制设计消息队列的“清道夫”既然冲突的焦点是那个共享的消息队列SDQueueID那么最直接的思路就是当A在使用SDIO时确保B产生的任何队列消息都不会被A误读反之亦然。但是我们没法阻止DMA完成中断去发送消息这是硬件行为。那么一个巧妙的“清道夫”机制就诞生了。核心思想是谁“惹的祸”谁自己收拾干净。具体来说就是让USB MSC驱动在每次完成自己的SDIO读写操作后主动去消息队列里把刚刚由它自己触发产生的那个消息取出来扔掉这样这个消息就不会留在队列里干扰FATFS了。怎么实现呢我们不能直接去修改CubeMX生成的usb_storage_if.c和sd_diskio.c因为下次重新生成代码会被覆盖。我们要做的是“打补丁”。首先在sd_diskio.c文件的末尾我们添加一个“清道夫”函数/* USER CODE BEGIN 12 */ // 这个函数专门给USB MSC驱动调用用于取走SDIO DMA完成产生的消息避免污染队列 void usd_msg_dummy_get(void) { osEvent event; // 尝试立即获取消息不等待 event osMessageGet(SDQueueID, 0); // 这里不关心消息内容取出来扔掉即可 } /* USER CODE END 12 */注意一定要放在USER CODE区间内。这个函数的作用就是尝试从SDQueueID队列里取一个消息出来如果队列里有消息就消耗掉它没有就立刻返回。然后我们去修改usb_storage_if.c文件中的两个核心函数STORAGE_Read_FS和STORAGE_Write_FS。这两个函数是电脑读写U盘时USB库调用的。我们在它们执行完SDIO DMA操作并等待SD卡状态恢复为“传输完成”HAL_SD_CARD_TRANSFER后调用刚才那个清道夫函数。int8_t STORAGE_Read_FS(uint8_t lun, uint8_t *buf, uint32_t blk_addr, uint16_t blk_len) { int8_t ret USBD_FAIL; /* ... 原有的DMA读取调用和状态等待循环 ... */ while(HAL_SD_GetState(hsd) HAL_SD_STATE_BUSY){}; while( HAL_SD_GetCardState(hsd) ! HAL_SD_CARD_TRANSFER ){}; // 关键补丁取走本次操作产生的消息 usd_msg_dummy_get(); return ret; }写函数也做同样的处理。这样一来USB MSC每次操作完都会把自己“制造”的消息垃圾带走保持了队列对FATFS的“干净”。实测下来这个方法是确保两个模块独立运行时不互相卡死的最有效、侵入性最小的办法。4. 安全切换外设的彻底卸载与重初始化有了“清道夫”保证运行时隔离我们还需要解决另一个问题模式切换。你不能简单地在FATFS正在写文件时突然把SDIO控制器交给USB那会导致硬件状态机混乱SD卡可能被锁死或者产生错误。必须有一个“仪式感”十足的切换流程。这个流程的核心是完全卸载当前使用者彻底复位硬件再以新使用者的身份完整初始化。我把它封装成了两个函数SD_set_use_by_usb()和SD_set_use_by_fatfs()。切换到USB MSC模式的步骤卸载FATFS首先调用f_mount(NULL, ...)卸载文件系统然后调用FATFS_UnLinkDriver解除FATFS底层驱动与SD卡的关联。这相当于告诉FATFS“你先别管这个盘了”。反初始化SDIO调用HAL_SD_DeInit(hsd)。这一步非常关键它会把SDIO控制器的寄存器、DMA配置等都恢复到复位状态清空所有内部状态机。初始化USB和SDIO依次调用MX_USB_DEVICE_Init()和MX_SDIO_SD_Init()。注意顺序先让USB设备就绪再初始化SDIO供USB使用。最后调用BSP_SD_Init()检查卡状态。此时SDIO硬件是完全为USB MSC服务的一个“裸”块设备。切换回FATFS模式的步骤则相反停止USB调用一个自定义的MY_USB_DEVICE_DeInit()函数你需要实现它至少停止USB内核让USB设备停止工作不再响应主机请求。再次反初始化SDIO同样调用HAL_SD_DeInit(hsd)把可能被USB MSC改乱的SDIO状态彻底清零。为FATFS初始化调用MX_SDIO_SD_Init()初始化硬件然后调用FATFS_LinkDriver将FATFS驱动重新关联到SD卡路径最后用f_mount挂载文件系统。我实测下来每次切换都完整地走一遍“卸载-复位-初始化”的流程虽然多花几十个毫秒但稳定性是极高的。再也没出现过切换后SD卡读写失败或者文件系统损坏的情况。这里有个小经验在反初始化SDIOHAL_SD_DeInit之后最好加一个几毫秒的短延时HAL_Delay(5)给硬件一个稳定的时间然后再进行新的初始化效果更佳。5. 实战应用在FreeRTOS任务中管理状态切换理论机制都有了最后我们要把它放到一个实际的、可用的程序框架里。在FreeRTOS环境下我强烈建议把SD卡的模式管理作为一个独立的状态机放在一个专有的任务里或者由某个主控任务来管理。首先定义两个状态变量和一个命令队列typedef enum { SD_MODE_FATFS, SD_MODE_USB_MSC } sd_usage_mode_t; static sd_usage_mode_t current_sd_mode SD_MODE_FATFS; QueueHandle_t xSdModeSwitchQueue; // 用于接收切换命令的队列然后创建一个任务SD_Manager_Task它的职责就是监听命令队列。当收到“切换到USB”的命令时它调用SD_set_use_by_usb()收到“切换回FATFS”的命令时调用SD_set_use_by_fatfs()。在切换期间这个任务可以挂起所有其他需要访问SD卡的任务通过信号量防止它们在切换过程中误操作。那么切换命令从哪里来呢一个典型的场景是通过USB的VBUS检测或者一个物理按键。比如你可以配置一个GPIO检测USB口的5V电压VBUS。当检测到电压上升USB插入且当前模式是FATFS时就向管理任务发送切换命令。当电压下降USB拔出再发送命令切回FATFS。// 在USB VBUS检测中断或轮询任务中 if (vbus_detected (current_sd_mode SD_MODE_FATFS)) { sd_switch_cmd_t cmd SWITCH_TO_USB; xQueueSend(xSdModeSwitchQueue, cmd, portMAX_DELAY); }对于FATFS的使用在你的数据记录任务里每次进行文件操作前先检查current_sd_mode是否为SD_MODE_FATFS并且获取一个SD卡访问信号量。这样可以避免在USB模式下去操作文件系统。同样在USB MSC模式下虽然USB底层驱动是中断驱动的但你也需要确保在切换回FATFS的过程中USB的读写操作已经全部结束。这种设计使得整个系统变得非常清晰和健壮。数据记录任务只管在FATFS模式下写它的日志用户插上USB线系统自动切换成U盘模式文件随便拷贝。拔掉线系统又悄无声息地切回来继续记录。整个过程对用户和上层应用几乎是透明的体验非常顺滑。6. 避坑指南与调试心得这条路我踩的坑可不少这里分享几个最让人头疼的问题和解决办法。第一个坑SD卡状态机异常。有时候切换模式后SD卡会一直处于“忙”状态HAL_SD_GetCardState返回的不是HAL_SD_CARD_TRANSFER。这多半是因为切换过程中某次SDIO命令没有正确结束。我的解决办法是在HAL_SD_DeInit之前尝试发送一个SD_CMD_STOP_TRANSMISSION命令CMD12来终止任何可能未完成的数据传输。可以在BSP_SD_Init函数里找找发送命令的代码把它封装成一个SD_StopTransfer函数在反初始化前调用。第二个坑DMA传输超时。在USB MSC模式下如果电脑传输大文件DMA传输偶尔会超时失败。除了检查SDIO和DMA的时钟配置更重要的是调整FreeRTOS的中断优先级。确保SDIO的全局中断优先级在CubeMX的NVIC配置里高于configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY即高于系统可管理的中断优先级但不要太高以免阻塞其他重要中断。同时USB中断的优先级也需要合理设置避免两者互相阻塞。第三个坑文件系统损坏。虽然我们做了安全卸载但如果在FATFS正在写一个簇的中间时强行切换还是可能导致那个簇的数据不完整。对于数据记录应用一个实用的建议是不要频繁切换。设定一个策略比如每分钟或每记录10条数据才同步一次文件f_sync。当检测到USB插入请求时先完成当前文件的最后一次同步再执行切换。这样能最大程度保证文件的完整性。调试时串口日志是你的好朋友。在每个切换步骤的开始和结束、每次f_mount和HAL_SD_Init的成功失败后都打印一条日志。同时把SD卡的状态HAL_SD_GetCardState、错误码HAL_SD_GetError也打印出来。当问题出现时这一串日志能帮你迅速定位到是在卸载、反初始化还是重新初始化的哪个环节出了岔子。最后关于性能。分时复用肯定比不上独占模式切换本身有开销。但对于大多数数据采集类应用这个开销百毫秒级是完全可接受的。关键在于它实现了一种低成本、高可靠性的双模存储方案让你的一块SD卡既能当设备的数据仓库又能临时客串一下便捷的U盘在产品开发和现场维护中这个功能带来的便利性远超那一点点性能损失。