简介:这是一份面向嵌入式初学者与工程实践者的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必须返回 512,FATFS 默认按 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 走 ACMD41,HCS 位必须置 1 */ while (sd_acmd41(0x40000000) != 0x00) { /* 超时计数在这里加 */ } sd_capacity = sd_read_ocr(); /* 是否 CCS=1 决定寻址方式 */ } 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_ALWAYS+FA_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~10ms;SDIO 分两段配置,识别阶段低速、识别完成再切高速;卡座上并联一个大电容抑制上电毛刺。
5.2 读到乱码文件名
现象:f_open返回FR_NO_FILE,f_readdir读出来的名字是乱码,但卡在电脑上完全正常。
原因:disk_read里把 LBA 扇区号当字节地址用了,或者GET_SECTOR_SIZE返回了非 512 的值,导致 FATFS 算出的簇起始偏移全部错位。另一种可能是长文件名没编译ffunicode.c。
解决:核对disk_read的地址换算,挂一个调试变量打印首次读到的引导扇区,看签名是不是0x55AA;FF_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 本身的问题,而是接口层没对齐。希望这套结构能帮到你,照着搭一遍,再把验收表跑完,剩下的就是调参数了。
本文还有配套的精品资源,点击获取