ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

STM32F103 SPI读写SD卡与FATFS文件系统移植实战

2026/9/16 21:52:47 拓冰建站 浏览量
STM32F103 SPI读写SD卡与FATFS文件系统移植实战 简介面向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。下面这张表是初始化阶段最常用到的命令命令参数响应类型作用CMD00x00000000R1复位 SD 卡进入 Idle 状态CMD80x000001AAR7检查 SD 卡版本和电压范围ACMD410x40000000R1初始化卡并读取状态CMD580x00000000R3读取 OCR 寄存器确认支持电压CMD17块地址R1 数据块读单个扇区CMD24块地址R1写单个扇区CMD550x00000000R1告诉卡下一条命令是应用相关命令CMD590R1开启/关闭 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并按下表配置参数名配置值说明Prescaler128初始频率约 140kHz初始化成功后调小Clock PolarityLowCPOL0Clock Phase1 EdgeCPHA1第一个边沿采集Data Size8 bitSD 卡 SPI 模式固定为 8 位First BitMSB FirstSD 卡命令帧是从最高位开始发送的为什么初始化时钟要选这么低因为 SD 卡上电后时钟频率不能超过 400kHzSPI 总线的片选信号在发送 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 patternSD 卡会在响应后返回同样的 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 引脚组合很多问题会重新出现。建议把初始化过程中的每一条命令响应打印出来便于对照卡的数据手册定位错误。调试完成后再把调试信息关闭保留一组干净的驱动接口。本文还有配套的精品资源点击获取