FatFS文件系统移植实战:从零构建嵌入式存储解决方案
1. 为什么你的嵌入式项目需要一个文件系统?
很多刚开始玩嵌入式开发的朋友,可能都经历过这样的阶段:想把传感器数据存下来,就直接往SD卡或者Flash的某个固定地址写;想读取一个配置文件,就得自己计算偏移量,小心翼翼地读出来。这样做一两个小功能还行,一旦项目复杂起来,比如要存不同日期的日志、要管理多个用户的配置文件、要存储图片或音频文件,这种“原始”的存储方式立马就变得捉襟见肘,代码又乱又容易出错。
这时候,文件系统 就该登场了。你可以把它理解为你电脑上的C盘、D盘。在电脑上,你不需要知道一个文件具体存在硬盘的哪个磁道哪个扇区,你只需要知道它在哪个文件夹、叫什么名字,就能轻松地打开、编辑、保存。文件系统就是帮你管理存储设备上这些“数据块”的管家,它负责把一个个文件,有序地组织、存储起来,并提供一套简单明了的接口(创建、打开、读、写、删)给你用。
在嵌入式领域,FatFS 就是这个“管家”里最受欢迎的一位。它完全用C语言写成,几乎不挑硬件平台,从51单片机到ARM Cortex-M系列都能跑。最关键的是,它遵循最通用的FAT/FAT32/exFAT格式,这意味着你在嵌入式设备里存的文件,可以直接把SD卡拔出来,插到电脑上读取,数据交换变得无比顺畅。我做过不少物联网设备的数据采集项目,从早期的直接写扇区,到后来全面转向FatFS,开发效率和系统的可维护性简直是天壤之别。
2. 动手之前:理清FatFS的“三层架构”
在开始敲代码之前,我们得先搞清楚FatFS是怎么工作的。它的设计非常清晰,采用了典型的“分层”思想,这让我们移植起来目标明确,不容易乱。
你可以把它想象成一个三明治:
- 最上层:应用层。这就是你写的业务代码。你调用
f_open,f_read,f_write,f_close这些API来操作文件,完全不用关心底层是SD卡还是U盘。 - 中间层:FatFS核心层。这就是我们从官网下载的
ff.c,ff.h等文件。它实现了FAT文件系统的所有逻辑,比如目录管理、文件分配表查找、簇链追踪等等。这一层我们通常完全不用动,是“黑盒”。 - 最底层:物理驱动层。这是连接FatFS和具体硬件的桥梁,也是我们移植工作的核心。FatFS通过一个叫
diskio.c的文件定义了几个标准接口(比如disk_read,disk_write),我们需要在这些接口里,调用你自己已经写好的SD卡读写函数。
所以,移植的本质就是:实现 diskio.c 里的那几个底层函数,让FatFS能指挥得动你的硬件。同时,通过配置 ffconf.h 文件,来裁剪不需要的功能,让这个文件系统更适合你资源有限的单片机。
3. 实战开始:以STM32 + SD卡为例,一步步移植
理论说再多,不如动手做一遍。我们以最常见的STM32F4系列单片机,通过SDIO接口连接SD卡为例,展示完整的移植流程。我假设你已经有了一个能正常读写SD卡底层扇区的工程(通常厂家提供的HAL库或标准库例程里都有)。
3.1 获取源码与工程准备
第一步,去FatFS的官网(elm-chan.org)下载最新源码。我们以R0.15版本为例。解压后,你会看到两个主要文件夹:documents(离线文档)和 source(源码)。我们只需要 source 文件夹里的东西。
把这几个文件添加到你的STM32工程中:
ff.c,ff.h:核心文件,不动。ffconf.h:配置文件,需要修改。diskio.c,diskio.h:驱动接口文件,需要大改。ffunicode.c:如果你需要长文件名或中文支持,需要添加。
在IDE(比如Keil MDK或STM32CubeIDE)里,为这些文件新建一个分组(例如命名为“FatFS”),并把它们包含进来。别忘了在工程设置里添加 source 文件夹的头文件路径。
3.2 核心战场:改造 diskio.c
这个文件是移植的“主战场”。打开它,你会发现里面已经有了一些用 #ifdef 隔开的示例代码。我们的任务就是把这些示例,替换成实实在在能操作我们SD卡的代码。
首先,定义你的设备号。 在文件开头,你会看到类似 #define DEV_MMC 1 的宏定义。它用来区分不同的存储设备(比如你有SD卡和SPI Flash两个设备)。我们只接了一个SD卡,可以简单点,直接定义:
#define DEV_SD_CARD 0 // 我们的SD卡对应物理驱动器0
然后,实现五个关键函数。
第一个函数:disk_initialize – 设备初始化。 FatFS在挂载卷之前会先调用它。这里就是调用你的SD卡初始化函数。
DSTATUS disk_initialize (BYTE pdrv)
{
DSTATUS stat = STA_NOINIT;
switch (pdrv) {
case DEV_SD_CARD:
if (SD_Init() == SD_OK) { // 你的SD卡初始化函数
stat = 0; // 成功则清除错误标志
}
break;
default:
stat = STA_NOINIT;
}
return stat;
}
第二个函数:disk_status – 获取设备状态。 检查SD卡是否还在、是否写保护等。如果SD卡驱动提供了状态查询函数就用,没有的话,对于简单应用可以直接返回0(表示正常)。
DSTATUS disk_status (BYTE pdrv)
{
// 如果有SD_GetStatus函数,可以在这里判断
// 否则,简单返回0
if (pdrv == DEV_SD_CARD) {
return 0;
}
return STA_NOINIT;
}
第三个函数:disk_read – 读扇区。 这是数据流通的关键。FatFS会把文件读写请求,转换成对逻辑扇区号的读写。你需要把扇区号(LBA)和数量,传递给你的底层SD卡读函数。
DRESULT disk_read (BYTE pdrv, BYTE *buff, LBA_t sector, UINT count)
{
DRESULT res = RES_ERROR;
if (pdrv == DEV_SD_CARD) {
// 注意:sector是LBA地址,count是扇区数
// 你的SD_ReadDisk函数需要能处理多扇区读取
if (SD_ReadDisk(buff, sector, count) == 0) {
res = RES_OK;
}
}
return res;
}
这里有个细节:你的 SD_ReadDisk 函数,其参数可能要求字节地址,而FatFS传的是扇区号。SD卡标准扇区大小是512字节,所以可能需要 sector * 512 来转换。一定要和你底层驱动的接口对齐!
第四个函数:disk_write – 写扇区。 和读类似,但写操作通常更需要注意。有些SD卡驱动要求写入的起始地址按扇区对齐,缓冲区地址最好也4字节对齐以提升效率。
#if FF_FS_READONLY == 0 // 只有在非只读配置下才需要实现
DRESULT disk_write (BYTE pdrv, const BYTE *buff, LBA_t sector, UINT count)
{
DRESULT res = RES_ERROR;
if (pdrv == DEV_SD_CARD) {
if (SD_WriteDisk((uint8_t*)buff, sector, count) == 0) {
res = RES_OK;
}
}
return res;
}
#endif
第五个函数:disk_ioctl – 设备控制。 这个函数用于获取设备信息或发送控制命令。对于使能格式化等功能,必须正确实现以下几个命令:
GET_SECTOR_SIZE:告诉FatFS扇区大小(通常是512)。GET_SECTOR_COUNT:告诉FatFS总共有多少个扇区。GET_BLOCK_SIZE:擦除块大小(对于SD卡,通常是1个扇区)。
DRESULT disk_ioctl (BYTE pdrv, BYTE cmd, void *buff)
{
DRESULT res = RES_PARERR;
if (pdrv == DEV_SD_CARD) {
res = RES_OK;
switch (cmd) {
case CTRL_SYNC:
// 确保之前的写操作完成,如果底层是同步的,可以不做事
break;
case GET_SECTOR_SIZE:
*(WORD*)buff = 512; // 扇区大小512字节
break;
case GET_SECTOR_COUNT:
// 这里应该用你SD卡驱动里获取容量的函数
*(DWORD*)buff = SD_GetCapacity() / 512;
break;
case GET_BLOCK_SIZE:
*(DWORD*)buff = 1; // 擦除块大小=1扇区
break;
default:
res = RES_PARERR;
}
}
return res;
}
最后,时间函数 get_fattime。 这个函数为创建、修改的文件提供时间戳。如果你的设备没有RTC,可以在 ffconf.h 里禁用时间戳功能。如果需要,就实现它,返回一个遵循FatFS格式的32位时间值。
DWORD get_fattime (void)
{
// 示例:2023年10月27日,下午2点30分15秒
return ((DWORD)(2023 - 1980) << 25) // 年从1980算起
| ((DWORD)10 << 21) // 月
| ((DWORD)27 << 16) // 日
| ((DWORD)14 << 11) // 时
| ((DWORD)30 << 5) // 分
| ((DWORD)15 >> 1); // 秒/2
}
3.3 精细调校:配置 ffconf.h
ffconf.h 是FatFS的“功能开关面板”,通过定义一系列宏来启用或禁用功能。合理配置能有效节省ROM和RAM。以下是几个最关键的配置:
#define FF_USE_LFN 2 /* 启用长文件名支持,2表示使用栈上的动态内存 */
#define FF_MAX_LFN 255 /* 长文件名最大长度 */
#define FF_CODE_PAGE 936 /* 使用简体中文代码页,需要ffunicode.c支持 */
#define FF_USE_MKFS 1 /* 启用格式化功能 f_mkfs() */
#define FF_VOLUMES 1 /* 使用的物理设备数量,我们只有1个SD卡 */
#define FF_MIN_SS 512 /* 最小扇区大小 */
#define FF_MAX_SS 512 /* 最大扇区大小,设为与最小相同即固定扇区 */
#define FF_FS_READONLY 0 /* 0:读写模式,1:只读模式 */
#define FF_FS_NORTC 1 /* 没有RTC,使用固定时间戳 */
#define FF_NORTC_YEAR 2023
#define FF_NORTC_MON 10
#define FF_NORTC_MDAY 27
特别注意 FF_USE_LFN:如果你需要支持长文件名(比如“我的配置文件.txt”),必须将其设置为1、2或3,并确保 ffunicode.c 文件被加入工程。选项2(栈上动态分配)比较常用,但要注意栈空间是否足够。
4. 从移植到应用:编写健壮的文件操作代码
移植完成,编译通过,只是万里长征第一步。接下来要用FatFS的API来真正操作文件。这里我分享一些实际项目中积累的经验,能帮你避开很多坑。
4.1 标准的文件操作流程
一个安全的文件操作,应该遵循“挂载->打开->读写->关闭->卸载”的流程,并且每次调用API后都必须检查返回值。
FATFS fs; // 文件系统对象,每个逻辑驱动器一个
FIL fil; // 文件对象
FRESULT fr; // 操作结果
UINT bw; // 实际读写的字节数
// 1. 挂载驱动器 "0:" 代表第一个物理驱动器
fr = f_mount(&fs, "0:", 1); // 第三个参数1表示立即挂载
if (fr != FR_OK) {
printf("挂载失败!错误码:%d\r\n", fr);
// 可能是没有文件系统,可以在这里尝试格式化
if (fr == FR_NO_FILESYSTEM) {
BYTE work[FF_MAX_SS]; // 格式化用的工作缓冲区
fr = f_mkfs("0:", 0, work, sizeof(work));
if (fr == FR_OK) {
f_mount(NULL, "0:", 0); // 先卸载
fr = f_mount(&fs, "0:", 1); // 重新挂载
}
}
}
// 2. 创建并打开一个文件用于写入
fr = f_open(&fil, "0:/data/log.txt", FA_CREATE_ALWAYS | FA_WRITE);
if (fr == FR_OK) {
// 3. 写入数据
char *data = "Hello, FatFS!\n";
fr = f_write(&fil, data, strlen(data), &bw);
if (fr == FR_OK && bw == strlen(data)) {
printf("写入成功,%d字节\r\n", bw);
}
// 4. 关闭文件!非常重要,否则数据可能丢失
f_close(&fil);
}
// 5. 再次打开文件读取
fr = f_open(&fil, "0:/data/log.txt", FA_READ);
if (fr == FR_OK) {
char buffer[64];
fr = f_read(&fil, buffer, sizeof(buffer) - 1, &bw);
buffer[bw] = '\0'; // 添加字符串结束符
printf("读取内容:%s\r\n", buffer);
f_close(&fil);
}
// 最后,不再使用文件系统时,可以卸载
f_mount(NULL, "0:", 0);
4.2 性能优化与稳定性技巧
在资源紧张的嵌入式环境里,直接照搬上面的代码可能会遇到性能或稳定性问题。下面几个技巧是我踩过坑后总结的:
第一,缓存与扇区对齐。 FatFS内部有缓存机制,但如果你能保证每次读写的数据长度是扇区大小(512字节)的整数倍,并且缓冲区地址4字节对齐,底层驱动效率会最高。对于需要频繁写入的日志文件,可以积累一定数据(比如512字节)再一次性写入,而不是每次写一行就调用一次 f_write。
第二,妥善处理 f_sync。 f_write 之后,数据可能还在FatFS的缓存里,并没有真正写到SD卡。在突然断电等异常情况下,这会导致数据丢失。对于关键数据,在 f_close 之前,可以调用 f_sync(&fil) 来强制将缓存数据写入物理设备。当然,这会影响速度,需要根据数据重要性做权衡。
第三,目录操作与遍历。 除了文件,FatFS也支持目录操作。创建目录用 f_mkdir。遍历目录下的文件是一个常用功能,示例代码如下:
DIR dir;
FILINFO fno;
fr = f_opendir(&dir, "0:/data"); // 打开目录
if (fr == FR_OK) {
while (1) {
fr = f_readdir(&dir, &fno); // 读取目录项
if (fr != FR_OK || fno.fname[0] == 0) break; // 错误或读完
if (fno.fattrib & AM_DIR) {
printf(" [目录] %s\r\n", fno.fname);
} else {
printf(" [文件] %s (大小: %lu字节)\r\n", fno.fname, fno.fsize);
}
}
f_closedir(&dir);
}
第四,错误处理要全面。 FatFS定义了丰富的错误码(FR_DISK_ERR, FR_INT_ERR, FR_NOT_READY, FR_NO_FILE等)。在你的应用代码里,不要只是打印错误码,最好能根据不同的错误类型进行不同的恢复操作。比如,如果是 FR_DISK_ERR(底层读写错误),可以尝试重新初始化SD卡;如果是 FR_NO_FILESYSTEM,可以提示用户格式化。
5. 避坑指南:那些移植路上常见的“拦路虎”
即使按照步骤来,第一次移植也难免会遇到问题。我把几个最常见的问题和解决办法列出来,你可以对照排查。
问题一:链接错误,提示 ff_memalloc 或 ff_memfree 未定义。 这是因为你启用了长文件名(FF_USE_LFN == 3)或其它需要动态内存的功能,但没有实现FatFS要求的内存管理函数。解决方法:要么在 ffconf.h 中将 FF_USE_LFN 改为2(使用栈内存),要么在 diskio.c 里实现这两个函数,简单地映射到 malloc 和 free(注意确保你的系统有堆管理)。
问题二:可以挂载,但一读写文件就死机。 这几乎是地址对齐问题的典型症状。请检查:
- 你传递给
disk_read/disk_write的缓冲区指针buff,是否4字节对齐?在STM32的HAL库SDIO驱动中,如果缓冲区不对齐,DMA传输可能会失败。可以尝试在定义缓冲区时加对齐属性:__align(4) uint8_t buffer[512];。 - 你的底层SD卡读写函数,是否能正确处理多扇区读写?有些驱动在读写多扇区时,要求扇区数是偶数,或者有最大数量限制。
问题三:创建的文件在电脑上打开是乱码,或者看不到。
- 乱码:检查
FF_CODE_PAGE设置。如果你在嵌入式端写了中文文件名或内容,在电脑上看是乱码,很可能是因为代码页不匹配。确保FF_CODE_PAGE=936(简体中文),并且ffunicode.c已加入工程。 - 看不到文件/文件大小不对:这通常是没有正确关闭文件导致的。
f_close操作会更新文件的目录项信息(包括大小、时间)。如果写完后没有f_close或系统意外复位,文件系统信息就不完整。务必确保每个f_open都有配对的f_close。
问题四:格式化(f_mkfs)失败。 首先确认 ffconf.h 中 FF_USE_MKFS 已设为1。然后,检查 disk_ioctl 函数是否正确实现了 GET_SECTOR_COUNT 等命令,返回的值是否正确。格式化需要一块连续的工作缓冲区,其大小至少为 FF_MAX_SS,确保你传递的缓冲区足够大。
移植成功后,建议你跑一个完整的测试用例:创建文件、写入数据、关闭、重新打开、读取验证、列出目录。这个过程能帮你全面检验文件系统的功能是否正常。嵌入式存储是很多产品的基础,一个稳定可靠的FatFS移植,能为你的项目省去无数后期的调试烦恼。
更多推荐


所有评论(0)