资讯动态

STM32 SDIO+DMA四线SD卡读写与FATFS工程实践

发布时间:2026/10/26 23:37:43 来源:尧图企业网站定制
简介面向使用 STM32 HAL 库开发 SD 卡读写功能的嵌入式学习者与工程师这份工程资料围绕 SDIODMA 模式展开重点解决块设备初始化、数据传输效率与中断配合等问题。配置层面采用 1bit 总线模式开启 DMA 传输与相关中断并涉及时钟跳变沿捕获、时钟分频器旁路、空闲模式时钟输出、硬件流控及分频因子等参数取舍适合对照理解 SDIO 底层时序与外设配置。压缩包共 112 个文件、约 1015KB以 73 个 .h 头文件、27 个 .c 源文件为主体另含 .ioc 工程配置、.uvprojx 与 .uvoptx 工程文件、.s 启动文件、.hex 产物及少量说明文本结构完整便于直接编译与二次移植。已有 668 人学习可作为 STM32F4 系列 SD 卡驱动调试、DMA 收发链路排查和 HAL 工程搭建的参考样例。1. 一块插卡不认的板子逼着我把 SDIODMA 这条链路从头捋了一遍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得到 24MHzCLKDIV 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_Channel4F4 固定映射 */ 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_Channel4TX 走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_NORMALDMA 会按你设的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) { /* 块大小固定 512BlockNbr 是总块数 */ 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 写数据时会先进 CacheDMA 直接从内存搬两边看到的不是同一份数据。所以写方向要先 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 接 FATFSdiskio.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 状态机当前在不在传输态。如果卡被拔了或者出错复位了这里会返回非TRANSFERFATFS 就知道要重新初始化。挂载和写入的调用顺序也有讲究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 编号填错一位就完全没波形。解决顺序我一般这么走先用示波器看 PC12CK上有没有 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_DeInitHAL_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 对接代码都留在源码包里拿到手先对着自己的板子核一遍引脚和时钟再决定要不要改分频。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