资讯详情

资讯详情

STM32 SD卡DMA模式FatFs回调不触发?从CubeMX到HAL库全链路排查

STM32 的 SD 卡方案用 CubeMX 加 FatFs 本来是最省事的一条路但一旦把读写切成 DMA 模式很多人就会在一个意料之外的地方栽跟头disk_read里的等待条件永远等不到程序就像被钉死一样卡在循环里。你可能以为问题出在 FatFs 配置或者 SD 卡本身但真正的原因往往藏在 CubeMX 的 DMA 配置、HAL 库回调触发链路、以及你自己的同步变量写法这三者之间的缝隙里。这篇文章就用我实际调试过的场景把这条链路从头到尾拆一遍。1. 现场症状分类同样是“死等”根因却可能完全不同1.1 卡在 disk_read 的 while 等待循环里这是最典型的现场。程序表现是f_mount正常能列出根目录的文件名文件也存在但执行f_read读取一块稍大的数据时程序就在某个位置不走了。用调试器暂停后看到的景象基本是这个样子DRESULT disk_read(BYTE pdrv, BYTE *buff, LBA_t sector, UINT count) { /* 前面省略厂家封装代码 */ HAL_SD_ReadBlocks_DMA(hsd, buff, sector * BLOCK_SIZE, count); while (sd_read_finished 0) /* PC 停在这一行 */ { /* 永远等不到 */ } return RES_OK; }很多人第一反应是“DMA 没完成”接着就去查 DMA 配置。但我要说这个判断下得太早了。我在 F407 板子上调试时就踩过这样的坑DMA 其实早就把数据搬完了中断也进来了只是中断服务函数里给某个局部标志变量置 1 的时候主循环里读的根本不是同一个变量。还有一次是回调函数写了但因为宏定义问题编译器把变量优化掉了while判断条件被直接当成常量处理。这类问题在-O2优化下尤其隐蔽。另一种常见情况是DMA 中断确实触发了但你在中断服务函数里没有触发用户层的HAL_SD_RxCpltCallback。别觉得不可思议HAL 库虽然有现成机制但如果你把 DMA 中断处理函数写错了或者漏掉了某个中间层回调这条链路就会断在半路。1.2 f_read 返回 FR_DISK_ERR 而不是死循环如果你的等待循环带了超时机制那么在回调始终不来时程序会走超时分支返回RES_ERRORFatFs 收到这个返回值后f_read就会返回FR_DISK_ERR。这种情况下你看到的就不是死等而是“读文件失败”。这种症状的定位方向比死循环要宽一些。它可能是 DMA 传输真的失败了例如 SD 卡时钟配置过高导致 CRC 校验失败也可能是 DMA 地址不可访问或者外设时钟没开启。此时你要先区分是“中断没进来”还是“中断进来了但没有正确完成”。一个简单的办法是直接在HAL_SD_ErrorCallback里打日志或设置断点如果这个回调被触发说明 HAL 层已经感知到传输异常问题多半在 DMA 启动参数或 SDIO 时钟配置上如果这个回调也没触发那就要往 NVIC 中断优先级、时钟使能方向排查。1.3 系统复位或者 HardFault还有一类看起来更严重的情况不是卡住而是直接复位或进入 HardFault。原因往往是在中断上下文里使用了非中断安全的操作系统 API。比如你用了 CMSIS-RTOS2 的信号量同步但在HAL_SD_RxCpltCallback里直接调用了osSemaphoreRelease而这个 API 在中断上下文里其实需要特定的使用方式某些断言会因为参数或上下文不匹配直接触发硬件错误。另一个容易引发 HardFault 的问题是缓冲区地址不在 DMA 可达的地址空间内。比如我把 FatFs 的读写缓冲区放在了 STM32F4 的 CCM RAM 里DMA 根本访问不到那块内存结果传输一启动就出了总线错误。这种问题跟回调本身无关但最终表现也像是“DMA 中断回调没正常完成”容易误导排查方向。症状直接原因候选初步定位手段卡在 while(status 0)同步变量未正确置位、回调未进入在回调函数入口打断点观察是否触发返回 FR_DISK_ERRDMA 传输失败、等待超时检查 HAL_SD_ErrorCallback 与状态寄存器HardFault / 复位缓冲区地址非法、中断中使用非安全 API查看 Fault 寄存器、检查内存地址范围2. CubeMX 配置链复盘很多“回调不触发”在配置阶段就埋了雷2.1 SDIO/SDMMC 的时钟和总线宽度设置不要图快很多人配置 SDIO 时喜欢把时钟拉到最高觉得速度快就是好。但对 SD 卡这类存储介质来说时序余量非常关键。CubeMX 里 SDIO 的时钟分频因子如果设成 0意味着接近最高时钟频率很多兼容性一般的老 SD 卡会直接出现 CRC 错误或超时最终在传输层表现为 DMA 回调进入HAL_SD_ErrorCallback你预期的完成回调永远不出现。我在第一次调试时用的是一张很老的 2GB MicroSD 卡一开始把分频设成了 0读文件总是偶发失败。后来把时钟分频调到 2 或 4问题立刻消失。建议在项目初期先用较保守的分频值把系统跑通再根据实际 SD 卡的读写稳定性逐步调高。不要一上来就追求极限速度否则你排查的很多问题其实是时钟余量不足引起的假象。总线宽度也值得检查。CubeMX 里支持 SD 1-bit 和 SD 4-bit 两种模式4-bit 模式需要 SD 卡的 DAT1-DAT3 引脚都正确连接到 MCU并且 GPIO 上拉配置正确。如果你的板子硬件只连了 DAT0那就算在 CubeMX 里选了 4-bit初始化阶段也可能没问题但数据吞吐一上来就会出怪问题。这类问题不直接表现为回调不触发但会间接导致 DMA 传输状态异常。2.2 DMA 流/通道的选择别让 CubeMX 自动分配的“正确”迷惑你在 STM32F407 这类芯片上SDIO 的 DMA 映射是固定的RX 走 DMA2 Stream 3TX 走 DMA2 Stream 6。CubeMX 在图形界面里会自动帮你选好但这里有几个操作误区。有些人会在 DMA Settings 里重复添加请求比如本来只需要一条 RX 和一条 TX结果鼠标多点了一下出现了两条 RX。CubeMX 通常会把第二条 RX 分配到另一个可用的 DMA Stream 上看起来也生效了但你在 NVIC 里可能只勾选了第一个 Stream 的中断导致第二条 DMA 传输完成时根本没有中断入口回调链直接断掉。还有一点CubeMX 的 DMA 模式要选Normal不是 Circular。SD 卡的一次块读取是单次传输传输完成 DMA 停止等下一次请求再启动。如果误选了 Circular 模式DMA 会一直在循环搬运数据每次完成都触发一次中断结果就是回调被反复调用缓冲区数据被不断覆盖。这个坑非常隐蔽因为程序不会死机但读出来的数据是错的而且很难复现。2.3 NVIC 设置SDIO 中断开了DMA 中断却被漏掉CubeMX 的 NVIC 配置界面里SDIO 外设有自己的全局中断DMA 流也有独立的中断。如果你勾选了 SDIO 的全局中断但漏了 DMA2 Stream3 或 Stream6 的中断使能那么 DMA 传输完成后 CPU 根本不会跳进 DMA 中断服务函数。此时 DMA 的状态寄存器已经置了传输完成标志数据也已经在 RAM 里了但你的回调永远没有机会执行。这个错误是我见过最多的。它不涉及任何复杂的原理纯粹是图形界面里少打了一个勾。所以每次有人跟我说“DMA 回调不触发”我的第一句话永远是先打开 CubeMX 的 NVIC 设置把 DMA Stream 的中断和 SDIO 全局中断都确认一遍。2.4 关于“Continuous Requests”这个热词的澄清搜索相关问题时你总会看到“Continuous Requests”这个词。这里要澄清一下CubeMX 里“连续请求”这个选项通常出现在 USART、SPI 这类外设的 DMA 配置中并不适用于 SDIO/SDMMC 的 DMA 配置。SDIO 的 DMA 请求是由外设硬件在每次命令/数据传输时自动拉起的不需要也不应该启用连续请求模式。但如果你用的 SD 卡是接在 SPI 接口上的那 Continuous Requests 就可能有讲究了。SPI 的 DMA 接收在缺少这个选项时每传输一个字节都需要 CPU 重新触发一次请求效率很低甚至可能导致接收不完整。不过这个场景下问题不是“回调不触发”而是数据根本搬不完。这里提醒一句尽量不要把 SPI-SD 和 SDIO-SD 的 DMA 配置经验混在一起两者在 CubeMX 上的配置参数完全不是一回事。3. 回调链路拆解从 DMA 中断到 HAL_SD_RxCpltCallback 之间到底经过了几层3.1 一条典型的 DMA 完成中断路径很多时候你误以为“回调不触发”是 FatFs 的问题其实 FatFs 和回调之间根本没有直接关系。真正的工作路径是DMA 硬件完成搬运后先触发 DMA 中断中断服务函数进入 HAL 库的 DMA 处理逻辑经过 HAL 内部的回调转发最后才调用你在应用层实现的HAL_SD_RxCpltCallback。以 STM32F407 为例SDIO RX 对应的中断入口写在stm32f4xx_it.c里void DMA2_Stream3_IRQHandler(void) { HAL_DMA_IRQHandler(hdma_sdio_rx); }HAL_DMA_IRQHandler会检查 DMA 传输完成标志清除中断标志然后调用hdma-XferCpltCallback。这个XferCpltCallback是 HAL 库的 SD 驱动在启动 DMA 传输时注册进去的内部函数在stm32f4xx_hal_sd.c里叫SD_DMA_RxCplt。这个内部函数会先做一些状态清理然后调用HAL_SD_RxCpltCallback(hsd);所以你真正实现的那个回调是整个链路的最后一站。前面任何一层出了问题你都会看到“回调不触发”。3.2 __weak 弱定义和符号匹配你写的回调不一定能被调到HAL 库里的HAL_SD_RxCpltCallback是一个用__weak修饰的弱函数默认实现是空函数。如果你在自己的代码里定义了一个同名的普通函数链接器会用强符号覆盖弱符号这没有问题。但要注意两个容易翻车的场景。如果你在 C 文件里实现了这个回调却没有用extern C包裹最终生成的符号会经过名字修饰链接时就可能形成两个名字不同的函数HAL 库调用的是修饰后的名字你定义的是另一个修饰后的名字结果就是“你明明写了回调但根本没被调用”。另外如果你在多个源文件里不小心定义了两个同名强符号链接阶段直接报错这个反而好排查最怕的是某个回调定义被放进了条件编译里因为某个宏没有定义整个函数根本没参与编译。我在实际项目中习惯把 SD 卡相关的 HAL 回调统一放在sd_diskio.c或单独的一个sd_hal_itf.c文件里用纯 C 语法写不做条件编译确保每次都能被链接进去。3.3 回调触发时你等到的和想要的不一定一致还有一个非常容易被忽略的点HAL_SD_RxCpltCallback触发时DMA 确实完成了搬运但此时数据是否已经稳定可见对 Cortex-M4 这类不带 Cache 的内核来说DMA 完成后 CPU 直接访问内存即可。但对 M7 这类带 DCache 的内核DMA 是把数据写进 RAM 的CPU 去读的时候读到的可能是 Cache 里残留的旧数据。这种情况不是“回调不触发”而是“回调触发了数据却不对”。很多人会因为数据错误反过来怀疑回调时序有问题实际上去做一次 Cache 失效操作就能解决。这个我在下一章专门说。4. 从根本上解决同步机制、超时兜底和缓存处理的完整实现4.1 方案 A全局标志位 超时兜底最简单可靠这是我从项目初期一直用的方案兼容性好不依赖操作系统也方便调试。核心思想是DMA 传输启动后disk_read在一个带超时判断的while循环里等待全局标志位被回调置位。/* sd_diskio.c 顶部 */ static volatile uint8_t sd_rx_done 0; static volatile uint8_t sd_tx_done 0; static volatile uint8_t sd_error 0; #define SD_TRANSFER_TIMEOUT 1000U /* 单位 ms */ void HAL_SD_RxCpltCallback(SD_HandleTypeDef *hsd) { if (hsd-Instance SDIO) /* F7/H7 系列可能是 SDMMC1 */ { sd_rx_done 1; } } void HAL_SD_TxCpltCallback(SD_HandleTypeDef *hsd) { if (hsd-Instance SDIO) { sd_tx_done 1; } } void HAL_SD_ErrorCallback(SD_HandleTypeDef *hsd) { sd_error 1; sd_rx_done 1; /* 唤醒等待者让它走错误检查逻辑 */ sd_tx_done 1; }对应的disk_read实现DRESULT disk_read(BYTE pdrv, BYTE *buff, LBA_t sector, UINT count) { if (pdrv ! SD_DRIVE_NUM) { return RES_PARERR; } uint32_t start HAL_GetTick(); sd_rx_done 0; sd_error 0; if (HAL_SD_ReadBlocks_DMA(hsd, buff, (uint32_t)(sector * BLOCK_SIZE), count) ! HAL_OK) { return RES_ERROR; } while (sd_rx_done 0) { if ((HAL_GetTick() - start) SD_TRANSFER_TIMEOUT) { (void)HAL_SD_Abort(hsd); return RES_ERROR; } } if (sd_error) { return RES_ERROR; } return RES_OK; }这里有几个细节要解释一下。第一标志变量必须用volatile修饰否则编译器在-O2优化下很可能把while (sd_rx_done 0)里的sd_rx_done优化成只读一次循环直接变成死循环。第二用HAL_GetTick()做超时判断而不是自己写一个纯软件延时循环因为回调本身靠中断触发中断系统里 SysTick 必须保持正常工作。第三超时后调用HAL_SD_Abort中止当前 DMA 传输这一步非常关键否则下次启动新传输时底层状态可能是错的。4.2 方案 BRTOS 信号量同步但要小心 ISR 使用规则如果你的系统跑的是 FreeRTOS 或 CMSIS-RTOS2更优雅的做法是用信号量把等待和唤醒做成交替。我在一个多任务采录项目里就是这么用的读文件的任务在disk_read里阻塞等待信号量DMA 完成回调里释放信号量。osSemaphoreId_t sd_rx_sem; osSemaphoreId_t sd_tx_sem; void SD_Sem_Init(void) { sd_rx_sem osSemaphoreNew(1, 0, NULL); sd_tx_sem osSemaphoreNew(1, 0, NULL); } void HAL_SD_RxCpltCallback(SD_HandleTypeDef *hsd) { osSemaphoreRelease(sd_rx_sem); }disk_read里等待信号量DRESULT disk_read(BYTE pdrv, BYTE *buff, LBA_t sector, UINT count) { if (HAL_SD_ReadBlocks_DMA(hsd, buff, sector * BLOCK_SIZE, count) ! HAL_OK) { return RES_ERROR; } if (osSemaphoreAcquire(sd_rx_sem, 1000) ! osOK) { (void)HAL_SD_Abort(hsd); return RES_ERROR; } return RES_OK; }这里最需要警觉的是CMSIS-RTOS2 的osSemaphoreRelease在中断上下文调用时理论上可用但在 FreeRTOS 底层会映射到xSemaphoreGiveFromISR。如果你的 DMA 中断优先级高于configMAX_SYSCALL_INTERRUPT_PRIORITY那这个调用就会被断言拦下系统可能直接 HardFault。所以信号量方案对 NVIC 优先级有额外要求不如标志位方案那样随便什么优先级都能跑。如果你不想为优先级纠结另一个替代方案是使用osThreadFlagsSet在线程标志位层面做同步它同样可以在 ISR 中调用而且对优先级的限制和信号量一样存在并没有本质优势。选信号量还是线程标志主要看你任务里更方便消费哪种机制。4.3 F7/H7 系列的 DCache 处理不做好缓存一致性就白搭从 STM32F7 开始MCU 内部带了 D-Cache。DMA 外设不会访问 Cache它直接读写 RAM。这样一来CPU 之前可能已经预取过某块内存的数据到 Cache 里当 DMA 把新数据写到 RAM 后CPU 再读的时候还是读到旧数据。解决办法是在 DMA 传输完成后对目标缓冲区做一次 Cache 失效操作#if defined(__DCACHE_PRESENT) (__DCACHE_PRESENT 1U) SCB_InvalidateDCache_by_Addr((uint32_t *)buff, (int32_t)(count * BLOCK_SIZE)); #endif这段代码要放在等待sd_rx_done或信号量成功返回之后、disk_read返回RES_OK之前。写操作方向相反要在启动 DMA 写前先把 CPU 缓存中的数据刷回 RAM#if defined(__DCACHE_PRESENT) (__DCACHE_PRESENT 1U) SCB_CleanDCache_by_Addr((uint32_t *)buff, (int32_t)(count * BLOCK_SIZE)); #endif很多人一开始为了图省事直接把 D-Cache 全局关掉这样做也能跑通但会牺牲整个系统的性能。正确做法是只在 DMA 缓冲区访问前后做局部 Cache 操作既保证一致性又保留 Cache 带来的加速效果。4.4 总线缓冲区对齐另一个隐藏的“不回调”源头使用HAL_SD_ReadBlocks_DMA时传入的缓冲区地址最好 4 字节对齐并且缓冲区的总长度是块大小通常 512 字节的整数倍。如果你的缓冲区是普通数组对齐通常没问题但如果你从 FatFs 的配置里改了FF_MIN_SS或者自定义扇区大小那就可能出现不对齐的请求。当 DMA 启动函数返回HAL_ERROR时HAL_SD_ReadBlocks_DMA根本不会进入传输流程你的等待循环自然永远等不到完成标志。所以我在disk_read里判断返回值不是HAL_OK就立刻返回RES_ERROR而不是继续傻等。这也是一个非常重要的健壮性设计。5. 现场排错链路从现象到根因的逐步定位法5.1 先用轮询模式把硬件链路跑通再做 DMA遇到 SD 卡读写出问题我从来不在 DMA 模式里死磕。第一步永远是先把 DMA 相关的代码注释掉改用HAL_SD_ReadBlocks和HAL_SD_WriteBlocks这类阻塞式接口。如果轮询模式能稳定读写说明 SD 卡本身、SDIO 初始化、FatFs 挂载这些底层都是正常的问题范围被压缩到了 DMA 和中断部分。如果轮询模式也不稳定那就别急着查回调了先把时钟频率、GPIO 配置、SD 卡供电这些硬件层面的问题搞清楚。这个分步法可以帮你节省大量无效排查时间。5.2 单独用 DMA 做一个最小测试绕开 FatFs接下来把 FatFs 暂时绕开直接针对 SD 驱动层做测试。在 main 函数的初始化流程后面手动调用一次HAL_SD_ReadBlocks_DMA然后在回调里设置一个全局标志主循环里不断检测这个标志触发后翻转一个 GPIO 或点亮 LED。uint8_t dma_test_buf[512] __attribute__((aligned(4))); volatile uint8_t dma_test_done 0; void HAL_SD_RxCpltCallback(SD_HandleTypeDef *hsd) { dma_test_done 1; } /* 初始化完成后手动执行一次 */ dma_test_done 0; HAL_SD_ReadBlocks_DMA(hsd, dma_test_buf, 0, 1); while (dma_test_done 0) { /* 等待 */ }这个测试如果通过说明整套 HAL 到 DMA 到中断的链路是通的问题大概率出在 FatFs 与 SD 驱动之间的接口上有变量没配对或者缓冲区有问题。如果这个最小测试都过不了那就可以进入下一步的寄存器级排查。5.3 在 DMA 中断处理程序和 HAL 内部回调函数里打断点调试器是排这种问题最直接的武器。我在关键位置设置以下断点按顺序观察DMA2_Stream3_IRQHandler入口HAL_DMA_IRQHandler内部SD_DMA_RxCplt内部HAL_SD_RxCpltCallback入口断点 1 如果没到说明 DMA 中断没有触发。检查 NVIC 是否使能检查 DMA 是否真的启动了看DMA2-ISR里的传输完成标志有没有被置位。断点 1 到了但断点 2 没到的情况基本不存在因为入口就是函数调用。断点 3 如果没到说明HAL_DMA_IRQHandler虽然跑进来了但错误判断走了XferErrorCallback分支这时候要去看 DMA 的错误状态位和 SD 的状态寄存器。断点 4 没到说明你实现的回调函数名可能没对上或者源文件没有参与编译链接。断点位置通过时的判断没通过时的排查方向DMA 中断入口中断已触发NVIC 使能、DMA 启动、相关时钟HAL_DMA_IRQHandlerDMA 中断处理正常检查 DMA 状态寄存器SD_DMA_RxCpltHAL 层收到完成事件检查是否走了错误回调分支用户回调入口链路全通检查弱符号覆盖、Cortex-M 符号匹配5.4 检查 DMA 状态寄存器分辨“完成”和“错误”当 DMA 中断触发但用户回调没有进入时直接在调试器里查看 DMA 状态寄存器是最快的判断方式。以 F4 的 DMA2 为例在DMA2-ISR里可以看到各个 Stream 的中断标志位。重点看传输完成标志TCIF和错误标志TEIF、FEIF、DMEIF。如果 TCIF 置位了说明 DMA 硬件层面认为传输完成了问题出在 HAL 库或你这一侧的同步机制上。如果 TEIF 置位说明出现了传输错误可能和缓冲区地址、传输宽度配置、外设请求信号有关。另外还要顺带看一眼 SDIO 的状态寄存器比如SDIO-STA里的DCRCFAIL、DTIMEOUT、RXOVERR等位。这些位能精确告诉你 SD 协议层发生了什么是 CRC 错了还是超时了还是接收溢出。我曾经遇到过一次因为 SD 卡供电不稳导致的偶尔RXOVERR数据读出来是坏的但一层层排查下来最后发现是硬件问题和代码一点关系都没有。6. 工程经验让 SD 卡 DMA 少出问题的几条方法论6.1 分层验证不要一把梭SD 卡存储系统的软件栈至少有三层硬件驱动层SDIO/SDMMC 外设配置、操作系统适配层FatFs 的 diskio 接口、应用层f_open/f_read 调用。每一层都可能出问题但问题表现都会往上层层传递。我建议每次改动只碰一层验证稳定后再动下一层。比如先确认纯轮询驱动能读写再加上 DMADMA 稳定后再挂 FatFsFatFs 稳定后如果还想加多任务和信号量那又是新一层验证。这种分层策略看起来慢但实际项目里往往是最快的因为它能第一时间把问题限定在某一层而不是在三层之间反复猜。6.2 超时和 Abort 处理绝不能省我知道有人的disk_read里就是这么写的启动 DMA 后直接一个无限while死等标志。没有超时没有错误恢复。这种写法在正常工作时没有问题可一旦 SD 卡接触不良、DMA 配置错误、或者中断被更高优先级任务长时间抢占程序就废了。更糟的是这种状态往往没法自动恢复只能复位重启。所以无论你的标志位方案还是信号量方案都必须带超时并且超时后要执行HAL_SD_Abort把 DMA 和 SDIO 状态机恢复到空闲状态这样下次调用还能继续工作。6.3 回调函数里只做轻量操作我见过有人在HAL_SD_RxCpltCallback里直接做大量数据处理比如拷贝缓冲区、解码协议、甚至调用printf打印日志。这个做法非常危险。中断回调的优先级是系统级的执行时间越短越好。正确的做法是回调里只做标志置位、信号量释放这类轻量操作具体业务逻辑放到主循环或者 RTOS 任务里做。如果你的回调执行时间过长不仅会影响系统实时性还可能在执行期间被新到的 SDIO 中断或其他外围中断打断引发更复杂的嵌套问题到时候排查起来就更痛苦了。6.4 不要太相信 CubeMX 的默认配置CubeMX 生成的代码是能跑的最小框架但它不会主动帮你规避一些工程层面的问题。比如默认的 NVIC 优先级、DMA 优先级、SDIO 时钟分频这些参数需要你根据自己的实际硬件和应用需求去调优。尤其是 DMA 优先级如果系统里同时跑着 ADC、USART、SD 卡多路 DMA低优先级的 SD 请求可能被高优先级请求不断抢占虽然传完还是能触发中断但响应时间会变得很不稳定。这时候把 SD 的 DMA 优先级设成 High 或者 Very High是有意义的。从我自己的经验来看SD 卡 DMA 的中断回调问题绝大多数不是 CPU 的问题也不是 HAL 库本身的问题而是几个环节之间的接口没有对齐。把每一条链路都搞清楚你的 SD 卡读写就能稳定得像一块石头。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →