
简介面向STM32微控制器的FatFs文件系统参考资料包聚焦在嵌入式环境中为设备增加FAT格式存储读写能力适用于使用裸机、RTOS或需要日志记录、固件升级等场景的开发者。内容覆盖FAT12/16/32文件系统的基本结构FatFs的配置流程ffconf.h裁剪、ff.h核心接口、常用API调用f_mount、f_open、f_read、f_write、f_sync等以及错误码排查、性能优化扇区大小、SPI/SDMMC参数调整和实际应用示例能帮助读者从原理到移植快速落地。包体约8.71MB以文字讲解和示例说明为主便于对照查阅。当前已有203人学习适合正在基于STM32进行文件系统开发或准备在SD卡上做数据管理的嵌入式工程师参考。1. FATFS不是驱动是文件系统STM32上为什么绕不开它很多拿到“FatFs参考资料.zip”的开发者第一反应是把它当成SD卡驱动库。装上之后发现SPI和SDIO还是得自己调DMA搬数也得自己写FATFS既不管时序也不碰寄存器。这个印象没错但方向反了FATFS解决的不是“怎么把字节搬到SD卡上”而是“这些字节在SD卡上以什么规则存成文件”。没有它你往SD卡写的每一块数据都要自己维护扇区索引、目录项和簇链而有了它f_open、f_write、f_close就是你在STM32上操作文件系统的入口。FATFS是STM32项目里最常见的文件系统方案适合做数据记录、固件升级包存储、配置文件掉电保存这类场景。它不是一个驱动库而是一个逻辑层真正干活的是你写的disk_read和disk_write。把这件事想清楚后面调不通时你才知道该查哪一端。2. FATFS的卷、目录、文件对象移植前要搞清楚的三个概念2.1 FATFS的三层结构为什么底层I/O函数才是移植的核心FATFS的源码分三层但平时改代码时你只需要看清下面这张对应关系。应用层是你自己写的业务代码通过FIL、DIR这些句柄操作文件中间FAT层负责把文件路径解析成簇号把簇号换算成扇区号底层就是disk_initialize、disk_read、disk_write这组函数它们负责跟具体的存储介质对话。应用层f_open / f_read / f_write / f_close / f_mount ----------------------------- FAT层路径解析、簇链管理、目录项读写 ----------------------------- 底层disk_status / disk_initialize / disk_read disk_write / disk_ioctl / get_fattime ----------------------------- 物理层SPI Flash、SD卡、内部Flash大部分人在STM32上遇到“FATFS能编译过但读不了卡”问题都在最下面一层。FATFS对底层函数的接口协议非常固定你的任务是把它翻译成STM32的SPI或SDIO读写。也就是说移植FATFS的工作量90%集中在底层六个函数而不是去改FATFS内部逻辑。2.2 底层六个接口的职责与返回值约定先看一张接口清单把每个函数的用途和返回值记牢。disk_initialize在挂载时被调用disk_read和disk_write是数据通路disk_ioctl负责获取扇区数、扇区大小这类几何信息get_fattime提供文件时间戳disk_status则用于检查介质是否在位。函数原型职责返回值约定DSTATUS disk_status(BYTE pdrv)获得磁盘当前状态STA_NOINIT、STA_NODISK、STA_PROTECTDSTATUS disk_initialize(BYTE pdrv)初始化底层介质初始完清掉STA_NOINITDRESULT disk_read(BYTE pdrv, BYTE* buff, LBA_t sector, UINT count)读取count个连续扇区到buffRES_OK或错误码DRESULT disk_write(BYTE pdrv, const BYTE* buff, LBA_t sector, UINT count)把buff内容写入连续扇区RES_OK或错误码DRESULT disk_ioctl(BYTE pdrv, BYTE cmd, void* buff)获取参数或执行控制命令RES_OK、RES_PARERRDWORD get_fattime(void)返回当前日期时间打包成FAT格式32位时间戳这里最容易被忽略的是disk_read的buff对齐问题。STM32的SPI外设和DMA对缓冲区的对齐要求不同如果buff地址不满足4字节或32字节对齐DMA传输会直接卡死或者产生总线错误。FATFS内部默认是用FF_DEFINED方式分配的缓冲但当你把FF_USE_DYNALLOC打开后buff来自堆内存对齐情况就不可控了这个时候你需要保证底层拷贝时自己做一次中转对齐否则日志记了半天全是乱码。另一个常踩的点是get_fattime。有些人移植时偷懒直接返回0文件系统也能跑但SD卡上文件的修改时间会变成1980年。如果项目里要对文件按时间排序这个函数必须接上STM32的RTC格式是(year - 1980) 25 | month 21 | day 16 | hour 11 | minute 5 | second / 2。2.3 ffconf.h里先改这几个宏再谈读写性能ffconf.h是FATFS唯一的配置入口。网上很多移植教程让你“把所有宏都看一眼”实际上绝大多数配置项保持默认就行真正影响STM32日常使用的是下面这六个。配置项推荐值作用与理由FF_USE_MKFS1允许用f_mkfs格式化。第一次用新卡时必须开否则盘上没文件系统挂载会返回FR_NO_FILESYSTEMFF_USE_LFN2启用长文件名且用静态缓冲区。设为0时只支持8.3短文件名SD卡上超过8个字符的文件名全会变成乱码FF_CODE_PAGE936简体中文代码页。配合LFN使用否则f_open里带中文会打不开文件FF_MAX_SS4096最大扇区大小。如果只用SD卡可以改成512但外部Flash常配4096字节扇区设成4096兼容性更好FF_USE_STRFUNC2启用f_printf并允许\n自动补成\r\n写日志时省一个字节FF_FS_MINIMIZE0保留f_lseek、f_opendir等全部API方便后续加目录操作FF_USE_LFN设为2时FATFS内部会用静态缓冲区存放文件名占的内存比设为3动态获取LFN工作缓冲区多但好处是内存分配失败的风险小。STM32F103这类芯片RAM只有20KB或64KB如果还要跑RTOS建议保持FF_USE_LFN2且不要用FF_USE_DYNALLOC。FF_USE_LFN3虽然省静态内存但在嵌入式环境里malloc失败很难排查。提示FF_CODE_PAGE936只影响FATFS层对文件名的解释。STM32工程源码里的中文字符串如果编码是UTF-8和936代码页不匹配时依然会乱码。最稳妥的做法是文件内容用UTF-8文件名只用ASCII。3. STM32上把FATFS跑起来的最小流程挂载、打开、读写、关闭3.1 底层初始化SD卡上电时序的固定套路无论用标准库还是HAL库SD卡的初始化顺序都不能乱。先看一个CubeMX环境的SPI方式初始化流程SDIO方式同理只是换成HAL_SD_Init。void SD_SPI_Init(void) { // 使能SPI时钟配好GPIO复用为SPI功能 SPI_InitTypeDef spi {0}; spi.Mode SPI_MODE_MASTER; spi.Direction SPI_DIRECTION_2LINES; spi.DataSize SPI_DATASIZE_8BIT; spi.CLKPolarity SPI_POLARITY_LOW; // 模式0空闲为低 spi.CLKPhase SPI_PHASE_1EDGE; // 第一个边沿采样 spi.NSS SPI_NSS_SOFT; // 用软件管理CS别用硬件NSS spi.BaudRatePrescaler SPI_BAUDRATEPRESCALER_256; // 初始低速 spi.FirstBit SPI_FIRSTBIT_MSB; // SD卡要求MSB先行 HAL_SPI_Init(hspi1); // SD卡上电后需要至少74个时钟周期才能稳定 CS_HIGH(); for (int i 0; i 10; i) { HAL_SPI_Transmit(hspi1, (uint8_t[]) {0xFF}, 1, 1000); } // 之后再切到高速分频 __HAL_SPI_SET_PRESCALER(hspi1, SPI_BAUDRATEPRESCALER_2); }这段代码的关键在于起始分频必须慢。SD卡协议要求初始化阶段SPI时钟不能超过400kHz等CMD0和CMD1或ACMD41应答成功后才能把分频系数调大。NSS_SOFT是必须的STM32硬件NSS在多字节传输时会自动翻转片选SD卡不认识这种时序会直接通信失败。CS引脚用普通GPIO手动拉高拉低这才是SPI模式操作SD卡的正确打开方式。SDIO方式下没有分频切换的问题但要注意HAL_SD_Init后执行HAL_SD_ConfigCard拿到卡容量和扇区数这些参数后面要给disk_ioctl用。3.2 移植disk_read和disk_write扇区读写函数这样写底层I/O的核心是两个函数用HAL库的SPI接口来写逻辑最清晰。DRESULT disk_read(BYTE pdrv, BYTE* buff, LBA_t sector, UINT count) { if (pdrv ! 0) return RES_PARERR; for (UINT i 0; i count; i) { CS_LOW(); // CMD17 0x51单块读后跟32位扇区地址和8位CRC // 注意FATFS传入的sector是逻辑扇区SPI模式下直接用它 SD_SendCmd(17, sector i, 0xFF); // 等待0xFE数据令牌 if (SD_WaitToken(0xFE) ! 0) { CS_HIGH(); return RES_ERROR; } // 读512字节数据 2字节CRC for (int j 0; j 512; j) { HAL_SPI_Receive(hspi1, buff[i * 512 j], 1, 1000); } CS_HIGH(); } return RES_OK; }SD_SendCmd负责拼装命令帧命令索引、参数、CRC。SPI模式下CRC可以填0xFF因为SD卡在SPI模式不校验CRC对于CMD0除外它的CRC是固定值0x95。SD_WaitToken循环等待SD卡返回的数据开始令牌这里必须加超时否则SD卡无响应时程序会卡死在while里。这也是SD卡相关死循环最常见的地方HAL_GetTick()做超时判断是必需的。DRESULT disk_write(BYTE pdrv, const BYTE* buff, LBA_t sector, UINT count) { if (pdrv ! 0) return RES_PARERR; for (UINT i 0; i count; i) { CS_LOW(); // CMD24 0x58单块写 SD_SendCmd(24, sector i, 0xFF); // 发送0xFE数据起始令牌 HAL_SPI_Transmit(hspi1, (uint8_t[]) {0xFE}, 1, 1000); // 发512字节数据 2字节伪CRC HAL_SPI_Transmit(hspi1, (uint8_t*)(buff i * 512), 512, 1000); HAL_SPI_Transmit(hspi1, (uint8_t[]) {0xFF, 0xFF}, 2, 1000); // 读取数据应答bit5为0表示写入成功否则为拒绝/CRC错 uint8_t resp; HAL_SPI_Receive(hspi1, resp, 1, 1000); if ((resp 0x05) ! 0) { CS_HIGH(); return RES_ERROR; } // 等待SD卡进入忙状态结束期间输出时钟 while (SD_IsBusy()) { HAL_SPI_Transmit(hspi1, (uint8_t[]) {0xFF}, 1, 1000); } CS_HIGH(); } return RES_OK; }上面代码里SD_IsBusy判断的是SD卡忙信号。写入后SD卡会拉低数据线表示正在擦写这个过程可能长达几十到几百毫秒必须在循环里继续给时钟同时带超时。不少人在这个环节只判断了resp就返回RES_OK结果FATFS认为写完了实际上卡还在写下次读取就是错误数据。3.3 挂载与格式化f_mount调用次数和f_mkfs的条件底层接口就绪后文件系统的挂载代码很简单但调用顺序有个细节。FATFS fs; FRESULT res; // 只注册一个卷不立即挂载。opt填1时这里安全检查的好奇心态 res f_mount(fs, 0:, 1); if (res ! FR_OK) { // 处理挂载失败 } // 实际挂载获取文件系统类型信息 res f_mount(fs, 0:, 0); // 0表示立即挂载 if (res FR_NO_FILESYSTEM) { // 新卡或文件系统被破坏需要格式化 BYTE work[FF_MAX_SS]; res f_mkfs(0:, FM_FAT32, 0, work, sizeof(work)); if (res ! FR_OK) { // 格式化失败检查disk_ioctl是否实现了GET_SECTOR_COUNT } }f_mount的第一个参数传入fs时是挂载传入空指针时是卸载。f_mount的第二个参数opt为1时只注册卷标不扫描文件系统为0时才真正读取引导扇区。很多人直接把f_mount放在f_open之前只调用一次然后f_open返回FR_NOT_READY原因就是opt写了1之后文件系统并没有被真正扫描。f_mkfs的work缓冲区大小必须等于FF_MAX_SS不能更小。格式化之前一定要确认disk_ioctl里实现了GET_SECTOR_COUNT和GET_SECTOR_SIZE否则f_mkfs拿不到卡的几何参数会返回FR_NOT_ENOUGH_CORE或者直接死机。3.4 f_open与f_write文件读写的最小样板文件写操作用FA_OPEN_ALWAYS | FA_WRITE组合打开已存在文件则保留内容不存在则新建。FIL file; UINT bw; char buf[] hello fatfs\r\n; res f_open(file, 0:/test.txt, FA_OPEN_ALWAYS | FA_WRITE); if (res ! FR_OK) { return; } // 把写指针移到文件末尾实现追加写 res f_lseek(file, f_size(file)); if (res ! FR_OK) { f_close(file); return; } res f_write(file, buf, strlen(buf), bw); if ((res ! FR_OK) || (bw strlen(buf))) { // 写入字节数和请求不符时要处理可能是磁盘满 } res f_close(file);写完之后f_close是必须的FATFS在f_close时才会把目录项和FAT表写回磁盘直接断电会丢数据。如果你要高频写日志f_close不能每一条都调用正确做法是f_open一次每次写完数据后调f_sync(file)把缓存刷到物理介质最后再f_close。读文件是镜像操作先f_open再f_read然后比较返回值bw和你要读的长度做边界判断就够了这里不重复贴代码。需要说明的是f_read的bw是指实际读到的字节数读到文件末尾时它会小于请求的长度这叫“干净结尾”不是错误。4. FATFS挂在SD卡上常踩的坑和排查参数4.1 f_mount返回FR_DISK_ERR或FR_NO_FILESYSTEM先按顺序排查FR_DISK_ERR的意思是底层disk_read不是RES_OK也就是说文件系统检测程序在执行disk_read时失败了。优先排查四件事SD卡的CS片选逻辑是否反了、SPI初始化分频是否太高、disk_read里有没有数据起始令牌超时、以及读出来的扇区内容用逻辑分析仪看是不是全0xFF。全0xFF通常是SPI模式不匹配或MISO没读到数据而不一定是硬件没接好。如果是FR_NO_FILESYSTEM说明disk_read是成功的但读到的前几个扇区里没有合法的引导记录。这种情况用读卡器把卡插到电脑上右键属性看文件系统是不是FAT32。如果电脑显示为RAW或exFATFATFS不支持exFAT除非启用FF_FS_EXFAT需要先用f_mkfs格式化。注意FF_FS_EXFAT默认是0即使开它也要编译期支持老旧版本的FATFS没有这个选项。// 排查f_mount失败时的最小测试直接读扇区0 uint8_t test_buf[512]; DRESULT dres disk_read(0, test_buf, 0, 1); if (dres ! RES_OK) { // 底层读就不通别谈FATFS } else if (test_buf[510] ! 0x55 || test_buf[511] ! 0xAA) { // 扇区0结束标记不对卡不是FAT32格式或SPI配置错 }4.2 文件名中文乱码与LFN开关的取舍SD卡上存着中文文件名的文件FATFS打开却返回FR_NOT_FOUND这是LFN没开或代码页不匹配的典型现象。FF_USE_LFN0时FATFS用8.3短文件名规则中文字符会被拆成~1这种缩写f_open(0:/测试.txt)自然找不到。开启FF_USE_LFN2且FF_CODE_PAGE936后还要注意STM32工程源码本身的编码。Keil MDK的默认编辑器可能把中文存成GB2312或GBK这时能被936识别如果文件编码是UTF-8f_open里的中文会拼错字符串。另一个办法是程序里不做中文字面量用专门的名字转换表把UTF-8转换为GBK再传给f_open但工程复杂度会上升。多数项目最终选择“文件内容中文文件名英文”的方案规避全套编码问题。4.3 写大文件掉电与跨扇区对齐日志类项目性能骤降的原因FATFS的f_write不是实时落盘的。写入数据先进入FATFS内部缓冲区至少攒满一个扇区后才调一次disk_write。如果在写的过程中f_close没执行就掉电最后几个扇区缓存在内存里文件大小和实际数据量不一致。解决方式只有一个写完关键数据后立刻f_sync或者干脆每写一帧数据加一条同步标记开机时扫描最后一条完整记录。跨扇区对齐影响的是另一个问题。FATFS在U盘这类介质上性能没问题但SD卡在SPI模式下每次读写都是串行时钟如果业务数据把每个扇区都只写一两个字节底层要做读-改-写整个系统性能会肉眼可见地下降。日志记录场景正确的姿势是定义固定行宽一条日志正好对齐512字节或者攒够一整个扇区的数据再f_write这样disk_write每次都是连续扇区写SD卡内部的页擦写也最友好。日志方式写放大倍数掉电丢数据风险适用场景每条记录立刻f_sync1低关键数据频率低攒满512字节再写1中定时传感器数据定时批量写高高不推荐做除非有掉电检测4.4 STM32 RAM有限FIL对象和堆栈的隐性支出FATFS的FIL结构体不是小物件。在FF_USE_LFN2、FF_VOLUMES1的配置下一个FIL对象大约占550字节FATFS对象占用更多主要是FF_FS_EXFAT开着的WIN结构约1KB多。如果还开了FF_USE_DYNALLOC这些内存会落到堆上而不开时它占静态存储区。STM32F103C8只有20KB RAM要同时跑控制逻辑和FATFS静态区分配表需要留足空间。堆栈溢出导致的HardFault是另一个难排查的点。disk_read里如果用了局部数组做512字节中转而这个数组定义在函数内部裸机环境下它消耗的是主栈。SPI HAL库的HAL_SPI_TransmitReceive内部还有Timeout参数和状态机递归调用时不注意容易栈溢出。遇到诡异的写读错乱先在启动文件里把堆栈从默认的1KB加大到4KB以上能解决相当一部分奇怪的死机。下面这个例子是错误示范局部变量存着整个扇区的数据然后传给DMA。DMA访问内存和CPU视角的内存不一致在Cortex-M上不存在但缓冲区如果定义在栈上函数返回后DMA还在写数据就没意义了。缓冲区要么static要么由调用方传进来。// 错误示范不要这样写 DRESULT disk_read_wrong(BYTE pdrv, BYTE* buff, LBA_t sector, UINT count) { BYTE tmp[512]; // 局部数组函数返回即失效 for (int i 0; i 512; i) { tmp[i] SPI_ReadByte(); } // 拷贝仅在本次函数内有效 memcpy(buff, tmp, 512); return RES_OK; }5. 在GD32、APM32、标准库与HAL之间迁移FATFS的注意点5.1 FATFS源码与STM32芯片型号无关迁移的是底层接口FATFS本身是纯C代码只要编译器支持C99Keil MDK、IAR、GCC都满足它在GD32、APM32、HK32这类国产Cortex-M芯片上和STM32的表现完全一样。所谓“GD32跑FATFS和STM32的差异”真正差异在SPI速度、DMA中断标志位和外设库函数的命名上。GD32的固件库虽然兼容大部分STM32标准库的函数名但SPI_I2S_GetFlagStatus这类函数返回值和参数类型在F1系列有细微差别编译报错时优先怀疑库函数原型而不是FATFS逻辑。比较常见的迁移路径是用STM32标准库写的工程移植到国产芯片底层SPI和GPIO的初始化函数基本可以逐行复制但注意GD32F103的RCU时钟树特性和STM32不完全一样开SPI1时钟使能的寄存器可能不同这部分不能直接照着抄。建议做法是把所有硬件操作集中在bsp_sd.c这一个文件里迁移平台时只改这个文件的实现FATFS源码一行不动。需要检查的接口STM32 HAL写法GD32标准外设库写法SPI发送字节HAL_SPI_Transmitspi_i2s_data_transmit等待发送完成HAL_SPI_GetStatespi_i2s_flag_get延时HAL_Delaydelay_1ms或自实现获取时钟节拍HAL_GetTick需要自己提供get_fattime5.2 CubeMX切换芯片型号时引脚别名和时钟树会反向影响FATFS很多人在项目中途用STM32CubeMX把芯片从F103C8换成F103RCT6重新生成代码后发现SD卡读写异常。这不是FATFS出了问题而是CubeMX重新生成时把SPI引脚复用改掉了或者HAL库版本升级后SPI初始化结构体多了NSS项的默认设置。解决办法是不要让CubeMX自动重新生成你已经改过的spi.c和qspi.c自己手改芯片型号后只保留GPIO和时钟的生成底层disk_*函数完全不动。用stm32cube 程序更改单片机型号换芯片时务必检查FF_MAX_SS和目标芯片的QSPI或SDIO缓存区大小。切换到F4系列时SPI速率为最高42MHzSD卡在SPI模式下理论上可以跑到这个速度但实际上很多TF卡在20MHz以上就出现偶发数据错误。遇到高速读多条扇区穿插失败的情况直接把SPI分频回调到4分频或8分频FATFS挂载速度几乎不受影响但稳定性明显改善。5.3 换掉标准库用HAL库时FATFS的性能分水岭在DMA标准库工程升级到HAL库最常见的问题不是API找不到而是SD卡读写掉速度。原因在于标准库的SPI_SendData是一次寄存器操作HAL库的HAL_SPI_Transmit每次都要检查状态位和超时for循环逐字节发512字节时开销远高于标准库。解决方式是读盘时用HAL_SPI_TransmitReceive一次性搬整块缓冲区或者对STM32F4及以上芯片改用DMA传输且开启__HAL_DMA_ENABLE。// 用HAL库一次读写512字节避免逐字节调用 HAL_StatusTypeDef status; status HAL_SPI_TransmitReceive(hspi1, (uint8_t*)tx_buf, rx_buf, 512, 1000); // 返回HAL_OK之外值时检查SPI状态机是否被多次重入这里要注意的是HAL_SPI_TransmitReceive在发送和接收同时进行时会使用同一个全局句柄状态。如果disk_write里的resp读取和下一个扇区的HAL_SPI_Transmit没做余量处理偶发状态下会返回HAL_BUSY。所以HAL移植版本里的SPI访问建议加一个临界段保护或者干脆每次调HAL_SPI_DeInit再HAL_SPI_Init重置代价是慢但能保证不卡死。6. 用f_printf写日志的隐藏技巧掉电安全的数据记录姿势FATFS标准接口里有f_printf但它默认是关闭的。在ffconf.h里把FF_USE_STRFUNC设为2代码里就能直接格式化输出到文件。这个组合适合做传感器日志和运行状态记录但有几个很多人不知道的限制f_printf不支持浮点数格式化%f会被当成普通字符输出它的格式化核心是内部的一个小型vsnprintf每个%d、%x都会实时计算如果你同时用RTOS多任务访问同一个文件还需要加互斥锁保护f_write调用链。掉电安全的关键不在f_printf而在何时调用f_sync。正确的做法是每写满一个固定大小的缓冲就同步一次而不是每条日志都同步。#define LOG_LINE_MAX 128 #define LOG_SYNC_COUNT 64 void write_log(const char* msg) { static uint16_t write_count 0; f_printf(g_log_file, %u %s, (unsigned)get_fattime(), msg); write_count; if (write_count LOG_SYNC_COUNT) { f_sync(g_log_file); // 强制把缓存写回SD卡更新目录项 write_count 0; } }f_sync本身不是高频操作它做的是刷新FATFS内部分配的扇区缓冲区并把文件的目录项写回。要不要每条日志都同步取决于丢失多少数据在你的项目里可以被容忍。数据记录仪通常选择每100ms同步一次这样断电最多丢100ms数据。另一个细节是文件本身是FA_OPEN_ALWAYS | FA_WRITE打开的掉电后重新上电文件大小是以FAT目录项里记录的大小为准的所以如果掉电发生在f_sync之前丢失的不仅是日志内容文件大小也可能回到上一次同步时的值。最后一个技巧是卷的强制重新检查。对于可插拔SD卡的产品f_mount(fs, 0:, 1)后拿到的法文件系统信息是缓存的拔卡换卡后再次挂载可能仍然使用旧参数。在代码里每次检测到卡状态变化时调用f_mount(NULL, 0:, 0)先卸载再f_mount(fs, 0:, 0)重新挂载强制检查引导扇区。这个流程在f_mount的帮助说明里叫“强制挂载”它不会自动执行f_mkfs遇到新卡依然要你先f_mkfs所以上电初始化时把“挂载失败→检测卷信息→格式化→重新挂载”这四步串联起来才能让SD卡替换场景做到真正免人工恢复。本文还有配套的精品资源点击获取