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_memallocff_memfree 未定义。 这是因为你启用了长文件名(FF_USE_LFN == 3)或其它需要动态内存的功能,但没有实现FatFS要求的内存管理函数。解决方法:要么在 ffconf.h 中将 FF_USE_LFN 改为2(使用栈内存),要么在 diskio.c 里实现这两个函数,简单地映射到 mallocfree(注意确保你的系统有堆管理)。

问题二:可以挂载,但一读写文件就死机。 这几乎是地址对齐问题的典型症状。请检查:

  1. 你传递给 disk_read/disk_write 的缓冲区指针 buff,是否4字节对齐?在STM32的HAL库SDIO驱动中,如果缓冲区不对齐,DMA传输可能会失败。可以尝试在定义缓冲区时加对齐属性:__align(4) uint8_t buffer[512];
  2. 你的底层SD卡读写函数,是否能正确处理多扇区读写?有些驱动在读写多扇区时,要求扇区数是偶数,或者有最大数量限制。

问题三:创建的文件在电脑上打开是乱码,或者看不到。

  1. 乱码:检查 FF_CODE_PAGE 设置。如果你在嵌入式端写了中文文件名或内容,在电脑上看是乱码,很可能是因为代码页不匹配。确保 FF_CODE_PAGE=936(简体中文),并且 ffunicode.c 已加入工程。
  2. 看不到文件/文件大小不对:这通常是没有正确关闭文件导致的。f_close 操作会更新文件的目录项信息(包括大小、时间)。如果写完后没有 f_close 或系统意外复位,文件系统信息就不完整。务必确保每个 f_open 都有配对的 f_close

问题四:格式化(f_mkfs)失败。 首先确认 ffconf.hFF_USE_MKFS 已设为1。然后,检查 disk_ioctl 函数是否正确实现了 GET_SECTOR_COUNT 等命令,返回的值是否正确。格式化需要一块连续的工作缓冲区,其大小至少为 FF_MAX_SS,确保你传递的缓冲区足够大。

移植成功后,建议你跑一个完整的测试用例:创建文件、写入数据、关闭、重新打开、读取验证、列出目录。这个过程能帮你全面检验文件系统的功能是否正常。嵌入式存储是很多产品的基础,一个稳定可靠的FatFS移植,能为你的项目省去无数后期的调试烦恼。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