☰
STM32 SDIO+DMA四线SD卡读写与FATFS工程实践
2026/10/26 23:37:39 网站建设 项目流程

简介:面向使用 STM32 HAL 库开发 SD 卡读写功能的嵌入式学习者与工程师,这份工程资料围绕 SDIO+DMA 模式展开,重点解决块设备初始化、数据传输效率与中断配合等问题。配置层面采用 1bit 总线模式,开启 DMA 传输与相关中断,并涉及时钟跳变沿捕获、时钟分频器旁路、空闲模式时钟输出、硬件流控及分频因子等参数取舍,适合对照理解 SDIO 底层时序与外设配置。压缩包共 112 个文件、约 1015KB,以 73 个 .h 头文件、27 个 .c 源文件为主体,另含 .ioc 工程配置、.uvprojx 与 .uvoptx 工程文件、.s 启动文件、.hex 产物及少量说明文本,结构完整,便于直接编译与二次移植。已有 668 人学习,可作为 STM32F4 系列 SD 卡驱动调试、DMA 收发链路排查和 HAL 工程搭建的参考样例。

1. 一块插卡不认的板子,逼着我把 SDIO+DMA 这条链路从头捋了一遍

STM32 配 HAL 库、走 SDIO 加 DMA 读写 SD 卡,是嵌入式项目里最常被复用的一套存储方案。我最近拆的一个采集类工程就用了它:板子每秒产几 KB 数据,要在现场连续记几小时,中途断电也不能把之前写的内容全毁掉。这种场景下用 SPI 接 SD 卡也不是不行,但速率上不去,CPU 还全程被占着,采集团那边一旦有高优先级中断就会丢点。换到 SDIO 四线 + DMA 之后,CPU 只在搬运开始和结束时各露一次面,中间该干嘛干嘛。

这套工程的适用面其实比想象中窄一点:它适合手上已经有 F4/F7/H7 这类带 SDIO/SDMMC 外设的板子、需要一个能跑 FATFS 或裸块读写的本地存储层的人。如果你只是想存几十条配置,内部 Flash 模拟 EEPROM 就够了,犯不上折腾。但要做数据记录仪、固件升级包、离线日志,这条路是绕不过去的。

2. 工程骨架与初始化链路:从 CubeMX 配置到 HAL_SD_Init 的完整调用顺序

拿到一份 SDIO 工程,我第一眼看的不是main.c,而是sdio.c和stm32f4xx_it.c。因为 SD 卡这东西,初始化顺序错一步就全盘报错,而且报的错往往只有一句HAL_ERROR,没有下文。先把目录结构摊开看,心里才有底。

2.1 目录结构与文件职责

这份工程沿用的是 CubeMX 生成的标准分层,没有做过多魔改,好处是后续重新生成代码不会把你的改动冲掉:

路径职责我需要动的频率
Core/Src/sdio.cSDIO 句柄定义、HAL_SD_MspInit里的引脚与 DMA 配置高,换板子必改
Core/Src/main.c初始化调用顺序、应用层读写调用高
Core/Src/stm32f4xx_it.cSDIO_IRQHandler、DMA2_Stream3_IRQHandler等中,确认有没有漏
Core/Inc/sdio.hextern SD_HandleTypeDef hsd;低
Middlewares/Third_Party/FatFs文件系统层与diskio.c中,挂载参数在这里
Drivers/STM32F4xx_HAL_DriverHAL 源码,出问题时要能翻进去看只读

有一点要提前说清楚:stm32f4xx_it.c里那几个中断函数不能只留个空壳。CubeMX 生成时如果勾了 DMA 中断,它会自动把HAL_DMA_IRQHandler(&hdma_sdio_tx)填进去;但如果你后来手动加了 DMA 通道却没回头补中断,现象就是传输永远等不到完成回调——这种坑我见过不止一次,属于典型的“代码看着全对,跑起来就是不动”。

2.2 从上电到卡识别的调用顺序

main()里这几行的顺序不能乱,尤其是 DMA 初始化必须排在 SDIO 前面:

int main(void) { HAL_Init(); SystemClock_Config(); /* 必须保证 PLL48CLK 输出 48MHz */ MX_GPIO_Init(); MX_DMA_Init(); /* 先开 DMA2 时钟,再初始化 SDIO */ MX_SDIO_SD_Init(); /* 内部会做卡识别,耗时几十到几百 ms */ MX_FATFS_Init(); /* 识别完成后才切四线,顺序反了会识别不到卡 */ if (HAL_SD_ConfigWideBusOperation(&hsd, SDIO_BUS_WIDE_4B) != HAL_OK) { /* 走线不好的板子退回一线继续跑,总比直接死机强 */ HAL_SD_ConfigWideBusOperation(&hsd, SDIO_BUS_WIDE_1B); } while (1) { /* 应用层 */ } }

为什么MX_DMA_Init()一定要在前?因为HAL_SD_Init()内部会回调HAL_SD_MspInit(),而 DMA 通道的时钟、中断优先级都是在这个回调里配的。如果此时 DMA 时钟还没开,往DMA2_Stream3->CR写配置就是往一片未上电的寄存器里写,写进去也不生效,后面HAL_SD_WriteBlocks_DMA会一直返回HAL_ERROR,而你又看不出任何异常。

HAL_SD_Init()本身干的事比名字多:它先按 400kHz 左右的低速时钟发 CMD0、CMD8、ACMD41、CMD2、CMD3 把卡识别出来,读到 CID/CSD,然后再把时钟切回你在hsd.Init.ClockDiv里配的工作频率,最后恢复总线宽度设置。所以你在Init结构体里填的BusWide是“识别完之后的工作宽度”,不是识别时用的宽度。

2.3 CubeMX 里几个必须手动改的参数

CubeMX 的默认值能跑通,但性能很难看。下面这几项我每次都会改:

参数默认值建议值影响
SDIO Clock Divider生成时按 400kHz 算让 SDIO_CK 落在 20~25MHz直接决定吞吐
Bus Wide1 bit4 bit(识别后切)四线理论带宽翻四倍
DMA 请求不开RX/TX 各一条 Stream不开就只能轮询
SDIO 全局中断优先级0抢占优先级高于 DMA避免完成回调抢不到状态
Hardware Flow ControlDisableDisable开了部分卡会挂

时钟这块要单独解释一下,F4 系列的公式是SDIO_CK = SDIOCLK / (2 + CLKDIV),SDIOCLK固定来自 PLL48CK 的 48MHz。所以CLKDIV = 0得到 24MHz,CLKDIV = 2得到 12MHz。H7 那边 SDMMC 的分频公式不一样,别照搬。我在 CubeMX 里一般直接填期望频率让它自己算,但生成后一定会去sdio.c核对一眼ClockDiv的值——有次 CubeMX 版本升级后算出来的值和预期差了一档,速率直接腰斩,查了半天才发现是分频配错了。

2.4 MSP 回调里的引脚、DMA 与中断配置

HAL_SD_MspInit是这份工程里唯一需要跟硬件强绑定的地方,换板子基本就是重写这个函数:

