简介:面向STM32嵌入式开发者的SD卡文件读写参考工程,基于STM32F103与SPI接口实现FAT32文件系统操作。工程包含完整的HAL/LL驱动、FATFS文件系统配置以及读写示例,适合需要掌握单片机存储扩展或文件管理的中级开发者。压缩包内共369个文件,约13.66MB,主要包含44个c源码、47个h头文件,以及大量o/d/crf等编译中间文件;同时提供uvprojx/uvoptx工程文件、hex/axf烧录文件、sct链接脚本和map映射文件,可直接查看编译结果或进行二次开发。已有547人学习下载。从内容预览看,工程涵盖定时器、FATFS文件系统与LCD显示等配套代码,可作为完整的学习范例。读者可以从中了解SD卡SPI初始化、CMD/ACMD命令序列、FATFS文件读写流程以及错误处理方式,还可以参考工程中的配置与链接脚本,快速理解整个开发环境搭建过程,缩短嵌入式存储功能的开发周期。
1. STM32F103 读写 SD 卡,SPI 方式是最不折腾的路线
老玩家都知道,SD 卡有两种驱动方式:SDIO 和 SPI。SDIO 速度快,但 STM32F103 的 SDIO 引脚和板子上其他外设经常打架,PCB 布线稍不注意就抓瞎。而 SPI 方式只要一根时钟线、两根数据线、一根片选,配合 FATFS 这类轻量级文件系统,完全可以满足数据记录仪、配置存储、固件升级文件读取这类需求。这套方案最大的价值在于它不依赖特定板卡:一块 stm32f103 最小系统,随便接一个 SPI 接口的 SD 卡座,把硬件 SPI 或软件模拟 SPI 调通,再挂个 FATFS,就能在 SD 卡上创建、读取、修改文件。下面直接拆解从底层命令到顶层文件 API 的实现,重点讲清楚 FAT32 格式下那些容易让人卡住的初始化时序、参数配置和排错手段。
2. FAT32 与 SD 卡 SPI 模式:先搞懂命令时序和文件系统布局
2.1 SD 卡 SPI 模式下的引脚与标准命令集
SD 卡上电后默认是 SD 模式,需要往 CMD0 里写入特殊参数才能进入 SPI 模式。进入 SPI 模式后,主机通过 SPI 接口发送 6 字节命令帧,格式统一为:起始位 + 命令索引(6 bit)+ 参数(32 bit)+ CRC7。SPI 模式下 CRC 并不是必需的,只有 CMD8 在部分场景下被要求带正确 CRC,其他命令可以直接填 0x00。下面这张表是初始化阶段最常用到的命令:
| 命令 | 参数 | 响应类型 | 作用 |
|---|---|---|---|
| CMD0 | 0x00000000 | R1 | 复位 SD 卡,进入 Idle 状态 |
| CMD8 | 0x000001AA | R7 | 检查 SD 卡版本和电压范围 |
| ACMD41 | 0x40000000 | R1 | 初始化卡并读取状态 |
| CMD58 | 0x00000000 | R3 | 读取 OCR 寄存器,确认支持电压 |
| CMD17 | 块地址 | R1 + 数据块 | 读单个扇区 |
| CMD24 | 块地址 | R1 | 写单个扇区 |
| CMD55 | 0x00000000 | R1 | 告诉卡下一条命令是应用相关命令 |
| CMD59 | 0 | R1 | 开启/关闭 CRC 校验 |
注意,ACMD41 并不是直接发送的命令,而是先发 CMD55,再发 ACMD41。初始化时可能要在循环里反复发送 CMD55 + ACMD41,直到 R1 响应中的 busy 位被清掉。响应格式上,R1 是一个字节,最高位永远是 0,剩下每一位代表不同错误状态。如果收到 0x01,表示卡处于 Idle 状态,这是正常的;如果一直卡在 0x01,说明初始化还没完成。
2.2 FAT32 文件系统在 SD 卡上的物理布局
FAT32 文件系统由引导扇区(DBR)、FAT 表、根目录区和数据区组成。SD 卡在 SPI 模式下是按块访问的,标准块大小是 512 字节。对于 FAT32 分区,第一个扇区是 MBR,里面记录分区表的偏移地址,从该地址找到 DBR 扇区。DBR 里包含每扇区字节数、每簇扇区数、保留扇区数、FAT 表个数、总扇区数等关键参数。FATFS 在挂载磁盘时会读取这些字段,如果字段被破坏,就会报 FR_NO_FILESYSTEM。
实际开发中,我们并不需要自己解析这些结构,FATFS 已经封装好了,但理解两个概念对排查问题有帮助:一是簇,FAT32 中文件以簇为单位分配空间,每簇可以包含 1 到 64 个扇区;二是 FAT 表,它记录了每个簇的下一跳地址。如果 FAT 表损坏,就会出现文件能打开但读到一半出错的现象。所以在移植 FATFS 时,disk_read 函数必须正确返回 512 字节的整块数据,否则 FAT 表读取会错位。
2.3 FATFS 移植需要实现哪些底层接口
FATFS 通过一个抽象层把上层文件 API 与具体存储介质隔离开。我们只需要实现 disk_initialize、disk_status、disk_read、disk_write、disk_ioctl 和 get_fattime 这几个函数。disk_ioctl 在 FATFS 中承担多种控制命令,比如获取扇区数量、获取块大小、强制同步等。下面是一个简化的函数骨架,严格按照 FATFS 的接口签名来写:
DSTATUS disk_initialize(BYTE pdrv) { if (pdrv != 0) return STA_NOINIT; if (sd_card_init() == SD_OK) { return 0; // 无错误 } return STA_NOINIT; } DSTATUS disk_status(BYTE pdrv) { if (pdrv != 0) return STA_NOINIT; if (sd_card_ready()) return 0; return STA_NOINIT; } DRESULT disk_read(BYTE pdrv, BYTE *buff, LBA_t sector, UINT count) { if (pdrv != 0 || !buff) return RES_PARERR; if (sd_read_sectors(sector, buff, count) == SD_OK) return RES_OK; return RES_ERROR; } DRESULT disk_write(BYTE pdrv, const BYTE *buff, LBA_t sector, UINT count) { if (pdrv != 0 || !buff) return RES_PARERR; if (sd_write_sectors(sector, (BYTE *)buff, count) == SD_OK) return RES_OK; return RES_ERROR; }“逻辑说明与参数说明”:
- pdrv 是物理驱动器编号,在单 SD 卡场景下始终为 0。FATFS 支持多驱动器,但大多数应用只需要一路。
- sector 是逻辑块号,FATFS 传入的地址是基于整个磁盘起始的扇区地址,不是某个分区的内部地址。底层驱动要把这个地址直接透传给 SD 卡读写命令。
- count 表示一次读取或写入的扇区数量,read/write 循环里要用这个值来控制批次数。
- 返回 RES_OK / RES_ERROR 的语义要严格遵守。disk_read 返回 RES_ERROR 后,FATFS 会向应用层返回错误码,很多“打开文件失败”的最终源头就是这里实现有误。
get_fattime 用于产生文件时间戳,它返回一个 32 位整数,按 FAT 时间格式编码。如果没有 RTC,直接返回一个固定合法值即可,例如 0x20251201 表示 2025 年 12 月 1 日。不实现这个函数,f_open 可能无法正常工作,因为 FATFS 写目录项时需要时间字段。
3. 硬件 SPI 驱动与 FATFS 集成:可抄作业的实现流程
3.1 用 CubeMX 配置 SPI1、卡检测和片选引脚
这里以 STM32F103C8T6 最小系统板为例,SPI 接口选择 SPI1,对应 PA5 时钟、PA6 MISO、PA7 MOSI,片选引脚用 PA4 推挽输出。SD 卡座如果带卡检测开关,可以接到 PA2 并配置为输入上拉。使用 HAL 库时,在 STM32CubeMX 里把 SPI1 设为 Full-Duplex Master,并按下表配置:
| 参数名 | 配置值 | 说明 |
|---|---|---|
| Prescaler | 128 | 初始频率约 140kHz,初始化成功后调小 |
| Clock Polarity | Low | CPOL=0 |
| Clock Phase | 1 Edge | CPHA=1,第一个边沿采集 |
| Data Size | 8 bit | SD 卡 SPI 模式固定为 8 位 |
| First Bit | MSB First | SD 卡命令帧是从最高位开始发送的 |
为什么初始化时钟要选这么低?因为 SD 卡上电后时钟频率不能超过 400kHz,SPI 总线的片选信号在发送 CMD0 之前必须保持高电平至少 74 个时钟周期。很多人一上来就用高速 SPI,结果初始化直接失败。初始化成功后,可以修改 SPI 预分频寄存器或调用 HAL_SPI_Init 重新配置,把时钟提高到 10MHz 左右。
3.2 编写 sd_spi_transfer 和 sd_card_init 核心函数
硬件 SPI 的底层收发非常简单,HAL 库的HAL_SPI_TransmitReceive可以同时完成发送和接收。SD 卡 SPI 通信中,读响应和数据块时主机必须持续发送 0xFF,所以在底层封装一个单字节交换函数是必要的:
static uint8_t sd_spi_transfer(uint8_t byte) { uint8_t rx = 0xFF; if (HAL_SPI_TransmitReceive(&hspi1, &byte, &rx, 1, 100) != HAL_OK) { return 0xFF; } return rx; }“逻辑说明与参数说明”:
- hspi1 是 CubeMX 生成的 SPI 句柄,函数不做任何数据缓存,只完成一个字节的数据交换。
- 发送的数据是 byte,同时接收到的数据放入 rx。SD 卡是半双工应答式的,主机通过发送 0xFF 来制造时钟,SD 卡在 MOSI 为高时才能把 MISO 上数据送出。
- 超时参数设为 100ms,如果 SPI 总线异常会返回 HAL_TIMEOUT,这里返回 0xFF 是一种容错处理,调用方再根据响应内容判断状态。
有了单字节交换函数,接下来处理 SD 卡初始化。初始化序列是固定的,下面这段代码兼容 SDSC 和 SDHC/SDXC,注意 CMD8 的发送条件:
uint8_t sd_card_init(void) { uint8_t i, cmd, response; uint32_t timeout; spi_set_speed(SD_SPI_LOW_SPEED); cs_high(); for (i = 0; i < 80; i++) { sd_spi_transfer(0xFF); } response = sd_send_command(CMD0, 0x00000000); if (response != 0x01) return SD_ERROR; response = sd_send_command(CMD8, 0x000001AA); if (response == 0x01) { sd_spi_transfer(0xFF); sd_spi_transfer(0xFF); sd_spi_transfer(0xFF); sd_spi_transfer(0xFF); } else { return SD_ERROR; } timeout = 10000; do { sd_send_command(CMD55, 0x00000000); response = sd_send_command(ACMD41, 0x40000000); timeout--; } while (response != 0x00 && timeout > 0); if (timeout == 0) return SD_ERROR; response = sd_send_command(CMD58, 0x00000000); if (response != 0x00) return SD_ERROR; sd_spi_transfer(0xFF); sd_spi_transfer(0xFF); sd_spi_transfer(0xFF); sd_spi_transfer(0xFF); spi_set_speed(SD_SPI_HIGH_SPEED); return SD_OK; }“逻辑说明与参数说明”:
- cs_high 先拉高片选,发送 80 个空时钟,确保 SD 卡上电启动完成。
- 第一个回应是 R1 格式,收到 0x01 表示卡已经进入 Idle 状态。
- CMD8 携带参数 0x000001AA,其中 AA 是 check pattern,SD 卡会在响应后返回同样的 4 字节数据,这里直接丢弃即可。如果卡不支持 CMD8,则说明是 V1.0 卡,但现代卡基本都支持。
- ACMD41 的参数 0x40000000 中,bit30 置 1 表示请求主机支持高容量卡。循环直到响应为 0x00,代表初始化完成。
- CMD58 用于读取 OCR 寄存器,如果返回值中 bit30 为 1,说明卡是 SDHC 或 SDXC,后续扇区地址逻辑块地址访问;否则是 SE 卡,需要转换为字节地址。绝大多数 FAT32 卡都支持块地址模式,驱动里统一用块地址访问即可。
3.3 文件读写流程与关键参数说明
挂载文件系统之前,先要在main函数里调用f_mount和f_open。FATFS 的FIL结构体是文件操作的句柄,读写过程中由文件系统内部维护当前位置。下面是一个典型的日志写入和读取范例:
FIL file; FRESULT res; UINT bytes_written, bytes_read; uint8_t buf[128]; res = f_mount(&fs, "", 1); if (res != FR_OK) { return; } res = f_open(&file, "0:/data/LOG.TXT", FA_CREATE_ALWAYS | FA_WRITE); if (res == FR_OK) { res = f_write(&file, "spi,stm32,fat32,ok\r\n", 20, &bytes_written); if (res != FR_OK || bytes_written != 20) { // 写入长度不够时需要处理,常见原因是磁盘满 } f_close(&file); } res = f_open(&file, "0:/data/LOG.TXT", FA_OPEN_EXISTING | FA_READ); if (res == FR_OK) { res = f_read(&file, buf, sizeof(buf), &bytes_read); if (res == FR_OK) { // bytes_read 是实际读到的字节数 } f_close(&file); }“逻辑说明与参数说明”:
- 路径中的
0:表示物理驱动器编号,对应disk_initialize里的 pdrv 参数。如果只有一个驱动器,可以省略为/data/LOG.TXT,但显式写出来更稳妥。 FA_CREATE_ALWAYS会覆盖已有文件,如果只想追加数据,应该改用FA_OPEN_ALWAYS | FA_WRITE并把文件指针移到末尾,例如调用f_lseek(&file, f_size(&file))。f_write的第四个参数是本次请求写入的字节数,第三个参数返回实际写入的字节数。FATFS 允许部分写入,所以必须检查返回值。如果bytes_written比请求数小,通常意味着磁盘空间不足。f_read读取长度可以大于文件剩余长度,返回时会通过bytes_read给出实际读取的字节数,读到 0 表示文件已经到末尾。- 每次写完数据后
f_close是必须的,直接断电可能导致 FAT 表尚未刷新而损坏文件。如果追求实时性,可以在写若干次后调用f_sync,它只刷新缓存,不需要关闭文件。
4. 初始化失败、64G 卡与写保护:涉及到底层的排错清单
4.1 初始化卡在 CMD0 或 ACMD41 的排查方法
SPI 模式下最常见的问题是卡一直卡在初始化。可以从三个层面检查:硬件连接、初始化时序、电压匹配。先看片选线,有些 SD 卡座自带上拉电阻,但若 MCU 引脚配置为推挽输出且初始状态为低,SD 卡可能直接进入无效状态。这里注意,SPI 模式下片选信号必须由软件拉低,而且在发送命令时全程保持低电平,命令结束后可以拉高。如果使用的是HAL_GPIO_WritePin控制,需确保在 CubeMX 中把 PA4 初始化为高电平。
初始化卡住还有一种常见原因是 MISO 上缺少上拉电阻。STM32F103 的 PA6 在浮空输入时抗干扰能力弱,SPI 线太长或面包板接触不良时,MISO 读到的高电平可能是噪声,导致响应字节解析为 0xFF。建议在 PA6 上接一个 10kΩ 上拉电阻到 3.3V。另外,CMD0 发送前必须拉低 CS 并发送至少 74 个时钟周期,很多代码只发送 40 个脉冲就会失败。
ACMD41 循环超时还可能跟卡的类型有关。对于大容量卡,初始化时间可能超过几百毫秒,循环次数不能太少。下面是一个更可靠的初始化轮询写法:
uint32_t timeout = 0x00FFFFFF; do { sd_send_command(CMD55, 0); response = sd_send_command(ACMD41, 0x40000000); timeout--; } while (response != 0x00 && timeout > 0);这里的 timeout 并没有用毫秒计数,而是用循环次数控制,每次循环发送两条 SPI 命令大约消耗几十微秒,所以 0x00FFFFFF 足够容纳最慢的 SD 卡初始化。如果在实际产品中需要严格超时,可以用定时器测量 2 秒窗口,超过 2 秒直接报错。
4.2 64G SD 卡无法在 STM32 上正常挂载的原因
64G SD 卡默认可能是 exFAT 文件系统,而 STM32F103 上的 FATFS 一般只启用 FAT32 支持。如果直接把 exFAT 卡插进去,f_mount会返回FR_NO_FILESYSTEM。解决办法是在电脑上把卡格式化为 FAT32。Windows 自带的格式化工具和第三方工具如 Rufus 都可以格式化,但要注意簇大小。格式化时选择默认簇大小即可,FATFS 在挂载时会自动读取 DBR 中的簇大小。如果你自己在内存里定义了很大的扇区缓冲区,可能会出现缓冲区不足的情况,这时让 FATFS 使用FF_USE_MKFS功能在 STM32 上直接格式化 SD 卡。
但有一个坑:64G 卡格式化为 FAT32 时,如果簇大小设置成 512 字节,FAT 表会异常庞大,大量占用内存,导致 FATFS 缓存频繁失效。建议用 32KB 或 64KB 簇,这样 FAT 表较小,读写性能也更好。SD 卡的 SPI 模式下,写操作的最小单位是块,也就是 512 字节。64G 卡底层依然是 SDHC 规范,初始化成功后通过 CMD58 读 OCR,如果 bit30 为 1,就说明可以按块地址访问,不会像旧版 SDSC 卡那样需要按字节地址转换。
4.3 文件写保护异常的几种表现
物理写保护开关是最直观的原因。SD 卡侧面有个小滑块,如果滑到锁住位置,SD 卡会拒绝写命令。但很多卡座并没有检测写保护开关的引脚,只有卡内部的 CSD 寄存器记录了写保护状态。如果写入时 FATFS 返回的扇区写入操作状态错误,检查一下卡座上的写保护开关和 MISO 引脚的卡检测逻辑。
更隐蔽的是 SPI 模式下不支持写保护位的读取,FATFS 并不知道卡被写保护了,只能在上层发现f_write调用后,底层disk_write返回错误。如果你的驱动实现中没有检查写命令响应,而是盲目认为发送完成就成功,那么应用程序会一直写入成功但实际数据没有落盘。正确做法是在sd_write_sectors中,每发送一个 CMD24 后读取响应字节,响应 bit0 为 1 表示卡处于锁定状态。下面的代码展示了如何检查写响应:
uint8_t status = sd_spi_transfer(0xFF); if (status & 0x05) { return SD_WRITE_ERROR; }这里的 status 与 0x05 按位与,最低位为 1 代表写保护或 CRC 错误,bit2 为 1 表示写失败。不同卡的数据手册可能有细微差异,但这一判断能覆盖绝大多数异常。
5. 进阶实践:软件片选、DMA 传输与多文件可靠性
5.1 硬件片选与软件片选的实际选择
STM32F103 的 SPI 硬件上支持 NSS 引脚,但很多 SD 卡驱动更倾向于用普通 GPIO 做软件片选。原因在于硬件 NSS 在多主机模式下的行为是自动控制的,而 SD 卡 SPI 模式要求非常精确地控制片选脉冲,稍有不慎就会引发命令响应错误。我之前调试过一块需要与翻转器共用 SPI 总线的板子,硬件 NSS 会在每次传输结束后拉高,但 SD 卡在写入数据块期间要求 CS 必须保持低电平,导致写大文件时频繁失败。改用软件片选后,直接控制 GPIO 状态,问题就消失了。
如果你需要和 W25Q64、OLED 等 SPI 外设共存,软件片选同样更方便。每个设备一个片选引脚,在操作前拉低对应引脚,结束后拉高,同时保证其他片选保持高电平。需要注意的是,SPI 总线上所有设备的 MISO 引脚不能同时为高,否则会冲突。SD 卡和 Flash 都不属于开漏输出外设,如果电路上没有串联电阻或使能控制,最好在驱动里每次只选中一个设备,其他设备片选必须保持高电平。
5.2 用 DMA 减少 CPU 开销、提高写文件速度
流式写文件时,如果每次只写一个字节,CPU 大部分时间都消耗在等待 SPI 收发上。用 DMA 配合定时器可以显著提升吞吐率。FATFS 的disk_write接口接收的是一个连续缓冲区,正好可以一次性交给 DMA 发送。在 SPI1 的 DMA 通道上,配置一个内存到外设的循环模式并不合适,正确做法是在每次写扇区前调用HAL_SPI_Transmit_DMA,然后查询 DMA 传输完成标志。
res = HAL_SPI_Transmit_DMA(&hspi1, buff, 512); if (res == HAL_OK) { while (HAL_DMA_STATE_READY != hspi1.hdmatx->State) { // 等待 } }这里等待 DMA 完成时,最好加入超时保护,否则总线异常会导致死循环。实现上可以用一个简单的 tick 计数器判断超时。DMA 的真正优点是让 SPI 时钟可以连续发送,不需要在每个字节之间插入软件延时。对于 STM32F103,时钟频率在 18MHz 时,DMA 写 SD 卡大约能跑到 1MB/s,但 FATFS 自身元数据更新会消耗一部分时间,实际文件写入速度通常在 300-500KB/s 之间。
5.3 多文件同时打开时的注意事项
FATFS 支持多文件同时打开,但前提是FF_FS_LOCK和FF_USE_LFN配置正确。在ffconf.h里,把FF_FS_LOCK设为 4 或更大,并确保每个打开的文件都占用独立的FIL结构体。很多人只定义一个FIL file,打开新文件前忘记关闭旧文件,结果第二次f_open返回FR_TOO_MANY_OPEN_FILES。更隐蔽的问题是底层disk_read被 FATFS 交叉调用时,同一个sd_spi_transfer函数如果中途被中断,另一个文件操作也来抢 SPI 总线,就会破坏数据。解决办法是让 SPI 驱动具备可重入能力,或用一个互斥量保护整个文件传输过程。对于单线程裸机程序,最简单的做法是在disk_read和disk_write函数里加上临界区保护,比如使用__disable_irq()和__enable_irq(),但要注意临界区不能耗时太长,否则会影响定时器精度。
最后要强调的是,SD 卡 SPI 驱动并不是调通一次就不用再管的。换一张不同容量的卡、换一块板子、换一个 SPI 引脚组合,很多问题会重新出现。建议把初始化过程中的每一条命令响应打印出来,便于对照卡的数据手册定位错误。调试完成后,再把调试信息关闭,保留一组干净的驱动接口。
本文还有配套的精品资源,点击获取