简介:面向STM32F103嵌入式开发者的FATFS文件系统管理例程,基于HAL库实现,适用于物联网单片机项目实战。例程采用KEIL开发环境,代码结构清晰、注释完整,可直接在F103系列上运行,若使用其他型号,只需调整KEIL中的芯片型号与FLASH容量即可,也适合初学者快速掌握文件系统挂载、读写与测试流程。资源包共226个文件,以C源码和头文件为主(95个.c、100个.h),另含原理图PNG、TXT配置说明、工程配置文件及KEIL工程文件等,压缩包仅2.43MB。代码中已定义好单片机与模块的接线方式,并对J-Link或ST-Link下载器选择做了提示,便于实际硬件调试。这份例程已有121人学习下载,适合需要快速验证FATFS功能或进行嵌入式项目开发的工程师参考。通过阅读带注释的源码,可迁移到文件管理、传感器接入等常见场景,减少重复开发成本。
1. 从一块没反应的小板卡到 FATFS 文件系统:问题其实在“层次”上
拿到STM32F103单片机HAL库例程-FATFS文件系统管理实验这个标题,多数人会默认它只是一个不需要细看的例程包,但真正在项目里用过的人清楚:把 FATFS 跑在 STM32F103 的 HAL 库上,核心工作往往不是“文件系统本身”,而是“把文件系统接到 SDIO 和 DMA 上的那一层”。文件系统挂了、读回乱码、格式化失败、只能调通一次,这些坑基本都出在底层的接口层,而不是 FATFFS 的 API 调用上。
这个实验标题其实覆盖了 STM32F103 开发中最常遇到的两个需求场景:一是让单片机板卡具备“读卡、存参数、写日志”的能力,那么自然需要文件系统;二是用 HAL 库而不是标准外设库来驱动底层接口。如果你正在做数据记录仪、Bootloader、参数配置卡这类项目,想把 SD 卡或 SPI Flash 上的文件系统真正跑起来,这篇内容按“配置 → 挂载 → 文件操作 → 排错”的顺序展开,先把什么叫“底层移植”讲清楚,再给可抄的最小可用代码,最后把几个真正误事的参数列出来。
核心一句话先放在这里:HAL 库对 FATFS 的适配,本质上不是让你学 FAT 表结构,而是把它要求的 6 个底层函数(disk_initialize、disk_status、disk_read、disk_write、disk_ioctl、get_fattime)逐一接对。接不对,上层 API(f_open、f_read、f_mount 这些)什么也不会发生。
2. STM32F103 最小系统上的 FATFS 移植到底在移什么
2.1 三个层次:存储介质、控制器和文件系统各管一段
在做任何底层驱动之前,先把三个“谁管谁”分清。最底层是存储介质,在实验里通常是 SD 卡,它响应读写命令和管理自己的扇区(SD 卡的扇区大小一般是 512 字节)。往上一层是控制器,在 STM32F103 上对应 SDIO 外设,HAL 库把它包装成一组HAL_SD_ReadBlocks/HAL_SD_WriteBlocks这样的 API。最上层才是 FATFS,它根本不关心数据存储在哪里,它只按“逻辑扇区号”向底层接口发出请求。
所以移植 FATFS 到 HAL 库的关键在于中间那一层适配。FATFS 提供一个名为diskio.c的接口文件,这个文件里面定义了 5 个需要用户实现的函数,外加一个时间戳函数。实验例程包里的“管理实验”其实指的就是这些函数能否正确配置并响应 SD 卡的容量、扇区大小和状态查询。
如果拿到例程后直接编译运行,发现f_mount返回FR_NOT_READY,问题通常不是 FATFS 配置,而是disk_status和disk_initialize这两个函数没有确认卡是否进入传输模式。HAL 库的HAL_SD_Init只保证卡在 SDIO 协议层被识别,要正确读到 MBR 和 FAT 表,还必须在底层确认卡的类型(SDv1、SDv2、SDHC),这会直接影响disk_ioctl中GET_SECTOR_COUNT的返回值。
2.2 为什么这里不用标准库,而选 HAL 库做例程更合理
很多从 STM32 标准外设库切过来的人,会觉得 HAL 库的 SDIO 驱动很绕:HAL_SD_Init只是一个入口,实际配置要看HAL_SD_MspInit,它负责 GPIO、时钟和 DMA 的初始化;而标准的库会让你直接操作 SDIO 的寄存器。这个差异在 FATFS 场景下很重要,因为 FATFS 是高度平台无关的代码,它的编译选项非常多(比如_USE_FASTSEEK、_MAX_SS),需要你有一层好操作的封装来跟踪当前状态和错误码,HAL 库恰好在这个位置提供了结构体和错误枚举。
还有一个现实原因:用 HAL 库的例程和你用 CubeMX 生成的工程在结构上天然匹配,FATFS 中间件根目录下自带ffconf.h,CubeMX 会帮你把 SDIO 外设连接过去。对于 5 年以上经验的开发者来说,从零手写底层也不是不行,但日常维护时,HAL 库在HAL_SD_ErrorCallback中给出的错误分类能让你快速定位时钟配置问题,这是标准库所欠缺的。
2.3 移植前你需要知道的几个 FATFS 必经参数
以下这些参数在例程代码里都会出现,但不会有人一开始就给你解释它们为什么必须这样设置。请看下表:
| 参数 | 位置 | 推荐值 | 若不设对会怎样 |
|---|---|---|---|
_MAX_SS | ffconf.h | 512 | 读取 FAT 表时返回FR_INVALID_PARAMETER,因为扇区大小不匹配。SD 卡默认就是 512 字节 |
_USE_MKFS | ffconf.h | 1 | 无法调用f_mkfs,对一张未格式化的新卡无法操作 |
_CODE_PAGE | ffconf.h | 936 或 437 | 中文文件名出现乱码。936 支持 GBK 码表,437 是英文 |
_USE_LFN | ffconf.h | 2 或 3 | 不支持长文件名时,超过 8.3 格式的文件会被截断,在 SD 卡上创建的中文名日志文件全部无法访问 |
SDIO_CMD和 SDIO_CLK 的 GPIO 速度 | HAL_SD_MspInit | 需要在 CubeMX 中设为MODE_AF_PP,速度设为GPIO_SPEED_FREQ_HIGH | 高速读卡时 CRC 错误,f_read偶尔返回FR_DISK_ERR |
这里值得特别注意_MAX_SS。FATFS 在访问 FAT 表时,假设扇区大小不超过_MAX_SS,它会使用内部缓冲区完成扇区读取。如果你在disk_ioctl里没有把扇区信息用GET_SECTOR_SIZE反馈给文件系统,而 FATFS 默认它是 512,但实际是 4096 的卡,表现不是直接报错,而是“能挂载但文件内容全乱”。所以实验过程中遇到乱码不要上来就怀疑 FATFS,先去确认这一项。
3. 用 CubeMX 配置 STM32F103 的 HAL 库工程并接入 FATFS
3.1 打开 SDIO 外设的最小配置面板
在 CubeMX 中新建 STM32F103 系列工程(无论 C8T6 最小的核心板还是 ZET6 的开发板),首先在Connectivity里找到SDIO并启用。F103 的 SDIO 支持 1 位模式和 4 位模式。实验例程默认使用 4 位模式,因为它读取速度更快,但注意如果你用的是“最小系统板 + 自制 SD 卡槽”而非模块,信号线最好缩短,否则硬件上容易受干扰。
接下来必须回头配置System Core -> RCC的 HSE 为外部晶振,并把时钟树里 SDIO 的时钟设置为HCLK / 2或更低,不要直接拉满。STM32F103 的 SDIO 适配器时钟不能超过 48MHz,一般在 CubeMX 的 Clock Configuration 栏里直接看SDIO 48 MHz的黄色警告,只要它不报红就是安全的。
配置完成后,去Middleware and Software Packs中勾选FATFS。CubeMX 会在这里弹出Mode选项,选择SD Card模式。完成这一步后,再回到Project Manager,Toolchain选择 MDK-ARM 或 STM32CubeIDE,然后生成代码。
生成后你可以看到工程多了一个FATFS/Target文件夹,里面有diskio.c、ff.c、ff_gen_drv.c等文件。diskio.c里的USER_ioctl函数中通常会有一段#ifdef分支,区分SD_CARD和RAM_DISK,这就是你要修改的主要位置。
3.2 中间层的自动生成代码为什么可能缺一个函数
CubeMX 生成的 FATFS 中间件会默认调用一个叫USER_initialize的函数,而这个函数内部又调用了HAL_SD_GetCardStatus和HAL_SD_GetCardInfo。这里就有一个常见坑:对于 STM32F103C8T6 这类没有完整 SDIO 引脚复用的最小系统板,某些 CubeMX 版本生成的代码会漏掉HAL_SD_MspInit中的 DMA 初始化,仅保留 GPIO 配置。
实测中比较稳妥的做法是在USER_initialize返回值上补一个RES_OK前的状态确认流程。代码结构如下(如果 CubeMX 已生成就直接替换内部实现):
#include "ff.h" #include "ff_gen_drv.h" #include "sd_diskio.h" extern SD_HandleTypeDef hsd; DSTATUS USER_initialize(BYTE pdrv) { /* 在此之前,先检查 HAL_SD_Init 是否已被调用过一次 */ if (HAL_SD_GetCardState(&hsd) != HAL_SD_CARD_TRANSFER) { HAL_SD_Init(&hsd); /* 对 SDHC 卡而言,Init 后需要等待状态切换完成 */ uint32_t timeout = 0xFFFF; while (HAL_SD_GetCardState(&hsd) != HAL_SD_CARD_TRANSFER && timeout--) { /* 轮询等待,不要在此处插入 HAL_Delay */ } } /* 将卡类型和容量填入全局变量,供 diskio 使用 */ HAL_SD_CardInfoTypeDef info; HAL_SD_GetCardInfo(&hsd, &info); return RES_OK; }这里第一个关键点是轮询等待,而不是HAL_Delay。HAL_Delay依赖 SysTick 中断,如果初始化位于某个关中断区域(比如在时钟配置过程中),延时永远不会结束。第二个关键点是你必须调用一次HAL_SD_GetCardInfo,因为 FATFS 在上层f_mkfs时需要知道块数量,这个值来自USER_ioctl中的GET_SECTOR_COUNT分支,而它是从该结构体读取的。例程中如果这里没被正确调用,f_mkfs会返回FR_INVALID_PARAMETER。
3.3 添加 time stamp 函数:一个永远缺失的小细节
FATFS 在创建或写入文件时必须调用get_fattime获取当前时间戳,否则文件日期会显示 1980-01-01 或直接无法写入目录项(某些文件系统严格模式下会报错)。HAL 库例程中diskio.c的get_fattime常写为:
DWORD get_fattime(void) { /* 简易实现:固定返回一个合法时间戳 */ return ((DWORD)(2024 - 1980) << 25) | ((DWORD)12 << 21) /* 月 12 */ | ((DWORD)6 << 16) /* 日 6 */ | ((DWORD)10 << 11) /* 时 10 */ | ((DWORD)30 << 5) /* 分 30 */ | ((DWORD)0 >> 1); /* 秒/2,越低越好 */ }秒数字段在 FATFS 规定中占 5 位,需要除以 2 后存入。如果你的工程接了 RTC,可以把 RTC 的值转成 BCD 后按位填入。日志场景下这步不是可选而是必须,因为 F103 例程默认不启用 RTC,数据记录仪文件的时间戳如果全是 1980 年,后续上位机排序就会完全错误。
4. 让 FATFS 真正“管理”SD 卡:挂载、格式化、读写目录与文件的完整流程
4.1 f_mount 挂载与首次使用的 f_mkfs 格式化判断
文件系统的挂载不像内存分配那样立即看到成果。第一次上电时,如果 SD 卡本身没有 FAT 文件系统,f_mount返回错误码是很正常的,并不代表移植失败。例程实验的价值就在这里:一个真正意义上的“管理实验”绝不会跳过“先判断要不要格式化”这一环。
先给出一段可靠的最小挂载逻辑,把步骤写在注释每行的后面:
FATFS fs; /* 文件系统对象,必须为全局或静态分配 */ FIL file; /* 文件对象 */ FRESULT res; /* FATFS 返回值,每个 API 都必须检查 */ /* 挂载到盘符 0:,第二个参数 1 表示立即挂载 */ res = f_mount(&fs, "0:", 1); if (res != FR_OK) { /* 常见返回值:FR_NOT_READY 说明底层卡尚未就绪,FR_NO_FILESYSTEM 说明卡里没格式化 */ if (res == FR_NO_FILESYSTEM) { /* 格式化:0 号盘的第一个扇区作为引导区,4096 字节每簇,FAT32 */ res = f_mkfs("0:", FM_FAT32, 4096, &work, sizeof(work)); if (res != FR_OK) { /* 常见后果:disk_ioctl 中 GET_SECTOR_COUNT 未实现 */ } /* 格式化完成必须重新挂载,而不是直接开始写文件 */ res = f_mount(NULL, "0:", 1); res = f_mount(&fs, "0:", 1); } }挂载参数1代表立即挂载,如果不加,后续每次f_open前都要手动调用f_mount。work缓冲区是格式化过程中给 FATFS 使用的临时空间,需要定义为uint8_t work[4096]之类的数组,不能是局部的,因为f_mkfs是一个阻塞操作,栈上分配可能被编译器优化掉。格式化完之后的重挂载步骤有意写成先卸载再挂载,是为了清空 FATFS 内部的卷对象缓存,跳过这一步,某些例程会在下一次f_getfree时读到脏数据。
4.2 目录枚举:构建一个查文件名的主循环
最典型的需求是把卡里已有的日志文件名全部列出来。这要用到f_opendir、f_readdir和FILINFO结构体。下面这段完整代码可以直接放进实验的测试分支:
DIR dir; FILINFO fno; /* 打开根目录,注意路径写法只能是 "0:/" 而不能是 "0:" */ res = f_opendir(&dir, "0:/"); if (res == FR_OK) { for (;;) { /* 每次调用读取一个目录项,返回 NULL 代表枚举结束 */ res = f_readdir(&dir, &fno); if (res != FR_OK || fno.fname[0] == 0) { break; } if (fno.fattrib & AM_DIR) { /* 目录项,你可以在这里拼接路径实现递归 */ printf("[DIR] %s\r\n", fno.fname); } else { /* 普通文件,fno.fsize 是大小 */ printf("[FILE] %-12s %lu\r\n", fno.fname, fno.fsize); } } f_closedir(&dir); }FILINFO的fname数组默认长度只有_MAX_LFN加 1,如果你在ffconf.h中开了长文件名支持,那么fname只保存短文件名,长文件名在lfsname中,需要在使用前把fno.lfname指向一个缓冲区并在fno.lfsize中指定长度,否则中文长文件名读出来空白。这是一个特别典型的“例程可用但一换卡就异常”的问题源。
4.3 写日志时怎样保证断电后已有文件不损坏
日志写入场景远比“创建测试文本文件”复杂。因为 SD 卡写入以扇区为单位,当f_write写入不足一个扇区的数据时,FATFS 会先执行“读-改-写”,也就是读回整个扇区,修改内容,再完整写回。例程的管理实验中如果只验证了“能写能读”,会漏掉一个真实问题:频繁小数据量写入会成倍增加底层f_write调用次数,而且让 FATFS 的缓存区(FF_FS_TINY选项)处于不断失效和重载的状态。
实践里建议在日志代码中显式维护一个 buffer,攒到 512 字节的整数倍再执行一次f_write:
FIL fil; UINT bw; res = f_open(&fil, "0:/data.txt", FA_OPEN_ALWAYS | FA_WRITE); /* 通过 f_lseek 将读写指针移到文件末尾,实现追加 */ res = f_lseek(&fil, f_size(&fil)); for (uint16_t i = 0; i < 256; i++) { char buf[16]; int n = snprintf(buf, sizeof(buf), "%d,%d.%03d\r\n", sensor[i].id, sensor[i].temp / 1000, sensor[i].temp % 1000); /* 减少 f_write 调用的最实在的办法:攒一段写一次 */ res = f_write(&fil, buf, n, &bw); if (res != FR_OK || bw != n) { /* 这里大概率是卡写保护或介质异常 */ break; } } f_close(&fil);使用f_lseek到文件末尾之前必须先知道文件大小。f_lseek(f_size(&fil))返回后,后面的写调用会覆盖文件尾。由于 SD 卡的磨损均衡和缓存管理完全由 FATFS 决定,而 FATFS 在默认配置下没有对掉电场景做特殊处理,所以直接用f_printf一行一行写是一个高风险行为,一旦中途掉电,缓冲区内的半扇区数据会丢失,还可能在 FAT 表中留下不一致簇链。
对于真实产品,方案是把日志分段刷到不同文件,每写满一个扇区整数倍再调用f_sync。比如你可以在每写完 5120 字节(约 10 个扇区)时追写一次f_sync(&fil);,代价是增加几百毫秒的耗时,换来的是掉电时最多丢最后这 5120 字节。工程上这是一个值得接受的折中。
5. FATFS 实验进阶:跨介质复用、f_sync 掉电保护与 3 种经典异常排查
到这里已经能实现“挂载、列出文件、追加写入、格式化”这四个全部实验目标。但例程包真正值得沉淀的是遇到异常怎么办。这一章直接讨论我在 SDIO + HAL 库场景下遇到过的三个最普遍的失败模式,它们共同指向一个结论:FATFS 报错永远只是结果,不是原因。
第一种是需要切换到 SPI Flash 上跑 FATFS,比如把数据存入 W25Q64 而不是 SD 卡。做法是保持diskio.c的五个函数签名不变,内部用SPI_FLASH_Write_Page填平扇区接口。disk_ioctl的GET_BLOCK_SIZE需要返回 Flash 的擦除块大小(通常是 4096),FATFS 会基于这个值做分配对齐,写日志性能会直接上一个台阶。
第二种是掉电保护的特殊思路:把f_mount的挂载参数设为0(不立即挂载),每次f_open前手动挂载一次,操作完成后再f_mount(NULL, "0:", 0)卸载。代价是每次多 30~50 毫秒,好处是每一条日志事务结束后文件系统处于一段干净状态,适合低频率采样和参数配置类应用。缺点是频繁插拔 U 盘、SD 卡时,不挂载会漏掉新一轮的目录项变化,取舍看业务场景。
第三种是一个具体而隐蔽的参数:_FS_LOCK。当实验里同时打开一个文件用于读、打开另一个文件用于写时,f_open可能返回FR_LOCKED。这是 FATFS 的互斥保护。默认_FS_LOCK是0,也就是根本不对文件句柄进行互斥检查,此时两个任务同时操作,不会报错但会数据错乱。例程如果只验证单线程逻辑不会暴露这个问题,凡是用在 RTOS 里的 F103 项目,至少把_FS_LOCK设为1,并配合FS_REENTRANT的同步函数实现。
日常排错前先看这三个地方:f_mount后立刻读一次f_getfree,如果返回的扇区数是 0,说明卡信息没正确传入 FATFS;f_write返回FR_INT_ERR说明内部断言失败,多半是ffconf.h的_MAX_SS与disk_ioctl的GET_SECTOR_SIZE不匹配;f_read偶尔返回FR_DISK_ERR且读写大文件时频繁出现,去检查HAL_SD_MspInit中 DMA 通道的优先级是否低于 SDIO 中断。把这三个位置定位完,百分之八十的 FATFS 移植问题会集中暴露出来,而且每个问题背后都对应一段可以被 HAL_Delay、缓存未刷新或时钟树配置掩盖的真实缺陷。
本文还有配套的精品资源,点击获取