1. 别被5分钟骗了FatFs移植真正的时间花在哪先把结论摆在前面FatFs这个文件系统本身从拿到源码到在STM32上跑通f_mount如果你熟悉流程确实用不了5分钟。但真正让大多数人卡住的从来不是FatFs而是它底下那层SD卡驱动——SPI模式初始化时序、CMD命令响应、块读写超时这些才是吃时间的黑洞。所以这篇东西我会把重心放在驱动适配上FatFs的移植反而讲得快因为那部分真的就是复制粘贴加改几个宏。FatFs是ChaN写的一个轻量级FAT文件系统模块纯C、无平台依赖、可裁剪ROM占用在几KB到十几KB之间RAM占用更小非常适合STM32这类资源受限的MCU。它支持FAT12/FAT16/FAT32和exFAT需要单独配置能读写文件、创建目录、获取文件信息对于做数据记录、配置存储、固件升级包读取这类需求来说是性价比极高的方案。适合谁看手上有STM32板子和一张SD卡、想快速把文件读写跑起来的人已经移植过但卡在f_mount返回FR_NOT_READY或者FR_DISK_ERR的人以及想搞清楚为什么我的SD卡SPI模式初始化老失败的人。如果你连SPI都没配过建议先把SPI收发跑通再来看这篇不然会有点吃力。我下面讲的流程基于STM32F1/F4系列加标准外设库或HAL库都适用SD卡走SPI模式不是SDIO因为SPI模式兼容性最好接线简单代价是速度慢一些。如果你追求速度后面我会提SDIO的取舍。2. 移植前必须想清楚的三个取舍2.1 SPI模式还是SDIO模式这不是随便选的很多人一上来就问哪个快但选型不能只看速度。SPI模式只需要4根线CS、SCK、MISO、MOSI任意GPIO都能模拟STM32的硬件SPI随便挑一个就行接线容错率高调试时用逻辑分析仪一抓就能看到命令交互。缺点是速度受限于SPI时钟一般跑到18MHz到24MHz就算不错了实际读写速度大概在几百KB/s到1MB/s出头。SDIO模式是SD卡的原生接口4位数据线并行理论速度能到十几MB/sSTM32F4的SDIO配合DMA跑高速卡能到10MB/s以上。但SDIO的坑在于时钟配置、DMA对齐、卡兼容性尤其是某些杂牌卡在SDIO下初始化会莫名其妙失败换SPI模式反而正常。我的建议很直接做数据记录、配置存储、偶尔读个文件SPI模式足够别折腾SDIO。只有当你需要连续高速写日志比如音频采样、高速ADC数据流时才值得上SDIO。这篇按SPI模式讲因为它是5分钟能跑通的那条路。2.2 硬件SPI还是软件模拟SPISTM32的硬件SPI外设用起来省心配置好时钟极性和相位就行收发由硬件完成CPU占用低。但硬件SPI有个坑不同STM32系列的SPI引脚是固定的如果你的PCB已经把SD卡接在了某个不能复用为SPI的引脚上那就只能用软件模拟。软件模拟SPI的好处是引脚随便选坏处是速度慢、代码里要手动翻转电平、时序靠延时保证。实测软件模拟SPI在72MHz的F1上大概能跑到1MHz左右的SCK读写速度会明显下降。所以能硬件SPI就硬件SPI这是第一优先级。2.3 用现成驱动还是自己写网上有大量STM32 SD卡SPI驱动的现成代码但质量参差不齐。有些驱动在初始化时延时不规范遇到高速卡就挂有些没有处理CMD响应中的忙状态写块时偶发失败。我的做法是参考现成驱动的框架但CMD命令的发送和响应解析自己重写一遍因为这部分是稳定性的核心必须自己心里有数。下面这张表是我在实际项目中对比过的几种方案供你选型参考方案接线复杂度实测读写速度稳定性适合场景硬件SPI 自写驱动低500KB/s~1MB/s高数据记录、配置存储软件模拟SPI低100~300KB/s中引脚受限的板子SDIO DMA中5~10MB/s中高高速数据流、音频SDIO 中断中3~6MB/s中中等速率需求3. SD卡SPI模式的初始化90%的失败都在这3.1 上电后的74个时钟为什么不能省SD卡协议里有一条硬性规定卡上电后在CS为高、MOSI为高的状态下必须至少给它发送74个SCK时钟让卡完成内部上电初始化。这74个时钟不是随便说说的少了卡可能不响应后续命令。很多人用硬件SPI时忘了这一步直接拉低CS发CMD0结果卡毫无反应。正确做法是SPI配置好后先不管CS直接发10个字节的0xFF10×880个时钟留点余量然后再拉低CS开始发命令。// SD卡上电初始化发送至少74个时钟 static void SD_PowerOnDelay(void) { uint8_t i; SD_CS_HIGH(); // CS拉高 for(i 0; i 10; i) { SPI_ReadWriteByte(0xFF); // 发80个时钟 } }注意这10个0xFF必须在CS为高的时候发如果CS是低的卡会把这些字节当成命令的一部分直接乱套。3.2 CMD0、CMD8、ACMD41这条命令链的逻辑SD卡SPI模式的初始化是一条固定的命令链顺序不能乱CMD0复位卡到idle状态。响应应该是0x01。CMD8发送接口条件检查卡是否支持2.7V~3.6V电压和是否是SDHC卡。响应0x01表示支持同时会返回卡的电压范围。如果返回0x05illegal command说明是老卡SD v1.x后面ACMD41的参数要改。CMD58读OCR寄存器判断卡是不是高容量卡SDHC/SDXC。这个命令在ACMD41成功之后发。ACMD41发送主机容量支持信息让卡完成初始化。这个命令要循环发送直到响应从0x01变成0x00。循环时每次要延时一段时间不能死循环猛发。CMD58再次读OCR确认卡初始化完成并获取CCS位判断容量类型。这里最容易出问题的是ACMD41的循环。有些人写了个while(response ! 0)死等结果卡一直返回0x01程序卡死。正确做法是加超时计数比如循环200次还没成功就报错退出。// ACMD41循环等待初始化完成 uint8_t SD_InitACM41(void) { uint8_t rsp; uint16_t retry 0; do { rsp SD_SendCmd(CMD55, 0, 0x01); // 先发CMD55 rsp SD_SendCmd(CMD41, 0x40000000, 0x01); // HCS位置1 retry; Delay_ms(10); } while((rsp ! 0x00) (retry 200)); if(retry 200) return 1; // 超时失败 return 0; }3.3 响应解析R1、R3、R7别搞混SD卡SPI模式下的响应有好几种格式搞混了就会解析出错R11字节最高位为0其余位是错误标志。CMD0、CMD55、ACMD41、CMD58都返回R1。R3R1 4字节OCR。CMD58返回这个。R7R1 4字节。CMD8返回这个包含电压信息和检查模式。解析R1的时候要注意卡在忙的时候会返回0xFFMISO一直高所以读响应要先等最高位变成0。我见过有人直接读一个字节就当响应结果读到0xFF还以为卡坏了。// 等待并读取R1响应带超时 uint8_t SD_GetR1Response(void) { uint8_t rsp; uint16_t retry 0; do { rsp SPI_ReadWriteByte(0xFF); retry; } while((rsp 0x80) (retry 200)); // 最高位为1说明还在忙 return rsp; }3.4 一个真实的排查案例我之前遇到一块板子SD卡初始化时CMD0能返回0x01CMD8返回0x01但ACMD41怎么循环都返回0x01最后超时。换了三张卡都一样说明不是卡的问题。排查过程先用逻辑分析仪抓SPI波形发现CMD55和CMD41的命令字节发出去是对的但CMD41的参数是0x40000000这个HCS位是给SDHC卡用的。问题在于——这块板子的SPI时钟太快了初始化阶段SCK跑到了18MHz而SD卡协议规定初始化阶段SCK不能超过400kHz。把SPI时钟分频调到400kHz以下ACMD41立刻就通过了。初始化完成后再把SPI时钟提到高速。这个坑非常典型初始化必须低速读写才能高速。// 初始化阶段低速SPI SPI_Init(SPI_BaudRatePrescaler_256); // 72MHz/256 ≈ 281kHz SD_Init(); // 初始化完成后切换到高速 SPI_Init(SPI_BaudRatePrescaler_4); // 72MHz/4 18MHz4. 块读写单块和多块的差异与陷阱4.1 CMD17读单块和CMD18读多块读操作相对简单。CMD17读单块发完命令后等一个数据起始令牌0xFE然后连续读512字节最后读2字节CRCSPI模式下可以忽略但必须读掉。CMD18读多块发完命令后可以连续读多个块每块前面都有一个0xFE令牌读完想要的块数后发CMD12停止传输。这里有个坑CMD12发完之后卡可能还在发送剩余数据必须等到卡不忙了才能发下一条命令。// 读单块 uint8_t SD_ReadBlock(uint32_t sector, uint8_t *buf) { if(SD_SendCmd(CMD17, sector, 0x01) ! 0x00) return 1; if(SD_WaitToken(0xFE) ! 0) return 2; // 等数据令牌 SPI_ReadBytes(buf, 512); SPI_ReadWriteByte(0xFF); // 读掉CRC SPI_ReadWriteByte(0xFF); return 0; }4.2 CMD24写单块忙状态判断是关键写操作比读操作麻烦因为写完数据后卡会进入忙状态MISO会被拉低一段时间。如果你不等卡忙完就发下一条命令命令会丢失。写单块的流程发CMD24等响应0x00发一个0xFE数据令牌发512字节数据发2字节CRC然后等卡忙完MISO变高。// 写单块 uint8_t SD_WriteBlock(uint32_t sector, const uint8_t *buf) { if(SD_SendCmd(CMD24, sector, 0x01) ! 0x00) return 1; SPI_ReadWriteByte(0xFF); // 一个字节的间隔 SPI_ReadWriteByte(0xFE); // 数据令牌 SPI_WriteBytes(buf, 512); SPI_ReadWriteByte(0xFF); // CRC SPI_ReadWriteByte(0xFF); uint8_t rsp SPI_ReadWriteByte(0xFF); if((rsp 0x1F) ! 0x05) return 2; // 数据响应错误 // 等卡忙完 uint16_t retry 0; while(SPI_ReadWriteByte(0xFF) ! 0xFF) { retry; if(retry 60000) return 3; // 超时 } return 0; }提示写块时的忙等待超时值要设得足够大。SD卡内部擦除一个块可能需要几百毫秒如果超时设小了会误判为写失败。我一般设60000次循环配合SPI速度大概能等几百毫秒。4.3 多块写的预擦除ACMD23的妙用CMD25写多块时如果直接连续写卡内部会边写边擦除速度慢且容易出错。更好的做法是先发ACMD23告诉卡我要写N个块让卡提前擦除这些块然后再发CMD25连续写。ACMD23的参数是要写的块数。发完之后再发CMD25卡就知道要写多少块内部可以优化擦除策略。实测这个操作能让多块写的稳定性明显提升尤其是写大文件的时候。4.4 扇区号还是字节地址SDHC和SDSC的区别这是另一个高频坑。SDSC卡标准容量小于2GB的读写命令参数是字节地址而SDHC/SDXC卡高容量的参数是扇区号。如果你用SDHC卡但按字节地址传参读写会完全错位。判断方法初始化时CMD58返回的OCR寄存器bit30是CCS位。CCS1表示SDHC/SDXC用扇区号CCS0表示SDSC用字节地址。FatFs的disk_read/disk_write接口传进来的是扇区号所以对于SDSC卡你需要把扇区号乘以512再传给底层驱动。// 根据卡类型转换地址 if(SD_CardType SDHC) { addr sector; // 扇区号直接用 } else { addr sector * 512; // 字节地址 }5. FatFs移植真正5分钟的部分5.1 源码文件怎么裁剪FatFs的源码就几个文件ff.c、ff.h、ffconf.h、diskio.c、diskio.h还有可选的ffunicode.c长文件名支持。如果你不需要中文文件名和长文件名可以把_USE_LFN设为0这样能省不少ROM。ffconf.h里几个关键配置宏建议值说明_FS_TINY0用普通模式性能更好_USE_LFN0或1不需要长文件名就设0_MAX_SS512扇区大小SD卡固定512_VOLUMES1挂载几个卷_FS_RPATH0不需要相对路径就关掉_FS_MINIMIZE0不裁剪功能5.2 diskio.c里要改的五个函数diskio.c是FatFs和底层驱动的桥梁需要实现五个函数disk_initialize调用你的SD_Init()disk_status返回卡状态简单返回0就行disk_read调用SD_ReadBlock注意多扇区时循环读disk_write调用SD_WriteBlock多扇区时循环写disk_ioctl处理CTRL_SYNC、GET_SECTOR_COUNT、GET_SECTOR_SIZE、GET_BLOCK_SIZE最容易出错的是disk_ioctl里的GET_SECTOR_COUNT这个要返回卡的总扇区数。如果你返回0f_mount可能成功但后续操作会出问题。总扇区数可以通过读CSD寄存器获取或者简单点用容量除以512估算。DRESULT disk_ioctl(BYTE pdrv, BYTE cmd, void *buff) { switch(cmd) { case CTRL_SYNC: return RES_OK; case GET_SECTOR_COUNT: *(DWORD*)buff SD_SectorCount; return RES_OK; case GET_SECTOR_SIZE: *(WORD*)buff 512; return RES_OK; case GET_BLOCK_SIZE: *(DWORD*)buff 1; return RES_OK; default: return RES_PARERR; } }5.3 f_mount之后为什么还是读写失败f_mount成功只代表FatFs挂载了逻辑驱动器不代表底层驱动没问题。很多人f_mount返回FR_OK但一调f_open就返回FR_DISK_ERR问题出在disk_read上。排查顺序先确认disk_read单独调用能不能读到正确数据比如读扇区0看最后两字节是不是0x55AA再确认disk_ioctl的GET_SECTOR_COUNT返回值对不对最后检查ffconf.h里的_MAX_SS是不是512。我遇到过一次f_mount成功但f_open失败查了半天发现是disk_read里多扇区读的时候扇区号没有递增每次都在读同一个扇区。这种低级错误在调试时反而容易被忽略。6. 那些文档里不会写的实操心得6.1 杜邦线是SD卡调试的隐形杀手SPI模式虽然接线简单但对信号质量敏感。用杜邦线接SD卡模块线长超过10厘米高速SPI下就会出现读写不稳定。表现是低速能读高速读一会儿就出错或者写小文件正常写大文件就失败。解决办法要么把SPI速度降下来要么把线缩短要么在SCK和MOSI上串22欧姆到100欧姆的电阻做阻抗匹配。我自己的板子是把SD卡座直接画在PCB上走线尽量短问题就没了。6.2 卡的热插拔检测不能省产品里SD卡是要插拔的如果不做检测卡拔了之后程序还在读写会卡死在忙等待里。最简单的做法是用一个GPIO检测卡座的CD引脚或者定期读CID寄存器判断卡是否还在。FatFs本身不处理热插拔你需要在应用层做。我的做法是每次读写前先调一次disk_status如果返回STA_NOINIT就重新初始化。虽然有点耗时但能避免卡死。6.3 写文件后必须f_sync或f_closeFatFs有缓存机制f_write之后数据可能还在缓存里没写到卡上。如果这时候断电数据就丢了。所以写完关键数据后要么调f_sync强制刷盘要么调f_close。f_close会顺便更新目录项更彻底。做数据记录的项目里我一般每写一批数据就f_sync一次虽然会损失一点速度但数据安全性高很多。6.4 长文件名和中文支持要额外开FF_USE_LFN如果你需要中文文件名_USE_LFN要设为1或2还要把ffunicode.c加进工程并且配置_CODE_PAGE为936简体中文。注意_USE_LFN设为1时用的是静态缓冲区设为2时用栈上的动态缓冲区后者更省RAM但需要栈够大。开了长文件名之后ROM占用会增加不少STM32F103C8这种64KB Flash的片子要留意空间。6.5 格式化用f_mkfs还是PC格式化新卡或者文件系统损坏的卡可以用f_mkfs在MCU上格式化。但f_mkfs格式化出来的卡在PC上可能识别不了因为默认的簇大小和PC不一样。我的建议是能用PC格式化就用PC格式化选FAT32簇大小默认。只有在没有PC的现场才用f_mkfs应急。f_mkfs调用时要注意它会擦除整个卡耗时可能几十秒期间不能断电。7. 从能跑到跑稳几个进阶优化方向7.1 用DMA减轻CPU负担SPI收发如果用查询方式CPU会一直被占用。STM32的SPI支持DMA配置好DMA通道后读写512字节可以由DMA完成CPU可以去干别的事。对于需要同时处理其他任务的项目DMA是必须的。配置DMA时注意SPI的TX和RX要分别配DMA通道传输完成中断里处理后续逻辑。DMA的缓冲区要4字节对齐否则在某些STM32系列上会出错。7.2 多扇区读写减少命令开销FatFs的disk_read/disk_write一次可能请求多个扇区。如果你在底层用CMD18/CMD25做多块读写比循环调用单块命令快很多。实测读1MB数据多块比单块循环快30%以上。实现多块读写时注意CMD18读完之后要发CMD12停止并且等卡忙完。CMD25写多块时最后要发一个0xFD停止令牌。7.3 文件系统碎片化对写入速度的影响FAT文件系统用久了会产生碎片新写入的文件可能分散在多个不连续的簇上导致写入速度下降。对于数据记录类应用建议定期整理或者用固定大小的文件循环覆盖避免碎片化。一个实用技巧创建文件时预分配空间f_expand这样文件在磁盘上是连续的写入速度稳定。7.4 掉电保护不只是f_sync工业场景下掉电是常态。除了f_sync还可以考虑用双备份文件写新文件成功后再删旧文件或者在文件头加校验和读取时校验损坏就丢弃。FatFs本身没有日志功能掉电保护要靠应用层设计。我在一个电表数据记录项目里用的是写临时文件→f_sync→重命名的流程重命名是原子操作能保证要么是旧文件要么是新文件不会出现半截文件。8. 常见问题速查现象可能原因排查方向f_mount返回FR_NOT_READYSD卡初始化失败检查74时钟、SPI速度、接线f_mount成功但f_open返回FR_DISK_ERRdisk_read有问题单独测disk_read检查扇区号递增写小文件正常写大文件失败多扇区写有bug或SPI不稳定检查CMD25流程、降低SPI速度读到的数据全是0xFF卡没响应或CS控制错误检查CS时序、命令发送偶尔读写失败信号质量或忙等待超时不够缩短接线、加大超时中文文件名乱码没开长文件名或代码页不对配置_USE_LFN和_CODE_PAGE格式化后PC不识别f_mkfs参数与PC不一致用PC格式化或调整簇大小SD卡和FatFs这套组合说简单也简单说坑多也真多。核心就一句话初始化阶段慢下来、命令响应等到位、忙状态判准确、写后记得刷盘。把这四点做到基本就能从能跑到跑稳。至于那5分钟等你把驱动调通之后再移植FatFs确实就是复制文件改宏定义的事——难的是前面那层驱动那才是真正花时间的地方。