第4章 文件系统基础:FatFS、LittleFS、SPIFFS的移植与配置

好,咱们进入第四章。前面聊了存储介质和分区,现在该让LVGL真正能从外部存储读图片了——这就要靠文件系统来管理那些图片文件。

说白了,文件系统就是给存储芯片搭个“书架”。没有它,你只能按扇区地址读写数据,跟记电话号码似的,累死人。有了文件系统,你才能用 /images/logo.bmp 这种路径去访问文件。

嵌入式领域常用的文件系统就三个:FatFS、LittleFS、SPIFFS。我这些年项目里都折腾过,各有各的脾气。下面挨个说。

4.1 三大文件系统对比

先给个总览表格,方便你快速选型:

特性 FatFS LittleFS SPIFFS
适用介质 SD卡、NAND Flash、NOR Flash NOR Flash、SPI Flash SPI Flash、NOR Flash
掉电安全 一般(需额外处理) 优秀(内建CRC校验) 良好(有磨损均衡)
RAM占用 约2-4KB(可配置) 约4-8KB 约1-3KB
最大文件大小 4GB(FAT32) 受Flash容量限制 受Flash容量限制
目录支持 完整支持 支持(但较简单) 不支持(扁平结构)
PC兼容性 可直接插电脑读写 需专用工具 需专用工具
典型场景 SD卡存储、数据记录 OTA升级、配置文件 图片资源、小文件存储

我个人习惯是:如果板子上有SD卡槽,优先用FatFS,因为调试方便,拔下来插电脑就能看文件。如果是SPI Flash存图片资源,LittleFS或SPIFFS更合适——掉电不丢数据,这点在嵌入式设备里太重要了。

4.2 FatFS移植与配置

FatFS是ChaN老师写的,开源、稳定、文档全。移植它其实就三步:

4.2.1 底层接口实现

你需要提供6个底层函数,FatFS才能跟你的存储介质通信。以SPI Flash为例:

// 必须实现的底层接口
DSTATUS disk_initialize(BYTE pdrv);           // 初始化
DSTATUS disk_status(BYTE pdrv);               // 获取状态
DRESULT disk_read(BYTE pdrv, BYTE* buff, LBA_t sector, UINT count);   // 读扇区
DRESULT disk_write(BYTE pdrv, const BYTE* buff, LBA_t sector, UINT count); // 写扇区
DRESULT disk_ioctl(BYTE pdrv, BYTE cmd, void* buff);  // 控制命令
DWORD get_fattime(void);                      // 获取当前时间

这里有个坑——disk_ioctl 里的 GET_SECTOR_COUNT 和 GET_SECTOR_SIZE 必须正确实现,否则FatFS不知道你的Flash有多大。我曾经在一个项目里忘了实现 GET_SECTOR_COUNT,结果格式化后只能读写前几个扇区,排查了一下午才发现。

4.2.2 配置裁剪

FatFS的 ffconf.h 里有几十个宏,但别怕,大部分保持默认就行。我一般只改这几个:

// ffconf.h 关键配置
#define FF_USE_MKFS         1       // 允许格式化
#define FF_USE_STRFUNC      1       // 支持长文件名
#define FF_USE_LFN          2       // 启用长文件名(堆分配)
#define FF_VOLUMES          1       // 卷数量
#define FF_MIN_SS           512     // 最小扇区大小
#define FF_MAX_SS           4096    // 最大扇区大小

嗯,这里要注意:如果你的Flash扇区大小不是512字节(比如W25Q64是4KB),一定要把 FF_MAX_SS 设成4096。否则读写会错位,数据全乱套。

4.2.3 挂载与使用

FATFS fs;
FRESULT res;

// 挂载文件系统
res = f_mount(&fs, "0:", 1);
if (res == FR_NO_FILESYSTEM) {
    // 首次使用,需要格式化
    res = f_mkfs("0:", NULL, 0);
    if (res == FR_OK) {
        res = f_mount(&fs, "0:", 1);
    }
}

// 打开文件
FIL fil;
res = f_open(&fil, "0:/images/logo.bmp", FA_READ);
if (res == FR_OK) {
    // 读取文件内容...
    f_close(&fil);
}
小技巧: 调试阶段可以在 disk_initialize 里加个打印,确认底层驱动是否正常。我习惯在初始化成功后打印Flash的JEDEC ID,一眼就能看出驱动对不对。

4.3 LittleFS移植与配置

