ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

STM32L496 SD卡读写:基于SDIO与HAL库的完整驱动方案

STM32L496 SD卡读写:基于SDIO与HAL库的完整驱动方案 简介面向嵌入式开发者的STM32L496 SD卡读写驱动工程基于HAL库编写适用于STM32L4系列多种型号可直接评估或移植到具体项目中。资源含288个文件包含151个C源文件与132个头文件以及Keil工程配置文件、HEX固件等压缩包大小1.98MB。目前已有123人浏览学习。这套工程从SDIO接口初始化、卡检测、容量获取到数据块读写均有完整实现同时集成FATFS文件系统便于直接进行文件级操作也可学习文件系统与驱动的衔接。工程实现了CMD0、CMD8、ACMD41等SD卡初始化命令序列并带有数据块读写与错误状态判断示例。通过学习可掌握HAL_SD_Init、HAL_SD_ReadBlocks、HAL_SD_WriteBlocks等关键函数的实际用法并理解错误处理、时钟配置与中断服务等细节适合想在STM32平台快速实现外部存储功能的嵌入式开发者深入理解微控制器与外部存储的交互方式。1. 用 STM32L496 做 SD 卡读写数据为什么最终选了 SDIOSTM32L496 做 SD 卡读写数据很多人第一反应是 SPI 接口因为网上例程多、接线少。但真正放到数据采集器或者连续日志记录场景里跑SPI 的短板会立刻露出来总线频率被时钟相位约束4 bit 带宽用不上CPU 还要在每字节中断里空转。HAL 库把 STM32L4 系列单片机的 SDMMC 外设封装得相当完整配合 stm32l4xx_hal_sd.c单块读写吞吐量能比 SPI 高出一个量级。这个项目就是基于 HAL 库驱动 SD 卡完整方案从 CubeMX 引脚配置、块读写、DMA 传输到 FATFS 挂载以及掉电时数据一致性处理适合做数据采集、黑匣子和需要大容量存储的嵌入式开发者参考。2. 先把 SDMMC 外设“喂饱”CubeMX 时钟配置与引脚绑定SD 卡驱动能不能稳稳工作八成取决于初始化前的硬件配置。STM32CubeMX 里这个外设叫 SDMMC1对应库文件 stm32l4xx_hal_sd.c。很多从标准库转过来的朋友以为 SDIO 是单纯的 UART 类外设配好串口号就能收发实际用起来卡死在HAL_SD_Init()的情况多半发生在时钟和引脚上。2.1 为什么选 SDMMC 而不是 SPI带宽与中断负载的对比SPI 接 SD 卡常用 4 线制CS、SCK、MOSI、MISO最高频率跑到 20 MHz 左右但实际连续读吞吐能到 1 MB/s 就不错。而 STM32L496 的 SDMMC 支持 1 bit、4 bit、8 bit 总线宽度SD 卡标准里通常只用到 4 bit。HAL 库启动时会把总线宽度配成 4 bit理论上数据线并行传输单块读延迟明显更短。对比项SDMMC 4 bitSPI数据线4 条 CMD CLK4 条含 CS典型时钟25 MHz20 MHz单扇区读耗时实测参考0.5 ms 级2 ms 级CPU 负担DMA 可卸载每块中断搬运初始化分支支持 SDSC/SDHC/SDXC 自动识别需手动区分 V1/V2 卡如果只是写配置小文件、量少SPI 也能接受。但要长期记录传感器数据或者写完后要快速检索SDMMC 的速率优势会直接决定系统能不能跟得上数据产生速率。HAL 库的好处是接口统一换到 STM32L4 系列其他型号只要引脚匹配驱动代码改动量很小。2.2 CubeMX 中需要锁定的几个关键参数在 STM32CubeMX 里选择 SDMMC1启用 4 bit 模式然后重点看这两个页面Clock Configuration 和 GPIO Settings。CLK 频率由 PLLQ 输出再经过hsd.Init.ClockDiv分频得到建议从 25 MHz 起步。CubeMX 会把stm32l4xx_hal_rcc_ex.c里的时钟使能自动生成好以下代码是手动初始化时最常写到的部分void MX_SDMMC1_SD_Init(void) { hsd.Instance SDMMC1; hsd.Init.ClockEdge SDMMC_CLOCK_EDGE_RISING; hsd.Init.ClockBypass SDMMC_CLOCK_BYPASS_DISABLE; hsd.Init.ClockPowerSave SDMMC_CLOCK_POWER_SAVE_DISABLE; hsd.Init.BusWide SDMMC_BUS_WIDE_4B; hsd.Init.HardwareFlowControl SDMMC_HARDWARE_FLOW_CONTROL_DISABLE; hsd.Init.ClockDiv 2; HAL_SD_Init(hsd); }ClockDiv越大SDMMC_CK 越低。ClockDiv 0 会让时钟跑在最极限值看上去可以压榨性能但会导致劣质卡识别失败。ClockBypass保持 DISABLE让控制器内部自己处理时钟相位别图省事打开旁路。HardwareFlowControl在 FATFS 场景下不启用交给 HAL 状态机就够了。CubeMX 配置界面会自动把 GPIO 复用设定好但也会隐藏一个细节SD 卡的 CMD 和 D0-D3 需要上拉不是默认 PushPull 就能工作。生成代码后检查MX_GPIO_Init()如果 Pull 配置是 NOPULL要改成 PULLUP。2.3 在 HAL_SD_MspInit 里完成 GPIO 和时钟的绑定HAL 的初始化流程分两层HAL_SD_Init()操作 SDMMC 寄存器HAL_SD_MspInit()负责底层 GPIO、时钟、DMA 绑定。每次上电时序不对第一步就是看 MspInit 里的引脚是否都使能。以 L496 常见封装为例SDMMC1 的 D0-D3 在 PC8-PC11CLK 在 PC12CMD 在 PD2所以要同时使能 GPIOC 和 GPIOD 时钟void HAL_SD_MspInit(SD_HandleTypeDef *hsd) { GPIO_InitTypeDef gpio {0}; __HAL_RCC_SDMMC1_CLK_ENABLE(); __HAL_RCC_GPIOC_CLK_ENABLE(); __HAL_RCC_GPIOD_CLK_ENABLE(); gpio.Pin GPIO_PIN_8 | GPIO_PIN_9 | GPIO_PIN_10 | GPIO_PIN_11 | GPIO_PIN_12; gpio.Mode GPIO_MODE_AF_PP; gpio.Pull GPIO_PULLUP; gpio.Speed GPIO_SPEED_FREQ_VERY_HIGH; gpio.Alternate GPIO_AF12_SDMMC1; HAL_GPIO_Init(GPIOC, gpio); gpio.Pin GPIO_PIN_2; gpio.Alternate GPIO_AF12_SDMMC1; HAL_GPIO_Init(GPIOD, gpio); }GPIO_SPEED_FREQ_VERY_HIGH并不是越快越好但对于 25 MHz 时钟信号低摆率会拉长边沿造成误码。GPIO_AF12_SDMMC1是 L4 系列的复用编号在参考手册的“Alternate function mapping”表里能查到。如果使用自己的板子务必用万用表量一遍 PC8-PC12、PD2 有没有虚焊CMD 和 D0-D3 上拉电阻建议 10 kΩ。2.4 HAL_SD_Init 内部发生的卡初始握手卡初始化不是一个函数调用就能蒙混过关的。HAL_SD_Init()内部会依次发送 CMD0 复位再发 CMD8 判断卡是 V1.0 还是 V2.0接着用 CMD55/ACMD41 启动卡初始化流程最后读 CID/CSD 确认容量和参数。很多开发者以为调用返回 HAL_OK 就能直接读写实际上初始化完成后的卡还停在 IDLE 态必须先取卡状态和容量信息HAL_SD_CardInfoTypeDef card_info; while (HAL_SD_GetCardState(hsd) ! HAL_SD_CARD_TRANSFER) { // 等待 SD 卡进入传输状态超时处理 } HAL_SD_GetCardInfo(hsd, card_info); if (card_info.CardType ! CARD_SDHC card_info.CardType ! CARD_SDSC) { Error_Handler(); }HAL_SD_GetCardState()会向卡发送 CMD13polling 返回值直到HAL_SD_CARD_TRANSFER表示卡可以处理数据命令。CardType决定后续是否按 SDHC 逻辑块寻址。SDSC 卡用字节地址SDHC/SDXC 用 512 字节扇区地址HAL 库内部会做转换所以应用层只需要记下LogBlockNbr作为总扇区数。这一层没通过下面所有 I/O 都会卡在超时上。3. 块读写实战HAL_SD_ReadBlocks 与 WriteBlocks 的轮询和 DMA 选择初始化完成后SDMMC 外设就变成了一个块设备。FATFS 最终也是通过块读写函数访问底层所以先要把最底层的HAL_SD_ReadBlocks/HAL_SD_WriteBlocks跑通再去谈文件系统。这一层最容易踩的坑不是 API 不会调用而是块地址单位、DMA 缓冲区对齐和卡状态轮询没搞对。3.1 轮询方式读第一个扇区最简单的方式是轮询读取。下面这段代码从扇区 0 读出 512 字节也就是 MBR 区域打印前两个字节是 0xEB 或 0xE9就能证明数据通路已经打通__ALIGN_BEGIN uint8_t buf[512] __ALIGN_END; HAL_StatusTypeDef st HAL_SD_ReadBlocks(hsd, buf, 0, 1, 1000); if (st ! HAL_OK) { Error_Handler(); } while (HAL_SD_GetCardState(hsd) ! HAL_SD_CARD_TRANSFER) { // 等待 SD 卡状态机回到 TRANSFER才能发下一条命令 }HAL_SD_ReadBlocks()的第三个参数是扇区号不是字节地址。有些标准库移植过来的人在这里会写0 * 512看起来对实际上 HAL 内部已经按 512 字节块寻址。第四个参数是块数量第五个参数是超时值单位毫秒。注意超时只是等待卡空闲的上限不代表传输完成后立即返回。函数返回 HAL_OK 只表示命令已经发出真正要等数据写完还得靠后面那个GetCardState循环。3.2 DMA 方式缓冲区对齐与传输完成判断轮询方式在单块读写够用但连续记录数据时 CPU 会被频繁打断。DMA 模式把数据搬运交给 DMAMUX 和 DMA1/DMA2HAL 库的 MspInit 里需要提前绑定 DMA 句柄__ALIGN_BEGIN uint8_t dma_buf[4096] __ALIGN_END; void HAL_SD_MspInit(SD_HandleTypeDef *hsd) { // 其他 GPIO 配置省略 hdma_sdmmc1.Instance DMA2_Channel0; hdma_sdmmc1.Init.Request DMA_REQUEST_SDMMC1; hdma_sdmmc1.Init.Direction DMA_PERIPH_TO_MEMORY; hdma_sdmmc1.Init.PeriphInc DMA_PINC_DISABLE; hdma_sdmmc1.Init.MemInc DMA_MINC_ENABLE; hdma_sdmmc1.Init.PeriphDataAlignment DMA_PDATAALIGN_WORD; hdma_sdmmc1.Init.MemDataAlignment DMA_MDATAALIGN_WORD; hdma_sdmmc1.Init.Mode DMA_NORMAL; HAL_DMA_Init(hdma_sdmmc1); __HAL_LINKDMA(hsd, hdmatx, hdma_sdmmc1); } HAL_SD_ReadBlocks_DMA(hsd, dma_buf, 128, 8);dma_buf必须做对齐处理。Cortex-M4 对 DMA 访问没有硬性的 cache 一致性要求但 SDMMC 控制器要求数据地址按 4 字节对齐最好直接给到 8 字节对齐。缓冲区定义用__ALIGN_BEGIN和__ALIGN_END宏不要用普通数组替代。DMA 模式下没有超时参数所以要额外加一个轮询循环否则下一条命令会覆盖上一条还没传完的数据。3.3 多块读写一次性搬 16 KB避免频繁等待写日志最忌讳一次写一个扇区因为每写完一块都要等卡内部编程完成。多块写可以显著提高吞吐HAL 库提供HAL_SD_WriteMultiBlocks()配合多块 DMA 完成连续传输__ALIGN_BEGIN uint8_t log_cache[32 * 512] __ALIGN_END; HAL_StatusTypeDef st HAL_SD_WriteMultiBlocks( hsd, log_cache, 2048, // 起始扇区号 512, // 每个块的字节数 32, // 块数量 5000 // 超时 ); while (HAL_SD_GetCardState(hsd) ! HAL_SD_CARD_TRANSFER) { // 等待多块写入完全结束 }这个例子从 2048 号扇区连续写 32 块正好 16 KB。块数量增大时卡内部的写延迟会被摊薄但也不是越多越好。卡固件会在连续写过程中执行擦除和搬移如果缓冲区太小、又急着发下一条命令反而容易触发HAL_SD_ERROR_DATA_TIMEOUT。我一般把多块写控制在 3264 块之间对应 16 KB 到 32 KB。3.4 排错表返回 HAL_BUSY / HAL_TIMEOUT / HAL_ERROR 分别看哪里返回值可能原因排查方向HAL_TIMEOUTSDMMC 时钟太高卡识别失败调大 ClockDiv降低 SDMMC_CKHAL_TIMEOUTCMD 线上拉缺失或断线检查 PC12 到卡座的 10 kΩ 上拉HAL_BUSY上一条命令未完成就发起新读写每次命令前等待 CardState 为 TRANSFERHAL_ERROR卡未初始化成功或卡类型不匹配重新跑 HAL_SD_Init 并读 CardTypeHAL_ERRORDMA 方向或句柄未绑定MspInit 里检查 __HAL_LINKDMA把这张表贴在调试站旁边很有用。大部分 SD 卡问题不是代码逻辑而是初始化序列漏掉了等待步骤。4. FATFS 对接从 disk_read 到 f_sync 的文件级可靠写裸的块读写没法直接当文件用。FATFS 这类文件系统会把 SD 卡组织成文件、目录和 FAT 表应用层调用f_open/f_write时底层通过 disk_open、disk_read、disk_write、disk_ioctl 四个函数跟块设备打交道。这个项目里diskio 层只需要把 FATFS 的扇区号转交给 HAL 库的函数。4.1 disk_read 和 disk_write 的移植FATFS 的DRESULT disk_read()每次传入的扇区号是逻辑块号正好和 HAL 库里的 BlockAdd 对应。单块和多块分别调用不同 HAL 接口这是网上很多例程容易漏掉的分支DRESULT disk_read(BYTE pdrv, BYTE *buff, LBA_t sector, UINT count) { if (pdrv ! 0) return RES_PARERR; while (HAL_SD_GetCardState(hsd) ! HAL_SD_CARD_TRANSFER); HAL_StatusTypeDef st; if (count 1) { st HAL_SD_ReadBlocks(hsd, buff, sector, 1, 1000); } else { st HAL_SD_ReadMultiBlocks( hsd, buff, sector, 512, count, 5000); } if (st ! HAL_OK) return RES_ERROR; while (HAL_SD_GetCardState(hsd) ! HAL_SD_CARD_TRANSFER); return RES_OK; }disk_write结构几乎一样只是把 Read 换成 Write。注意 FATFS 传入的buff可能是内部任意地址尤其在多扇区读时缓冲区不一定是 4 字节对齐。保守做法是在编译选项里打开 FATFS 的FF_USE_FASTSEEK之外的对齐检查或者在两端加 memcpy 中转。4.2 disk_ioctl 要实现的几个命令FATFS 初始化时会调用disk_ioctl()获取卡容量和块大小至少要实现三个 caseDRESULT disk_ioctl(BYTE pdrv, BYTE cmd, void *buff) { if (pdrv ! 0) return RES_PARERR; HAL_SD_CardInfoTypeDef ci; switch (cmd) { case GET_SECTOR_COUNT: HAL_SD_GetCardInfo(hsd, ci); *(LBA_t *)buff ci.LogBlockNbr; return RES_OK; case GET_BLOCK_SIZE: *(DWORD *)buff 1; // 物理块大小单位是扇区 return RES_OK; case CTRL_SYNC: while (HAL_SD_GetCardState(hsd) ! HAL_SD_CARD_TRANSFER); return RES_OK; } return RES_PARERR; }LogBlockNbr在 HAL 库中是逻辑块数量FATFS 拿它去算 FAT 表和根目录位置。如果这里填了卡的总字节数后续挂载会报FR_DISK_ERR。GET_BLOCK_SIZE返回值影响 FATFS 的分配策略大多 SD 卡物理擦除块是 1 个扇区填 1 不会出错。4.3 f_mount、f_open 与 f_write 的标准用法挂载文件系统时f_mount的第三个参数为 1 表示立即挂载。如果返回FR_NOT_ENABLED先确认 diskio 里的函数有没有被链接进去返回FR_DISK_ERR则回到上一章的排错表FATFS fs; FIL fil; FRESULT fr; fr f_mount(fs, , 1); if (fr ! FR_OK) { Error_Handler(); } fr f_open(fil, data.csv, FA_OPEN_ALWAYS | FA_WRITE); if (fr ! FR_OK) { Error_Handler(); } UINT written; fr f_write(fil, log_buf, sizeof(log_buf), written); if (fr ! FR_OK || written sizeof(log_buf)) { Error_Handler(); } f_sync(fil); f_close(fil);FA_OPEN_ALWAYS在文件不存在时创建已存在时从末尾追加。如果写的是 CSV 表头第一次要单独处理。这里的f_sync()很关键它会把 FATFS 内部缓存刷到 SD 卡代价是插入一次文件系统操作耗时可能到几十毫秒。日志系统可以采用积累到 32 KB 再 f_sync 的策略别每 100 字节就同步。4.4 簇大小与长文件名的实际影响一块标称 32 GB 的 SD 卡出厂格式化大概率是 exFAT 或 32 KB 簇的 FAT32。STM32 的 SDMMC 驱动读 32 KB 连续扇区比读 4 KB 分块要快。FATFS 默认按簇管理空间簇太小会让 FAT 表变大文件每多一片都要更新 FAT写压力翻倍簇太大会浪费小文件空间。做日志建议直接在电脑上把卡格式化为 FAT32簇选 32 KB。关于中文文件名FATFS 的默认配置FF_USE_LFN是 0只能读取 8.3 格式文件名。要写温度_20250601.csv这种文件需要把ffconf.h里的FF_USE_LFN设为 2并给 FATFS 提供一个工作缓冲区。但这会让写路径更慢对实时性要求高的场景建议文件名仍用 ASCII。5. 实测吞吐量与掉电断点恢复的两个验证手段驱动写完不能凭感觉调优要把吞吐量和掉电行为量化。5.1 用 HAL_GetTick 测连续写速度在 FATFS 层测写吞吐最简单的方法是连续写 1 MB 数据记录总耗时uint32_t start HAL_GetTick(); for (int i 0; i 64; i) { f_write(fil, temp_buf, 16384, written); } f_sync(fil); uint32_t elapsed HAL_GetTick() - start;这一段写 1 MB耗时约 1.2 s 就是约 0.83 MB/s性能较好的 SDHC 卡配合 DMA 能跑到 35 MB/s。如果测出的值低于 1 MB/s重点检查是不是轮询模式下 CPU 被中断拖慢了。测试时把编译优化从 -O0 改为 -O2结果会明显改善因为循环里赋值和地址计算开销在 Debug 模式被放大了。5.2 掉电前是否“完整写入”的验证法SD 卡写异常掉电最危险的不是文件系统损坏而是不知道最后一条日志写到哪。FATFS 的 f_write 返回 FR_OK 不代表数据已经落盘只代表进了 FATFS 的缓存。验证方法是手动断电前不断 f_sync然后重新上电扫描日志文件确认最后一行有没有半截。为了快速定位可以在每条记录里加序号typedef struct { uint32_t seq; uint32_t tick_ms; uint16_t sensor[4]; } LogRecord;每次写入后把当前seq保存到一个小结构体里放在文件头部区域。掉电后上电先读文件前 64 字节找到上次的 seq再跳转到该 seq 对应的偏移位置继续追加。这样即使最后一条 f_write 没刷完也能定位断点而不是拿着f_gets整文件扫描。把这个检查放进上电自检流程能省下大量人工核对时间——对日志类设备这比把吞吐再调快 10% 更有价值。本文还有配套的精品资源点击获取
返回列表