3、文件系统基础(FatFS):从零开始理解存储管理

说实话,做嵌入式开发这么多年,我见过太多工程师在数据存储上栽跟头。有人直接用地址写Flash,结果数据一乱,整个系统就崩了。也有人自己写文件管理,最后发现比写业务逻辑还累。嗯,这时候你就需要一个成熟的文件系统——FatFS就是嵌入式领域最常用的选择。

3.1 FatFS文件系统简介

FatFS,全称是Fat File System,专门为嵌入式系统设计的轻量级文件系统。它支持FAT12、FAT16、FAT32,甚至exFAT。我最早接触它是在一个农业灌溉项目里,当时需要记录每小时的土壤湿度数据。如果不用文件系统,你得自己管理地址、擦除、磨损均衡……想想就头大。

FatFS的核心优势是什么?说白了就是:

  • 代码量小:完整版也就几千行C代码,ROM占用不到10KB
  • 移植简单:你只需要实现底层读写函数,其他都帮你封装好了
  • 兼容性好:PC上读出来的SD卡,插到嵌入式设备上照样能用

重要提醒:FatFS不是实时文件系统。如果你需要毫秒级的写入响应,得考虑其他方案。但在99%的灌溉控制器场景里,它完全够用。

3.2 文件与目录的基本概念

先聊聊最基础的东西。文件系统里,文件和目录是两个核心概念。

文件:就是一段连续的数据,有个名字。比如"2024-06-15_soil_data.csv"。文件有属性:只读、隐藏、系统、归档。我在项目中习惯把配置文件设为只读,防止误操作。

目录:也叫文件夹,用来组织文件。你可以把不同传感器的数据放到不同目录下。比如:

/data
  /soil
    sensor1.csv
    sensor2.csv
  /weather
    temp.csv
    humidity.csv
  /config
    system.cfg

这里有个坑,我曾经遇到过:FatFS的路径分隔符是/,不是Windows的\。第一次用的时候我写成了反斜杠,结果文件死活打不开。嗯,记住这个细节。

3.3 f_open / f_close / f_read / f_write API详解

这四个函数是FatFS最常用的API。我一个个说。

3.3.1 f_open —— 打开或创建文件

函数原型:

FRESULT f_open (
    FIL* fp,           // 文件对象指针
    const TCHAR* path, // 文件路径
    BYTE mode          // 访问模式
);

mode参数很关键,常用的有:

模式 含义 我常用的场景
FA_READ 只读 读取配置文件
FA_WRITE 只写 很少单独用
FA_OPEN_EXISTING 打开已有文件 追加日志时用
FA_CREATE_NEW 创建新文件(已存在则报错) 防止覆盖历史数据
FA_CREATE_ALWAYS 总是创建新文件(覆盖旧文件) 初始化时用
FA_OPEN_ALWAYS 打开或创建 最常用,省心

举个例子,打开一个日志文件,如果不存在就创建:

FIL fil;
FRESULT res = f_open(&fil, "0:/log/data.txt", FA_WRITE | FA_OPEN_ALWAYS);
if (res != FR_OK) {
    // 处理错误
    printf("文件打开失败,错误码:%d\n", res);
}

我的小技巧:每次打开文件后,先检查返回值。我曾经偷懒没检查,结果文件系统满了,写入失败,数据丢了整整一周。从那以后,我每个f_open后面都跟着错误处理。

3.3.2 f_close —— 关闭文件

函数原型:

FRESULT f_close (FIL* fp);

这个函数看似简单,但很多人会忘。不关闭文件会有什么后果?

  • 缓存数据没刷入存储介质
  • 文件描述符泄漏(FatFS默认支持同时打开的文件数有限)
  • 下次打开同一个文件可能失败

我习惯在函数末尾统一关闭文件,或者用goto语句做清理。比如:

FIL fil;
FRESULT res = f_open(&fil, "test.txt", FA_WRITE | FA_CREATE_ALWAYS);
if (res != FR_OK) goto exit;

// 写入数据...

f_close(&fil);
exit:
    // 清理工作
    return res;

3.3.3 f_read —— 读取文件数据

函数原型:

FRESULT f_read (
    FIL* fp,        // 文件对象指针
    void* buff,     // 读取缓冲区
    UINT btr,       // 要读取的字节数
    UINT* br        // 实际读取的字节数
);

注意br参数,它返回实际读到的字节数。如果小于btr,说明读到文件末尾了。我经常用这个来判断是否读完:

uint8_t buffer[64];
UINT bytes_read;
while (1) {
    res = f_read(&fil, buffer, sizeof(buffer), &bytes_read);
    if (res != FR_OK || bytes_read == 0) break;
    // 处理buffer中的数据
}

3.3.4 f_write —— 写入文件数据

函数原型:

FRESULT f_write (
    FIL* fp,        // 文件对象指针
    const void* buff, // 写入数据缓冲区
    UINT btw,       // 要写入的字节数
    UINT* bw        // 实际写入的字节数
);

写入时有个常见问题:bw可能小于btw。这通常意味着存储介质满了。我曾经在一个项目中没检查这个,结果数据写到一半停了,日志文件不完整,排查了好久才发现是SD卡满了。

正确的写法:

UINT bytes_written;
res = f_write(&fil, data, sizeof(data), &bytes_written);
if (res != FR_OK || bytes_written != sizeof(data)) {
    // 写入失败或未完全写入
    printf("写入错误!\n");
}

3.4 长文件名支持配置

FatFS默认只支持8.3格式的短文件名(比如"DATA001.TXT")。但现实中,我们更想要"2024-06-15_土壤湿度_传感器1.csv"这样的名字。这就需要开启长文件名(LFN)支持。

配置方法很简单,在ffconf.h中修改:

#define FF_USE_LFN  2   // 1:静态缓冲区 2:动态缓冲区 3:从栈分配

我个人推荐用2(动态缓冲区),这样内存利用率高。但要注意:

  • 开启LFN后,ROM占用会增加约2-4KB
  • 文件名最大支持255个字符
  • 需要额外配置FF_MAX_LFN(默认255)

警告:开启长文件名后,f_open的路径参数必须用Unicode编码(UTF-8或UTF-16)。如果你用的是普通ASCII字符串,文件名会乱码。我刚开始就踩过这个坑,文件名显示成"??????.csv",后来才发现是编码问题。

另外,ffconf.h里还有几个相关配置:

宏定义 说明 我的建议
FF_LFN_UNICODE 0:ASCII 1:UTF-16 2:UTF-8 用2,兼容性好
FF_LFN_BUF 长文件名缓冲区大小 默认255够用
FF_USE_FIND 文件搜索功能 建议开启,方便遍历目录

嗯,到这里文件系统的基础就讲完了。下一节我们会实际动手,在STM32上移植FatFS,并实现一个简单的日志记录功能。到时候你会发现,这些API用起来其实很顺手。