void HAL_SD_MspInit(SD_HandleTypeDef *hsd) { GPIO_InitTypeDef GPIO_InitStruct = {0}; if (hsd->Instance == SDIO) { __HAL_RCC_SDIO_CLK_ENABLE(); __HAL_RCC_GPIOC_CLK_ENABLE(); __HAL_RCC_GPIOD_CLK_ENABLE(); __HAL_RCC_DMA2_CLK_ENABLE(); /* PC8~PC11 = D0~D3, PC12 = CK, PD2 = CMD */ GPIO_InitStruct.Pin = GPIO_PIN_8 | GPIO_PIN_9 | GPIO_PIN_10 | GPIO_PIN_11 | GPIO_PIN_12; GPIO_InitStruct.Mode = GPIO_MODE_AF_PP; GPIO_InitStruct.Pull = GPIO_PULLUP; /* 板上已有外部上拉可改 NOPULL */ GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_VERY_HIGH; GPIO_InitStruct.Alternate = GPIO_AF12_SDIO; HAL_GPIO_Init(GPIOC, &GPIO_InitStruct); GPIO_InitStruct.Pin = GPIO_PIN_2; /* CMD */ HAL_GPIO_Init(GPIOD, &GPIO_InitStruct); /* RX: DMA2_Stream3_Channel4, TX: DMA2_Stream6_Channel4(F4 固定映射) */ hdma_sdio_rx.Instance = DMA2_Stream3; hdma_sdio_rx.Init.Channel = DMA_CHANNEL_4; hdma_sdio_rx.Init.Direction = DMA_PERIPH_TO_MEMORY; hdma_sdio_rx.Init.PeriphInc = DMA_PINC_DISABLE; hdma_sdio_rx.Init.MemInc = DMA_MINC_ENABLE; hdma_sdio_rx.Init.PeriphDataAlignment = DMA_PDATAALIGN_WORD; hdma_sdio_rx.Init.MemDataAlignment = DMA_MDATAALIGN_WORD; hdma_sdio_rx.Init.Mode = DMA_PFCTRL; /* 外设流控,别改成 NORMAL */ hdma_sdio_rx.Init.Priority = DMA_PRIORITY_HIGH; HAL_DMA_Init(&hdma_sdio_rx); __HAL_LINKDMA(hsd, hdmarx, hdma_sdio_rx); /* SDIO 中断优先级要高于 DMA,否则完成回调可能读不到正确状态 */ HAL_NVIC_SetPriority(SDIO_IRQn, 5, 0); HAL_NVIC_EnableIRQ(SDIO_IRQn); HAL_NVIC_SetPriority(DMA2_Stream3_IRQn, 6, 0); HAL_NVIC_EnableIRQ(DMA2_Stream3_IRQn); } }

几个参数值得说透。DMA_PFCTRL是外设流控模式,SDIO 会自己在需要时拉 DMA 请求,如果误设成DMA_NORMAL,传输会在第一次请求后就停住。PeriphDataAlignment和MemDataAlignment都设成 WORD,是因为 SDIO 的 FIFO 是 32 位宽,按字对齐传输效率最高,配成 BYTE 虽然也能跑,但会多出很多次总线访问。

Pull这一项容易被忽略。SD 卡规范要求 CMD 和 D0~D3 有 10k~50k 上拉,很多开发板外部已经焊了,此时内部上拉开着也无所谓;但如果是自己画的板子且省了外部电阻,内部上拉在 24MHz 下偏弱,四线模式就会出现“跑一会儿报一次 CRC 错误”的玄学现象。这种情况我一般直接改成外部 10k 上拉,比在软件里加各种重试靠谱得多。

3. 核心模块拆解:SDIO 四线模式与 DMA 通道的绑定细节

初始化跑通只是开始,真正决定这套东西稳不稳的,是时钟、DMA、总线和内存这四件事有没有对齐。这一章拆的就是这几个容易被跳过的地方。

3.1 时钟树:48MHz 从哪来,CLKDIV 怎么算

SDIO 的时钟源不是随便挑的,F4 系列里它固定挂在PLL48CK上,也就是主 PLL 的 Q 分频输出。CubeMX 的时钟树页面上,那个标着 “48MHz clock” 的分支就是它,必须正好是 48MHz。如果主频配到 168MHz 后 Q 分频算出 47.99MHz 之类,通常也能跑,但我不建议留这种余量,直接调 PLLQ 让它是整数 48。

分频系数决定实际 SDIO_CK:

/* 24MHz = 48MHz / (2 + 0),F4 系列的分频公式 */ hsd.Init.ClockDiv = 0; /* 12MHz = 48MHz / (2 + 2),走线较长或信号质量差时用这个 */ /* hsd.Init.ClockDiv = 2; */

为什么不直接拉到 48MHz?因为 SDIO_CK 是 SD 卡侧的时钟,规范里四线高速模式的上限是 50MHz,但那是理想条件:卡座、排线、上拉电阻任何一环不到位,眼图就废了。我在 24MHz 上跑过的板子不下十块,绝大多数稳定;有次用了根 10cm 的排线飞出来接卡座,24MHz 下偶发写失败,降到 12MHz 就再没出过问题。这种时候别跟硬件较劲,降频是最省事的方案。

