☰
STM32+FATFS SD卡文件读写与数据记录实战避坑
2026/11/5 7:03:51 网站建设 项目流程

简介:面向嵌入式初学者与STM32开发者的SD卡文件读写实践工程,围绕在KEIL环境下为STM32接入FATFS文件系统展开,着力解决裸机项目缺少文件创建、读写与目录管理能力的问题。压缩包共229个文件、约1.48MB,以83个.h头文件与83个.c源文件为主体,承载SD卡底层驱动、FATFS内核及多语言代码页配置,36个.s汇编文件对应启动代码与中断向量,另附uvproj、uvopt等KEIL工程文件与少量说明文档,导入后即可编译。目前已有3377人学习下载。工程完整呈现SPI与SDIO接口初始化、扇区读写以及f_open、f_read、f_write等API的调用与错误处理流程,读者可据此理清驱动分层与配置项含义,并在此基础上扩展数据记录、日志存储等应用,适合作为课程设计、毕业设计与自学练手的参考模板。

1. 插上 SD 卡就能读文件:这个 STM32 + FATFS 项目到底解决什么问题

很多人第一次在 STM32 上做数据记录,都是把采集值直接写进内部 Flash 或者干脆通过串口打印出来看。等到要存几万条采样、要拔下存储卡插到电脑上看 CSV 文件的时候,才发现问题来了:电脑认不出你写进去的二进制块是什么,你也无法直接在电脑上打开这张卡里的文件。这个项目的价值就在这儿——它把 SD 卡驱动和 FATFS 文件系统接到 STM32 上,让单片机写出来的东西是真正的LOG001.CSV,拔卡插电脑就能双击打开。

项目面向的是刚上手 STM32 外设、正准备做数据采集记录、或者要给设备加一个「可插拔存储」功能的开发者。核心链路是 SD 卡底层读写(SPI 或 SDIO)→ 磁盘 I/O 接口层diskio.c→ FATFS 的f_open/f_write/f_read这一套 POSIX 风格 API。读完之后你应该能独立完成:把文件系统挂载起来、按行追加写日志、按偏移读回数据,并且知道卡没识别、挂载失败、写进去乱码的时候分别该查哪一层。

2. 架构拆解:从 SD 卡扇区到 f_write 的三层结构

FATFS 不是一个驱动,它是一套文件系统协议实现,本身不关心你的卡是插在 SPI 上还是 SDIO 上。所以整个项目自然分成三层,分层是否干净直接决定了你后面换硬件时要不要改业务代码。

2.1 三层职责划分与目录组织

自底向上看,最下面是物理传输层,负责发命令、收响应、读写 512 字节的扇区。SPI 模式下你只需要 4 根线(CS、SCK、MOSI、MISO),速率一般配置在 10~25 MHz;SDIO 模式下走 4 位数据总线,速率能上到 48 MHz,适合高速连续采样。这一层通常放在bsp_sd_spi.c或者sdio_sd.c里,只对外暴露初始化、读块、写块、获取卡容量这几个函数。

中间是适配层diskio.c,这是 FATFS 和硬件之间唯一的桥。它对上实现disk_initialize / disk_status / disk_read / disk_write / disk_ioctl这几个固定签名的函数,对下调用你的物理层。很多人移植 FATFS 翻车都翻在这一个文件上,因为它看起来只是转发,实际上扇区号、扇区大小、多块读写标志都必须严格对上。

最上面是 FATFS 内核(ff.c)和你的应用层。应用层只 includeff.h,用f_mount、f_open、f_write这些接口。典型目录结构如下:

project/ ├── Core/ # 芯片厂商生成的启动与中断文件 ├── Drivers/ │ ├── BSP/ │ │ ├── sd_spi.c # 物理层:SPI 时序、CMD 命令 │ │ └── sd_spi.h │ └── STM32xxx_HAL_Driver/ ├── Middlewares/ │ └── FATFS/ │ ├── src/ │ │ ├── ff.c # 文件系统核心,一般不动 │ │ ├── ffconf.h # 配置裁剪,按需改 │ │ └── diskio.c # 适配层,必须自己实现 │ └── inc/ └── App/ └── data_logger.c # 业务:采集 + 写文件

