ARTICLE DETAIL

建站实战干货

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

STM32 SPI读写SD卡与FATFS移植全攻略(实测可用)

2026/9/2 4:02:42 拓冰建站 浏览量
STM32 SPI读写SD卡与FATFS移植全攻略(实测可用) 简介面向STM32开发者的SD卡读写工程基于ARM Cortex-M内核覆盖SPI/SDIO接口通信、SD卡协议、CMD0~CMD25初始化与读写流程并包含DMA传输、中断处理和FATFS文件系统挂载等关键环节适合正在学习嵌入式存储扩展或在项目中需要快速移植SD卡功能的读者。压缩包共106个文件以51个.h和44个.c源码文件为主附带Keil工程配置uvproj/uvopt、hex固件、烧录脚本及说明文档整体约204KB可直接对照源码理解完整读写链路。资源已获得1177人次学习/下载具有较高的参考性。实验39 SD卡实验提供了从初始化、单块/多块读写到错误处理的完整示例代码结构清晰便于在现有工程基础上裁剪后接入自己的主控项目。 最近翻出一个积灰的STM32F103C8T6小开发板想做一个数据记录器把传感器数值实时存到SD卡里。网上流传的SD卡读取例程非常多但绝大多数要么是旧项目搬出来的、要么缺了关键配置直接抄经常卡死在CMD0或者ACMD41文件系统更是不用想了。把整个流程完整跑通之后我决定把这份实测可用的方案整理出来硬件接线、SPI模式下的SD卡初始化、读写扇区驱动、FATFS移植再到我踩过的那些坑。适合刚接触SD卡读写或者已经下载了例程但一直调不通的开发者。代码用STM32标准外设库写但原理完全通用换到HAL库也只需要把SPI收发函数替换掉。1. 项目背景与需求拆解1.1 为什么在STM32上读写SD卡最适合用SPI模式STM32读写SD卡有两条路一条是SDIO接口一条是SPI接口。SDIO速度快能跑四线并行但引脚占用多还得处理复杂的控制器寄存器和中断对新手来说门槛并不低。SPI模式只需要四根线SCK、MOSI、MISO、CS几乎任何STM32型号都有SPI外设代码逻辑也简单得多。缺点是速度上限大概在2MB/s左右但做日志存储、配置读取、数据记录这类应用完全够用。这次项目对速度没有极端要求所以选了SPI模式这也是大部分入门的首选方案。还有一层原因SD卡的SPI模式是它的兼容模式任何SD卡、SDHC卡、甚至部分SDXC卡在SPI模式下都能工作不受底层协议差异影响。你不需要关心卡是MLC还是TLC也不需要处理SDIO那套复杂的命令队列SPI把所有东西都简化成“发命令—等响应—传数据”这个循环。1.2 运行环境与硬件清单这次使用的是STM32F103C8T6标准外设库3.5版本SD卡用FATFS文件系统R0.15版本。硬件清单如下STM32F103C8T6最小系统板MicroSD卡模块带电平转换的那种或裸卡座8GB/16GB TF卡一张建议先用低速卡验证杜邦线若干3.3V电源板载AMS1117即可强调一下我建议买带电平转换的SD卡模块这样卡座供电、上拉电阻、电平匹配都做好了省掉很多排查时间。如果自己做裸卡座SD卡信号的IO电平必须保持在3.3V千万不要直接接5V否则STM32的GPIO有烧毁风险。2. SPI硬件接好不等于万事大吉上电与初始化细节2.1 三线SPI接线怎么接为什么要先拉高CSSPI模式下SD卡座一般引出6个引脚CS、SCK、MOSI、MISO、VCC、GND。和STM32的SPI1对应关系如下STM32引脚功能连接到SD卡模块PA4或任意GPIOCS卡座CSPB13SPI1_SCK卡座SCKPB14SPI1_MISO卡座MISOPB15SPI1_MOSI卡座MOSI3.3V电源卡座VCCGND地卡座GND选定的CS引脚是PA4完全可以用普通GPIO模拟。接线时有一个特别容易忽略的细节STM32复位和上电的瞬间CS引脚必须保持高电平。如果CS在上电时是低电平SD卡会误以为主机要发送命令进入不可预期的状态导致后面初始化失败。#define SD_CS_HIGH() GPIO_SetBits(GPIOA, GPIO_Pin_4) #define SD_CS_LOW() GPIO_ResetBits(GPIOA, GPIO_Pin_4)另外MISO引脚最好配置成浮空输入或带上拉输入MOSI、SCK、CS配置成推挽输出。不少例程把MISO也配成了推挽输出这会导致读回来的数据一直是0xFF卡永远不就绪。2.2 SD初始化流程CMD0、CMD8、ACMD41到底在做什么SD卡SPI模式初始化是整个项目最容易卡死的地方核心流程是这套命令序列拉高CS发送至少74个时钟周期的0xFF拉低CS发送CMD0GO_IDLE_STATE等待响应0x01发送CMD8SEND_IF_COND判断卡类型是否支持SDHC/SDXC循环发送ACMD41SD_SEND_OP_COND等待卡退出IDLE状态发送CMD58READ_OCR读取OCR寄存器确认卡容量类型发送CMD16SET_BLOCKLEN设置块大小为512字节这些命令的本质一句话总结就是先让卡进入SPI模式然后告诉它“我要用扇区寻址而不是字节寻址”最后确认它准备好工作了。CMD8的返回值很关键如果返回0x01并且后面跟着0x00 0x00 0x01 0xAA说明卡支持SDHC如果CMD8完全没响应说明是SD1.x老卡或者MMC卡ACMD41的参数要调整。uint8_t SD_Init(void) { uint16_t retry 0; uint8_t r1 0; SD_CS_HIGH(); for (uint16_t i 0; i 10; i) { SD_SPI_RW(0xFF); } SD_CS_LOW(); retry 0; while (retry 20) { r1 SD_SendCmd(CMD0, 0x00000000, 0x95); if (r1 0x01) break; retry; } if (retry 20) return 1; r1 SD_SendCmd(CMD8, 0x000001AA, 0x87); if (r1 0x01) { for (uint8_t i 0; i 4; i) SD_SPI_RW(0xFF); // 读R7响应 retry 0; while (retry 200) { SD_SendCmd(CMD55, 0, 0x01); r1 SD_SendCmd(ACMD41, 0x40000000, 0x01); if (r1 0x00) break; retry; } if (retry 200) return 2; } else { retry 0; while (retry 200) { SD_SendCmd(CMD55, 0, 0x01); r1 SD_SendCmd(ACMD41, 0x00000000, 0x01); if (r1 0x00) break; retry; } if (retry 200) return 3; } if ((SD_SendCmd(CMD58, 0, 0x01) 0x00)) { // OCR寄存器bit30 1 表示SDHC/SDXC块寻址之后无需移位 // 这里简单确认即可也可以保存标志供读扇区使用 for (uint8_t i 0; i 4; i) SD_SPI_RW(0xFF); } if (SD_SendCmd(CMD16, 512, 0x01) ! 0x00) return 4; SD_CS_HIGH(); SD_SPI_RW(0xFF); return 0; }2.3 时钟分频初始化400kHz、传输18MHz的切换时机这段是很多例程没写清楚的地方。SD卡在刚上电时SPI时钟频率不能太高官方文档建议不要超过400kHz。你手里的STM32主频72MHzSPI1挂载在APB2总线如果上来就用2分频跑36MHz很多卡会直接初始化失败甚至卡死。我的做法是SPI初始化阶段先设256分频实测频率约281kHz安全通过初始化等SD卡返回就绪再把SPI分频切到2分频提升后续读写速度。SPI_Cmd(SPI1, DISABLE); SPI_InitStructure.SPI_BaudRatePrescaler SPI_BaudRatePrescaler_2; SPI_Init(SPI1, SPI_InitStructure); SPI_Cmd(SPI1, ENABLE);切换分频时要先把SPI外设关掉改完再重新使能。还有一件事容易被忽略改完SPI频率之后发一个0xFF来产生额外时钟脉冲让卡从上一个命令的收发状态中完全恢复。3. 读写扇区的核心驱动代码直接抄但顺序别改3.1 命令发送函数与R1响应解析SD卡在SPI模式下的每一条命令都是一个固定格式的6字节帧如表所示字节内容字节00x40 命令号比如CMD17就是0x51字节1参数最高8位字节2参数字节3参数字节4参数最低8位字节5CRC校验字节SPI模式初始化后可不校验但CMD0和CMD8必须正确命令发送的代码要严格控制时序发完6个字节后主机要继续给时钟SD卡才能输出响应。响应是一个字节的R1最高位是0表示响应有效bit0是1表示卡还处于IDLE状态。这里有一个小技巧读取响应的时候要连续读直到读到最高位为0的字节为止最多等8个时钟周期否则就按超时处理。uint8_t SD_SendCmd(uint8_t cmd, uint32_t arg, uint8_t crc) { uint8_t r1 0; uint8_t retry 0; SD_SPI_RW(cmd | 0x40); SD_SPI_RW(arg 24); SD_SPI_RW(arg 16); SD_SPI_RW(arg 8); SD_SPI_RW(arg); SD_SPI_RW(crc); do { r1 SD_SPI_RW(0xFF); retry; } while ((r1 0x80) retry 8); return r1; }很多初次接触的人会奇怪为什么这个函数在发命令时不控制CS答案是CS的拉高拉低放在更高层的读扇区、写扇区函数里因为一次完整的读写操作可能包含多条命令比如ACMD41需要先发CMD55再发ACMD41CS必须保持低电平直到整条命令序列结束。3.2 单扇区读写的实现与返回值判定读扇区用CMD17参数是扇区号。注意这里的扇区号是以512字节为单位的块编号不是字节地址。如果卡是SDHC/SDXC扇区号直接传如果是SD1.x需要先把块号左移9位变成字节地址。读数据的过程分三步发命令、等0xFE数据令牌、连续读512字节和2字节CRC。CRC在SPI模式下不强制校验但主机必须把CRC字节读掉否则后续数据会错位。uint8_t SD_ReadBlock(uint32_t block, uint8_t *buf) { uint8_t r1 0; uint16_t retry 0; SD_CS_LOW(); r1 SD_SendCmd(CMD17, block, 0x01); if (r1 ! 0x00) { SD_CS_HIGH(); return 1; } while (SD_SPI_RW(0xFF) ! 0xFE) { retry; if (retry 2000) { SD_CS_HIGH(); return 2; } } for (uint16_t i 0; i 512; i) { buf[i] SD_SPI_RW(0xFF); } SD_SPI_RW(0xFF); SD_SPI_RW(0xFF); SD_CS_HIGH(); SD_SPI_RW(0xFF); return 0; }写扇区用CMD24比读稍微麻烦一点。发完命令之后要接着发数据令牌0xFE、512字节数据、2字节CRC然后主机继续给时钟读回一个数据响应字节。这个响应字节的低5位必须是0x05表示“数据已接受”。最后还要轮询卡是否忙忙的时候MISO一直拉低要一直读0xFF直到读到0xFF为止。如果这里不等待下次读扇区时大概率会读到错误数据。uint8_t SD_WriteBlock(uint32_t block, const uint8_t *buf) { uint8_t r1 0; uint8_t resp 0; uint16_t retry 0; SD_CS_LOW(); r1 SD_SendCmd(CMD24, block, 0x01); if (r1 ! 0x00) { SD_CS_HIGH(); return 1; } SD_SPI_RW(0xFE); for (uint16_t i 0; i 512; i) { SD_SPI_RW(buf[i]); } SD_SPI_RW(0xFF); SD_SPI_RW(0xFF); resp SD_SPI_RW(0xFF); if ((resp 0x1F) ! 0x05) { SD_CS_HIGH(); return 2; } retry 0; while (SD_SPI_RW(0xFF) ! 0xFF) { retry; if (retry 60000) { SD_CS_HIGH(); return 3; } } SD_CS_HIGH(); SD_SPI_RW(0xFF); return 0; }3.3 多扇区读写到底要不要做这里直接说结论如果只是记录几百字节的文本日志单扇区读写就够了如果需要存音频采样、连续采集传感器数据多扇区读写能显著减少命令开销。FATFS在读写大文件时会频繁调用disk_read和disk_write参数count往往是大于1的所以我在移植时直接把多扇区拆成循环单扇区操作它也是可以用的只是性能打折。真正追求性能可以用CMD18读多扇区、CMD25写多扇区。多块读时SD卡会连续输出多个0xFE数据块要一直读到主机发CMD12STOP_TRANSMISSION才停止。这个逻辑本身不复杂但调试起来比单块麻烦尤其要注意CMD12必须在CS拉低时发送。第一次项目建议先跑通单扇区确认硬件和时序没问题再优化成多扇区。4. 移植FATFS文件系统从裸扇区到“能存文件”4.1 FATFS源码怎么组织ffconf.h必须改的三个选项裸扇区读写只能操作固定地址要像电脑一样创建文件必须上文件系统。FATFS是一个轻量级开源FAT文件系统移植起来非常顺。把ff.c、ff.h、ffconf.h、diskio.c、diskio.h五个文件加进工程然后重点改三个配置FF_USE_MKFS改为1否则无法用f_mkfs格式化SD卡FF_USE_LFN改为1开启长文件名支持不然只能存8.3短文件名FF_CODE_PAGE改为936适配简体中文字符集这里的FF_CODE_PAGE值得提一下很多例程默认是437写中文文件名会乱码或者写不进去。改成936后编译器处理字符串时还要注意编码格式最好用GBK/ANSI编码保存源文件否则f_open传入的中文文件名在底层会对应不上。4.2 diskio.c底层适配五个函数一个都不能少FATFS通过diskio.c里的几个接口和底层硬件打交道。必须实现的函数有disk_status、disk_initialize、disk_read、disk_write、disk_ioctl以及get_fattime。它们的作用很直接就是把FATFS的逻辑请求翻译成SD卡驱动函数调用。DSTATUS disk_status(BYTE pdrv) { if (pdrv) return STA_NOINIT; if (SD_InitDone 0) return STA_NOINIT; return 0; } DSTATUS disk_initialize(BYTE pdrv) { if (pdrv) return STA_NOINIT; if (SD_Init() 0) { SD_InitDone 1; return 0; } return STA_NOINIT; } DRESULT disk_read(BYTE pdrv, BYTE *buff, LBA_t sector, UINT count) { for (UINT i 0; i count; i) { if (SD_ReadBlock(sector i, buff i * 512) ! 0) return RES_ERROR; } return RES_OK; } DRESULT disk_write(BYTE pdrv, const BYTE *buff, LBA_t sector, UINT count) { for (UINT i 0; i count; i) { if (SD_WriteBlock(sector i, buff i * 512) ! 0) return RES_ERROR; } return RES_OK; } DRESULT disk_ioctl(BYTE pdrv, BYTE cmd, void *buff) { switch (cmd) { case CTRL_SYNC: return RES_OK; case GET_SECTOR_SIZE: *(WORD *)buff 512; return RES_OK; case GET_BLOCK_SIZE: *(DWORD *)buff 1; return RES_OK; case GET_SECTOR_COUNT: *(LBA_t *)buff 2000000; return RES_OK; } return RES_PARERR; } DWORD get_fattime(void) { return 0; // 简单返回不生成标准时间戳 }get_fattime返回0会导致文件时间显示为1980年不影响写入和读取。如果要做时间戳可以把RTC实时时钟的值按FATFS规定的位域拼出来。disk_ioctl里的GET_SECTOR_COUNT必须返回正确容量否则格式化会失败。容量可以在SD_Init阶段通过CMD9读取CSD寄存器计算也可以先用一个固定值测试项目定版后建议读取真实的CSD。4.3 用f_mkfs格式化以及创建文件验证读写格式化SD卡只需要一行f_mkfs但前提前面说过必须在ffconf.h开启FF_USE_MKFS。格式化会清空卡上所有数据调试阶段没问题但别拿存了重要资料的卡直接试。FATFS fs; FIL fil; FRESULT res; res f_mount(fs, , 1); if (res ! FR_OK) { return; } // 如果返回FR_NO_FILESYSTEM执行格式化 res f_mkfs(, 0, 0); if (res FR_OK) { res f_mount(NULL, , 0); res f_mount(fs, , 1); } res f_open(fil, data.txt, FA_CREATE_ALWAYS | FA_WRITE); if (res FR_OK) { UINT bw; f_write(fil, hello sd card\r\n, 15, bw); f_close(fil); }这里有一个非常容易踩的地方f_mount只在第一次调用时注册工作区格式化完之后必须先用f_mount(NULL, , 0)卸载再重新挂载否则新格式化的文件系统不会生效。写完文件之后不要急着拔卡调用f_close或者f_sync把缓存刷进SD卡不然最后一批数据可能只写在FATFS的RAM缓存里。5. 亲测过程中踩过的几个硬坑与排查5.1 上电第一次读卡失败时序与延时我最开始测试时每次冷启动后第一次初始化都会卡在ACMD41但按一下复位键就好了。排查了很久发现是上电时序的问题。STM32跑起来的速度比SD卡供电稳定快得多如果SD卡还没完全上电就发命令它自然不会响应。解决办法是在SPI初始化和SD_Init之间加一个200ms左右的延时同时让CS保持高电平先发送几十个空时钟脉冲。这种“先给电、再给时钟、最后发命令”的顺序是SD卡SPI模式最稳定的启动姿势。如果用的是质量一般的卡座延时建议加到300ms以上。5.2 文件创建成功但写入丢失f_mount挂载与f_sync有次测试发现f_open返回OKf_write返回OK但把SD卡插到电脑上文件是空的。这个问题其实不在文件系统逻辑而是我没有调用f_sync导致数据还留在文件系统的缓冲区里。FATFS为了性能写操作会先缓存主动调用f_sync才会把数据刷到物理扇区。如果直接掉电拔卡缓存数据就是白写了。频繁调用f_sync会影响写入速度所以工程上一般是关键数据落盘时调用一次或者按固定间隔批量刷新。日志系统里可以每写满512字节调用一次既不浪费性能又能保证数据安全。5.3 无法识别的卡和不兼容的卡SPI模式局限不是所有SD卡都能在SPI模式下正常工作。测试中一张杂牌64G TF卡CMD0一直收不到0x01串口打印输出显示超时换用闪迪16G C10后就正常了。后来查资料得知有些新出的大容量SDXC卡在SPI模式下的兼容性确实差甚至有些卡为了追求速度直接去掉了SPI模式的支持。如果你遇到初始化不过的卡建议按这个顺序排查先用低速时钟然后手动给卡补上拉电阻最后换卡。我手头备了三张不同品牌型号的卡一张高速卡、一张普通卡、一张老卡专门用来测试兼容性。这个习惯一直保留到现在做存储相关项目时特别有用。5.4 故障速查表现象可能原因解决方案CMD0返回0xFFMISO接反、SPI时钟极性不对交换MOSI/MISO检查CPOL/CPHACMD8无响应上电时序过快、老卡不支持加200ms延时忽略CMD8走老卡流程ACMD41一直超时卡太新/不兼容、时钟太快降低SPI分频换卡测试读扇区卡在等0xFE卡损坏、上一条写命令未完成检查写命令后的忙状态等待f_mkfs返回FR_NO_FILESYSTEMFF_USE_MKFS未开、容量未填写打开宏disk_ioctl返回真实容量文件名乱码FF_CODE_PAGE配置错误改为936用GBK编码保存源码还有一个细节SD卡初始化完成后第一次读扇区前最好先清空缓冲区并做一次空读。有些卡在初始化结束和第一个数据块之间需要额外时钟脉冲来切换状态补一个0xFF就能避免偶发的读错块问题。6. 我总结的几条实用心得回头看不难发现SD卡SPI驱动本身不难难的是时序和供电这类看不到的问题。初始化时CS必须拉高、时钟必须从低速开始、上电必须留足延迟这三件事占了整个调试过程中八成的时间。做嵌入式开发就是这种风格代码只有几十行但每个细节都可能卡你一整天。这次工程我只用了单扇区读写加循环后面如果数据量变大可以考虑上DMA加双缓冲把SPI收发从CPU中解放出来或者用RTOS把写盘任务放到后台避免传感器采集被打断。FATFS还支持多卷可以同时挂载SD卡和SPI Flash做参数备份很方便。调试阶段建议串口打印关键状态把SD卡初始化的每一步返回值都打出来这样问题出在哪一层会非常清楚。本文还有配套的精品资源点击获取