另外要提醒一句,hsd.Init.ClockDiv配的是识别完卡之后的工作频率,识别阶段 HAL 内部会临时用约 400kHz 的低速时钟,这个切换是自动的,不用你操心。但如果你的板子上电时供电爬升慢,可以在HAL_SD_Init前加 10~20ms 延时,等电源稳了再开始识别。

3.2 DMA 通道绑定:方向、流号与外设流控

F4 系列里 SDIO 的 DMA 映射是固定的,RX 走DMA2_Stream3_Channel4,TX 走DMA2_Stream6_Channel4,不能随便换 Stream,换了就收不到请求。这条对应关系在参考手册的 DMA 请求映射表里,配错的现象是HAL_SD_ReadBlocks_DMA返回HAL_OK但回调永远不触发。

两个方向的配置差异只有三处,其余完全对称:

/* 接收方向:外设 -> 内存 */ hdma_sdio_rx.Init.Direction = DMA_PERIPH_TO_MEMORY; /* 发送方向:内存 -> 外设 */ hdma_sdio_tx.Init.Direction = DMA_MEMORY_TO_PERIPH;

Mode字段两边都得是DMA_PFCTRL。这个模式的意思是“外设流控”,SDIO 每准备好 32 位数据就拉一次请求,DMA 搬完一次等下一次。如果改成DMA_NORMAL,DMA 会按你设的BufferSize一口气搬完,但 SDIO 的 FIFO 根本塞不下那么多,结果就是丢数据或者总线卡死。

优先级我习惯给DMA_PRIORITY_HIGH,因为 SD 卡是有超时机制的,一次传输拖太久卡内部状态机就复位了。如果你的系统里还有别的 DMA 通道在跑(比如串口、ADC),至少保证 SDIO 这条不比它们低。

3.3 四线模式切换与卡信息解析

HAL_SD_ConfigWideBusOperation这个函数做的事比名字多:它会先发 CMD55 + ACMD6 告诉卡“准备切宽度”,然后改 SDIO_CLKCR 里的宽度位,再检查卡是否真的接受了。返回值不是HAL_OK时,多数是卡不支持四线或者切换过程中出错。

切完之后一定要把卡信息读出来,后面算容量、算块地址都靠它:

HAL_SD_CardInfoTypeDef cardInfo; if (HAL_SD_GetCardInfo(&hsd, &cardInfo) == HAL_OK) { /* 块大小固定 512,BlockNbr 是总块数 */ uint64_t capacity_bytes = (uint64_t)cardInfo.BlockNbr * cardInfo.BlockSize; printf("card type:%lu, blocks:%lu, size:%lu MB\r\n", cardInfo.CardType, cardInfo.BlockNbr, (uint32_t)(capacity_bytes >> 20)); }

CardType这个字段很重要,它区分 SDSC(标准容量,≤2GB)、SDHC 和 SDXC。HAL 内部的读写函数会根据这个字段决定BlockAdd是块地址还是字节地址——新版本 HAL 已经自动处理了,但如果你手上是几年前的老库,SDSC 卡需要自己把块号乘 512。这个差异在换卡测试时特别坑,同一份代码大卡能用小卡不能用,八成就是这里。

3.4 Cache 与内存对齐:F7/H7 上最容易被忽略的一段

这一节是给带 D-Cache 的芯片准备的。F4 没有 Cache,代码怎么写都对;一旦换到 F7 或 H7,同样的代码就会出“数据偶发错乱”的问题,而且极难复现。

原理不复杂:CPU 写数据时会先进 Cache,DMA 直接从内存搬,两边看到的不是同一份数据。所以写方向要先 clean,把 Cache 里的数据刷回内存;读方向要先 invalidate,把 Cache 里的旧数据作废,否则 CPU 读到的还是缓冲区上次的残留。

/* 写卡之前:把 CPU 写进 Cache 的数据刷到内存,否则 DMA 搬走的是旧内容 */ SCB_CleanDCache_by_Addr((uint32_t *)buf, len); /* 读卡之前:作废 Cache 里的旧行,避免 DMA 写完后 CPU 仍读缓存 */ SCB_InvalidateDCache_by_Addr((uint32_t *)buf, len); /* 读卡完成后:再作废一次,覆盖 DMA 期间 CPU 可能预取的行 */ SCB_InvalidateDCache_by_Addr((uint32_t *)buf, len);