把diskio.c单独放在 Middlewares 里、把物理层放进 BSP,好处是后面从 SPI 换到 SDIO 时只改 BSP 和diskio.c里的几个调用,业务层代码一行不动。我见过把 SD 命令直接写在业务函数里的工程,换硬件时等于重写,血泪经验。

2.2 diskio.c 适配层的四个必备函数

适配层是整个项目最容易出问题的地方,逐个看关键实现。下面是 SPI 方案下最常见的一种写法:

#include "ff.h" #include "diskio.h" #include "sd_spi.h" /* 物理驱动编号,单卡场景固定为 0 */ #define SD_PDRV 0 DSTATUS disk_initialize(BYTE pdrv) { if (pdrv != SD_PDRV) return STA_NOINIT; if (sd_spi_init() != 0) /* 上电时序 + CMD0 进 idle 态 */ return STA_NOINIT; if (sd_spi_get_capacity() == 0) /* 读 CSD 寄存器拿容量 */ return STA_NOINIT; return 0; /* 0 表示初始化成功 */ } DSTATUS disk_status(BYTE pdrv) { return (pdrv == SD_PDRV) ? 0 : STA_NOINIT; } DRESULT disk_read(BYTE pdrv, BYTE *buff, LBA_t sector, UINT count) { if (pdrv != SD_PDRV || count == 0) return RES_PARERR; /* 多块读:一次 CMD18 连续读 count 个扇区,比循环单块快得多 */ if (sd_spi_read_blocks(sector, buff, count) != 0) return RES_ERROR; return RES_OK; } DRESULT disk_write(BYTE pdrv, const BYTE *buff, LBA_t sector, UINT count) { if (pdrv != SD_PDRV || count == 0) return RES_PARERR; if (sd_spi_write_blocks(sector, buff, count) != 0) return RES_ERROR; return RES_OK; } DRESULT disk_ioctl(BYTE pdrv, BYTE cmd, void *buff) { switch (cmd) { case CTRL_SYNC: /* 等待写完成,写操作后必须实现 */ return sd_spi_wait_ready() == 0 ? RES_OK : RES_ERROR; case GET_SECTOR_COUNT: /* f_mkfs 需要,返回总扇区数 */ *(LBA_t *)buff = sd_spi_get_sector_count(); return RES_OK; case GET_SECTOR_SIZE: /* 返回 512 */ *(WORD *)buff = 512; return RES_OK; case GET_BLOCK_SIZE: /* 擦除块大小,单位扇区 */ *(DWORD *)buff = 1; return RES_OK; default: return RES_PARERR; } }

这段代码里有三处值得单独说。第一,disk_initialize的返回值语义是「状态位」,返回 0 才代表就绪,返回STA_NOINIT会让f_mount直接失败,所以初始化里任何一步失败都要如实返回,不要图省事写return 0。第二,disk_read/disk_write的count可能大于 1,FATFS 在读写大文件时会一次请求多个扇区,如果你的底层只实现了单块读写,一定要在这里循环,而不是只处理第一个扇区——这是「小文件正常、大文件损坏」的经典成因。第三,CTRL_SYNC必须实现成真正的等待忙状态结束,否则f_close返回了但数据还在卡内部缓冲里,掉电就丢。

disk_ioctl里那几个宏的返回类型要和 FATFS 版本对上。较新的 FATFS(R0.13 以后)用LBA_t表示扇区号,老版本是DWORD,混用会在 64 位或者大容量卡上出问题。参数上,GET_SECTOR_COUNT只在格式化时才用得到,但如果你的项目要支持「卡是空的就自动格式化」,这个就必须返回真实值。

2.3 ffconf.h 裁剪:三个必须改的开关

ffconf.h是 FATFS 的行为配置文件,默认值能跑但很浪费。资源紧张的芯片上,我一般会动这几个:

宏默认建议值影响
FF_USE_LFN00 或 1长文件名支持,开 1 需要额外提供 Unicode 转换和堆内存
FF_MAX_SS512512扇区大小上限,SD 卡固定 512,别改大
FF_FS_READONLY0按需只读场景置 1 可省下写路径代码
FF_VOLUMES11挂载的卷数,单卡就是 1
FF_MIN_SS512512与 MAX_SS 一致时走静态缓冲区,更省 RAM

