STM32F103 SD卡驱动与FATFS文件系统移植实战指南 简介面向STM32开发者的SD卡驱动与FATFS文件系统移植工程包围绕SPI模拟时序展开包含三个递进式工程SD卡扇区底层读写、FATFS文件系统目录与文件基本测试、以及基于FATFS的截屏保存BMP图片与图片解码显示。从寄存器级驱动到文件API调用再到典型图像应用为嵌入式存储与文件管理学习提供完整链路适合正在学习STM32裸机驱动或文件系统移植的初中级工程师。包内共425个文件以C源码、头文件为主同时包含Keil编译生成的o/axf/hex等产物以及uvproj工程配置结构上按01、02、03三个子工程明确划分配合作者博客中的原理讲解与代码注释便于对照理解SPI时序、SD卡命令帧和FATFS挂载流程。压缩包仅12.99MB轻量小巧。目前已有617人学习下载对于需要快速验证SD卡读写或移植FATFS的开发者来说是一份可运行的实践参考。1. 从一块不认卡的板子说起手上一块 STM32F103C8T6 的最小系统板焊好 SD 卡座、连完 SPI 引脚上电后插卡调试发现要么读回全是 0xFF要么初始化卡死在 ACMD41 的循环里。这种问题几乎每个做过 SD 卡读写的人都会遇到——不是 FATFS 的锅而是底层驱动和移植步骤里藏着太多“不写出来就没人告诉你”的细节。本文就沿着“SD 卡驱动 FATFS 文件系统移植”这条链路从硬件选型、协议初始化、FATFS 接口对接到实际工程里的常见坑一起拆完最终落到一个能稳定跑起来的读写工程。适合想用 STM32F103 标准库或 HAL 库做数据记录、日志存储、离线采集的应用开发者也适合初次接触文件系统移植的嵌入式工程师对照检查自己卡在哪一步。2. SD 卡驱动选型SPI 模式与 SDIO 模式的取舍、时序框架与初始化实现2.1 SD 卡的物理层协议模式与 STM32F103 的匹配SD 卡协议原生支持 SD 总线模式但 STM32F103 系列里只有带 SDIO 外设的型号如 F103VC、F103ZE能走 4 位 SD 总线而最常见的 F103C8T6 没有 SDIO 外设只能退而使用 SPI 模式。SPI 模式的劣势是单线传输、速度上限约 12~25Mbit/s取决于系统时钟和 SD 卡本身支持但换来的是引脚少、任何自带 SPI 外设的 MCU 都能驱动。带 SDIO 的型号也不要急着上 4 位模式一是 PCB 布线时序要求高二是卡兼容性未必比 SPI 好很多工业数据记录仪最终仍选择 SPI 模式换取稳定和简单。对比项SPI 模式SDIO 4 位模式所需引脚CS、SCLK、MOSI、MISO 共 4 根CLK、CMD、D0-D3 共 6 根最高时钟25MHz保守建议 12.5MHz 内48MHzF103 的 SDIO 最高 48MHz卡兼容性好所有卡都支持 SPI 模式部分老卡在 4 位模式下初始化失败CPU 占用按字节收发可用 DMA 减轻硬件 CRC、FIFO、DMA占用低适用场景数据记录、配置存储、小文件读写大文件连续读写、多媒体SPI 模式下 SD 卡实际上是被 MCU 当作一个 SPI 从设备来操作的主机发命令帧CMD0、CMD8、ACMD41 等卡返回响应R1、R3、R7然后按扇区读写数据块。难点在于 SD 卡对时序有严格要求上电至少 74 个时钟周期的等待、每发一条命令前后要有若干个空时钟、读数据块时需要等待起始令牌 0xFE。这些细节在逻辑分析仪上看得最清楚纯读代码容易漏。2.2 初始化序列从 CMD0 到 ACMD41 的状态推进初始化是 SD 卡驱动里最容易卡死的环节。完整的 SPI 模式初始化序列为上电延时、发送 74 个时钟、拉低 CS 后发送 CMD0、发送 CMD8、重复发送 ACMD41 直到卡返回就绪、读取 OCR 寄存器确认容量类型、设置块长度 512 字节。其中 CMD8 的作用是区分 SDSCV1.x和 SDHC/SDXCV2.0卡不支持 CMD8 时应按 V1.x 卡处理使用 CMD1 替代 ACMD41 完成初始化支持时继续走 ACMD41并通过 CCS 位判断 SDSC 还是 SDHC。这个分支不写对就会出现“新卡能初始化、旧卡直接死循环”的怪异现象。下面是基于标准库的初始化函数骨架核心是把上面的状态推进落实为代码uint8_t SD_Init(void) { uint8_t retry 0; uint8_t r1 0xFF; // 1. 上电后延时 10ms让卡内部电压稳定 delay_ms(10); // 2. 先拉高 CS主机发 74 个空时钟 SD_CS_HIGH(); for (uint16_t i 0; i 16; i) SD_SPI_ReadWriteByte(0xFF); // 16 * 8 128 个时钟 // 3. 拉低 CS发送 CMD0进入 SPI 模式 SD_CS_LOW(); r1 SD_SendCmd(CMD0, 0x00, 0x95); // 0x95 是 CMD0 的 CRC if (r1 ! 0x01) return SD_ERROR; // 正确响应是 0x01空闲状态 // 4. 发送 CMD8区分 V1.x 与 V2.0 卡 r1 SD_SendCmd(CMD8, 0x1AA, 0x87); if (r1 0x01) { // V2.0 及以上卡需继续校验卡返回的电压范围 uint8_t buf[4]; if (SD_ReadBytes(buf, 4) ! SD_OK) return SD_ERROR; if ((buf[2] 0x0F) ! 0x01 || buf[3] ! 0xAA) return SD_ERROR; // 电压范围不支持 // 5. 循环发送 ACMD41直到卡退出空闲状态 do { r1 SD_SendCmd(CMD55, 0, 0); // CMD55 告诉卡下一条是应用命令 r1 SD_SendCmd(ACMD41, 0x40000000, 0); // HCS1支持 SDHC retry; } while (r1 ! 0x00 retry 255); } else { // V1.x 卡走 CMD1 初始化 do { r1 SD_SendCmd(CMD1, 0, 0); retry; } while (r1 ! 0x00 retry 255); } if (retry 255) return SD_ERROR; SD_CS_HIGH(); // 释放片选 return SD_OK; }这段代码里的三个关键点CMD0 的 CRC 0x95 是 SD 规范里规定的固定值不能随便改CMD8 的 0x1AA 表示供电范围 2.7~3.6V 且检测字节为 0xAA这是卡协议里的一个“握手暗号”ACMD41 之前必须先发 CMD55否则卡会把它当作普通命令直接返回错误。整个初始化最耗时的部分不是命令本身而是 ACMD41 的循环等待老卡可能要几十次轮询才能完成内部上电校验因此超时值设在 255 次是合理的。2.3 单块与多块读写的命令时序和代码实现初始化完成后进入数据读写阶段。单块读是 CMD17 加地址单块写是 CMD24 加地址多块读是 CMD18、多块写是 CMD25。这里最需要注意的是 SDHC 卡的地址是块地址而不是字节地址——SDSC 卡的 CMD17 参数必须是字节地址即块号乘以 512SDHC 卡的参数直接就是块号。如果统一按块号发送SDSC 卡会读到错误位置且几乎不会报错属于最隐蔽的逻辑错误之一。uint8_t SD_ReadSector(uint32_t block, uint8_t *buf) { uint8_t r1; uint32_t addr 0; // SDSC 卡地址 块号 * 512SDHC 卡直接用块号 if (SD_Type SD_TYPE_V1) { addr block * 512; } else { addr block; } SD_CS_LOW(); r1 SD_SendCmd(CMD17, addr, 0); if (r1 ! 0x00) { SD_CS_HIGH(); return SD_ERROR; } // 等待数据起始令牌 0xFE超时 200ms uint16_t timeout 2000; while (SD_SPI_ReadWriteByte(0xFF) ! 0xFE timeout--); if (timeout 0) { SD_CS_HIGH(); return SD_TIMEOUT; } // 接收 512 字节数据 2 字节 CRCCRC 可忽略但必须读走 for (uint16_t i 0; i 512; i) buf[i] SD_SPI_ReadWriteByte(0xFF); SD_SPI_ReadWriteByte(0xFF); SD_SPI_ReadWriteByte(0xFF); SD_CS_HIGH(); return SD_OK; }磁盘 IO 层的“扇区”概念和 SD 卡协议里的“块”是一一对应的因此文件系统读写的最小单位就是 512 字节。上面的读函数中等待 0xFE 令牌的循环是必须的不能省略——如果不等待就直接读数据读到的将是卡内部状态而不是扇区内容。多块读时 CMD18 之后的第一个 0xFE 后是连续的 512 字节数据块每块之间间隔约两个时钟周期读取循环里最好在每块之间补发 8 个空时钟以确保时序裕量。写操作同理CMD24 后主机要发送数据起始令牌 0xFE再发送 512 字节数据最后接收卡的 CRC 响应令牌0x05 表示数据接受并等待卡进入忙状态解除DOUT 引脚拉高这个忙等待在上电后第一次写入时尤其耗时有的卡能忙到几百毫秒。3. FATFS 移植的接口拆解diskio 层、ffconf 配置与挂载集成3.1 移植 FATFS 的本质是补齐“底层六函数”FATFS 是 ChaN 开发的开源 FAT 文件系统模块它把文件系统的逻辑层目录项管理、簇链分配、FAT 表维护与硬件层完全隔离用户只需要实现 diskio.c 里的几个接口函数disk_status、disk_initialize、disk_read、disk_write、disk_ioctl、get_fattime。移植工程质量的高低九成取决于这六个函数的实现质量。很多人在网上找现成的 diskio.c 改一改能用就完事但遇到卡死、文件损坏、磁盘容量不对时还是要回来看这层是否真的按规范实现。接口函数作用常见错误实现disk_initialize调用底层 SD 驱动完成卡初始化返回 0 表示成功只返回状态码不重新初始化热插拔后失效disk_status返回当前磁盘状态STA_NOINIT 表示未初始化常量返回 0导致文件系统层误判disk_read读一个或多个扇区到 buf返回 FR_OK 或错误码不处理多扇区一次只读一个disk_write写一个或多个扇区返回 FR_OK 或错误码不处理写保护不等待卡忙结束disk_ioctl处理容量查询、扇区数、擦除块边界等控制命令GET_SECTOR_COUNT 返回错误值导致容量异常get_fattime返回当前时间戳FATFS 用它写文件日期返回固定值RTC 异常时导致文件日期为 1970disk_read 的多扇区读是 FATFS 最依赖的核心路径——文件顺序读时 f_read 会以一次请求多个扇区的方式减少函数调用开销如果底层实现是一个扇区一个扇区地读然后拼装性能会急剧下降。多扇区读正好对应 SD 卡的 CMD18 多块读命令两者结合能最大化吞吐。但要注意 FATFS 的读请求并不保证扇区连续磁盘 IO 层收到可能是不连续的扇区列表底层必须逐个检查并分别处理。常见做法是检查扇区地址是否连续连续的直接发多块读不连续的拆成多个单块读。连续扇区判断是常见优化点批量写日志时文件系统分配的是连续簇而不是连续扇区这中间还有一次文件系统层的映射纯靠底层无法解决文件碎片问题。下面是 diskio.c 里 disk_read 的关键实现配合前面 SD 驱动DRESULT disk_read(BYTE pdrv, BYTE *buff, LBA_t sector, UINT count) { if (pdrv ! 0) return RES_PARERR; if (count 0) return RES_PARERR; // FATFS 的 sector 参数类型是 LBA_t底层必须转换类型 uint32_t block (uint32_t)sector; if (count 1) { // 单扇区读直接走 CMD17 if (SD_ReadSector(block, buff) ! SD_OK) return RES_ERROR; } else { // 多扇区读尝试走 CMD18底层驱动内部会判断连续性与否 if (SD_ReadMultiSector(block, buff, count) ! SD_OK) return RES_ERROR; } return RES_OK; }实际工程里SD_ReadMultiSector内部还要做的工作包括发送 CMD18、等待 0xFE 起始令牌、逐块接收数据、最后发送 CMD12 停止传输。CMD12 是多块读取束时最重要的收尾动作漏掉它会导致卡继续输出来自后续扇区的数据使总线数据错位。多块写则要注意 CMD25 之后卡会对整个数据块序列统一执行写入而非逐块写忙等待的时机是在停止命令之后判断这和单块写的等待卡忙时机有差异。这些差异如果不测试到位会在文件系统做大量连续写入时暴露为偶发性的数据错误概率低但一旦出现就极难排查。3.2 ffconf.h 里必调的 5 个宏与工程匹配FATFS 的配置集中在 ffconf.h不同版本宏的名字略有差异FATFS R0.14 之后一些宏从 _ 前缀改为不带下划线但核心的配置项保持稳定。移植时最常调的是这几个FF_USE_LFN决定是否启用长文件名置 1 且FF_MAX_LFN为 255 即可支持长文件名但需要额外分配一块 512 字节的缓冲区LFN 工作缓冲区在 FatFs 内部定义为局部变量栈小的 MCU 必须调大任务栈或改用动态分配FF_FS_MINIMIZE置 0 保留 f_stat、f_chdir 等完整功能FF_USE_MKFS置 1 启用 f_mkfs 格式化接口FF_USE_STRFUNC置 1 启用 f_printf/f_gets 格式化 IO调试日志场景极其常用;FF_FS_RTC置 1 后系统会调用用户实现的 get_fattime 函数防止文件时间戳全部是 1980 年。// ffconf.h 中的关键配置按工程实际裁剪 #define FF_USE_LFN 1 // 启用长文件名最大 255 字节 #define FF_MAX_LFN 255 #define FF_USE_MKFS 1 // 允许在板卡上直接格式化 SD 卡 #define FF_USE_STRFUNC 1 // 启用 f_printf日志输出方便 #define FF_CODE_PAGE 936 // 简体中文字符编码 #define FF_FS_RTC 1 // 使用用户自定义 RTC 时间戳 #define FF_FS_MINIMIZE 0 // 完整功能FF_CODE_PAGE这个宏容易被忽略——如果只写英文文件名和内容设成任何值都无所谓但一旦文件名含中文LFN 模式下文件名在写入时会被转换为当前代码页对应的编码。STM32F103 工程里如果接了 DS3231 等 RTC 芯片get_fattime的实现从 RTC 读回时间并打包成 FATFS 规定的 32 位格式年份偏移 1980月份、日、时、分、秒各占对应位域。没有 RTC 的板子可以编译条件编译让所有文件时间戳固定为编译日期这样在 Windows 上查看文件属性时日期正常不会出现 1980-01-01 这种明显异常。3.3 f_mount 的挂载时序与首次使用格式化流程文件系统移植完成后的集成代码按以下步骤执行FATFS fs; // FATFS 对象必须全局或静态分配 FRESULT res; // 1. 注册并挂载文件系统立即挂载表示必须成功 res f_mount(fs, 0:, 1); if (res ! FR_OK) { // 挂载失败可能是未格式化或文件系统损坏 res f_mkfs(0:, NULL, 0); // 自动格式化整个 SD 卡 if (res FR_OK) { res f_mount(fs, 0:, 1); } } // 2. 挂载成功后测试写一个文件 FIL file; res f_open(file, 0:test.txt, FA_CREATE_ALWAYS | FA_WRITE); if (res FR_OK) { UINT written; f_write(file, FATFS ok\r\n, 10, written); f_close(file); }挂载的第一个参数是 FATFS 结构体指针第二个参数是逻辑驱动器号0: 对应 FATFS 内部的卷表下标。第三个参数为 1 表示立即挂载若磁盘本身有文件系统会立刻读取引导扇区和 FAT 表此时若卡初始化还没完成就会返回 FR_NOT_READY置 0 表示延迟挂载只有第一次访问文件时才真正读取文件系统结构。初次上电的板子更推荐置 0 加失败重试的方式避免因上电时序差异导致首次挂载失败后还要手动卸载重挂。f_mkfs 格式化之前必须保证底层驱动的读写至少是单扇区正确的否则格式化过程中产生的错误会直接把卡变成不可识别状态需要重新用读卡器格式化才能恢复这点要在移植调试阶段就准备一张可牺牲的测试卡。4. 板级集成与整体调优引脚分配、多扇区性能与掉电保护策略4.1 最小系统板上的 SD 卡引脚分配与冲突排查以最常见的 STM32F103C8T6 最小系统板为例SPI1 的引脚默认映射在 PA5SCK、PA6MISO、PA7MOSICS 可以选任意 GPIO习惯上用 PA4。查板子原理图时要注意部分最小系统板的 PA5 被板载 SPI Flash 或 LED 复用PA6 被按键复用直接接 SD 卡会发生电平竞争。排查方法很简单用万用表量引脚对地电阻或下载一个 GPIO 翻转程序让引脚输出方波后用示波器看是否被其他器件拉低。功能引脚配置备注SPI1_SCKPA5复用推挽输出 50MHz初始化时先配 GPIO 再配 SPISPI1_MISOPA6浮空输入或上拉输入必须配置为上拉否则卡返回电平不稳SPI1_MOSIPA7复用推挽输出—SD_CSPA4推挽输出空闲拉高操作前拉低卡检测 CDPB1可选上拉输入卡插入为低电平用于热插拔检测MISO 引脚的上下拉是新手最容易忽略的SD 卡在 SPI 模式下 MISO 是三态输出卡未选中时呈高阻态如果主机引脚配成浮空输入读到的电平会不确定表现为卡状态时而正常时而乱码。配成上拉输入后未选中时读到高电平这是 SD 卡协议要求的“无数据时不驱动总线”的正确电平。SPI 外设本身的配置也有讲究——时钟极性 CPOL 和相位 CPHA 都必须为 0即空闲时 SCK 为低电平、数据在上升沿采样这是 SD 卡 SPI 模式的唯一正确时序组合。如果之前 SPI 挂过别的器件例如 Flash 用模式 3不改回来直接接 SD 卡必然会读卡失败。4.2 SPI 时钟分频策略从慢速初始化到快速读写SD 卡初始化阶段要求 SCLK 不超过 400kHz这是因为卡上电后内部时钟还没有切换到高速模式过快的时钟会导致命令响应乱掉。初始化完成后可以把时钟提高到卡支持的速度上限STM32F103 的 SPI1 挂在 APB2 总线上时钟为 72MHz分频系数为 256、128、64、32、16、8、4、2。初始化用 256 分频281.25kHz完成后再切到 8 分频9MHz或 4 分频18MHz。具体用哪个分频取决于卡和 PCB 走线质量保守选择 8 分频较少出错如果追求吞吐再逐步提高并做稳定性测试。void SPI_Config_ForSD(uint8_t fast) { SPI_InitTypeDef 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_Low; // SD 卡要求 SCK 空闲为低 spi.SPI_CPHA SPI_CPHA_1Edge; // 上升沿采样 spi.SPI_NSS SPI_NSS_Soft; spi.SPI_BaudRatePrescaler fast ? SPI_BaudRatePrescaler_8 : SPI_BaudRatePrescaler_256; spi.SPI_FirstBit SPI_FirstBit_MSB; SPI_Init(SPI1, spi); SPI_Cmd(SPI1, ENABLE); }切换时钟后建议额外发送 8 个空时钟让卡内部状态机切换完成再继续发命令或者数据读写。系统主频如果不是 72MHz 而是 8MHz 内部 RC分频后的 SCLK 会大幅低于卡支持的上限传输速度也会相应下降但能正常工作。分频切换时机应当放在 ACMD41 返回成功后、第一次 CMD17/CMD24 之前有些卡对初始化状态下的突发提速会丢失响应必须严格遵守先慢后快的步序。4.3 掉电保护与日志型应用的可靠写策略FATFS 作为通用文件系统对掉电的保护能力有限——这是 FAT 表的轮转写入机制决定的。日志、采集类应用里最常见的写策略是“文件打开-追加-关闭-再打开-再追加”每次 f_close 都会把 FAT 表项和数据目录写回卡里掉电时最多丢失当前未关闭的文件缓冲区已关闭文件的元数据是完整的。但追求性能时的做法打开文件后一直不关持续 f_write在掉电时可能导致文件长度字段没更新重新上电后文件显示长度为 0 或目录项损坏。折中的方案是每隔一段时间或写满一定大小后主动 f_sync 一次把缓存刷到卡上再继续写f_sync 不会关闭文件但会强制 FAT 表和目录项同步。// 每写满 4096 字节执行一次 f_sync兼顾性能与掉电安全 uint8_t log_buf[64]; UINT written, total 0; while (1) { // 模拟采集 64 字节数据 res f_write(file, log_buf, sizeof(log_buf), written); total written; if (total 4096) { res f_sync(file); // 强制同步避免掉电丢目录项 if (res FR_OK) total 0; } }更深一层的保护是把当前写入位置同时记录到文件系统的末尾快捷方式里掉电重启后断电恢复模块先扫描日志文件末尾丢弃还未写完的半条记录再继续追加。FATFS 本身不提供预分配式的日志迁移机制实现成本不高效果却非常明显。这类做法在“掉电保护”这个搜索词下有大量讨论原理上主要是通过牺牲极端情况下的最新几字节数据换取文件系统结构的长期稳定。5. 进阶验证吞吐量基准测试、长文件名实测与复杂工具链排错文件系统移植完成后如何证明它是“能用”而不是“碰巧跑通”建议按以下顺序做三轮验证。第一轮做连续读写吞吐测试用DTRT类似的基准方法先写入一个 1MB 的临时文件再顺序读回并校验。写入速度主要由 SPI 时钟和卡写性能决定标准库 SPI 轮询方式下 9MHz 时钟速度的连续写吞吐约为 400~600KB/s读吞吐约 700~900KB/s。如果远低于这个区间检查是不是没有使用多扇区读写或者 SD_ReadMultiSector 内部按单块方式逐块调用导致每条命令都有额外开销。做吞吐测试时每次读写缓冲至少 16 个扇区8KB并开启 DMA 传输减少 CPU 等待字节的时间。第二轮验证长文件名与中文名。创建文件名“测试日志_20240715_001.txt”通过 f_open 创建并写入内容后在 Windows 读卡器上查看是否正常。若文件名乱码检查FF_CODE_PAGE是否设置为 936以及 U 盘格式化时采用的编码是否匹配。要注意 FATFS 的 LFN 本身使用 Unicode 存储FF_CODE_PAGE只影响文件名的显示转换方式和代码页相关的 byte-in 转换。长文件名创建失败时也要检查FF_USE_LFN是否为 1 且FF_MAX_LFN是否大于文件名字节长度LFN 缓冲区不足会直接返回FR_INVALID_NAME。第三轮做插拔与异常恢复测试。在文件写入过程中直接断电、在文件读取过程中拔卡、在卡处于写保护状态时尝试删除文件——三轮做完后重新插卡确认原有文件可见且未损坏。这轮测试建议用一份与业务一致的采集数据格式进行验证到位的工程在后续实际环境里极少出现文件系统层面的问题。值得注意的是在第 2、3 轮测试中发现异常时优先检查底层 SD 驱动而不是 FATFS 配置——很多 FATFS 层面的幽灵问题实际上是底层读写返回了错误标志但驱动吞掉了错误FATFS 拿到错误标志后才表现为各种逻辑异常。调试时养成习惯引入一个SD_DebugPrint的函数把每次底层读写的扇区号、块数、返回值打出来对照 FATFS 的调用关系图排查效率远高于盲试配置参数。文件系统移植的完整闭环不是把 demo 跑通而是能预判、复现和解释每一类异常场景。本文还有配套的精品资源点击获取