比手动维护更稳的做法是用 MPU 把 DMA 缓冲区所在的内存段配成 non-cacheable,一次配置,后面都不用管:

/* 把一块 32KB 的 SRAM 区域配成 non-cacheable,专门给 DMA 用 */ MPU_Region_InitTypeDef mpu = {0}; mpu.Enable = MPU_REGION_ENABLE; mpu.Number = MPU_REGION_NUMBER0; mpu.BaseAddress = 0x20010000; mpu.Size = MPU_REGION_SIZE_32KB; mpu.SubRegionDisable = 0x00; mpu.TypeExtField = MPU_TEX_LEVEL1; mpu.AccessPermission = MPU_REGION_FULL_ACCESS; mpu.DisableExec = MPU_INSTRUCTION_ACCESS_DISABLE; mpu.IsShareable = MPU_ACCESS_SHAREABLE; mpu.IsCacheable = MPU_ACCESS_NOT_CACHEABLE; mpu.IsBufferable = MPU_ACCESS_BUFFERABLE; HAL_MPU_ConfigRegion(&mpu);

对齐同样是硬性要求。DMA 缓冲区必须至少 4 字节对齐,在带 Cache 的芯片上最好 32 字节对齐(一个 Cache line 的大小),否则 clean/invalidate 会连带影响相邻变量,出现“改了 A 变量,B 变量莫名其妙变了”的血泪现场。我一般在定义时就写死:

/* 32 字节对齐,8 个块 = 4KB,一次 DMA 搬完 */ static uint8_t sd_tx_buf[8 * 512] __attribute__((aligned(32)));

4. 读写封装:轮询、中断与 DMA 三种传输方式的取舍

HAL 给每个读写动作都提供了三个版本,选错了不是不能跑,是要么占 CPU,要么调试起来像黑匣子。这一章把三者的差别、封装方式和实测表现讲清楚。

4.1 三个 API 的差异与适用场景

API阻塞行为CPU 占用适用场景
HAL_SD_ReadBlocks全程阻塞100%初始化自检、单次读配置
HAL_SD_ReadBlocks_IT调用即返回中等小块、低频、中断负载轻
HAL_SD_ReadBlocks_DMA调用即返回极低连续大数据量记录

参数签名三者一致:(hsd, pData, BlockAdd, NumberOfBlocks, Timeout)。前四个都好理解,最后一个Timeout单位是毫秒,只在轮询版里真正起作用,DMA 版里这个参数基本被忽略——因为函数发完请求就返回了,等待逻辑得你自己写。很多人第一次用 DMA 版时会以为传了 Timeout 就自带超时保护,结果卡死在那里等回调,等成了死循环。

块大小固定 512 字节,不管你传多少块,NumberOfBlocks的上限受卡和 HAL 版本限制。我一般一次不超过 16 块(8KB),太大了出错重传的代价高,太小了命令开销占比又上去了。

4.2 阻塞式封装:把异步 DMA 包成同步调用

应用层多数时候想要的是“我调一个函数,写完返回”,所以需要一层封装把 DMA 回调转成同步等待:

static volatile uint8_t s_tx_done = 0; static volatile uint8_t s_rx_done = 0; static volatile HAL_StatusTypeDef s_last_err = HAL_OK; /* HAL 弱函数重写,DMA 传输完成后由 HAL 调用 */ void HAL_SD_TxCpltCallback(SD_HandleTypeDef *hsd) { if (hsd->Instance == SDIO) { s_tx_done = 1; } } void HAL_SD_RxCpltCallback(SD_HandleTypeDef *hsd) { if (hsd->Instance == SDIO) { s_rx_done = 1; } } /* 错误回调也要接,否则出错时标志位永远不置位 */ void HAL_SD_ErrorCallback(SD_HandleTypeDef *hsd) { s_last_err = HAL_ERROR; s_tx_done = 1; s_rx_done = 1; } HAL_StatusTypeDef SD_WriteBlocks(uint32_t blk, const uint8_t *buf, uint32_t cnt, uint32_t timeout_ms) { uint32_t start = HAL_GetTick(); s_tx_done = 0; s_last_err = HAL_OK; /* 写方向先清 Cache,保证 DMA 读到的是最新数据 */ SCB_CleanDCache_by_Addr((uint32_t *)buf, cnt * 512U); if (HAL_SD_WriteBlocks_DMA(&hsd, (uint8_t *)buf, blk, cnt) != HAL_OK) { return HAL_ERROR; } while (!s_tx_done) { if (HAL_GetTick() - start > timeout_ms) { HAL_SD_Abort(&hsd); /* 主动中止,让卡回到传输态 */ return HAL_TIMEOUT; } } return s_last_err; }