如果开了FF_USE_LFN = 1,必须提供ff_memalloc / ff_memfree或者配置静态 LFN 工作缓冲区,否则编译能过但一调用f_open就进 HardFault。这一点后面避坑章节还会提到。

3. 跑通流程:挂载、写入、读回的最小闭环

架构清楚了,接下来是让它真正跑起来。整个顺序不能乱:先确认卡能被识别,再确认文件系统能挂载,最后才谈读写。跳过前两步直接写业务代码,出问题时你连是哪一层坏了都不知道,跟黑匣子一样。

3.1 硬件连接与 SPI 初始化参数

SPI 方案下接线是固定的四根加电源:CS 接任意 GPIO(建议带上拉),SCK/MOSI/MISO 接 SPI 外设对应引脚,卡座 VCC 接 3.3V,GND 共地。注意 SD 卡是 3.3V 器件,5V 直连会烧卡。上电后要先给足够多的时钟(至少 74 个)让卡进入就绪状态,然后在低速下(一般 100~400 kHz)发 CMD0 进 idle,初始化完成后再把 SPI 时钟切到高速。

/* 初始化阶段低速,兼容性好;初始化成功后再提速 */ void sd_spi_init_sequence(void) { spi_config(SPI_BAUD_400K); /* 上电先跑 400kHz */ for (int i = 0; i < 10; i++) /* 至少 74 个时钟,这里给 80 个 */ spi_transfer(0xFF); /* CMD0:复位进 idle 态,CS 全程拉低 */ uint8_t r = sd_send_cmd(CMD0, 0); if (r != 0x01) { /* 卡没进 idle,检查供电和接线 */ } /* CMD8:探测电压,区分 v1/v2 卡 */ r = sd_send_cmd(CMD8, 0x1AA); /* ACMD41 循环等待卡退出 idle,带超时防止死等 */ uint32_t t = 0; do { r = sd_send_acmd(ACMD41, 0x40000000); } while (r != 0x00 && ++t < 20000); spi_config(SPI_BAUD_12M); /* 初始化完成,切高速 */ }

CMD0的响应必须是0x01,如果一直是0xFF,八成是 CS 没拉低或者 MISO 没接上;如果一直超时,先降 SPI 时钟到 100 kHz 试。ACMD41的循环一定要带超时计数,卡有问题时死循环会让整个系统卡在初始化里,连看门狗都救不回来。切高速这一步建议放在挂载成功之后,避免初始化阶段时序不稳定。

3.2 f_mount 到 f_write 的完整调用链

挂载和写入的代码不长,但每一步的返回值都要检查。下面是一个典型的「上电后追加一行记录」的流程:

#include "ff.h" FATFS fs; /* 文件系统工作区,必须全局或静态,不能放栈上 */ FIL fil; FRESULT fr; int logger_init(void) { /* 第三个参数 1 表示立即挂载,0 表示延迟到首次访问 */ fr = f_mount(&fs, "0:", 1); if (fr != FR_OK) return -1; return 0; } int logger_append(const char *line) { UINT bw = 0; /* FA_OPEN_ALWAYS:不存在则创建;FA_OPEN_APPEND 追加写 */ fr = f_open(&fil, "0:LOG001.CSV", FA_OPEN_ALWAYS | FA_WRITE | FA_OPEN_APPEND); if (fr != FR_OK) return -1; fr = f_write(&fil, line, strlen(line), &bw); if (fr != FR_OK || bw != strlen(line)) { f_close(&fil); return -2; /* 写入不完整,可能是卡满或掉卡 */ } fr = f_close(&fil); /* 必须关闭,否则 FAT 表和目录项不落盘 */ return (fr == FR_OK) ? 0 : -3; }