LittleFS是ARM推出的,专门为Flash设计。它的核心优势是掉电安全和磨损均衡。如果你做的是电池供电的设备,或者经常意外断电,LittleFS比FatFS靠谱得多。

4.3.1 底层接口

LittleFS的接口比FatFS少,但更抽象:

// LittleFS 配置结构体
struct lfs_config {
    // 底层读写函数
    int (*read)(const struct lfs_config *c, lfs_block_t block,
                lfs_off_t off, void *buffer, lfs_size_t size);
    int (*prog)(const struct lfs_config *c, lfs_block_t block,
                lfs_off_t off, const void *buffer, lfs_size_t size);
    int (*erase)(const struct lfs_config *c, lfs_block_t block);
    int (*sync)(const struct lfs_config *c);

    // 硬件参数
    lfs_size_t read_size;      // 读粒度(通常1字节)
    lfs_size_t prog_size;      // 写粒度(通常256字节)
    lfs_size_t block_size;     // 擦除块大小(通常4096字节)
    lfs_size_t block_count;    // 总块数
    lfs_size_t cache_size;     // 缓存大小(建议等于prog_size)
    lfs_size_t lookahead_size; // 预读大小(建议16或32)
    int32_t    block_cycles;   // 磨损均衡周期(建议500-1000)
};

移植时最容易被忽略的是 read_size 和 prog_size。你想想看,如果你的Flash支持按字节读,但写必须按页(比如256字节),那 prog_size 就必须设成256。设小了,LittleFS会认为可以按更小粒度写,结果底层驱动报错。

4.3.2 配置示例

// W25Q64 的 LittleFS 配置
#define FLASH_BLOCK_SIZE    4096    // 4KB擦除块
#define FLASH_BLOCK_COUNT   2048    // 64MB / 4KB
#define FLASH_PAGE_SIZE     256     // 页大小

struct lfs_config cfg = {
    .read  = flash_read,
    .prog  = flash_prog,
    .erase = flash_erase,
    .sync  = flash_sync,

    .read_size      = 1,
    .prog_size      = FLASH_PAGE_SIZE,
    .block_size     = FLASH_BLOCK_SIZE,
    .block_count    = FLASH_BLOCK_COUNT,
    .cache_size     = FLASH_PAGE_SIZE,
    .lookahead_size = 32,
    .block_cycles   = 500,
};

4.3.3 挂载与使用

lfs_t lfs;
lfs_file_t file;

// 挂载
int err = lfs_mount(&lfs, &cfg);
if (err) {
    // 首次使用,格式化
    err = lfs_format(&lfs, &cfg);
    if (err == 0) {
        err = lfs_mount(&lfs, &cfg);
    }
}

// 打开文件
err = lfs_file_open(&lfs, &file, "logo.bmp", LFS_O_RDONLY);
if (err == 0) {
    // 读取文件...
    lfs_file_close(&lfs, &file);
}
注意: LittleFS的 block_cycles 参数直接影响Flash寿命。设得太小(比如50),频繁写操作会很快耗尽擦除寿命;设得太大(比如5000),磨损均衡效果变差。我一般取500-1000,兼顾寿命和性能。

4.4 SPIFFS移植与配置

SPIFFS是最轻量的选择,专为SPI Flash设计。它不支持目录,所有文件都在根目录下。如果你的图片资源不多(几十个文件),SPIFFS完全够用。

4.4.1 底层接口

SPIFFS的接口更简单,只需要三个函数:

// SPIFFS 底层接口
s32_t spiffs_read(u32_t addr, u32_t size, u8_t *dst);
s32_t spiffs_write(u32_t addr, u32_t size, u8_t *src);
s32_t spiffs_erase(u32_t addr, u32_t size);

注意这里的地址是绝对地址,不是块号。SPIFFS内部会自己管理块映射。

4.4.2 配置示例

// SPIFFS 配置
#define SPIFFS_PHYS_ADDR    0x000000    // Flash起始地址
#define SPIFFS_PHYS_SIZE    (4 * 1024 * 1024)  // 4MB空间
#define SPIFFS_PHYS_PAGE    256         // 页大小
#define SPIFFS_PHYS_BLOCK   4096        // 块大小