这里有几个设计点值得说。s_last_err单独存一份,是因为HAL_SD_WriteBlocks_DMA返回HAL_OK只代表请求发出去了,真正的结果在回调里才知道。HAL_SD_Abort是超时后的后悔药,如果不调用,SDIO 状态机会停在数据传输态,下一次读写直接返回HAL_BUSY,看起来就像“卡死了”。timeout_ms我一般给cnt * 100 + 500,给足余量但不至于无限等。

读方向的封装对称,区别只有 Cache 操作顺序:调用前 invalidate 一次,回调置位后再 invalidate 一次。

4.3 双缓冲结构:让采集和写卡并行

如果采集速率接近写卡速率,单缓冲会互相拖累:采集时不能写,写时不能采,最后要么丢数据要么降采样率。做法是开两块缓冲区轮转,一块在写卡时另一块继续采集:

#define BLK_PER_BUF 8 /* 每块 8 个扇区 = 4KB */ #define BUF_SIZE (BLK_PER_BUF * 512) static uint8_t s_buf[2][BUF_SIZE] __attribute__((aligned(32))); static volatile uint8_t s_write_idx = 0; /* 正在写的缓冲序号 */ static volatile uint8_t s_fill_idx = 1; /* 正在采集的缓冲序号 */ static volatile uint8_t s_buf_ready = 0; static uint32_t s_next_block = 0; /* 采集侧:当前缓冲写满后切换 */ void recorder_push(const uint8_t *data, uint32_t len) { static uint32_t offset = 0; memcpy(&s_buf[s_fill_idx][offset], data, len); offset += len; if (offset >= BUF_SIZE) { offset = 0; s_write_idx = s_fill_idx; /* 交给写卡侧 */ s_fill_idx = 1 - s_fill_idx; /* 切到另一块继续采 */ s_buf_ready = 1; } } /* 主循环侧:有满缓冲就异步发出,不等写完 */ void recorder_task(void) { if (s_buf_ready && !s_tx_done) { s_buf_ready = 0; SD_WriteBlocksAsync(s_next_block, s_buf[s_write_idx], BLK_PER_BUF); s_next_block += BLK_PER_BUF; } }

关键点是写卡函数必须是非阻塞的异步版本,发出去就返回,靠s_tx_done判断上一笔有没有完成。如果上一笔还没完成就发下一笔,HAL 会返回HAL_BUSY,数据就丢了。所以判断条件里!s_tx_done不能省。

这种结构能撑住的写入速率大致是“单次 4KB DMA 传输时间 + 少量空隙”,具体数字和卡本身、时钟频率强相关。我手上的板子在 24MHz 四线下跑,4KB 一批的连续写入能稳定在每秒几 MB 的量级,够绝大多数采集场景用了。想再快就得加大单次传输块数,但超过 16 块之后提升就不明显了,瓶颈转到了卡内部。

4.4 接 FATFS:diskio.c 里的四个函数

裸块读写只适合自己管分区,要 PC 能直接读,还得挂 FATFS。CubeMX 勾上 FATFS 之后会生成diskio.c,需要把里面几个函数指到 SD 驱动上:

