STM32 SD卡FATFS文件系统移植与稳定读写实战 简介这是一份面向嵌入式初学者与工程实践者的STM32 SD卡读写FATFS文件系统学习包聚焦在KEIL环境下完成SD卡初始化、扇区读写与文件系统挂载并通过f_open、f_read、f_write等接口实现文件打开、读取、写入和关闭。压缩包共229个文件以83个C源文件与83个头文件为核心配合启动汇编、工程配置及少量说明文档整体约1.48MB便于直接导入KEIL对照编译与调试。内容覆盖SPI或SDIO硬件连接、底层驱动编写、FATFS参数配置、错误处理以及串口或LCD验证思路适合想打通STM32存储与文件管理链路的开发者参考。已有3377人学习下载可作为课程设计、数据记录类项目的基础范例也便于在此基础上扩展更复杂的文件操作。1. SD 卡读不出文件的那晚我把 FATFS 方案重做了一遍做嵌入式存储方案的人都遇到过这种场景板子上电、SD 卡插好、卡里放了配置文件代码却卡在f_mount后面读到的文件名是一串乱码或者干脆返回FR_NO_FILESYSTEM。SD 卡本身就是一块没有文件系统概念的裸 NAND 介质STM32 的 SDIO 外设只负责按扇区收发数据想把卡里的config.txt当成文件打开中间必须有一层文件系统。FATFS 就是这层适配层它把扇区读写抽象成f_open/f_read/f_write/f_sync这套标准接口让 MCU 用几 KB RAM 就能操作 FAT16/FAT32 卷。这套方案要解决的问题很具体让 STM32 稳定地挂载 SD 卡、按文件名读写数据、掉电后数据不丢并且换卡、换容量、换簇大小都不需要改应用层代码。适合正在做存储类毕设/课设的学生也适合要给设备加日志、配置、数据回放功能的从业者跟着搭一遍就能跑通。2. 总体方案设计从 PBMC 到 FATFS 的四层职责划分先给结论这套系统的稳定性八成取决于分层有没有划清。我在方案里按四层来切层级负责的事对应组件不负责的事物理层上电时序、电压、插拔检测SD 卡座、LDO、卡检测引脚不关心扇区内容驱动层命令/响应、扇区读写STM32 SDIO 外设 中断/DMA不关心文件中间层扇区地址换算、多块读写适配diskio.c不关心目录结构文件层目录、FAT 表、簇分配FATFS 源码不关心硬件这四层的边界一旦模糊后面所有问题都会变成玄学。最常见的是把卡初始化写进应用逻辑里读失败就重试整个f_mount结果卡已经被复位成 SPI 模式SDIO 驱动再也认不出来。2.1 硬件与引脚设计SDIO 4 位模式还是一线三用STM32 上接 SD 卡有两条路SDIO 外设走 4 位总线或者用 SPI 模拟。选型不用纠结——只要芯片有 SDIO 且 PCB 布线不太长就走 SDIO。4 位模式理论带宽比 SPI 高一个数量级读写一个 2 MB 日志文件的时间差能到十几秒。SPI 模式的唯一优势是引脚少、代码好移植适合引脚资源被占满的老板子。SDIO 侧的连线是固定的SDMMC_CK、SDMMC_CMD、SDMMC_D0~D3加上SDMMC_DET卡检测和电源使能。这里有个容易漏的设计点——CMD 和 D 线必须上拉通常 10kΩ 到 47kΩ板载没有就得在软件里启用内部上拉。我曾见过一块板子因为漏了上拉冷机能识别、热插拔就挂查了两天才发现是上拉电阻没焊。卡检测引脚建议接到一个支持外部中断的 GPIO配置成下降沿触发用来处理热插拔。上电时序上SD 卡要求供电稳定后至少 1ms 才能发第一条命令所以HAL_Delay(1)之后再做HAL_SD_Init()这一步省掉就是间歇性初始化失败。2.2 软件分层与移植点FATFS 只需要改一个文件FATFS 的可移植性做得很干净接入一个新平台本质上只实现diskio.c里的五个函数/* diskio.c —— FATFS 与底层驱动的唯一接口层 */ DSTATUS disk_status(BYTE pdrv) { if (pdrv ! 0) return STA_NOINIT; /* 本方案只挂一个卷 */ return sd_card_ready() ? 0 : STA_NOINIT; /* 卡在位且已初始化 */ } DSTATUS disk_initialize(BYTE pdrv) { if (pdrv ! 0) return STA_NOINIT; return (sd_init() SD_OK) ? 0 : STA_NOINIT; } DRESULT disk_read(BYTE pdrv, BYTE *buff, LBA_t sector, UINT count) { /* sector 是逻辑扇区号单位 512 字节直接交给 SDIO 多块读 */ if (sd_read_blocks(sector, buff, count) ! SD_OK) return RES_ERROR; return RES_OK; } DRESULT disk_ioctl(BYTE pdrv, BYTE cmd, void *buff) { switch (cmd) { case CTRL_SYNC: return RES_OK; /* 无写缓存无需刷盘 */ case GET_SECTOR_COUNT: *(DWORD *)buff sd_block_count(); return RES_OK; case GET_SECTOR_SIZE: *(WORD *)buff 512; return RES_OK; case GET_BLOCK_SIZE: *(DWORD *)buff 1; return RES_OK; /* 擦除块1扇区 */ default: return RES_PARERR; } }关键点有三个。第一disk_ioctl里的GET_SECTOR_SIZE必须返回 512FATFS 默认按 512 字节扇区计算返回别的值会让簇偏移全部错位。第二GET_BLOCK_SIZE表示擦除块大小SD 卡没有独立擦除块概念填 1 是安全的。第三disk_read的sector是 LBA 扇区号而非字节地址如果底层驱动习惯用字节地址一定要乘 512这个错误会导致读出来的内容整体错位一个扇区表现为文件名是乱码。ffconf.h里要动的只有几个开关#define FF_FS_READONLY 0 /* 需要写日志就置 0 */ #define FF_USE_LFN 1 /* 长文件名需要配套 ffunicode.c */ #define FF_MAX_SS 512 /* 扇区大小与 diskio 保持一致 */ #define FF_FS_MINIMIZE 0 /* 保留 f_lseek 等接口 */ #define FF_USE_MKFS 1 /* 允许格式化产测阶段很有用 */FF_USE_LFN打开后必须把ffunicode.c一起编译并且给长文件名预留缓冲区只改宏不加源文件链接阶段才会报错比编译报错更难看出原因。2.3 挂载与读写流程一次 f_mount 背后的真实开销挂载不是一次简单调用。f_mount会先读引导扇区解析 BPB 里的每扇区字节数、每簇扇区数、FAT 表数量和保留扇区再算数据区起始位置。任何一个参数读错后面的f_open就直接返回FR_NO_FILESYSTEM。推荐的初始化顺序是这样的上电延时 →HAL_SD_Init()→ 获取卡信息容量、块大小GPIO 检测卡在位确认非空卡f_mount(fs, 0:, 1)第三参数 1 表示立即挂载检查返回值FR_OK才继续FR_NO_FILESYSTEM说明卡没有格式化或格式不被识别打开或创建文件写操作后必须f_sync或f_close写文件时最容易翻车的是缓冲区。FATFS 写入是先在内存里攒满一个簇再下发f_write返回成功只代表数据进了缓存。如果此时直接断电缓存里的内容就全丢了。所以每轮写入结束都跟一次f_sync代价是一次刷盘换来掉电安全。3. SPI 模式底下要做到稳健初始化、超时与错误码这一章讲另一种常见形态板子没有 SDIO只能用 SPI 接卡。SPI 模式下 FATFS 一样能用但可靠性的坑更多因为所有时序都由软件控制。3.1 卡初始化命令序列SD 卡在 SPI 模式下有两套初始化路径上电是 SD 模式还是 SPI 模式。上电后先发 80 个时钟然后发CMD0进入空闲态接下来分两种情况——CMD8有响应说明是 SDHC/SDXC走 V2.0 流程CMD8无响应就走 V1.0 的ACMD41循环。/* 精简的初始化骨架重点在分支判断而非全量代码 */ static uint8_t sd_spi_init(void) { uint8_t r; sd_cs_high(); for (uint8_t i 0; i 10; i) spi_xfer(0xFF); /* 80 个时钟卡内部复位 */ sd_cs_low(); r sd_cmd(CMD0, 0); /* 进 idle 态期望 0x01 */ if (r ! 0x01) { sd_cs_high(); return SD_ERR_IDLE; } r sd_cmd(CMD8, 0x1AA); /* 探测 2.0 卡 */ if (r 0x01) { uint8_t buf[4]; spi_recv(buf, 4); /* 电压窗口 0x1AA 校验不匹配说明卡不支持 2.7~3.6V */ if (!(buf[2] 0x01 buf[3] 0xAA)) { sd_cs_high(); return SD_ERR_VOLT; } /* SDHC 走 ACMD41HCS 位必须置 1 */ while (sd_acmd41(0x40000000) ! 0x00) { /* 超时计数在这里加 */ } sd_capacity sd_read_ocr(); /* 是否 CCS1 决定寻址方式 */ } else { /* V1.0 老卡字节寻址 */ while (sd_acmd41(0x00) ! 0x00) { /* 同样要超时 */ } sd_is_sdhc 0; } sd_cs_high(); return SD_OK; }这段里最需要留意的是sd_is_sdhc这个标志。SDHC/SDXC 卡的读写命令用块地址老卡用字节地址两者差 512 倍。很多方案里 R/W 一次就对、换一张卡就全错根源就在这里。3.2 超时与重试策略SPI 模式下每条命令都要等响应等的时限必须自己定。我一般按「命令响应 200ms数据读写按扇区数动态计算」来设并且给重试留余量单扇区读失败重试 3 次仍失败就置卡为异常态交给上层决定是否重新初始化。超时不能单纯靠循环次数因为不同主频下循环次数没有可比性。正确做法是用一个基于 systick 的毫秒计数做上界判断循环里每隔一段检查一次是否超时。这样只要主频一变超时不会跟着失真。另一个细节是重试后的状态清理。读失败往往意味着卡处于未定义状态直接重发命令会被判错。重试前先拉高 CS发几个空闲时钟让卡退出上一次的事务比原地重试成功率高得多。3.3 错误码如何映射到应用层FATFS 返回的是FRESULT底层返回的是自己的错误码两者之间要做映射否则上层只知道「失败了」不知道「该重新挂载还是该提示用户换卡」。底层错误FATFS 返回值应用层处理无卡 / 初始化失败FR_NOT_READY提示插入卡可重试挂载扇区读失败FR_DISK_ERR重试仍失败则降级为只读无文件系统FR_NO_FILESYSTEM询问是否格式化文件不存在FR_NO_FILE创建默认配置文件写保护FR_WRITE_PROTECTED关闭写日志功能继续读这套映射关系我通常会写成一个小的函数让app层拿到语义化结果。直接上抛原始错误码应用层只能做两件事忽略或卡死。4. 目录与文件管理配置、日志、回放三类数据的落盘方式实际项目里SD 卡上通常不止一个文件。怎么组织目录、用多大的簇、文件怎么轮转直接影响长期运行表现。4.1 目录结构与文件命名推荐按用途分目录而不是全塞根目录/ ├── cfg/ │ ├── device.ini # 设备参数小文件随机读 │ └── calib.bin # 标定数据定长二进制 ├── log/ │ ├── 20240101.log # 按天轮转 │ └── 20240102.log └── data/ └── rec_0001.bin # 采集回放按序号轮转分目录的意义有两层。一是根目录的簇链在 FAT32 中是固定区域文件过多会拖慢查找二是按天/按序号轮转便于删除旧文件而不影响正在写的文件。轮转策略我一般用「保留最近 N 天超出的先删最旧」遍历目录时用f_readdir拿到文件信息取fno.fdate比较比按文件名排序更稳。4.2 小文件写入与缓存策略配置文件是大头之外的坑。device.ini这类小文件几百字节如果每次改参数都f_open/f_write/f_close一遍闪存写入次数会很快耗尽而且掉电时存在写一半文件的可能。比较稳妥的做法是先写临时文件再重命名写device.tmp→f_sync→ 删旧文件 → 把device.tmp改成device.ini。这样任何时刻磁盘上都有一个完整版本最坏情况是丢掉本次修改不会读到残缺配置。FATFS 里没有直接的 rename 接口用f_rename即可前提是FF_FS_RPATH相关配置允许跨目录操作。提示FATFS 的f_rename在同一卷内有效跨卷会返回FR_INVALID_DRIVE不要指望用它做卡间搬移。4.3 日志追加与掉电保护日志文件是顺序写的典型场景。用FA_OPEN_ALWAYSFA_OPEN_APPEND打开追加写不用每次 seek写入效率更高FRESULT log_append(const char *line) { FIL fil; UINT bw; FRESULT fr f_open(fil, 0:/log/today.log, FA_OPEN_ALWAYS | FA_WRITE); if (fr ! FR_OK) return fr; fr f_lseek(fil, f_size(fil)); /* 移到文件尾追加模式 */ if (fr ! FR_OK) { f_close(fil); return fr; } fr f_write(fil, line, strlen(line), bw); if (fr FR_OK bw ! strlen(line)) fr FR_DISK_ERR; f_sync(fil); /* 关键刷盘 */ f_close(fil); return fr; }这里f_sync是掉电保护的后悔药代价是性能下降。如果日志频率高可以改成「每 N 条或每 M 秒同步一次」但必须接受最后几条丢失。想兼顾的话加一个超级电容做掉电缓冲让f_sync有时间完成比任何软件技巧都可靠。5. 避坑指南SD 卡与 FATFS 组合里最容易翻车的五个点5.1 上电偶尔挂载失败现象冷启动十次里有固定一两次f_mount返回非FR_OK重复上电又正常。原因SD 卡供电上升沿不干净或者初始化时延不够卡还没准备好就发了CMD0。另外 SDIO 时钟在初始化阶段应限制在 400kHz 以内很多方案直接给 25MHz卡在识别阶段就超时。解决上电后HAL_Delay给足 5~10msSDIO 分两段配置识别阶段低速、识别完成再切高速卡座上并联一个大电容抑制上电毛刺。5.2 读到乱码文件名现象f_open返回FR_NO_FILEf_readdir读出来的名字是乱码但卡在电脑上完全正常。原因disk_read里把 LBA 扇区号当字节地址用了或者GET_SECTOR_SIZE返回了非 512 的值导致 FATFS 算出的簇起始偏移全部错位。另一种可能是长文件名没编译ffunicode.c。解决核对disk_read的地址换算挂一个调试变量打印首次读到的引导扇区看签名是不是0x55AAFF_USE_LFN打开时确认ffunicode.c已加入工程。5.3 写进去的数据只能看到前半截现象写 4KB 数据重启后文件只有 2KB 或者最后一簇内容是旧的。原因没有f_sync数据停在 FATFS 的窗口缓存里或者文件关闭前断电目录项没更新文件长度字段还是旧值。解决写操作结束必须f_sync或f_close对可靠性要求高的场景写关键数据前先写临时文件成功后重命名保证任何时刻文件都是完整的。5.4 换大容量卡后容量算错现象16GB 卡能识别换成 64GB 卡读写就出错或者GET_SECTOR_COUNT返回的值明显偏小。原因SDXC 卡要求走ACMD41时校验CCS位卡容量状态位决定用块寻址还是字节寻址有的驱动只看容量高位把超过 32 位的扇区数截断了。解决GET_SECTOR_COUNT用 32 位或 64 位变量接f_getfree依赖这个值在初始化末尾打印一次sd_block_count()与卡标称容量换算出来的扇区数对照。5.5 热插拔后系统卡死现象运行中拔卡程序在f_write里死等看门狗复位。原因拔卡瞬间 SDIO 总线事务无响应底层驱动在等一个永远不来的中断或者文件系统还持有旧句柄重新插卡后继续用旧句柄写。解决卡检测中断里立即f_mount(NULL, 0:, 1)卸载关闭所有文件句柄把状态机切到「无卡」底层读写全部加超时上界绝不允许无限等待。看门狗在这里不是可选是必需的。6. 验收与答辩把 SD 卡读写做成可复现的测试用例方案写完只是纸面验收得靠可复现的用例。我习惯把测试拆成三组基础功能、边界条件、异常恢复每组都有明确的通过标准答辩时直接放测试记录比讲原理更有说服力。基础功能用例格式化卡 → 挂载 → 创建test.bin→ 写入 1MB 递增数据 → 关闭 → 重新挂载 → 回读并逐字节比对。这组用例能覆盖 90% 的接线和配置问题。用递增数据而不是全 0是因为全 0 会掩盖地址错位类问题写进去读出 0 看着就「对」了实际可能根本没写。边界条件用例空文件、单个文件写满到簇边界前一字节/后一字节、文件名最长、目录内文件数接近上限、剩余空间不足时写入。簇边界这两条尤其值得测FATFS 在跨簇时有一次缓存切换写坏大多发生在这里。异常恢复用例写到一半断电、运行中拔卡、卡写保护、卡内无文件系统。每条都记录系统表现和恢复动作形成一张验收表用例输入期望结果观测项1MB 回读比对递增序列逐字节一致耗时、CRC簇边界写跨簇 2 字节数据完整文件大小字段中途断电写至 60% 断电文件存在且不越界可重新挂载运行中拔卡写日志时拔卡不卡死状态切无卡看门狗计数无文件系统卡空白卡提示格式化返回值答辩现场还有一个技巧准备一张已经预置好数据的卡和一张空白卡现场演示挂载、读配置、写日志、拔卡恢复四个动作全程不超过三分钟。比 PPT 上的架构图更能说明问题。从那以后我每次做存储相关方案都会先在纸上把diskio.c五个函数的返回值和边界写清楚再动代码——这习惯救过我很多次因为绝大部分 SD 卡问题都不是 FATFS 本身的问题而是接口层没对齐。希望这套结构能帮到你照着搭一遍再把验收表跑完剩下的就是调参数了。本文还有配套的精品资源点击获取