三个关键点。FATFS fs这个结构体有几百字节,绝对不能定义在函数栈上,栈溢出后表现是随机的内存踩踏,非常难查。f_open的FA_OPEN_APPEND会让文件指针自动移到末尾,省掉你自己f_lseek,但注意它在某些老版本里和FA_OPEN_ALWAYS组合行为不一致,稳妥做法是先f_lseek(&fil, f_size(&fil))。f_close不只是关句柄,它负责把缓存的目录项和 FAT 表写回卡里,不调用的话拔卡后文件长度可能是 0。如果是一秒写一次的日志场景,每次开-写-关的开销可以接受;如果采样率很高,应该保持文件常开、定期f_sync。

3.3 用 f_read 验证数据是否真的落盘

写完不算完,得读回来确认。最省事的验证方法是拔卡插电脑,但如果板子有串口,直接在程序里读回打印更便于定位:

int logger_dump_head(void) { char buf[128]; UINT br = 0; fr = f_open(&fil, "0:LOG001.CSV", FA_READ); if (fr != FR_OK) return -1; fr = f_read(&fil, buf, sizeof(buf) - 1, &br); if (fr == FR_OK) { buf[br] = '\0'; /* 手动补结束符,f_read 不会加 */ printf("head: %s\r\n", buf); } f_close(&fil); return 0; }

f_read的第四个参数是实际读到的字节数,读到最后不足缓冲区大小时它小于请求值,这是正常的,不要当成错误。返回值只反映操作是否成功。另外f_read不会给缓冲区补\0,直接当字符串用会读到缓冲区外的垃圾,这个细节坑过不少人。

4. 常见问题排查:卡识别、挂载失败、数据损坏的定位顺序

这一章按「现象 → 原因 → 解决」记录几条真实踩过的坑,顺序基本就是推荐的排查顺序:硬件层 → 适配层 → 配置层 → 业务层。

4.1 现象:f_mount 返回 FR_NOT_READY 或 FR_DISK_ERR

原因通常有两个。一是disk_initialize返回了非 0 状态位,但你的上层没检查,FATFS 拿到未就绪的盘就直接报错。二是 SD 卡上原本是 exFAT 或者 NTFS 分区,FATFS 默认只认 FAT12/16/32。

解决:先在disk_initialize里打断点或者点灯,确认它真的返回 0;再拿一张确认是 FAT32 的卡(8GB 以下、用系统工具格式化成 FAT32)测试。如果是 64GB 以上的卡出厂多为 exFAT,FATFS 需要额外开FF_FS_EXFAT = 1,并且ffconf.h里相关宏要配套打开,代码体积会明显变大。生产环境我一般直接规定用户使用 32GB 以下的 FAT32 卡,省掉一堆兼容问题。

4.2 现象:初始化偶尔成功偶尔失败,复位几次才行

原因多半是上电时序。SD 卡内部有稳压和上电复位电路,VCC 上升太慢或者去耦电容不足时,卡还没准备好你就开始发 CMD0 了。另一个常见原因是 SPI 时钟一开始就配得太高。

解决:初始化前加 10~50 ms 延时,保证 3.3V 稳定;严格按「低速发 74+ 时钟 → CMD0 → ACMD41 轮询 → 提速」的顺序走;卡座电源脚旁边并 100nF 加 10uF。如果板子是热插拔设计,还要处理「运行中拔卡再插回」的情况,这时必须重新走一遍disk_initialize,FATFS 侧要f_mount(NULL, "0:", 0)先卸载再挂载。

4.3 现象:文件能创建,但内容是乱码或者长度对不上

原因通常是扇区层面的对齐或计数问题。一是disk_read/disk_write没有正确处理count > 1的多块请求,只搬了第一个扇区;二是缓冲区没有 4 字节对齐,某些 SDIO 或 DMA 配置下会搬错数据;三是f_write返回成功但你传的bw没校验,实际只写了一部分。

解决:把多块读写改成循环单块先验证功能是否正常,确认后用 CMD18/CMD25 优化;所有给底层用的缓冲区用__attribute__((aligned(4)))修饰;每次f_write后比对bw和请求长度。另外注意f_write是以二进制方式写的,如果你 printf 风格拼字符串,记得\r\n自己加。

4.4 现象:开了长文件名后一调用 f_open 就 HardFault