DSTATUS disk_status(BYTE pdrv) { if (pdrv != 0) return STA_NOINIT; return (HAL_SD_GetCardState(&hsd) == HAL_SD_CARD_TRANSFER) ? 0 : STA_NOINIT; } DSTATUS disk_initialize(BYTE pdrv) { if (pdrv != 0) return STA_NOINIT; return (HAL_SD_GetCardState(&hsd) == HAL_SD_CARD_TRANSFER) ? 0 : STA_NOINIT; } DRESULT disk_read(BYTE pdrv, BYTE *buff, DWORD sector, UINT count) { if (pdrv != 0 || count == 0) return RES_PARERR; return (SD_ReadBlocks(sector, buff, count, count * 100 + 500) == HAL_OK) ? RES_OK : RES_ERROR; } DRESULT disk_write(BYTE pdrv, const BYTE *buff, DWORD sector, UINT count) { if (pdrv != 0 || count == 0) return RES_PARERR; return (SD_WriteBlocks(sector, buff, count, count * 100 + 500) == HAL_OK) ? RES_OK : RES_ERROR; }

disk_status和disk_initialize里用HAL_SD_GetCardState判断,比用一个全局标志位可靠,因为它直接问的是 SDIO 状态机当前在不在传输态。如果卡被拔了或者出错复位了,这里会返回非TRANSFER,FATFS 就知道要重新初始化。

挂载和写入的调用顺序也有讲究:

FATFS fs; FIL fil; UINT bw; f_mount(&fs, "", 1); if (f_open(&fil, "log.bin", FA_WRITE | FA_OPEN_ALWAYS) == FR_OK) { f_lseek(&fil, f_size(&fil)); /* 追加写,不覆盖历史数据 */ f_write(&fil, data, len, &bw); f_sync(&fil); /* 关键:不 sync 断电必丢 */ f_close(&fil); }

f_sync这一行是血泪经验。FATFS 默认在缓冲区里攒数据,攒够了才真正写卡,如果不 sync 直接断电,最后几秒的数据全没了,而且文件系统可能留下未完成的簇链,下次挂载报FR_NO_FILESYSTEM。每次写完关键数据就 sync 一次,代价是多了几次写操作,换来的是断电后还能读。

5. 常见问题排查:卡不识别、DMA 卡死、写入丢数据的五类现场

这一章按“现象 → 原因 → 解决”整理,都是我在实际板子上碰到过的,顺序大致按出现频率排。

5.1 现象:HAL_SD_Init一直返回HAL_ERROR,串口什么也打不出来

原因通常是三类。一是供电,SD 卡在识别瞬间电流能冲到 100mA 以上,如果板子用 LDO 且后级还有其他负载,电压会瞬间跌落,卡直接不响应。二是时钟,PLL48CK没到 48MHz 或者根本没使能,SDIO 拿不到时钟自然发不出命令。三是引脚复用配错,特别是 CMD 和 CK 这两根,AF 编号填错一位就完全没波形。

解决顺序我一般这么走:先用示波器看 PC12(CK)上有没有 400kHz 的方波,没有就查时钟树和__HAL_RCC_SDIO_CLK_ENABLE;有波形但不认卡,量一下 VDD 在识别瞬间有没有掉到 3.0V 以下;都正常再回头看 GPIO 的 AF 配置和上拉。有一回折腾了两小时,最后发现是卡座的一个焊盘虚焊,这种就只能靠万用表了。

5.2 现象:切到四线模式后,读写偶发失败,错误码飘忽不定

原因是信号完整性。四线模式下 D0~D3 同时翻转,如果走线不等长、没有地线隔离、或者上拉电阻太大,就会出现间歇性 CRC 错误。1 位模式只有 D0 一根线,对时序要求低,所以“一线能用四线不能用”几乎就是这个原因。

解决办法按成本从低到高:先把ClockDiv加大一档,把 SDIO_CK 从 24MHz 降到 12MHz,多数情况立竿见影;不行就把内部上拉改成外部 10k;再不行检查 PCB 上 D0~D3 有没有走成一组、旁边有没有高速信号平行走线。我见过最离谱的是一块板子把 SDIO 的线走在开关电源电感正下方,怎么调都不稳,最后只能改板。

5.3 现象:F7/H7 上写进去的数据读出来全是乱的,或者前一半对后一半错

这是 Cache 一致性问题,只有带 D-Cache 的芯片才会出现。表现是同一份代码在 F4 上跑得好好的,换到 H7 就出问题,而且断电重启后有时又正常了——因为 Cache 里的内容每次都不一样。

