简介:围绕STM32平台读取SD卡并挂载FATFS文件系统,这套实战工程包面向嵌入式初学者、课程设计学生及需要快速验证存储方案的开发者,重点解决SPI/SDIO接口下SD卡初始化、扇区读写与文件系统移植问题。资料将HAL库、FATFS配置、KEIL工程组织与文件操作示例整合在一起,可用于数据记录、配置存储等场景的原型搭建。压缩包共229个文件,以83个C源文件和83个头文件为核心,配合36个汇编启动文件及工程配置、说明文档等,整体约1.48MB,目录中保留完整工程结构,便于对照编译与裁剪。已有3377人学习下载。读者可从中获得可编译的KEIL工程、SD卡底层驱动与FATFS接入思路,并借鉴文件打开、读写、关闭及异常处理的组织方式,适合作为课程实践和项目移植的参考底稿。
1. 从一张读不出来的卡说起:这套 SD 卡 + FATFS 方案到底解决什么问题
一块 STM32 板子插上 SD 卡,跑一个读文件的例程,很多人把它当成新手练手项目,直到f_mount返回FR_NO_FILESYSTEM,而同一张卡插到电脑上一切正常。这套基于 STM32 的 SD 卡读取 FATFS 文件系统方案,真正的价值点在于把“卡能不能被认出来”这件事拆成可定位的几层:SPI 或 SDIO 的物理时序、diskio.c里的扇区读写、ffconf.h的配置开关、最后才是f_open那层 API。适合手里有 STM32 板子、要给设备加一个本地存储或数据导出功能的开发者,也适合想在裸机环境里跑通文件系统、后续接 USB 或网络传输的人;熟手能在这套分层里直接改底层驱动,新手照着顺序也能把第一行日志写进卡里。
2. 硬件与工程骨架:SPI 还是 SDIO,文件从总线到扇区的完整通路
拿到需求先别急着写f_open,先确定卡怎么接到 MCU 上。这一步选错,后面所有调试都会变成玄学。
2.1 接口选型与最小系统连线
STM32 接 SD 卡常见两条路:SPI 和 SDIO。SPI 的好处是引脚少、任意 SPI 外设都能用、驱动写起来直观,缺点是速度被压在十几兆以内,且一张卡独占一个 SPI;SDIO 走 4 位数据线,理论带宽高得多,适合连续采样落盘,但引脚固定、DMA 配置和时钟树要一起调。
我一般这样分:数据量小、只是偶尔导个配置或日志,用 SPI 足够,调试成本最低;要做音频、图像或高频传感器记录,直接上 SDIO,别在中途换方案,换一次等于底层重写。SPI 模式下连线是 SCK、MOSI、MISO、CS 四根,加上 3.3V 和地;CS 建议用软件控制,初始化阶段要在发命令前手动拉低、发完拉高,硬件 NSS 在一些型号上行为不好控。SDIO 模式则是 CLK、CMD、D0~D3,外加卡检测脚(CD)可选。
电源部分容易被忽略:SD 卡在写入瞬间电流会冲到 100mA 以上,板子上的 3.3V LDO 如果余量不够,表现就是“读正常、写随机失败”。这不是代码问题,是供电问题,先量电压再改程序。
提示:SPI 的 MOSI/MISO 上建议串 22~100 欧电阻,长线或飞线接卡座时能压住过冲,省掉很多偶发 CRC 错误。
2.2 工程目录与分层结构
这套工程我习惯按“总线层 → 扇区层 → 文件系统层 → 业务层”四层摆,目录大致长这样:
project/ ├── Drivers/ │ ├── sd_spi.c / sd_spi.h # 卡初始化、命令、扇区读写 │ └── diskio.c # FATFS 要求的 6 个移植函数 ├── Middlewares/ │ └── fatfs/ │ ├── ff.c / ff.h │ ├── ffconf.h # 配置开关全在这里 │ └── diskio.h ├── App/ │ ├── storage.c # 挂载、打开、写日志的封装 │ └── main.c └── BSP/ └── bsp_spi.c # SPI 外设与片选s d_spi.c只认命令和扇区号,不认文件名;diskio.c把 FATFS 的disk_read/disk_write翻译成sd_read_blocks/sd_write_blocks;storage.c才是业务侧真正调f_open的地方。这样分层的好处是换接口时只动前两层,ff.c一行不改。
2.3 时钟、DMA 与初始化顺序
SD 卡上电后的第一段通信必须慢。规范要求识别阶段时钟不超过 400kHz,很多卡在 1MHz 以上就开始不响应 CMD0。所以初始化分两步:先低速发命令把卡识别出来,再切高速跑数据。
/* SPI2 低速初始化:APB1 36MHz / 128 = 281kHz,落在 400kHz 以内 */ static void sd_spi_low_speed(void) { SPI_InitTypeDef spi; SPI_StructInit(&spi); spi.SPI_Direction = SPI_Direction_2Lines_FullDuplex; spi.SPI_Mode = SPI_Mode_Master; spi.SPI_DataSize = SPI_DataSize_8b; spi.SPI_CPOL = SPI_CPOL_High; /* 模式 3,卡在时钟上升沿采样 */ spi.SPI_CPHA = SPI_CPHA_2Edge; spi.SPI_NSS = SPI_NSS_Soft; /* 片选手动控制 */ spi.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_128; spi.SPI_FirstBit = SPI_FirstBit_MSB; SPI_Init(SPI2, &spi); SPI_Cmd(SPI2, ENABLE); } /* 识别完成后切高速,36MHz / 4 = 9MHz,先别一上来就 18MHz */ static void sd_spi_high_speed(void) { SPI2->CR1 &= ~SPI_CR1_SPE; SPI2->CR1 = (SPI2->CR1 & ~SPI_CR1_BR) | SPI_BaudRatePrescaler_4; SPI2->CR1 |= SPI_CR1_SPE; }SPI_BaudRatePrescaler_128是分频系数,不是频率值,算的时候用外设挂载的总线时钟去除。SPI_CPOL/CPHA固定为模式 3,这是 SD 卡 SPI 模式的标准时序,改错的表现是命令能发出去但回的永远是 0xFF。
初始化顺序我固定为:GPIO → SPI 低速 → 上电延时(至少 1ms,让卡内部稳压器稳定)→ 发 80 个时钟 → CMD0 进 idle → CMD8 查电压 → ACMD41 等卡就绪 → CMD58 读 OCR 判断是不是 SDHC → 切高速。少一步都可能让某些卡“时好时坏”。
SDIO 模式下顺序类似,但多一步 DMA:SDIO的 IDMA 要配成双缓冲或单缓冲,接收/发送的缓冲区必须 4 字节对齐,且不能放在栈上临时变量里,否则 DMA 搬运时地址对不齐会直接进错误中断。
3. FATFS 移植层:diskio.c 六个函数怎么落到 SD 卡上
FATFS 是纯软件层,它不知道底下是 SD 卡还是 NAND,全部通过diskio.c里那几个函数拿扇区。这一层写对了,ff.c就是黑盒。
3.1 ffconf.h 里必须先改的几个开关
ffconf.h是这套代码里最容易被跳过、也最容易埋雷的文件。默认配置面向小内存,直接拿来用会在几个地方翻车:
| 宏 | 默认/建议 | 作用与踩坑点 |
|---|---|---|
_FS_READONLY | 0 | 置 1 后所有写 API 被裁掉,只读场景可省 flash |
_USE_MKFS | 1 | 需要格式化的板子要开,否则只能用电脑预格式化 |
_USE_LFN | 1 或 2 | 0 时只支持 8.3 短名,中文名直接丢失 |
_MAX_SS | 512 | 必须是 2 的幂,改大改小都要跟卡的实际扇区一致 |
_MIN_SS | 512 | 与_MAX_SS一起决定扇区缓冲大小 |
_VOLUMES | 1 | 挂多个分区或卡才需要加 |
_FS_REENTRANT | 0 | 多任务同时读写必须开,并实现ff_req_grant/ff_rel_grant |
_FS_TINY | 0 | 置 1 省 RAM,但一次只能有一个文件对象在读写 |
_USE_LFN开到 1 时,长文件名缓冲放在栈上,深度不够会直接 HardFault;开到 2 用堆,要保证_MAX_LFN乘上字符宽度不超过堆余量,常见做法是给 255 字节的静态缓冲。中文文件名还有一个隐含条件:_CODE_PAGE要选 936(GBK)或 437/850 之外的值,否则目录里读出来的是一串问号。
3.2 disk_initialize / disk_status 的实现
这两个函数决定 FATFS 认为“卡在不在”。返回值不是布尔,是一组状态位组合。
#include "ff.h" #include "diskio.h" #include "sd_spi.h" #define DRV_SD 0 DSTATUS disk_status(BYTE pdrv) { if (pdrv != DRV_SD) return STA_NOINIT; /* 卡被拔掉或初始化失败时返回 STA_NOINIT,FATFS 会拒绝后续操作 */ return sd_card_ready() ? 0 : STA_NOINIT; } DSTATUS disk_initialize(BYTE pdrv) { if (pdrv != DRV_SD) return STA_NOINIT; if (sd_init() != SD_OK) { return STA_NOINIT | STA_NODISK; /* 没卡或初始化失败 */ } return 0; /* 0 表示就绪 */ }STA_NODISK表示物理上没有卡,STA_NOINIT表示初始化没完成,两个可以叠加。disk_status不要写成每次都重新初始化卡,它会被 FATFS 频繁调用,重初始化会把正在进行的写操作打断。
3.3 disk_read / disk_write 与扇区对齐
读写函数是把 FATFS 的扇区号转成 SD 卡命令。SPI 模式下读单块用 CMD17、写单块用 CMD24;连续多块用 CMD18/CMD25,能明显减少命令开销。
DRESULT disk_read(BYTE pdrv, BYTE *buff, LBA_t sector, UINT count) { if (pdrv != DRV_SD || count == 0) return RES_PARERR; /* sector 是逻辑块地址,SDHC 卡直接按块寻址,标准卡要乘 512 */ if (sd_read_blocks((DWORD)sector, buff, count) != SD_OK) return RES_ERROR; return RES_OK; } DRESULT disk_write(BYTE pdrv, const BYTE *buff, LBA_t sector, UINT count) { if (pdrv != DRV_SD || count == 0) return RES_PARERR; if (sd_write_blocks((DWORD)sector, buff, count) != SD_OK) return RES_ERROR; return RES_OK; }LBA_t在 FATFS R0.13 之后才有,早期版本是DWORD;从旧工程迁移时这个类型对不上会报一堆编译错误,先把ff.h版本确认清楚。buff的地址必须能按 4 字节对齐,DMA 模式下更是硬要求,否则搬出来的数据整体错位,表现是文件能打开但内容是乱码。
写函数里还有一个隐含动作:写完一个扇区后卡内部要编程,这段时间不响应新命令,必须等忙信号释放再返回,否则下一次读会拿到旧数据。
3.4 disk_ioctl 的 CTRL_SYNC 和 GET_SECTOR_COUNT
disk_ioctl是 FATFS 用来问底层能力的接口,最常被调用的是这三个命令:
DRESULT disk_ioctl(BYTE pdrv, BYTE cmd, void *buff) { if (pdrv != DRV_SD) return RES_PARERR; switch (cmd) { case CTRL_SYNC: /* 刷缓存,等卡内部写完 */ return sd_wait_ready() == SD_OK ? RES_OK : RES_ERROR; case GET_SECTOR_COUNT: /* 总扇区数,f_mkfs 要用 */ *(DWORD *)buff = sd_card_capacity_blocks(); return RES_OK; case GET_SECTOR_SIZE: /* 单扇区字节数 */ *(WORD *)buff = 512; return RES_OK; case GET_BLOCK_SIZE: /* 擦除块大小,单位扇区 */ *(DWORD *)buff = 1; return RES_OK; default: return RES_PARERR; } }CTRL_SYNC不能空实现直接返回RES_OK,那样f_sync就失去意义,掉电时最后几条记录必然丢。GET_BLOCK_SIZE返回 1 是最保守的写法,格式化时按单扇区擦除,速度慢但兼容性最好;知道卡的实际擦除块大小可以填大,能显著加快f_mkfs。
4. 文件操作实战:挂载、遍历、读写与日志落盘
底层通了之后,业务侧真正每天打交道的就那几个 API,但顺序和参数错一个,现象都长得差不多。
4.1 f_mount 与路径前缀
f_mount是文件系统的入口,它把FATFS对象和某个逻辑盘符绑起来。第三个参数在 R0.13 之后才有,表示是否立即挂载;旧版本只有两个参数,直接调用会编译不过。
FATFS fs; FIL logf; FRESULT storage_init(void) { FRESULT fr = f_mount(&fs, "0:", 1); /* 1 = 立即挂载并检查 */ if (fr != FR_OK) return fr; /* FA_OPEN_ALWAYS:不存在就创建,存在就打开,适合日志文件 */ fr = f_open(&logf, "0:/log/data.csv", FA_OPEN_ALWAYS | FA_WRITE | FA_READ); if (fr != FR_OK) { f_mount(NULL, "0:", 0); /* 失败要卸载,避免状态残留 */ return fr; } f_lseek(&logf, f_size(&logf)); /* 定位到文件末尾做追加 */ return FR_OK; }路径里的0:是逻辑盘符,必须和_VOLUMES对得上;0:/log/data.csv这种写法要求log目录已经存在,f_open不会自动建目录,得先f_mkdir。挂载失败先看返回值:FR_NO_FILESYSTEM是卡上没有识别到 FAT 结构,FR_DISK_ERR是底层读写失败,FR_NOT_READY是卡没初始化好,三种原因完全不同,不要一上来就怀疑卡坏了。
4.2 目录遍历与文件筛选
导出数据、查找最新日志都离不开遍历目录。f_readdir的结束条件是返回的文件名为空,不是返回错误码。
FRESULT list_dir(const char *path) { DIR dir; FILINFO fno; FRESULT fr = f_opendir(&dir, path); if (fr != FR_OK) return fr; while (1) { fr = f_readdir(&dir, &fno); if (fr != FR_OK || fno.fname[0] == 0) break; /* 空名 = 读完 */ if (fno.fattrib & AM_DIR) { /* 目录项,递归前先确认栈够深,别在中断里递归 */ } else { /* fno.fsize 是 32 位大小,fno.fdate/ftime 是修改时间 */ } } f_closedir(&dir); return FR_OK; }FILINFO里的fname在_USE_LFN打开时是长文件名,否则是 8.3 短名。遍历大目录时不要每读一项就打开文件,f_open的开销远大于f_readdir,先按名字或后缀筛,再对命中项操作。
4.3 按块读写与追加写日志
写日志最忌讳一次f_write写一大块然后长时间不刷。正确做法是小块多次写,每次写完调f_sync。
FRESULT append_record(const char *line, UINT len) { UINT bw = 0; FRESULT fr = f_write(&logf, line, len, &bw); if (fr != FR_OK || bw != len) return FR_DISK_ERR; /* 每条都同步,牺牲吞吐换掉电安全;批量场景可改成每 N 条或定时刷 */ return f_sync(&logf); }f_write的第三个参数是请求写入的字节数,第四个是实际写入数,两者不等就说明卡写满了或底层出错。大文件顺序读用f_read循环配 512 字节对齐的缓冲,一次读 4KB~8KB 比较平衡;缓冲太小命令开销占比高,太大占 RAM。
4.4 掉电保护:f_sync 的时机
FATFS 默认有写缓存,f_write返回成功不代表数据已经落到卡上。设备突然断电,缓存里那部分就没了,文件长度可能还是 0。
策略看场景:日志类数据要求“最多丢最后一条”,就每条f_sync;批量采集要求吞吐,就按时间或条数攒批,比如每 2 秒或每 64 条刷一次,同时在掉电检测中断里补一次f_sync。前提是掉电检测的保持电容够大,能撑到刷完。
注意:
f_sync内部会调disk_ioctl(CTRL_SYNC),如果那个函数是空实现,这里的保护就是假的。
5. 常见问题排查:五个把调试时间拉长的坑
这一章按“现象 → 原因 → 解决”记,都是实际调试里反复出现的。
5.1 f_mount 返回 FR_NO_FILESYSTEM,但卡在电脑上正常
现象:同一张卡,电脑能读能写,板子上f_mount就是失败,换一张小容量卡反而好了。
原因通常有两个。一是卡被格式化成 exFAT 或 NTFS,FATFS 默认只认 FAT12/16/32,大容量卡出厂经常是 exFAT。二是分区表类型是 GPT,MBR 解析拿不到分区起始扇区。三是_MAX_SS和卡的实际扇区大小不一致,读出来的 BPB 全是错位数据。
解决:先在电脑上确认文件系统类型,格式化成 FAT32 再试;容量超过 32GB 的卡用第三方工具格式成 FAT32。确认分区表是 MBR。然后核对ffconf.h里_MAX_SS/_MIN_SS都是 512,disk_ioctl的GET_SECTOR_SIZE也返回 512。三步走完还不行,就把读到的第一个扇区前 64 字节打印出来看,0x55AA在不在、0xEB跳转指令在不在,一眼就能判断是读错了还是解析错了。
5.2 f_open 偶发 FR_DISK_ERR,重启后又正常
现象:跑一段时间随机失败,失败率不高但一直有,示波器看 SPI 波形也正常。
原因多半是写操作没等卡忙完。SPI 模式写扇区后卡内部编程需要几毫秒,这期间发任何命令都返回 0xFF,代码如果没检查忙信号直接发下一条,就会拿到错误响应。另一个可能是 SPI 时钟切太高,9MHz 能跑但 18MHz 就不稳,卡与卡之间差异很大。
解决:在sd_write_blocks结尾加忙等待,超时给 500ms,超时返回错误而不是死等。SPI 高速分频先退回 8 或 16,稳定后再往上试。DMA 模式下检查收发缓冲是否 4 字节对齐、是否放在会被编译器优化掉的位置。
5.3 写入成功,拔卡后文件长度是 0
现象:程序里f_write返回FR_OK,f_size也在涨,断电或复位后文件变空,或者只剩最后一段。
原因是没有f_sync,或者f_sync调了但CTRL_SYNC是空实现。FATFS 的目录项和 FAT 表更新有延迟,文件长度字段要等同步时才写回。
解决:关键数据每次写完调f_sync;disk_ioctl的CTRL_SYNC必须等卡忙释放;关闭文件用f_close而不是只把FIL对象丢掉。调试阶段可以在每次f_sync后重新f_open读一遍长度做校验,确认真的落盘了。
5.4 中文文件名读出乱码或直接不显示
现象:电脑上命名的中文文件,板子上遍历出来是问号或者干脆跳过。
原因:_USE_LFN为 0 时只保留 8.3 短名,中文被截断;_CODE_PAGE不是 936 时,GBK 编码的字节按单字节码页解析,出来就是乱码。栈空间不够时_USE_LFN=1还会直接跑飞。
解决:_USE_LFN设 1 或 2,_CODE_PAGE设 936,_MAX_LFN给 255;_USE_LFN=1时把启动文件里的栈调大,至少 1KB 余量;遍历时用fno.fname直接按字节处理,不要假设它是 UTF-8。
5.5 多任务同时读写直接死机或返回 FR_TOO_MANY_OPEN_FILES
现象:单任务跑得好好的,加了 RTOS 或中断里也写日志之后,偶发死机、返回FR_LOCKED或打开文件数超限。
原因:FATFS 默认不可重入,ff.c内部的静态变量会被并发踩坏;同时打开的文件数超过_FS_LOCK设定值;在中断里调用文件 API,而中断优先级高于正在执行的文件操作。
解决:_FS_REENTRANT置 1 并实现ff_req_grant/ff_rel_grant两个同步函数,用二值信号量即可;_FS_LOCK按实际并发打开数配置;文件操作统一放到一个任务里,其他任务通过队列把数据交给它,中断里只往环形缓冲写字节,不碰 FATFS。这条改完之后,之前那些“偶发”“随机”的问题会一起消失。
6. 二次开发:把裸机读写改成带队列的采集存储模块
裸机例程里“采一个数写一行”的写法放到真实设备上撑不住,采集频率一高,f_write的耗时会把采样周期拖垮。我一般的改法是加一层环形缓冲,采集任务只负责入队,另一个低优先级任务负责批量刷盘。
/* 采集侧:只写内存,不碰文件系统 */ bool storage_push(const void *data, uint16_t len) { return ringbuf_write(&g_rb, data, len) == len; } /* 存储任务:攒够 4KB 或超时 1s 就落一次盘 */ void storage_task(void *arg) { uint8_t buf[4096]; for (;;) { size_t n = ringbuf_read(&g_rb, buf, sizeof(buf), 1000); if (n > 0) { UINT bw = 0; f_write(&logf, buf, n, &bw); f_sync(&logf); /* 批量刷,比逐条刷吞吐高一个量级 */ } } }改造时有两点要盯住。一是环形缓冲的读写指针要用原子操作或关中断保护,否则生产者和消费者会互相覆盖;二是f_write的返回值和实际写入数每次都要判,卡写满或出错时把数据转到备用文件或置错误标志,别静默丢弃。验证方法很直接:连续跑 24 小时,每 10 分钟记一次文件大小和队列水位,队列持续上涨说明刷盘速度跟不上采集速度,要么加大批大小,要么换 SDIO 接口。
从那以后我每次移植 FATFS,都强制先把ffconf.h和disk_ioctl过一遍再写业务代码,因为那两处出的问题最像玄学,查起来最费时间。希望帮到你。
本文还有配套的精品资源,点击获取