4、文件系统集成:FatFS 与 LVGL 的对接

好,到了这一章,我们终于要动真格的了。前面几章我们把 LVGL 的显示驱动和触摸输入都调通了,但说实话,那只是让屏幕亮起来、能点两下。真正的嵌入式 UI 产品,哪个不得从 SD 卡或者 Flash 里读图片?

LVGL 本身不带文件系统,它只认一个抽象层——lv_fs_drv_t。你得把底层的 FatFS 挂上去,告诉 LVGL:「喏,这就是你的文件系统」。说白了,就是写一个适配器。

4.1 为什么需要文件系统适配层

LVGL 的设计思路很清晰:它只管 UI 渲染,不管数据从哪来。图片、字体、配置文件,这些统统交给外部存储。你想想看,如果把图片数据全部编译进固件,那 Flash 得有多大?而且换一张图就得重新烧录,这谁受得了。

所以 LVGL 定义了一套文件系统接口。你只要实现这几个回调函数,LVGL 就能像操作本地数组一样读写外部文件。我最早接触这个设计时觉得有点绕,后来发现这其实是嵌入式软件里很经典的「面向接口编程」——上层不关心底层是 SD 卡还是 NAND Flash,底层也不关心上层是 LVGL 还是别的什么库。

核心概念: LVGL 的文件系统驱动是一个函数指针结构体。你填充它,注册它,剩下的交给 LVGL。

4.2 FatFS 与 LVGL 的对接流程

整个对接过程,我习惯分成三步走:

  1. 初始化 FatFS —— 挂载 SD 卡或 Flash 分区
  2. 实现 LVGL 文件操作回调 —— open、close、read、seek、tell
  3. 注册驱动到 LVGL —— 调用 lv_fs_drv_register()

嗯,这里有个坑:很多人一上来就写回调,结果发现 LVGL 读文件时返回乱码。为什么?因为 FatFS 的文件指针和 LVGL 的文件对象没对应好。我待会儿会细说。

4.3 关键数据结构:lv_fs_drv_t

先看看 LVGL 2.x 里这个结构体长什么样(LVGL 8.x 也类似,只是成员名略有变化):

typedef struct _lv_fs_drv_t {
    char letter;                /* 驱动器盘符,比如 'S' 代表 S: */
    uint16_t cache_size;        /* 缓存大小,0 表示不缓存 */
    bool (*ready_cb)(void);     /* 检查驱动是否就绪 */

    lv_fs_res_t (*open_cb)(void *file, const char *path, lv_fs_mode_t mode);
    lv_fs_res_t (*close_cb)(void *file);
    lv_fs_res_t (*read_cb)(void *file, void *buf, uint32_t btr, uint32_t *br);
    lv_fs_res_t (*seek_cb)(void *file, uint32_t pos, lv_fs_whence_t whence);
    lv_fs_res_t (*tell_cb)(void *file, uint32_t *pos_p);
} lv_fs_drv_t;

注意看 open_cb 的第一个参数 void *file——这个指针指向的是 LVGL 内部维护的一个文件对象。你的任务就是把 FatFS 的 FIL 结构体地址塞进去,或者反过来从里面取出来。

个人经验: 我习惯在 open_cb 里动态分配一个 FIL 结构体,把指针存到 file 参数指向的位置。这样每个打开的文件都有独立的 FatFS 文件句柄,不会互相干扰。

4.4 代码实现:完整的 FatFS 适配器

下面是我在实际项目中用过的代码片段,去掉了硬件相关的细节,保留了核心逻辑:

/* 盘符定义 */
#define FS_LETTER 'S'

/* FatFS 文件对象指针,存放在 LVGL 的文件对象中 */
typedef struct {
    FIL fil;
} fatfs_file_t;

/* 检查 SD 卡是否插入 */
static bool fs_ready_cb(void)
{
    return (SD_Detect() == 0) ? true : false;
}

/* 打开文件 */
static lv_fs_res_t fs_open_cb(void *file, const char *path, lv_fs_mode_t mode)
{
    fatfs_file_t *fp = (fatfs_file_t *)file;
    BYTE fatfs_mode;

    /* 转换 LVGL 模式到 FatFS 模式 */
    if (mode == LV_FS_MODE_RD) {
        fatfs_mode = FA_READ;
    } else if (mode == LV_FS_MODE_WR) {
        fatfs_mode = FA_WRITE | FA_CREATE_ALWAYS;
    } else if (mode == (LV_FS_MODE_RD | LV_FS_MODE_WR)) {
        fatfs_mode = FA_READ | FA_WRITE | FA_OPEN_ALWAYS;
    } else {
        return LV_FS_RES_UNKNOWN;
    }

    /* 调用 FatFS 打开文件 */
    if (f_open(&fp->fil, path, fatfs_mode) == FR_OK) {
        return LV_FS_RES_OK;
    }
    return LV_FS_RES_NOT_EX;
}

/* 读取文件 */
static lv_fs_res_t fs_read_cb(void *file, void *buf, uint32_t btr, uint32_t *br)
{
    fatfs_file_t *fp = (fatfs_file_t *)file;
    UINT bytes_read;

    if (f_read(&fp->fil, buf, btr, &bytes_read) == FR_OK) {
        *br = bytes_read;
        return LV_FS_RES_OK;
    }
    return LV_FS_RES_UNKNOWN;
}

