
简介围绕STM32平台通过SPI或SDIO接口读取SD卡并挂载FATFS文件系统的完整工程面向具备一定单片机基础、希望掌握嵌入式文件管理的开发者与高校学习者。资料共229个文件以83个C源文件和83个头文件为核心辅以36个汇编启动文件及少量工程配置、说明文档压缩包约1.48MB便于在KEIL中直接查阅与移植。已有3377人学习下载说明其在SD卡驱动与FATFS结合应用上具有较高参考价值。包内覆盖SD卡初始化与扇区读写、FATFS参数配置、f_open、f_read、f_write等文件操作接口、异常情况处理以及串口或LCD验证思路并保留完整工程组织结构。读者可据此对照理解从底层驱动到文件系统调用的衔接方式快速定位KEIL工程配置与常见读写问题也可作为数据记录、远程监控等嵌入式项目的文件存储基础。1. 一块 8GB 卡插上去只认 0 字节先看清这条链路到底有几层焊好的板子第一次插卡f_mount返回FR_NO_FILESYSTEM串口打出来的容量是 0 字节。这样的现场在 STM32 接 SD 卡、跑 FATFS 的项目里几乎人人都撞过一次。整条链路其实分成四层卡座与上拉、SDIO 或 SPI 的总线时序、diskio.c这层胶水、FATFS 的文件抽象。任何一层没有对齐上层看到的都是同一句“没有文件系统”而故障点可能完全不在同一层。这套基于 STM32 的 SD 卡读取 FATFS 方案价值不在把 FATFS 源码搬进工程——用 CubeMX 点两下就能生成——而在于把四层的参数对齐总线宽度、时钟分频、扇区大小、长文件名编码。它适合手里已经有板子和卡、想把采集数据按文件写入存储而不是裸写扇区的嵌入式开发者也适合调了半天diskio还没挂载成功、想找一份可对照清单的人。2. SDIO 还是 SPI把总线选型和 CubeMX 参数一次定死选型这一步如果选错后面所有的调参都是在错误的前提下做无用功。STM32 读 SD 卡只有两条路SDIO 主机控制器或者拿 SPI 外设模拟。两者不是“性能高一点低一点”的关系而是会直接影响你能不能稳定跑下去。2.1 两种接法的代价对比先看下面这张表再决定画哪一版板子。很多项目返工就是因为第一版选了 SPI结果发现写入速度不够。维度SDIO 4-bitSDIO 1-bitSPI 模式引脚数CLK/CMD/D0-D3 共 6 根CLK/CMD/D0 共 3 根CLK/MOSI/MISO/CS 共 4 根理论带宽高48MHz 分频后仍远超 SPI约为 4-bit 的 1/4受 SPI 上限约束CPU 占用支持 DMA占用低支持 DMA多为阻塞查询占用高兼容性需要 MCU 带 SDIO/SDMMC同左任意 SPI 外设排错难度中等时序问题偏玄学中低逻辑分析仪一看就懂适用场景数据记录、音频、图片引脚紧张的低速场景小数据量配置读写、验证阶段一个经验上比较稳妥的结论是单次写入超过 4KB、或者有连续记录需求直接上 SDIO 4-bit只是偶尔存个参数表、配置文件SPI 完全够用而且省下来的调试时间更值钱。2.2 硬件连线与上拉电阻4-bit 模式下CMD和D0~D3这五根线都必须有上拉典型值 10kΩ 到 47kΩCLK不要上拉。很多“1 线能跑、4 线初始化偶发失败”的板子问题就出在这里卡座内部自带的弱上拉不足以覆盖长走线和多负载。走线上给三条建议CLK尽量短并远离开关电源和晶振CMD与D0~D3尽量等长卡座电源脚旁边放 100nF 加 10uF 去耦。SD 卡在写入瞬间有脉冲电流电源不稳会表现成随机的FR_DISK_ERR看起来像软件问题实际是硬件没兜住。注意卡座供电固定 3.3V不要接 5V。部分卡座带电平转换或卡检测脚卡检测脚要接到 GPIO 并配置上拉输入热插拔判断就靠它。2.3 CubeMX 里的关键勾选项时钟树先确认 SDIO 的时钟源已经使能一般是 48MHz。SDIO 时钟由SDIO_CK 时钟源 / (2 × CLKDIV)得到初始化阶段按规范不能超过 400kHz也就是分频系数要拉得很大初始化成功、卡进入传输态之后再切到高速。CubeMX 里按下面的顺序配置顺序错了会出现“生成代码后没有diskio.c”这种低级翻车Connectivity → SDIO或SDMMC模式选SD 4 bits Wide bus时钟分频先填一个大值比如初始 24 分频。Middleware → FATFS勾选SD CardConfiguration里把MAX SS和MIN SS都设成 512。Project Manager生成代码确认工程里出现了Middlewares/Third_Party/FatFs目录。生成之后SDIO 的初始化调用已经被放进MX_SDIO_SD_Init()但总线的宽度切换往往需要手动补一句这一点后面第 3 章会具体处理。3. 从 diskio.c 到 f_mountFATFS 落地的四层接线FATFS 分层很清楚ff.c是纯逻辑不碰任何硬件diskio.c是唯一与硬件打交道的接口层只要把这一层的五个函数写对上层立刻就能用。CubeMX 生成的diskio.c通常只是骨架真正要改的就是这一份。3.1 diskio 五个函数的最小实现先看读路径。下面这段是 SDIO 轮询模式下的实现重点在disk_read之后那次状态等待它决定了长时间读会不会卡死。#include ff.h #include diskio.h #include sdio.h /* CubeMX 生成的句柄声明 */ extern SD_HandleTypeDef hsd; /* 卡状态只需区分是否已初始化 */ DSTATUS disk_status(BYTE pdrv) { if (pdrv ! 0) return STA_NOINIT; return 0; } /* 初始化先低速握手成功后切 4 线 */ DSTATUS disk_initialize(BYTE pdrv) { if (pdrv ! 0) return STA_NOINIT; if (HAL_SD_Init(hsd) ! HAL_OK) return STA_NOINIT; /* 4 线切不过去就退回 1 线保证能跑 */ if (HAL_SD_ConfigWideBusOperation(hsd, SDMMC_BUS_WIDE_4B) ! HAL_OK) HAL_SD_ConfigWideBusOperation(hsd, SDMMC_BUS_WIDE_1B); else HAL_SD_WideBusOperation_Config(hsd, SDMMC_BUS_WIDE_4B); return 0; } /* 读扇区count 是扇区数每扇区固定 512 字节 */ DRESULT disk_read(BYTE pdrv, BYTE *buff, LBA_t sector, UINT count) { if (pdrv ! 0 || count 0) return RES_PARERR; if (HAL_SD_ReadBlocks(hsd, buff, sector, count, 1000) ! HAL_OK) return RES_ERROR; /* 轮询模式下必须等卡回到 TRANSFER 态否则下一条命令会失败 */ while (HAL_SD_GetCardState(hsd) ! HAL_SD_CARD_TRANSFER) { } return RES_OK; } /* 写扇区结构对称多一次 FLUSH 语义 */ DRESULT disk_write(BYTE pdrv, const BYTE *buff, LBA_t sector, UINT count) { if (pdrv ! 0 || count 0) return RES_PARERR; if (HAL_SD_WriteBlocks(hsd, (BYTE *)buff, sector, count, 1000) ! HAL_OK) return RES_ERROR; while (HAL_SD_GetCardState(hsd) ! HAL_SD_CARD_TRANSFER) { } return RES_OK; } /* 控制命令容量、扇区大小、擦除块这三个必须有返回值 */ DRESULT disk_ioctl(BYTE pdrv, BYTE cmd, void *buff) { if (pdrv ! 0) return RES_PARERR; switch (cmd) { case CTRL_SYNC: while (HAL_SD_GetCardState(hsd) ! HAL_SD_CARD_TRANSFER) { } return RES_OK; case GET_SECTOR_COUNT: *(LBA_t *)buff hsd.SdCard.BlockNbr; /* 总扇区数 */ return RES_OK; case GET_SECTOR_SIZE: *(WORD *)buff 512; return RES_OK; case GET_BLOCK_SIZE: *(DWORD *)buff hsd.SdCard.BlockSize; /* 擦除块大小 */ return RES_OK; default: return RES_PARERR; } }逻辑上要说明三处。第一disk_initialize里的宽总线切换是两套 APIHAL_SD_ConfigWideBusOperation在较新的 HAL 里负责配置加使能老版本拆成HAL_SD_ConfigWideBusOperation加HAL_SD_WideBusOperation_Config具体看自己工程里的头文件不要直接复制粘贴。第二读完之后的状态等待不能省卡内部还在编程时发下一条命令返回的就是 CRC 错误表现出来是偶发的RES_ERROR非常像接触不良。第三GET_SECTOR_COUNT依赖hsd.SdCard.BlockNbr这个字段只有在HAL_SD_Init成功之后才有值所以顺序上不能先查容量再初始化。如果工程用 DMAdisk_read、disk_write要换成非阻塞版本加信号量回调里释放。轮询版的好处是不用管中断优先级坏处是大块传输时 CPU 全程干等读 512KB 能明显感觉到。3.2 ffconf.h 里必改的四个宏ffconf.h是 FATFS 的行为开关改错这里会得到一堆看起来莫名其妙的返回值。常用的四个宏建议值作用与影响FF_USE_LFN2开长文件名缓冲放栈上改 1 则放静态区FF_MAX_LFN255长文件名最大长度配合上一条FF_CODE_PAGE936中文文件名需要编码走 GBKFF_FS_TINY0置 1 会把文件缓冲并进 FIL省 RAM 但写性能下降FF_USE_LFN 2把缓冲放在栈上对栈空间不宽裕的芯片是个隐患255 字节乘上调用深度很容易过界所以小 RAM 的片子我一般直接改成 1让缓冲落到 BSS 段代价是多占一点静态内存换来的是不会莫名其妙的进 HardFault。3.3 挂载与第一次打开f_mount只是把工作区和逻辑盘号关联起来第二个参数是卷路径第三个参数决定要不要立刻访问卡。opt 1表示立即挂载能第一时间发现卡的问题。#include ff.h FATFS fs; /* 必须是全局或静态绝不能是函数局部变量 */ FIL g_fil; FRESULT app_init(void) { FRESULT res; DWORD fre_clust; FATFS *pfs; res f_mount(fs, 0:, 1); /* 1 立即挂载 */ if (res ! FR_OK) return res; /* 查一次剩余空间顺便验证磁盘层 */ res f_getfree(0:, fre_clust, pfs); if (res ! FR_OK) return res; /* csize 单位是扇区扇区 512B换算成 MB */ printf(free %lu MB\r\n, (unsigned long)(fre_clust * pfs-csize / 2048UL)); return FR_OK; }这里有两个参数值得多说一句。f_mount的第一个参数是FATFS对象的地址它的生命周期必须覆盖整个文件操作过程写成函数里的局部变量函数一返回这块内存就被复用后面所有调用都踩着随机数据跑症状是f_open时好时坏。第三个参数置 1 会立即执行disk_initialize如果返回值是FR_NOT_READY问题一定在硬件或总线不用再往上查。f_getfree顺手把pfs指针填成当前卷的FATFS对象csize是每簇的扇区数乘上剩余簇数就是剩余扇区再除以 2048 就得到 MB。这个换算里 2048 来自 512 字节扇区乘以 1024单位换算错了会得到一个大得离谱的数字。4. 读写与目录遍历六个 API 撑起实际业务挂载成功只是进门真正的业务是往文件里写数据、把数据读回来、遍历目录找文件。FATFS 的 API 不多f_open、f_write、f_read、f_lseek、f_sync、f_opendir这六个覆盖了九成场景难点全在调用时序上。4.1 写文件从 f_open 到 f_close 的完整时序追加写日志是最高频的需求。下面这段把“打开-定位-写-同步-关闭”串在一起顺序不能乱。#include string.h /* 追加一行到 CSVfile 不存在则创建 */ FRESULT log_append(const char *line, UINT len) { FIL fil; UINT bw 0; FRESULT res; /* FA_OPEN_ALWAYS存在则打开不存在则新建 */ res f_open(fil, 0:/log.csv, FA_OPEN_ALWAYS | FA_WRITE); if (res ! FR_OK) return res; /* FA_OPEN_ALWAYS 定位在文件头必须手动挪到末尾 */ res f_lseek(fil, f_size(fil)); if (res ! FR_OK) { f_close(fil); return res; } res f_write(fil, line, len, bw); if (res FR_OK bw ! len) res FR_DISK_ERR; /* 写少了也算失败 */ f_sync(fil); /* 强制回写 FAT 和缓存掉电兜底 */ f_close(fil); return res; }三个参数点。FA_OPEN_ALWAYS | FA_WRITE这组标志少了任何一个都不行只有FA_WRITE打开不存在的文件会返回FR_NO_FILE。f_lseek(fil, f_size(fil))是追加的关键FA_OPEN_ALWAYS打开已有文件时读写指针在 0 位置不挪过去就会从头覆盖这个坑踩过一次就记住了。f_sync与f_close的区别在于前者不释放文件对象可以继续写适合在记录循环里定期调用后者内部也会同步但之后fil就失效了。bw一定要检查。曾经遇到过写入返回值是FR_OK但bw小于请求长度的情况原因是大块写入时中途卡返回了错误FATFS 把已写的部分记下就返回了不看bw会以为全写进去了最后文件长度对不上。4.2 读文件与随机定位读相对简单但大文件分块读的循环条件容易写错。下面这段是典型的流式读取/* 循环读直到读完br 为 0 或小于缓冲即结束 */ FRESULT dump_file(const char *path, void (*sink)(const void *, UINT)) { FIL fil; UINT br 0; FRESULT res; static BYTE buf[1024]; res f_open(fil, path, FA_READ); if (res ! FR_OK) return res; do { res f_read(fil, buf, sizeof(buf), br); if (res ! FR_OK) break; sink(buf, br); } while (br sizeof(buf)); f_close(fil); return res; }循环条件写成while (br sizeof(buf))而不是while (br 0)是因为后者会在最后一次刚好读满时多跑一轮空转虽然不出错但多一次系统调用。f_read返回的br是实际读到的字节数读到文件末尾时它小于请求长度这就是终止信号。随机访问靠f_lseek配合f_tell。比如要从文件里跳过 512 字节的头再读数据直接f_lseek(fil, 512)然后正常f_read。f_lseek只能往前跳到f_size以内超出范围会返回FR_INVALID_POSITION需要扩展文件长度得用f_lseek配合f_truncate。4.3 目录遍历与容量查询目录遍历的坑集中在结束判断上。f_readdir读到末尾时返回FR_OK但fno.fname[0]是\0只看返回值会死循环。/* 列出目录下的文件和子目录 */ FRESULT list_dir(const char *path) { DIR dir; FILINFO fno; FRESULT res; res f_opendir(dir, path); if (res ! FR_OK) return res; for (;;) { res f_readdir(dir, fno); if (res ! FR_OK) break; /* 出错 */ if (fno.fname[0] \0) break; /* 遍历结束 */ if (fno.fattrib AM_DIR) printf([DIR ] %s\r\n, fno.fname); else printf([FILE] %s %lu bytes\r\n, fno.fname, (unsigned long)fno.fsize); } f_closedir(dir); return res; }两个细节。fno.fattrib用位与判断AM_DIR、AM_HID、AM_RDO不要用等号。fno.fsize是FSIZE_t在默认配置下是 32 位超过 4GB 的文件需要用FF_FS_EXFAT相关的 64 位配置SD 卡场景里基本碰不到。顺带提醒目录遍历会频繁调用disk_read如果卡里文件有上千个遍历一次能花好几秒。上产品的代码里不要在中断里做这件事也不要放在主循环开头每次都跑。5. 排查清单SD 卡读不出来的五类现场这一章是从调试现场里摘出来的五条都按“现象、原因、解决”记录。遇到问题时按顺序对照能省掉大量猜测。5.1 挂载前就失败硬件与时序现场第一条f_mount返回FR_NO_FILESYSTEM卡在电脑上读写正常。原因基本可以锁定在格式上。FATFS 默认只认 FAT12、FAT16、FAT32卡出厂或被电脑默认格式化时很可能是 exFAT尤其是 64GB 以上的卡。解决办法是把卡重新格式化成 FAT32分配单元大小选 4096 字节64GB 以上的卡在部分系统里不给选 FAT32那就用命令行或第三方工具强制格式化。簇太大的 FAT32比如 32KB 簇在小文件多的场景会浪费空间但能跑。第二条1 线模式稳定切 4 线后初始化偶发失败或者干脆起不来。两个原因叠在一起很常见。一个是上拉缺失或阻值太大CMD与D0~D3在 4 线模式下同时切换电平边沿变缓导致采样错误另一个是初始化阶段时钟就偏快。解决分两步先把时钟分频调大让SDIO_CK落在 400kHz 附近等HAL_SD_Init成功、卡进入传输态之后再通过HAL_SD_Init之后的宽总线配置切到高速同时在硬件上把五根线上拉补到 10kΩ。软件上还可以留一条退路4 线切失败就退回 1 线功能先能跑起来。第三条读写一段时间后随机返回RES_ERROR断电重启又正常。大概率是等待时间不够加上电源不稳。读取超时参数给的是 1000ms看起来宽裕但HAL_SD_ReadBlocks内部的分步超时是按每次操作算的卡在做内部垃圾回收时会长时间不响应。增加的办法是把超时放宽到 3000ms并在写完一批数据后主动调一次CTRL_SYNC。如果换了卡就不出现说明那张卡的内部管理更激进属于卡的问题不是代码的问题产品里建议固定卡型号别让用户随便换。5.2 挂载成功但读写异常文件系统层现场第四条f_open返回FR_INVALID_NAME路径看上去没问题。名字里带中文或者超过 8.3 格式。原因是没有开长文件名支持或者码页不对。检查ffconf.hFF_USE_LFN至少为 1FF_MAX_LFN给到 255FF_CODE_PAGE设为 936。还有一点容易被忽略——传进去的字符串编码要和码页一致源码文件本身是 UTF-8 的直接传中文常量会变成乱码路径要么转成 GBK要么干脆全用英文名。产品代码里我倾向于目录名英文、文件名带序号省掉整条编码链路。第五条写入后立刻断电文件长度是 0 或者内容是上一次的。f_write只把数据写进 FATFS 的窗口缓存真正落盘发生在f_sync或f_close缓存是 512 字节的扇区窗口写不满一个扇区根本不会触发磁盘操作。要保证数据安全方法是每写完一批就调f_sync并且接受它带来的性能损失——一次f_sync可能要几十毫秒。要求更高时用“临时文件加重命名”的写法写data.tmpf_sync之后f_close再f_rename(0:/data.tmp, 0:/data.csv)这样即使中途掉电原文件也不会被破坏。6. 提速与掉电保护把吞吐拉上去的几个实操手段调通之后的第一个问题往往是慢。按默认参数一行一行写日志实测下来一秒只能记几十条瓶颈不在卡在调用粒度。6.1 把单次读写粒度放大SD 卡的最小访问单位是扇区每次f_read请求 10 字节底层照样要读 512 字节中间的差异全被丢弃。把缓冲放大到 8KB 到 32KB吞吐能差出好几倍。缓冲区必须 4 字节对齐长度是 4 的倍数否则 SDIO 配 DMA 时会直接报传输错误。/* 32KB 缓冲4 字节对齐长度是 4 的倍数 */ __attribute__((aligned(4))) static uint8_t io_buf[32 * 1024]; FRESULT stream_out(const char *src, UART_HandleTypeDef *huart) { FIL fil; UINT br 0; FRESULT res; res f_open(fil, src, FA_READ); if (res ! FR_OK) return res; do { res f_read(fil, io_buf, sizeof(io_buf), br); if (res ! FR_OK) break; HAL_UART_Transmit(huart, io_buf, br, 1000); } while (br sizeof(io_buf)); f_close(fil); return res; }缓冲大小要按芯片 RAM 来定32KB 在 128KB SRAM 的片子上没压力64KB 的片子上就得往下调否则链接阶段直接报空间不足。另外别把这个数组放栈上32KB 的局部数组一进函数就爆栈表现是莫名其妙进 HardFault。6.2 掉电与热插拔的兜底热插拔的处理核心是“不要在卡不在的时候还拿着旧状态”。卡检测脚接 GPIO 中断中断里只置一个标志主循环里判断标志再执行卸载和重挂/* 卡检测中断回调里只置标志不做耗时操作 */ volatile uint8_t g_card_changed 0; void HAL_GPIO_EXTI_Callback(uint16_t pin) { if (pin CARD_DET_Pin) g_card_changed 1; } /* 主循环中处理 */ void card_poll(void) { if (!g_card_changed) return; g_card_changed 0; f_mount(NULL, 0:, 0); /* 先卸载释放旧工作区 */ HAL_Delay(50); /* 等卡座触点稳定这一步别省 */ if (f_mount(fs, 0:, 1) FR_OK) printf(card remounted\r\n); else printf(no card\r\n); }f_mount(NULL, 0:, 0)传空指针表示注销卷这一步必须做否则重新挂载时旧的工作区还指着已经失效的卡状态。50ms 的延时是针对卡座触点的抖动实测短了会出现“插着卡却报无卡”的玄学现象。6.3 上板验证的三步写完代码不要直接跑业务按这三步走一遍能挡住大部分问题。第一步f_mount之后立刻f_getfree把剩余容量打出来容量对得上说明disk_ioctl三个命令都正常。第二步写一个固定内容的小文件f_close之后拔卡插到电脑上看内容和长度长度对但内容乱说明disk_write的扇区号算错了。第三步连续写 1000 条记录再整体读回比对这一步专门用来暴露“写满一个扇区才落盘”导致的边界问题。这三步我一般在每次换卡、换板、改时钟之后都重跑一遍花不了两分钟但能避免在业务层查出底层问题。有一次赶进度跳过了第三步结果在客户现场发现每 512 字节丢一个字节回头查是 DMA 缓冲区没对齐补上aligned(4)就好了。从那以后我每次动到diskio.c或者改 SDIO 分频都强制把这三步走一遍再往上层写一行业务代码。希望帮到你。本文还有配套的精品资源点击获取