ARTICLE DETAIL

建站实战干货

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

STM32F103+HAL库+FATFS:文件系统管理实验的底层移植与排错指南

2026/9/13 15:34:30 拓冰建站 浏览量
STM32F103+HAL库+FATFS:文件系统管理实验的底层移植与排错指南 简介面向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逐一接对。接不对上层 APIf_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.hCubeMX 会帮你把 SDIO 外设连接过去。对于 5 年以上经验的开发者来说从零手写底层也不是不行但日常维护时HAL 库在HAL_SD_ErrorCallback中给出的错误分类能让你快速定位时钟配置问题这是标准库所欠缺的。2.3 移植前你需要知道的几个 FATFS 必经参数以下这些参数在例程代码里都会出现但不会有人一开始就给你解释它们为什么必须这样设置。请看下表参数位置推荐值若不设对会怎样_MAX_SSffconf.h512读取 FAT 表时返回FR_INVALID_PARAMETER因为扇区大小不匹配。SD 卡默认就是 512 字节_USE_MKFSffconf.h1无法调用f_mkfs对一张未格式化的新卡无法操作_CODE_PAGEffconf.h936 或 437中文文件名出现乱码。936 支持 GBK 码表437 是英文_USE_LFNffconf.h2 或 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 库工程并接入 FATFS3.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 ManagerToolchain选择 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_writeFIL 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 的擦除块大小通常是 4096FATFS 会基于这个值做分配对齐写日志性能会直接上一个台阶。第二种是掉电保护的特殊思路把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说明卡信息没正确传入 FATFSf_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、缓存未刷新或时钟树配置掩盖的真实缺陷。本文还有配套的精品资源点击获取