2. 外部存储驱动层设计

好,我们直接进入正题。上一章聊了为什么要把图片扔到外部存储,这一章咱们就动手解决「怎么让LVGL跟外部存储里的文件系统对上话」。

说白了,LVGL本身不认识FATFS,也不认识LittleFS。它只认一个东西——文件描述符和读写回调函数。所以我们要做的,就是在中间搭一座桥:文件系统抽象层。

核心思路: LVGL通过标准接口调用驱动 → 驱动内部转发给FATFS或LittleFS → 文件系统操作物理存储介质。

2.1 为什么需要文件系统抽象层?

你想想看,如果你的项目今天用SD卡(FATFS),明天换成SPI Flash(LittleFS),难道要把LVGL的图片加载代码全部重写一遍?

我在项目中遇到过这种惨痛教训。早期一个HMI项目,直接让LVGL调用FATFS的f_read,后来换Flash存储,改代码改到怀疑人生。从那以后,我养成了一个习惯:不管底层用什么文件系统,上层永远只认一套抽象接口。

抽象层的好处很明显:

  • 解耦:LVGL不依赖具体文件系统API
  • 可替换:换文件系统只需改底层驱动,上层不动
  • 可测试:可以Mock文件系统做单元测试

2.2 抽象层接口设计

我个人习惯把抽象层设计成一组函数指针结构体。这样既灵活,又方便切换。

/* 文件系统抽象层接口 */
typedef struct {
    void   (*init)(void);                    // 初始化
    int    (*open)(const char *path, int mode);  // 打开文件
    int    (*close)(int fd);                 // 关闭文件
    int    (*read)(int fd, void *buf, uint32_t size);  // 读取数据
    int    (*seek)(int fd, uint32_t offset, int whence); // 定位
    uint32_t (*size)(int fd);                // 获取文件大小
    int    (*eof)(int fd);                   // 判断是否到文件尾
} fs_driver_t;

嗯,这里要注意:open返回的fd不能跟标准库的混淆。我一般用int类型,从0开始递增,内部维护一个文件句柄表。

2.3 FATFS对接实现

FATFS是嵌入式领域最常用的文件系统,尤其跟SD卡搭配。对接其实不复杂,就是把抽象层的函数映射到FATFS的API。

/* FATFS驱动实例 */
static FATFS fatfs;
static FIL file_table[MAX_OPEN_FILES];

static int fatfs_open(const char *path, int mode) {
    // 找一个空闲句柄
    for (int i = 0; i < MAX_OPEN_FILES; i++) {
        if (file_table[i].flag == 0) {
            FRESULT res = f_open(&file_table[i], path, FA_READ);
            if (res == FR_OK) {
                file_table[i].flag = 1;
                return i;  // 返回句柄索引
            }
        }
    }
    return -1;
}

static int fatfs_read(int fd, void *buf, uint32_t size) {
    UINT bytes_read;
    FRESULT res = f_read(&file_table[fd], buf, size, &bytes_read);
    return (res == FR_OK) ? bytes_read : -1;
}

/* 注册到抽象层 */
fs_driver_t fs_driver = {
    .init = fatfs_init,
    .open = fatfs_open,
    .close = fatfs_close,
    .read = fatfs_read,
    .seek = fatfs_seek,
    .size = fatfs_size,
    .eof  = fatfs_eof
};
避坑指南: 我曾经在FATFS的f_open上踩过坑——忘记检查返回值。SD卡热插拔时,f_open可能返回FR_DISK_ERR,如果不处理,LVGL会直接卡死。所以,每个FATFS调用都要检查返回值。

2.4 LittleFS对接实现

LittleFS更适合Flash存储,掉电安全、磨损均衡。它的API跟FATFS不太一样,但抽象层可以统一。

/* LittleFS驱动实例 */
static lfs_t lfs;
static lfs_file_t file_table[MAX_OPEN_FILES];

static int lfs_open(const char *path, int mode) {
    for (int i = 0; i < MAX_OPEN_FILES; i++) {
        if (file_table[i].flag == 0) {
            int res = lfs_file_open(&lfs, &file_table[i], path, LFS_O_RDONLY);
            if (res == LFS_ERR_OK) {
                file_table[i].flag = 1;
                return i;
            }
        }
    }
    return -1;
}

static int lfs_read(int fd, void *buf, uint32_t size) {
    int res = lfs_file_read(&lfs, &file_table[fd], buf, size);
    return (res >= 0) ? res : -1;
}