/* 关闭文件 */
static lv_fs_res_t fs_close_cb(void *file)
{
    fatfs_file_t *fp = (fatfs_file_t *)file;
    f_close(&fp->fil);
    return LV_FS_RES_OK;
}

/* 定位文件指针 */
static lv_fs_res_t fs_seek_cb(void *file, uint32_t pos, lv_fs_whence_t whence)
{
    fatfs_file_t *fp = (fatfs_file_t *)file;
    FSIZE_t fatfs_offset;

    switch (whence) {
        case LV_FS_SEEK_SET: fatfs_offset = pos; break;
        case LV_FS_SEEK_CUR: fatfs_offset = f_tell(&fp->fil) + pos; break;
        case LV_FS_SEEK_END: fatfs_offset = f_size(&fp->fil) + pos; break;
        default: return LV_FS_RES_UNKNOWN;
    }

    if (f_lseek(&fp->fil, fatfs_offset) == FR_OK) {
        return LV_FS_RES_OK;
    }
    return LV_FS_RES_UNKNOWN;
}

/* 获取当前文件位置 */
static lv_fs_res_t fs_tell_cb(void *file, uint32_t *pos_p)
{
    fatfs_file_t *fp = (fatfs_file_t *)file;
    *pos_p = f_tell(&fp->fil);
    return LV_FS_RES_OK;
}

/* 注册驱动 */
void lv_fs_fatfs_init(void)
{
    lv_fs_drv_t drv;
    lv_fs_drv_init(&drv);

    drv.letter    = FS_LETTER;
    drv.ready_cb  = fs_ready_cb;
    drv.open_cb   = fs_open_cb;
    drv.close_cb  = fs_close_cb;
    drv.read_cb   = fs_read_cb;
    drv.seek_cb   = fs_seek_cb;
    drv.tell_cb   = fs_tell_cb;

    lv_fs_drv_register(&drv);
}
我曾经踩过的坑: 在 open_cb 里忘记初始化 FIL 结构体,导致 f_open 返回 FR_INVALID_OBJECT。后来我养成了一个习惯——每次 open 之前先 memset 清零,再调用 f_open。

4.5 路径格式与盘符约定

注册好驱动之后,LVGL 怎么引用文件呢?答案是:S:/images/bg.png。前面的 S: 就是你在 drv.letter 里设置的盘符。

这里有个容易混淆的地方:FatFS 的路径是 /images/bg.png(从根目录开始),但 LVGL 要求带上盘符前缀。实际上,LVGL 在调用你的 open_cb 之前,会把 S: 去掉,只把 /images/bg.png 传进去。所以你不用在回调里处理盘符。

LVGL 路径 实际传给 open_cb 的路径 说明
S:/logo.bin /logo.bin 根目录下的文件
S:/images/bg.png /images/bg.png 子目录下的文件
S:fonts/16px.bin /fonts/16px.bin 注意没有前导斜杠时,FatFS 默认从根目录开始

4.6 验证对接是否成功

写完代码别急着加载图片。先跑一个简单的文件存在性检查:

lv_fs_file_t file;
lv_fs_res_t res;

res = lv_fs_open(&file, "S:/test.txt", LV_FS_MODE_RD);
if (res == LV_FS_RES_OK) {
    LV_LOG_USER("文件打开成功!");
    lv_fs_close(&file);
} else {
    LV_LOG_ERROR("文件打开失败,错误码:%d", res);
}

如果这一步能打印出「文件打开成功」,那恭喜你,对接基本没问题了。接下来就可以用 lv_img_set_src() 加载外部图片了。

调试小技巧: 我习惯在 ready_cb 里加一个 LED 指示。SD 卡插上时 LED 亮,拔掉时 LED 灭。这样一眼就能看出文件系统是否就绪,不用每次都看串口日志。

4.7 本章知识结构图

下面这张图梳理了 FatFS 与 LVGL 对接的完整数据流:

FatFS 与 LVGL 对接数据流 LVGL 上层 (UI 渲染) LVGL FS 驱动层 (lv_fs_drv_t) 适配器 (open/close/read/seek/tell) 回调函数实现 FatFS 文件系统层 SD 卡 / NAND Flash / 外部 SPI Flash 关键点 • 盘符 'S:' 由 letter 定义 • file 参数传递 FIL 指针 • 路径自动去除盘符前缀 • 每个文件独立 FIL 句柄

从这张图可以看得很清楚:LVGL 不直接操作 FatFS,中间隔了一层适配器。这一层虽然代码量不大,但它是整个外部存储加载图片的「咽喉要道」。只要这里不出问题,后面加载图片、字体就顺风顺水。

4.8 常见问题与避坑

  • 文件打开失败但 SD 卡正常 —— 检查路径格式,是不是忘了加盘符?或者路径里用了反斜杠 \?FatFS 只认正斜杠 /。
  • 读取数据全是 0xFF —— 大概率是文件指针没定位对。在 seek_cb 里加个日志,看看传进来的 pos 和 whence 对不对。
  • LVGL 卡死或重启 —— 检查 cache_size 设置。如果 SD 卡速度慢,建议设成 256 或 512 字节,不要设 0。
  • 多个文件同时打开时崩溃 —— 确认你的 open_cb 里为每个文件分配了独立的 FIL 结构体。共用同一个 FIL 会导致文件指针互相覆盖。

好了,这一章的内容就到这里。文件系统对接是 LVGL 外部存储加载图片的基石,基础打牢了,后面的事情就简单了。