spiffs fs;
spiffs_config cfg = {
    .phys_size = SPIFFS_PHYS_SIZE,
    .phys_addr = SPIFFS_PHYS_ADDR,
    .phys_erase_block = SPIFFS_PHYS_BLOCK,
    .log_page_size = SPIFFS_PHYS_PAGE,
    .log_block_size = SPIFFS_PHYS_BLOCK,
    .hal_read_f = spiffs_read,
    .hal_write_f = spiffs_write,
    .hal_erase_f = spiffs_erase,
};

4.4.3 挂载与使用

// 分配工作缓冲区
u8_t spiffs_work_buf[SPIFFS_PHYS_PAGE * 2];
u8_t spiffs_fds[32 * sizeof(spiffs_fd)];
u8_t spiffs_cache_buf[(SPIFFS_PHYS_PAGE + 32) * 4];

// 挂载
spiffs_mount(&fs, &cfg, spiffs_work_buf, spiffs_fds,
             sizeof(spiffs_fds), spiffs_cache_buf,
             sizeof(spiffs_cache_buf));

// 打开文件
spiffs_file fd = SPIFFS_open(&fs, "logo.bmp", SPIFFS_RDONLY, 0);
if (fd > 0) {
    // 读取文件...
    SPIFFS_close(&fs, fd);
}
关键点: SPIFFS的工作缓冲区大小直接影响并发操作数。如果你的LVGL需要同时打开多个图片文件(比如滑动列表时预加载),spiffs_fds 数组要适当增大。我一般设成64,够用又不浪费RAM。

4.5 文件系统选择决策流程

说了这么多,到底该选哪个?我画了个决策图,帮你快速判断:

文件系统选择决策流程 选择文件系统 需要PC直接读写? 是 否 FatFS 需要目录结构? 是 否 LittleFS SPIFFS 集成到LVGL文件系统接口 选择建议 • SD卡/需要PC调试 → FatFS • SPI Flash/掉电安全优先 → LittleFS • 资源紧张/文件少 → SPIFFS

4.6 与LVGL文件系统接口对接

不管你选哪个文件系统,最后都要跟LVGL的 lv_fs 模块对接。LVGL提供了一个抽象层,你只需要注册几个回调函数:

// LVGL文件系统驱动注册
static void * fs_open(lv_fs_drv_t * drv, const char * path, lv_fs_mode_t mode);
static lv_fs_res_t fs_close(lv_fs_drv_t * drv, void * file_p);
static lv_fs_res_t fs_read(lv_fs_drv_t * drv, void * file_p, void * buf, uint32_t btr, uint32_t * br);
static lv_fs_res_t fs_seek(lv_fs_drv_t * drv, void * file_p, uint32_t pos, lv_fs_whence_t whence);
static lv_fs_res_t fs_tell(lv_fs_drv_t * drv, void * file_p, uint32_t * pos_p);

void lv_fs_drv_init(void) {
    lv_fs_drv_t drv;
    lv_fs_drv_init(&drv);

    drv.letter = 'F';           // 驱动器字母,LVGL用 "F:/images/logo.bmp"
    drv.open_cb = fs_open;
    drv.close_cb = fs_close;
    drv.read_cb = fs_read;
    drv.seek_cb = fs_seek;
    drv.tell_cb = fs_tell;

    lv_fs_drv_register(&drv);
}

这里有个细节:drv.letter 是驱动器字母,LVGL会用它来解析路径。比如你设成 'F',那LVGL里访问图片就要写成 "F:/images/logo.bmp"。我习惯用 'S' 表示SPI Flash,'C' 表示SD卡,这样一眼就能看出文件存在哪个介质上。

经验之谈: 对接完成后,先用LVGL自带的 lv_fs_test() 函数验证一下。这个函数会遍历所有注册的驱动器,尝试打开、读写、关闭文件。如果这一步能过,后面LVGL加载图片基本不会出文件系统的问题。

4.7 避坑指南

最后总结几个我踩过的坑,你遇到了可以少走弯路:

  • 扇区大小不匹配: FatFS默认扇区512字节,如果你的Flash扇区是4KB,一定要改 FF_MAX_SS。否则格式化后读写全是错的。
  • 缓存不足导致死机: LittleFS的 cache_size 设得太小,频繁读写时会触发断言失败。我建议至少设成 prog_size 的两倍。
  • SPIFFS文件数限制: SPIFFS默认支持同时打开的文件数很少(通常4-8个)。如果你的LVGL界面复杂,同时加载多个图片,记得增大 SPIFFS_FD_SPACE。
  • 路径分隔符: FatFS用 / 或 \ 都行,但LittleFS和SPIFFS只认 /。统一用 / 最省心。