解决就是前面 3.4 节说的那套:写之前 clean,读之前和读之后各 invalidate 一次,或者干脆用 MPU 把 DMA 缓冲区配成 non-cacheable。后者更省心,配一次就不用每次调用都想着。判断有没有踩这个坑有个简单办法:在 DMA 传输前后各打印一次缓冲区的首字节,如果传输前是 A、传输后 CPU 读出来还是 A 但卡里实际是 B,那就是 Cache 没刷。

5.4 现象:写卡过程看着都成功,断电重启后文件损坏或者最后一段数据丢失

原因是没有等待写完成就断电。SD 卡内部有自己的缓存和垃圾回收,主机这边HAL_SD_WriteBlocks_DMA返回HAL_OK只代表数据进了卡的 buffer,不代表已经落到 Flash 上。这时候断电,卡内部还在搬数据,文件系统元数据就可能只写了一半。

解决分两层。应用层每次写完关键数据调f_sync,强制把 FATFS 缓冲和卡缓存都刷下去;驱动层在系统掉电检测中断里,先停止新的写请求,等HAL_SD_GetCardState返回TRANSFER且没有 pending 的 DMA 传输,再让电源彻底断掉。如果板子有超级电容或者大电容,这几百毫秒的缓冲时间刚好够用。

5.5 现象:连续跑几小时后,某次读写返回HAL_BUSY,之后再也恢复不了

原因是错误没有被正确清除。SDIO 出错后状态机会停在某个中间态,如果只是重试调用读写函数,HAL 看到状态不是TRANSFER就直接返回HAL_BUSY,形成死锁。这种问题在长时间运行的系统里特别隐蔽,因为前几小时都正常。

正确的恢复流程是:检测到错误后先调HAL_SD_Abort,它会给卡发 CMD12 中止当前传输并把状态机拉回传输态;如果 abort 也失败,就得走完整的HAL_SD_DeInit+HAL_SD_Init重初始化,代价大但能保证恢复。我在应用层会加一个错误计数,连续三次恢复失败就点亮一个故障灯,让人到现场处理,而不是让设备在那死循环。

6. 二次开发改造:把裸块读写做成能自恢复的数据记录器

拿到这套代码之后,多数人的下一步不是继续调 SDIO,而是把它变成业务能用的东西。我一般做三件事。

第一件是加一层块设备抽象,把 SDIO 的读写、FATFS 的读写、错误恢复都收在一个storage_*接口后面。上层只调storage_write(buf, len),至于底层走裸块还是走文件、出错怎么重试,全在里头处理。这样以后换 SPI Flash 或者换芯片,上层一行不用改。

第二件是加写前校验。每次批量写完,随机抽一个块读回来比对 CRC,确认卡真的收下了。这个动作会拖慢整体速率,所以我只在每 100 次写入后做一次抽检:

/* 抽检:写完读回比对,尽早发现卡进入只读或坏块状态 */ uint8_t verify[512] __attribute__((aligned(32))); if ((write_cnt % 100) == 0) { if (SD_ReadBlocks(last_blk, verify, 1, 1000) == HAL_OK && memcmp(verify, expect, 512) != 0) { storage_mark_dirty(); /* 触发上层告警 */ } }

第三件是把挂载做成幂等的。设备在现场可能被拔卡、可能异常复位,f_mount不能只在开机调一次。我现在的做法是每次写文件前检查disk_status,如果返回STA_NOINIT就先f_mount(NULL, "", 0)卸载再重新挂载,挂载失败就尝试重新初始化 SDIO,最多三轮。

验证方法很土但有效:写一个脚本让设备连续记录 24 小时,中途随机断电 10 次,每次重启后把文件读出来校验 CRC。跑过三轮不出问题,这套存储层我才敢交付。

从那以后我每次开新项目接 SD 卡,都会先把“时钟降到 12MHz 能不能跑通”当成第一道验证,能过再往上加频率。很多看起来玄学的问题,其实都是频率和时序余量不够,先退一步把链路跑稳,比在 24MHz 上死磕省时间得多。这份工程里的 CubeMX 配置、sdio.c、DMA 封装和 FATFS 对接代码都留在源码包里,拿到手先对着自己的板子核一遍引脚和时钟,再决定要不要改分频。希望帮到你。

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

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

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

立即咨询