/* 注册到抽象层 */
fs_driver_t fs_driver = {
    .init = lfs_init,
    .open = lfs_open,
    .close = lfs_close,
    .read = lfs_read,
    .seek = lfs_seek,
    .size = lfs_size,
    .eof  = lfs_eof
};

你看,上层代码完全不用改,只需要在初始化时选择用哪个驱动实例就行。

2.5 LVGL对接:文件系统回调

LVGL从v8开始,提供了lv_fs_drv_t结构体来注册自定义文件系统。我们只需要把抽象层的函数指针填进去。

/* LVGL文件系统驱动注册 */
static lv_fs_drv_t fs_drv;

void lv_fs_driver_init(void) {
    lv_fs_drv_init(&fs_drv);
    
    fs_drv.letter = 'S';          // 驱动器字母,例如 S:/
    fs_drv.open_cb = fs_open_cb;
    fs_drv.close_cb = fs_close_cb;
    fs_drv.read_cb = fs_read_cb;
    fs_drv.seek_cb = fs_seek_cb;
    fs_drv.tell_cb = fs_tell_cb;
    fs_drv.size_cb = fs_size_cb;
    
    lv_fs_drv_register(&fs_drv);
}

/* 回调函数实现 */
static void *fs_open_cb(lv_fs_drv_t *drv, const char *path, lv_fs_mode_t mode) {
    int fd = fs_driver.open(path, mode);
    if (fd < 0) return NULL;
    
    /* LVGL要求返回void*,我们把fd包装一下 */
    int *p_fd = lv_mem_alloc(sizeof(int));
    *p_fd = fd;
    return p_fd;
}

static lv_fs_res_t fs_read_cb(lv_fs_drv_t *drv, void *file_p, void *buf, uint32_t btr, uint32_t *br) {
    int fd = *(int *)file_p;
    int ret = fs_driver.read(fd, buf, btr);
    if (ret >= 0) {
        *br = ret;
        return LV_FS_RES_OK;
    }
    return LV_FS_RES_UNKNOWN;
}
注意: LVGL的open_cb返回的是void*,不是文件描述符本身。我习惯动态分配一个int来存fd,但一定要在close_cb里释放,否则会内存泄漏。我曾经因为这个漏了释放,跑几天就崩了。

2.6 整体架构图

下面这张图展示了从LVGL到物理存储的完整调用链路:

文件系统抽象层架构图 LVGL 应用层 LVGL FS驱动 (lv_fs_drv_t) 文件系统抽象层 (fs_driver_t) init / open / close / read / seek / size / eof FATFS 文件系统 LittleFS 文件系统 SD卡 / NAND Flash SPI Flash / NOR Flash 上层统一接口,底层按需切换,LVGL无需关心具体文件系统

2.7 关键对比:FATFS vs LittleFS

选哪个文件系统,取决于你的硬件平台。我整理了一张对比表,方便你决策:

对比项 FATFS LittleFS
适用介质 SD卡、NAND Flash SPI Flash、NOR Flash
掉电安全 一般(需额外处理) 优秀(内建CRC校验)
磨损均衡 不支持 支持
RAM占用 约2-4KB 约1-2KB
最大文件大小 4GB(FAT32) 取决于配置
PC兼容性 好(可直接读卡) 差(需专用工具)
典型场景 大容量图片存储 小文件、频繁写入
我的建议: 如果项目用SD卡存图片,无脑选FATFS,PC端调试方便。如果用的是板载Flash,LittleFS更省心,掉电不怕丢数据。

2.8 初始化流程

最后,把整个初始化流程串起来。我一般这样写:

void storage_init(void) {
    /* 1. 初始化硬件(SPI/SDIO等) */
    hal_storage_init();
    
    /* 2. 初始化文件系统 */
    #if defined(USE_FATFS)
        fs_driver = fatfs_driver;   // 选择FATFS
    #elif defined(USE_LITTLEFS)
        fs_driver = lfs_driver;     // 选择LittleFS
    #endif
    fs_driver.init();
    
    /* 3. 注册到LVGL */
    lv_fs_driver_init();
    
    /* 4. 验证:尝试打开一个测试文件 */
    int fd = fs_driver.open("S:/test.bin", 0);
    if (fd >= 0) {
        // 文件系统工作正常
        fs_driver.close(fd);
    } else {
        // 处理错误
    }
}

嗯,这里有个小细节:hal_storage_init()负责初始化硬件接口,比如SPI时钟、SDIO模式等。这部分跟具体芯片强相关,我一般单独放在hal目录下。