原因:FF_USE_LFN设为 1 或 2 之后,FATFS 需要一个工作缓冲区来做 UTF-16 与本地编码的转换。设成 1 时它调用ff_memalloc,而你的工程里根本没实现这个函数(或者FF_USE_LFN = 3需要你提供静态缓冲区数组),函数指针为空,一调用就跳飞。

解决:要么把FF_USE_LFN改回 0,只用 8.3 短文件名(LOG001.CSV这种完全够用);要么实现ff_memalloc/ff_memfree并确保堆够大,或者用模式 3 配一个static TCHAR lfn_buf[FF_MAX_LFN + 1]。日志类项目我一般直接选前者,短文件名在嵌入式中更省心。

4.5 现象:断电后最后几条记录丢失

原因:f_write只是写进了 FATFS 的缓存和卡的内部缓冲,没有真正落到 Flash 上。不调用f_close或者f_sync,掉电时缓存里的内容就没了。

解决:每次写完调用f_sync(&fil),它会触发disk_ioctl(CTRL_SYNC)等待卡内部编程完成,比f_close轻量(不用重开文件)。如果数据极其重要,还可以在f_sync后做一次校验读回。代价是写入速度下降,一秒一条完全没压力,高速采样就要权衡批量写的策略。

5. 二次开发:把日志系统改成可配置的记录器

跑通最小闭环之后,真正花时间的是把它变成能用的东西。原始 demo 一般是「上电挂载、写一行、结束」,实际项目里你需要考虑文件名策略、写入频率、异常恢复,以及换硬件时的可移植性。

第一个改造点是文件命名。固定LOG001.CSV会导致每次上电都覆盖或者无限追加,工程上的做法是上电时扫描根目录找最大编号再加一,或者用 RTC 时间戳命名。扫描目录用f_findfirst/f_findnext:

/* 找根目录下编号最大的 LOGnnn.CSV,返回下一个可用编号 */ int next_log_index(void) { FILINFO fno; DIR dir; int max_idx = 0; if (f_opendir(&dir, "0:") != FR_OK) return 1; while (f_readdir(&dir, &fno) == FR_OK && fno.fname[0]) { int idx; /* 只匹配 LOG + 3 位数字 + .CSV,避免误伤其他文件 */ if (sscanf(fno.fname, "LOG%3d.CSV", &idx) == 1 && idx > max_idx) max_idx = idx; } f_closedir(&dir); return max_idx + 1; }

f_readdir返回FR_OK且fno.fname[0] == 0时表示目录读完,这个终止条件必须写对,否则会死循环。sscanf在单片机上的开销不小,如果嫌大可以手写字符匹配,但一般日志场景一次上电只扫一遍,影响不大。注意sscanf的返回值和格式串里%3d的宽度限制,防止文件名里的其他数字干扰。

第二个改造点是写入策略。高频采样场景下每次开关文件是不可接受的,正确做法是保持文件常开,攒够 N 条或者每 T 毫秒f_sync一次。这里的关键是错误恢复:写失败时要f_close再重新f_open,因为一旦 FATFS 内部状态异常,继续在原句柄上写会一直失败。

场景策略代价
低频(<10 次/秒)每次开-写-关简单可靠,速度慢
中频(10~1000 次/秒)常开 + 定时 f_sync需要定时器配合
高频(>1000 次/秒)内存环形缓冲 + 批量写扇区掉电可能丢一批

第三个改造点是移植。把diskio.c里的sd_spi_xxx换成 SDIO 版本的函数,接口签名保持一致,业务层不用改。验证方法很简单:先跑一遍写 1MB 随机数据、读回做 memcmp 的测试,比对通过再上业务。我一般会在disk_ioctl里加个自测入口,产线老化测试时直接调用。

最后一个容易忽略的点:卡满和拔卡的处理。f_write返回FR_DENIED基本就是空间不足,这时候要停止写入并点灯告警,而不是无限重试;拔卡则表现为连续的FR_DISK_ERR,需要卸载后重新检测。从那以后我每次做存储相关项目,都强制在初始化阶段先跑一遍「写满 512 字节、读回比对、断电重插再读」的验证流程,这一步能提前暴露八成的适配层问题。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询