简介:面向STM32F4系列微控制器开发者,提供了一个基于SPI模式驱动SD卡并集成FATFS文件系统的完整工程方案,重点解决在无RTOS环境下SD卡初始化、底层读写驱动编写及FATFS平台移植的常见问题。压缩包共包含368个文件,总大小约21.11MB,文件类型以C和H源码为主,覆盖FATFS核心层、STM32 HAL库驱动、Keil工程配置、链接脚本及编译生成文件,另有PNG说明图和TXT文档辅助理解,目录结构清晰便于按模块检索。目前已有3661人学习浏览,适合嵌入式入门者以及需要在存储类项目中快速落地的开发者参考。内容围绕SPI模式SD卡初始化命令序列(GO_IDLE_STATE、SEND_IF_COND、CMD8、ACMD41)、FATFS全局参数配置、SPI底层收发实现及文件打开、读写、关闭等API调用展开,并附带完整uvprojx工程与批处理脚本,能够帮助读者贯通从硬件接口、驱动层到文件系统的完整链路,也可作为STM32F4存储应用二次开发的直接模板。
1. 给 MCU 加上文件系统:SPI 模式 SD 卡到底值不值得折腾
很多人一听「带FATFS文件系统的SD卡读写(SPI模式)」,第一反应是:SPI 这么慢,图什么?还有一句常听到的话是「直接上 SDIO 不就完了」。但落到实际项目里,你会发现大量资源紧张的板子根本没引出 SDIO 的 4 条数据线,或者换了一颗便宜的 MCU 之后根本跑不了 SDIO 时序,这时候 SPI 模式几乎是唯一不需要改硬件的出路。SPI 模式读写 SD 卡,配上 FATFS,数据可以直接拔卡插到电脑上读,目录、文件名、追加写入都由文件系统打理,不用自己拜扇区地址。这篇文章不打算夸它好,也不打算劝退,就按我平时在板子上从零移植的路径,把协议要点、可复现的驱动代码、常见翻车点、性能边界一次讲完。适合手里有 SPI 空闲引脚、想把数据记录成文件的开发者,也适合被 SDIO 折腾到怀疑人生的那批人。
2. 先从协议和 FATFS 架构找切入点:SPI 模式的时序边界在哪里
2.1 SPI 模式下 SD 卡协议:命令、响应和状态怎么卡
SD 卡在 SPI 模式下是一个典型的字节流设备:所有操作都从主机侧发起,向卡写入 6 字节命令帧,然后从卡读回 1 个或多个响应字节。6 字节命令帧的格式是固定的:第 1 字节是 0x40 加上命令索引(CMD0 就是 0x40,CMD17 就是 0x51),中间 4 字节是大端参数,最后 1 字节是 CRC7 加上结束位。和 SDIO 模式最大的区别是,SPI 模式只用到一根数据线做双向传输,时钟、片选、命令、数据全走同样的引脚,所以时序边界非常清晰:初始化阶段时钟必须压在 400kHz 以下,正常运行阶段最高 25MHz;任何一次读或写都遵循「发命令、等响应、传数据、等忙」的顺序,少一步都不行。
初始化序列是大多数人第一次翻车的重灾区。上电或插入卡后,主机要先把片选拉高,发至少 74 个时钟周期(实践上直接发 80 个 0xFF),让卡完成上电同步。然后拉低片选,发 CMD0(参数全 0,CRC 固定为 0x95),卡返回 0x01 表示进入空闲状态,这一步同时把卡切换到 SPI 模式。接下来建议发 CMD8(参数 0x000001AA,CRC 固定为 0x87),它用于识别卡是 V1.0 还是 V2.0,同时确认工作电压;如果卡没有响应 R7,说明是老旧 V1.0 卡,可以跳过它。真正的初始化循环在 CMD55 + ACMD41 上:CMD55 告诉卡下一个命令是应用特定命令,ACMD41 的参数带 0x40000000 表示主机支持高容量卡(SDHC/SDXC);卡忙的时候返回 0x01,初始化完成后返回 0x00,这个循环需要加超时,常见代码里给 1 秒左右。
初始化完成后还要发 CMD58 读 OCR 寄存器。OCR 返回 4 个字节,其中最高字节的 bit6 是 CCS 标志:CCS=1 表示 SDHC/SDXC,后续扇区地址直接用块地址;CCS=0 表示标准容量卡,地址要乘以 512 换算成字节地址。这一步不做对,后面读写很容易出现只对前 512 字节有效、再往后全乱的诡异现象。
FATFS 标准移植流程对低速时序的要求不敏感,但对「响应是否合法」非常敏感。我维护驱动的经验是,所有等待循环都必须有超时,而且超时后要清掉卡上残留的数据字节,否则下次发送命令时状态会串位。SPI 模式下主机发出的每个字节都会换回卡上的一个字节,如果上一个等待超时没把缓冲读干净,下一个命令的响应里就会混进垃圾数据,这是黑匣子式问题里面最典型的一种。
2.2 FATFS 的移植面:磁盘 I/O 层到底要承担什么
FATFS 把自己定位成「上层文件系统,下层不管」,它不会直接碰 SPI 寄存器,也不会发 SD 卡命令。移植者要做的,是把它和具体硬件之间那一层磁盘 I/O 接口填满,这个接口就是 diskio.c 里那 5 个函数:disk_initialize、disk_status、disk_read、disk_write、disk_ioctl。这里有一个很关键的认知:FATFS 只认「扇区」这个抽象概念,默认 512 字节一个扇区,它调用 disk_read(pdrv, buff, sector, count) 读取连续若干扇区,调用 disk_write 写连续若干扇区,其它事情一概不管。
这层抽象带来的好处是明显的:你可以在上午把 disk_read 映射到 SPI 驱动,下午改成 SDIO 驱动,文件系统代码一行不动。但代价是 disk_read / disk_write 必须正确返回 RES_OK 或错误码,而且 disk_ioctl 必须能回答几个固定问题,尤其是 GET_SECTOR_COUNT。f_mount 时需要读每个分区的引导扇区,f_mkfs 时需要知道整卡容量,这两个操作都绕不开容量查询。如果容量返回 0 或者返回的不是 512 的整数倍,文件系统层就会报 FR_NO_FILESYSTEM 或者 FR_MKFS_ABORTED,这属于最常见的移植失败原因之一,卡本身没坏,纯粹是上层问话你答不上来。
还有一个容易被忽略的函数是 get_fattime。文件系统写目录项时要填时间戳,它在 ff.c 里被调用,如果板子上没有 RTC,这个函数也要实现,返回一个固定日期即可,比如编译日期或者 2024-01-01。留意 FATFS 里有个小习惯:启动时 f_mount 会立即读写扇区来挂载卷,如果底层硬件还没初始化好,返回的 FRESULT 会是 FR_NOT_READY 而不是 FR_OK,这个细节常被新手当成驱动 bug 查半天,最后发现只是挂载顺序写反了。
2.3 选型理由:为什么是 FATFS 而不是裸读写
裸读写 SD 卡在工程上不是不行,很多小项目也这么干过:固定偏移写日志、自己维护一个循环缓冲区索引。刚开始确实清爽,但随着需求膨胀会陆续撞上几堵墙:没有目录结构,上位机要按偏移读;没有文件名,不拔卡根本不知道哪片数据是哪天的;没有坏块和断电保护策略,某次写一半断电,整段数据作废还得人工找边界。而 FATFS 把这些都变成了通用问题:拔卡插到电脑上直接看到 log 目录、昨天的数据叫 20250209.csv,这个体验对现场维护人员有多重要,做过设备调试的人都懂。
拿 FATFS 和现在流行的掉电安全文件系统(比如面向 Flash 设计的那一类)对比,方向也不一样。FATFS 面对的是 SD 卡这种块设备,底层本身做了擦写均衡;LittleFS 这类文件系统主要解决的是 NOR Flash 的磨损均衡和掉电原子性,移植到 SD 卡上反而发挥不出优势。在 MCU 资源占用上,FATFS 的 RAM 开销主要来自 FIL 结构体、扇区缓冲区和长文件名缓冲,常见配置下大概 1~4KB 量级,ROM 看裁剪深度,最小裁剪版可以压到 10KB 以内。对一个 64KB Flash、20KB RAM 的常规 MCU 来说,这个代价换回目录、长文件名、追加写、PC 直接读盘,划得来,这也是它在单片机上长盛不衰的原因。
还有一个实际的选型理由:调试时可观测性。裸读写时,你只能靠逻辑分析仪去看命令时序;有 FATFS 以后,可以用电脑读卡验证文件内容,甚至直接对比 PC 标准格式化后的 FAT 布局,反向排查驱动问题。这种「文件系统帮你挡一层」的好处,往往在项目中期才能体会出来。
3. 把 FATFS 在 32 位 MCU 上跑通:底层驱动的可复现步骤
3.1 SPI 初始化的正确姿势(低速握手 + 高速运行)
先说明一下:下面的代码是平台无关的写法,用 spi_txrx 表示「发送一个字节并读回一个字节」,用 sd_cs_low / sd_cs_high 表示片选操作,用 platform_delay_ms 表示毫秒延时,你把它替换成手头 MCU 的原厂库函数即可。这样写是为了让代码能直接照搬到不同厂家的芯片上,而不是绑定某个具体型号的寄存器写法。
static uint8_t sd_spi_xfer(uint8_t byte) { // 替换成平台 SPI 收发函数:同时发送一个字节并读回一个字节 return spi_txrx(byte); } static int sd_spi_init(void) { // 1. SPI 先配置为低速模式:CPOL=0, CPHA=0, 时钟 400kHz 以下 spi_set_speed(400000); // 2. CS 拉高,让 SD 卡处于释放状态,先发 80 个时钟完成上电同步 sd_cs_high(); for (int i = 0; i < 80; i++) sd_spi_xfer(0xFF); // 3. 拉低 CS,开始命令初始化 return sd_init_sequence(); } static int sd_init_sequence(void) { uint8_t r1; uint8_t ocr[4]; sd_cs_low(); if (sd_cmd(0, 0x00000000, &r1, NULL)) return -1; // CMD0,CRC 自动补 0x95 if (r1 != 0x01) return -2; // 0x01 表示空闲状态 sd_cmd(8, 0x000001AA, &r1, NULL); // CMD8,识别 V2.0 sd_cmd(55, 0x00000000, &r1, NULL); // CMD55,为 ACMD41 做准备 int ok = 0; for (int i = 0; i < 1000; i++) { // 超时约 1 秒 sd_cmd(55, 0x00000000, &r1, NULL); sd_cmd(41, 0x40000000, &r1, NULL); // ACMD41,HCS=1 表示支持高容量卡 if (r1 == 0x00) { ok = 1; break; } if (r1 != 0x01) return -3; // 返回其它值表示出错而非忙 platform_delay_ms(1); } if (!ok) return -4; sd_cmd(58, 0x00000000, &r1, ocr); // CMD58 读 OCR g_sd_hc = (r1 == 0x00) && (ocr[0] & 0x40) ? 1 : 0; // ocr[0] 的 bit6 = CCS // 4. 初始化完成后切到高速 spi_set_speed(12000000); sd_cs_high(); sd_spi_xfer(0xFF); // 让卡恢复接收状态 return 0; }这里有几个参数值得展开。初始化时钟压在 400kHz 是 SD 协议的硬性要求,很多高速卡在 1MHz 以下才肯响应 CMD0,我见过有人在 4MHz 初始化,卡直接死掉,只能断电重来。CMD0 的 CRC 固定 0x95 不能省,CMD8 的固定 0x87 同样,这两条命令在 SPI 模式下卡是强制做 CRC 检查的,其它命令多数卡不校验,但统一走 crc7 计算最省心。ACMD41 的循环设计要注意:卡忙时返回 0x01,成功返回 0x00,返回 0x05 这类值说明 ACMD41 被拒绝,多半是 CMD55 没被正确处理或者 SPI 模式没真正进入。切高速之前补一个空字节是为了同步,这个细节别的文章很少提,但漏掉后第一批高速数据往往错位。
3.2 命令层和扇区读写:CRC7、超时、多块读写的实现
命令发送是这个驱动里最核心的公共函数,所有读写都建立在它上面。下面给出一份完整可用的实现,包含 CRC7 计算:
static uint8_t crc7_calc(const uint8_t *buf, int len) { uint8_t crc = 0; for (int i = 0; i < len; i++) { crc ^= buf[i]; for (int j = 0; j < 8; j++) { crc = (crc & 0x80) ? (crc << 1) ^ 0x09 : (crc << 1); } } return (crc << 1) | 1; // 左移一位,最低位补结束位 1 } static int sd_cmd(uint8_t cmd, uint32_t arg, uint8_t *r1, uint8_t *extra) { uint8_t frame[6]; frame[0] = 0x40 | cmd; frame[1] = (arg >> 24) & 0xFF; frame[2] = (arg >> 16) & 0xFF; frame[3] = (arg >> 8) & 0xFF; frame[4] = arg & 0xFF; frame[5] = crc7_calc(frame, 5); for (int i = 0; i < 6; i++) sd_spi_xfer(frame[i]); for (int i = 0; i < 64; i++) { // 等待 R1 响应,最多 64 个时钟 uint8_t b = sd_spi_xfer(0xFF); if (b != 0xFF) { // 非 0xFF 说明响应开始 *r1 = b; if (extra != NULL) { // 需要额外数据时(R3/R7),继续读 4 字节 for (int j = 0; j < 4; j++) extra[j] = sd_spi_xfer(0xFF); } return 0; } } return -1; // 超时,没有响应 }sd_cmd 的逻辑说明:frame 数组前 5 字节参与 CRC 计算,第 6 字节放计算结果,这样 CMD0 自然算出 0x95,CMD8 算出 0x87,不需要为某条命令写死特例。R1 响应是 1 字节,0x01 表示空闲状态,0x00 表示就绪,其它位分别表示参数错误、地址错误、CRC 错误等。读取额外字节支持 R3 和 R7 响应,OCR 或电压回执就在那 4 字节里。等待响应的循环上限设 64 个时钟,不是硬性指标,但必须存在,否则卡不响应时主机会死等。
扇区读写接着实现,单块是最低要求,批量读在性能章节再扩展:
static int sd_read_block(uint32_t block, uint8_t *buf) { if (g_sd_hc == 0) block *= 512; // 标准容量卡用字节寻址 uint8_t r1, token; int r = sd_cmd(17, block, &r1, NULL); if (r != 0 || r1 != 0x00) return -1; token = 0xFF; for (int i = 0; i < 64; i++) { // 等待数据令牌 0xFE token = sd_spi_xfer(0xFF); if (token != 0xFF) break; } if (token != 0xFE) return -2; // 0x04/0x05 等错误令牌 for (int i = 0; i < 512; i++) buf[i] = sd_spi_xfer(0xFF); sd_spi_xfer(0xFF); // 丢弃 2 字节 CRC sd_spi_xfer(0xFF); return 0; } static int sd_write_block(uint32_t block, const uint8_t *buf) { if (g_sd_hc == 0) block *= 512; uint8_t r1, resp; int r = sd_cmd(24, block, &r1, NULL); if (r != 0 || r1 != 0x00) return -1; sd_spi_xfer(0xFF); // 写前导空字节 sd_spi_xfer(0xFE); // 数据开始令牌 for (int i = 0; i < 512; i++) sd_spi_xfer(buf[i]); sd_spi_xfer(0xFF); // 写 CRC 占位字节,SPI 模式多数卡忽略 sd_spi_xfer(0xFF); for (int i = 0; i < 64; i++) { // 读数据响应令牌 resp = sd_spi_xfer(0xFF); if (resp != 0xFF) break; } if ((resp & 0x1F) != 0x05) return -2; // 0x05 表示数据被卡接受 for (int i = 0; i < 64; i++) { // 等待卡忙释放,忙时 MISO 拉低 resp = sd_spi_xfer(0xFF); if (resp == 0xFF) return 0; // 0xFF 表示空闲 platform_delay_ms(1); } return -3; // 写超时 }读块的逻辑:CMD17 参数是块地址,SDHC 直接用块号,标准容量卡乘 512。等待数据令牌的循环必须读到 0xFE 才算数据开始,如果卡回了 0x04 或 0x05,表示地址错误或 CRC 错误。数据后的 2 字节 CRC 在 SPI 模式下主机可以不校验,但必须读掉,否则下一轮命令会吃到残留字节。写块的前导空字节是规范要求,写数据后卡会回一个数据响应令牌,低 5 位 0x05 表示接受,接着整卡进入忙状态,MISO 被拉低,必须读到 0xFF 才认为写完。这里的忙等待超时不能太短,有的低速卡写完一整块要几十毫秒,64 次乘 1 毫秒正好覆盖。
3.3 diskio 四个函数 + ffconf 配置:最小挂载代码
把上面的 SD 驱动接进 FATFS,只差最后一座桥。disk_initialize 直接调用 sd_spi_init,disk_read / disk_write 按扇区循环搬运,disk_ioctl 重点实现 GET_SECTOR_COUNT。容量查询我直接从 CSD 寄存器解析,比固定返回假容量靠谱:
static int sd_read_csd(uint8_t csd[16]) { uint8_t r1, token; sd_cmd(9, 0, &r1, NULL); if (r1 != 0x00) return -1; for (int i = 0; i < 64; i++) { // 等数据令牌 token = sd_spi_xfer(0xFF); if (token != 0xFF) break; } if (token != 0xFE) return -2; for (int i = 0; i < 16; i++) csd[i] = sd_spi_xfer(0xFF); sd_spi_xfer(0xFF); sd_spi_xfer(0xFF); return 0; } static uint32_t sd_sector_count(void) { uint8_t csd[16]; uint32_t c_size; if (sd_read_csd(csd)) return 0; if (((csd[0] >> 6) & 0x03) == 0x01) { // CSD v2.0,SDHC 卡 c_size = ((uint32_t)(csd[6] & 0x3F) << 16) | ((uint32_t)csd[7] << 8) | csd[8]; return (c_size + 1) * 1024; // 每簇 1024 个 512B 扇区 } return 0; // CSD v1.0 老卡解析略,当前基本碰不到 2GB 以下旧卡 } DRESULT disk_ioctl(BYTE pdrv, BYTE cmd, void *buff) { if (pdrv != 0) return RES_PARERR; switch (cmd) { case GET_SECTOR_COUNT: *(uint32_t *)buff = sd_sector_count(); return RES_OK; case GET_BLOCK_SIZE: *(uint32_t *)buff = 1; // 擦除块粒度按 1 扇区返回,FATFS 只参考 return RES_OK; case CTRL_SYNC: return RES_OK; default: return RES_PARERR; } }CSD 解析的参数说明:CSD 是卡出厂时写死的描述寄存器,16 字节,CSD v2.0 里容量字段 C_SIZE 占 22 位,从第 6 字节的低 6 位开始跨 3 个字节。容量公式是 (C_SIZE + 1) * 1024,单位是扇区数,因为高容量卡内部每簇是 1024 个 512B 扇区。这段代码只解析 SDHC/SDXC,V1.0 老卡会返回 0,导致 f_mkfs 报容量错误,如果确实在用老卡,需要补 C_SIZE_MULT 和 READ_BL_LEN 那套公式,工程上现在已经很少遇到。
ffconf.h 里主要配置几项,按实际需求给参数:
#define _USE_MKFS 1 // 允许在裸卡上格式化,量产时用它写一次 #define _FS_READONLY 0 // 需要写文件,必须为 0 #define _MIN_SS 512 // 扇区下限 #define _MAX_SS 512 // 扇区上限,固定 512 省内存 #define _FS_MINIMIZE 0 // 保留 f_lseek 等完整 API #define _USE_FASTSEEK 1 // 日志追加场景开 #define _USE_EXPAND 1 // 预分配文件用,掉电章节会提到 #define _CODE_PAGE 936 // 中文长文件名用 GBK 码页 #define _LFN_UNICODE 0 // 0 表示本地编码,配合 936_CODE_PAGE 和 _LFN 组合是最容易忽略的:只把 _CODE_PAGE 改成 936,但 _LFN 没开,长文件名一样是 8.3 短名,中文写不进;反过来开了 LFN 但码页还是 437,中文会变乱码。RAM 够的话建议 LFN 配置开大一点,换取目录扫描和长文件名的省心。
3.4 上电测试:格式化、挂载、写文件一条龙
主程序的验证代码尽量短,目标只有一个:通电后能挂载,能写文件,能断电再读回来。下面这段可以直接照抄:
FATFS fs; FIL fil; UINT bw; FRESULT fr; uint8_t work_buf[512]; // f_mkfs 需要的工作缓冲区,至少一个扇区大小 f_mount(&fs, "", 1); // 立即挂载,路径为空表示默认驱动器 if (fr == FR_NO_FILESYSTEM) { // 卡上还没有 FAT 文件系统,格式化一次 fr = f_mkfs("", FM_FAT32, 0, work_buf, sizeof(work_buf)); if (fr != FR_OK) { // 格式化失败先查 disk_ioctl 的 GET_SECTOR_COUNT while (1); } f_mount(&fs, "", 1); // 格式化后重新挂载 } fr = f_open(&fil, "log.txt", FA_CREATE_ALWAYS | FA_WRITE); if (fr != FR_OK) while (1); // 写失败看返回码排查 fr = f_write(&fil, "hello sd card", 13, &bw); f_sync(&fil); // 关键:把文件系统缓冲刷进卡里 f_close(&fil);这段代码的书写顺序有讲究:f_mount 第 2 个参数传 1 并不是「立即挂载」的开关,而是「立即挂载并扫描文件系统」的开关,传 0 时只有真正读写文件才触发扫描,对排查问题不利,建议调试期都传 1。f_mkfs 成功会返回 FR_OK,但它格式化的是「当前挂着这个卷的磁盘」,在格式化之前必须先 f_mount 一次,即使挂载失败也要先挂,否则 f_mkfs 找不到卷。work_buf 至少 512 字节,_MAX_SS 越大要求越大,固定 512 时这个尺寸正好。f_sync 很多人只在文件关闭时想,但日志型应用必须按批次手动调,否则掉电丢的是最后的缓冲区内容。
4. SD 卡 SPI 读写避坑:五次翻车后的排查清单
4.1 CMD0 无响应或 MISO 一直拉高
现象:上电后发 CMD0 一直读不回 0x01,逻辑分析仪上 MISO 始终是 1。
原因:最可能是初始化时钟太高,或者上电后没有先发 74 个时钟就把 CS 拉低了;还有一种情况是 SPI 模式配置不对,用了 CPOL=1/CPHA=1,卡根本不认这种极性的时钟。
解决:初始化阶段强制把 SPI 时钟降到 400kHz,发满 80 个 0xFF 再拉低 CS。检查 SPI 模式的四个组合,SD 卡 SPI 模式必须用 CPOL=0、CPHA=0,也就是空闲时钟为低、第一个边沿采数据。如果换了时钟和相位还不行,再检查 CS 引脚是否被其它外设复用,很多 MCU 的 SPI 片选和调试引脚复用,SD 卡会被调试器意外拉低导致状态错乱。
4.2 挂在 ACMD41,初始化超时
现象:CMD0 和 CMD8 都正常返回,但 ACMD41 循环一直读到 0x01,1000 次超时退出。
原因:CMD55 和 ACMD41 之间不能插入其它命令,也不能让 CS 在中间拉高;有些移植把 ACMD41 写在一个 if 里,精简代码时把 CMD55 放在循环外,只发了一次,卡根本不认第二次 ACMD41。
解决:把 CMD55 放进循环体,每次都先发 CMD55 再发 ACMD41,两者之间不要做任何片选翻转。另外注意 ACMD41 的参数必须带 0x40000000(HCS=1),否则旧卡会以标准容量模式初始化,后续地址换算错位。如果反复横跳都超时,试着手动给卡断电重新上电,而不是只拉 CS,SPI 模式下有些卡进入错误状态后不会自行恢复。
4.3 f_mount 报 FR_NO_FILESYSTEM,f_mkfs 报 FR_MKFS_ABORTED
现象:驱动写好了,挂载返回 FR_NO_FILESYSTEM,执行 f_mkfs 又返回 FR_MKFS_ABORTED。
原因:disk_ioctl 的 GET_SECTOR_COUNT 返回 0,或者返回的扇区数不是 512 的整数倍。FATFS 在格式化前要确认磁盘容量,容量不对直接中止。
解决:在 f_mkfs 之前先用串口打印 sd_sector_count() 的返回结果。如果返回 0,绝大多数是 CSD 解析写错了字节位置,或者 CMD9 读取 CSD 时没有把数据令牌清干净;如果返回的扇区数明显偏小或偏大(比如 8GB 卡返回几万),检查是不是把 SDHC 块地址乘了 512 导致溢出了。
4.4 断电后文件变成 0 字节,目录项损坏
现象:写完后 f_close 正常返回,但只要一断电再上电,文件大小变成 0,或者目录里出现一堆乱码文件名。
原因:f_close 只是释放了文件对象,把文件系统缓存写回卡里是 f_sync 干的事;如果只写了 f_write 就立刻断电,FAT 表里的簇链根本没落盘。还有一类是写数据过程中断电,卡内部多块写还没完成,物理扇区处于半更新状态。
解决:日志型应用每写满一批记录就 f_sync 一次,关键帧写完立刻 f_close。更稳的做法是固定大小记录追加,配合「先写临时文件再改名」的提交方式,这个在最后一章展开。硬件层面,SD 卡 VCC 上要留一个足够大的电容,避免电机或射频模块拉低电压导致卡先于 MCU 掉电,这是很多人忽略的物理层原因。
4.5 高速读回数据错位,降到低速反而正常
现象:400kHz 初始化后切到 12MHz,f_read 读回来的数据从某个位置开始错位,但降到 4MHz 又一切正常。
原因:SPI 高速模式下采样边沿设置不对,或者 CS 拉低到第一个时钟之间的建立时间不足;还有可能是 SPI 外设用了 DMA,DMA 触发时机晚于片选,数据流中间丢了一个字节。
解决:先确认高速模式仍用 CPOL=0/CPHA=0,然后在 CS 拉低后、发命令之前插入几个空时钟的延时,给卡留出准备时间。如果用了 DMA,确保 DMA 传输完成中断在片选拉高之前全部结束,否则最后一个字节可能还没读完就被下一轮命令覆盖。另外,把接收缓冲区声明为 4 字节对齐,很多 MCU 的 SPI DMA 在非对齐地址上会丢数据或触发硬件错误。
5. 参数调优与性能验证:把读写速度从「能跑」提到「够用」
5.1 三个必调参数:SPI 时钟、_MAX_SS、_FS_TINY
驱动跑通只是第一步,后面真正影响体验的是三个参数,按影响幅度排是:SPI 时钟、FATFS 内存占用模式、扇区大小配置。
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 初始化时钟 | 400kHz | 协议硬限制,不能高 |
| 运行时钟 | 12~25MHz | 高速但稳定是前提,SPI 模式上限 25MHz |
| _MAX_SS | 512 | 只插普通 SD 卡固定 512,省一个扇区缓冲 |
| _FS_TINY | 0 或 1 | 0 每文件独占缓冲更快;1 共享缓冲更省 RAM |
| _USE_FASTSEEK | 1 | 文件大、频繁 append 时显著减少 FAT 链扫描 |
SPI 运行时钟别一上来就追 25MHz。很多卡在 20MHz 以上对信号质量敏感,飞线超过 10cm 时还会引入反射和串扰,我习惯先跑 12MHz 验证数据一致,再逐步往上加到上不去为止。_MAX_SS 设 512 的前提是你的卡都支持 512 扇区,现在市面上的卡基本都支持,如果设 1024 会白白多占 512 字节 RAM。_FS_TINY 的取舍关键看内存:开 1 的时候所有文件共用一块扇区缓冲,省内存但每个 f_read/f_write 都要多搬一次数据,CPU 占用高;RAM 在 20KB 以上就建议保持 0,速度优先。
5.2 多块读写与 DMA:把连续扇区合并起来
FATFS 在读取连续簇时,底层 disk_read 拿到的是连续的扇区区间,比如 count=8。如果你的驱动只调单块循环 8 次,功能正确但性能打折,因为每次 CMD17 都有命令响应延迟,这个延迟在 SPI 模式下能把速度吃掉近一半。正确做法是把连续读映射到 CMD18 多块读,一次发命令连续拉数据,再用 CMD12 停止:
static int sd_read_multi(uint32_t block, uint8_t *buf, uint32_t count) { uint8_t r1; sd_cmd(18, block, &r1, NULL); if (r1 != 0x00) return -1; for (uint32_t i = 0; i < count; i++) { // 等待 0xFE 令牌,然后读 512 字节,丢弃 2 字节 CRC // 开启 DMA 时这里可以整体搬运,提高吞吐 } sd_cmd(12, 0, &r1, NULL); // CMD12 停止多块读 // 卡会回一个 R1b 响应,需要等到忙释放 return 0; }多块读的加速原理是一根命令线换一批数据,把每个扇区的命令延迟抹掉了。多块写对应 CMD25,写完后同样要等忙释放。DMA 的作用在读场景最明显,SPI 接收一个字节通常占一个中断周期,12MHz 下 CPU 会被频繁进中断吃掉大半性能,用 DMA 循环接收后 CPU 只做搬移。注意 DMA 模式下缓冲区必须保持有效的生命周期,FATFS 传入的 buff 在 f_read 返回前都有效,不用担心,但如果是自己开了异步预读,就要处理好所有权。
5.3 扇区对齐、簇大小和缓冲区的隐藏坑
FAT 文件系统对扇区对齐的依赖比多数人以为的严重。FATFS 内部处理小于 512 字节的写时,先把目标扇区读进缓冲区,改写后整体写回,这个过程叫读改写。如果应用频繁写 10 字节、50 字节这种小尾巴,每次触发一次完整扇区读改写,性能会直线下降,而且缓冲区里的旧数据如果过期了还会把其他字节覆盖错。规避方法是应用层做对齐:把日志记录凑成 512 字节倍数,或者至少按 1 扇区长度批量写。
簇大小对速度和空间的影响需要权衡。f_mkfs 不指定簇大小时,FAT32 按容量自动选,16GB 卡通常默认 8KB 簇。如果文件都是几百字节的小记录,8KB 簇每个文件平均浪费 4KB,卡看空间飞快变少;反过来如果写的是几十 MB 的连续数据,4KB 簇会让文件碎片化严重,读写速度掉一半。我一般给数据记录仪的卡直接用 8KB 或 16KB 簇,配合 512 字节对齐的批量写,速度和空间都比较均衡。
缓冲区对齐经常被忽略,但它会引出一类特别难查的 bug。SPI DMA 在有些 MCU 上要求缓冲区 4 字节对齐,否则传输长度不对或者直接 HardFault。FATFS 里如果开了 _USE_LFN,长文件名缓冲由内部 malloc 分配,malloc 默认对齐到 8 字节没问题;但如果你自己定义一个全局数组当 f_write 的源数据,编译器可能把它排到任意地址。稳妥做法是声明时加 aligned 属性,或者在工程配置里把全局变量默认对齐调成 4 字节。
5.4 用实测数据找瓶颈
性能问题靠猜是猜不出来的,建议上电后直接做三组计时对比:裸驱动连续读 1MB、通过 FATFS 连续读 1MB、通过 FATFS 写入 1MB。裸驱动循环调用 sd_read_block 读 2048 个扇区,记录耗时;FATFS 用 f_read 读同一个 1MB 文件,记录耗时。两组数据的差值就是文件系统层的开销,差值不大说明瓶颈在 SPI 总线上,差值明显说明 FAT 链扫描或缓冲策略出了问题。
具体方法是在 main 里用定时器计毫秒,读完后串口打印。SPI 12MHz 时钟下,单块模式读速度大致在 300~600KB/s 这个量级,原因是每块命令响应和令牌等待要吃掉不少时钟周期;实现多块读 + DMA 后通常能冲到 1MB/s 以上,接近理论带宽的一半。写速度因为卡内部忙等待和擦写,通常比读慢 20%~40%,都是正常的。如果你的数据远低于这个区间,先看 GPIO 翻转是不是慢;很多板子的 SPI 是软件模拟的,每字节要几十条指令,这时候跑 12MHz 时钟也白搭,瓶颈在代码本身。最后强调一点:测量时文件要在同一个分区、连续存放,最好先写一个大文件把碎片清掉,否则碎片会让 f_read 表现出极大的波动,干扰判断。
6. 进阶:掉电不坏的「追加式数据记录」设计
如果只做到「能读写」,拿去做玩具没问题,但拿到现场会挨骂。数据记录类设备的宿命是随时可能被断电,而 FATFS 在掉电时能保证的,只有「f_sync 之前的数据不一定在卡上」这一条残酷事实。与其赌断电时机,不如把应用设计成「掉电最多丢最后一条记录」,这个思路我用了很多次,靠的是三样东西:定长记录、魔数校验、预分配加追加。
做法是这样:每条记录固定 512 字节,头部放 4 字节魔数、2 字节长度、2 字节 CRC16,后面是载荷。写入时先填头部计算 CRC,再整体写一个扇区,然后 f_sync。上电检查时打开文件,从尾部往前按 512 字节扫描,找到最后一条魔数合法且 CRC16 通过的位置,把它当作有效末尾,之后的残留数据全部忽略。文件用 f_expand 预分配一个固定大小,比如 64MB,写满后从头部覆盖;这样文件大小几乎不变,FAT 表的簇链在预分配后就不再变更,掉电时不会出现「簇链写了一半天没落盘」的灾难。
typedef struct { uint32_t magic; // 固定值,例如 0xA5C3B001 uint16_t len; // 有效数据长度 uint16_t crc16; // 对 len + payload 算 CRC uint8_t payload[504]; } log_rec_t; // 写一条记录:填头、算 CRC、整扇区写入 log_rec_t rec; rec.magic = REC_MAGIC; rec.len = sizeof(sensor_data); rec.crc16 = crc16_calc(&rec.len, sizeof(sensor_data)); memcpy(rec.payload, sensor_data, sizeof(sensor_data)); f_write(&fil, &rec, sizeof(rec), &bw); f_sync(&fil); // 这一句是命根子,不能省 // 上电恢复:从尾部往前扫,直到魔数不合法或 CRC 失败 f_lseek(&fil, fil.obj.fsize); f_lseek(&fil, fil.fptr - sizeof(log_rec_t)); f_read(&fil, &rec, sizeof(rec), &br); if (rec.magic == REC_MAGIC && crc16_ok(&rec)) { // 这条记录有效,继续前一条 }这里还有一个容易被忽略的细节:f_expand 预分配后,文件大小接近 64MB,但 f_read 往回扫描时要控制最大回扫次数,否则文件尾部全是无效残留时会把整个文件读一遍。我习惯只回扫最后 4KB,找到有效记录就停,找不到就认为文件已写满或损坏,不再浪费启动时间。掉电保护这件事,靠文件系统本身做不到,但靠定长记录 + 校验 + 预分配,可以让文件系统在最坏情况下只损失一条记录,这是我在调试设备时学到的第一课:先设计好崩溃后的样子,再去写业务逻辑。希望帮到你。
本文还有配套的精品资源,点击获取