2、LVGL文件系统接口:lv_fs驱动注册、文件打开/读取/关闭回调函数实现

好,咱们进入第二章。上一章我们把外部存储的硬件接口打通了,但LVGL怎么知道你的SD卡、Flash或者U盘里存了什么东西?它需要一个桥梁——文件系统接口。

说白了,LVGL本身不直接操作硬件扇区,它只认lv_fs这套抽象层。你给它注册几个回调函数,它就能像操作本地文件一样,去外部存储里读图片、读字体。我最早做这个的时候,以为只要把文件系统挂载上就完事了,结果LVGL死活打不开图片,后来才发现——回调函数没注册全。

2.1 lv_fs驱动注册:让LVGL认识你的存储

先看注册入口。LVGL里每个文件系统驱动都是一个lv_fs_drv_t结构体。你需要填充它,然后调用lv_fs_drv_register。

lv_fs_drv_t fs_drv;
lv_fs_drv_init(&fs_drv);          // 初始化驱动结构体

fs_drv.letter = 'S';              // 盘符,比如 "S:/image.bin"
fs_drv.ready_cb = fs_ready_cb;    // 检测存储是否就绪
fs_drv.open_cb  = fs_open_cb;     // 打开文件
fs_drv.read_cb  = fs_read_cb;     // 读取数据
fs_drv.close_cb = fs_close_cb;    // 关闭文件
fs_drv.seek_cb  = fs_seek_cb;     // 定位文件指针
fs_drv.tell_cb  = fs_tell_cb;     // 获取当前指针位置
fs_drv.dir_open_cb   = fs_dir_open_cb;
fs_drv.dir_read_cb   = fs_dir_read_cb;
fs_drv.dir_close_cb  = fs_dir_close_cb;

lv_fs_drv_register(&fs_drv);      // 注册到LVGL内核

这里有个关键点——盘符。你注册时指定的letter,就是LVGL访问这个存储的路径前缀。比如你设成'S',那LVGL里所有以S:开头的路径,都会走你这套回调。

经验之谈:我习惯把SD卡映射成'S',Flash映射成'F',U盘映射成'U'。这样代码里一看路径就知道数据存在哪,调试时特别方便。

2.2 回调函数实现:打开、读取、关闭

注册只是第一步。真正干活的是那三个核心回调:open_cb、read_cb、close_cb。LVGL每次加载图片,都会依次调用它们。

2.2.1 文件打开回调:open_cb

这个回调负责把LVGL传过来的路径,转换成底层文件系统的文件句柄。注意,LVGL传的是带盘符的完整路径,比如S:/images/logo.bin。你需要把盘符去掉,再传给底层API。

static void * fs_open_cb(lv_fs_drv_t * drv, const char * path, lv_fs_mode_t mode)
{
    // 去掉盘符 "S:",得到 "/images/logo.bin"
    const char * real_path = path + 2;

    // 根据mode决定打开方式
    uint8_t flags;
    if (mode == LV_FS_MODE_RD)
        flags = FA_READ;
    else if (mode == LV_FS_MODE_WR)
        flags = FA_WRITE;
    else if (mode == (LV_FS_MODE_RD | LV_FS_MODE_WR))
        flags = FA_READ | FA_WRITE;
    else
        return NULL;

    FIL * file = lv_mem_alloc(sizeof(FIL));
    if (file == NULL) return NULL;

    FRESULT res = f_open(file, real_path, flags);
    if (res != FR_OK) {
        lv_mem_free(file);
        return NULL;
    }

    return (void *)file;  // 返回文件句柄
}

这里有个坑——内存分配。文件句柄必须用lv_mem_alloc分配,不能用malloc。因为LVGL有自己的内存管理,混用会导致内存碎片甚至崩溃。我曾经在这个问题上折腾了一下午,最后发现是malloc和lv_mem_alloc混用导致的。

2.2.2 文件读取回调:read_cb

LVGL读取图片数据时,会反复调用这个回调。它每次要读多少字节,你就得给它多少。注意,一定要检查实际读取的字节数,不能假设底层一定读满了。

static lv_fs_res_t fs_read_cb(lv_fs_drv_t * drv, void * file_p, void * buf, uint32_t btr, uint32_t * br)
{
    FIL * file = (FIL *)file_p;
    UINT bytes_read;

    FRESULT res = f_read(file, buf, btr, &bytes_read);
    if (res != FR_OK) {
        *br = 0;
        return LV_FS_RES_UNKNOWN;
    }

    *br = bytes_read;  // 实际读取的字节数
    return LV_FS_RES_OK;
}

注意:btr是请求读取的字节数,br是实际读取的字节数。如果文件读到末尾,br可能小于btr。LVGL会根据br判断是否读完,所以这个值必须正确返回。

2.2.3 文件关闭回调:close_cb

这个回调相对简单,但容易忘——释放内存。文件句柄是你在open_cb里分配的,必须在close_cb里释放,否则内存泄漏。

static lv_fs_res_t fs_close_cb(lv_fs_drv_t * drv, void * file_p)
{
    FIL * file = (FIL *)file_p;

    f_close(file);          // 关闭底层文件
    lv_mem_free(file);      // 释放内存

    return LV_FS_RES_OK;
}

嗯,这里要特别提醒:先关闭底层文件,再释放内存。顺序反了的话,你释放了内存再去关文件,底层API可能操作野指针,轻则报错,重则死机。

2.3 完整回调函数表

我把常用的回调函数整理成了一张表,方便你对照检查。实际项目中,至少要实现加粗的那几个。

回调函数 必须实现? 作用 常见坑点
ready_cb 是 检测存储设备是否就绪 SD卡未插入时返回0,否则LVGL会卡死
open_cb 是 打开文件,返回句柄 路径要去掉盘符;句柄用lv_mem_alloc分配
read_cb 是 读取文件数据 必须返回实际读取字节数
close_cb 是 关闭文件,释放资源 先关文件,再释放内存
seek_cb 推荐 定位文件读写指针 LVGL解码图片时需要随机访问
tell_cb 推荐 获取当前指针位置 配合seek_cb使用
dir_open_cb 按需 打开目录 文件浏览器功能需要
dir_read_cb 按需 读取目录项 同上
dir_close_cb 按需 关闭目录 同上

2.4 知识结构图:lv_fs驱动注册与回调流程

下面这张图展示了从注册到文件操作的整体流程。你可以看到,LVGL应用层通过盘符找到驱动,然后驱动回调与底层文件系统交互。

lv_fs驱动注册与文件操作流程 LVGL应用层(图片加载、文件读取) 盘符路由(S: → SD卡驱动) lv_fs驱动回调函数 open_cb → read_cb → seek_cb → close_cb FatFS(SD卡) LittleFS(Flash) POSIX(Linux) SD卡 / TF卡 SPI Flash / NAND 硬盘 / eMMC

调试小技巧:如果你注册完驱动后,LVGL还是报LV_FS_RES_NOT_IMP,说明某个回调函数没实现或者返回了错误码。我一般会在每个回调入口加一个printf打印路径和参数,这样一眼就能看出是哪个环节出了问题。

2.5 避坑指南:我踩过的三个坑

最后分享几个实际项目中容易踩的坑,都是我亲身经历过的。

  • 路径格式不一致:LVGL传过来的路径是S:/dir/file.bin,但FatFS期望的是/dir/file.bin。别忘了去掉盘符和冒号。
  • 文件句柄生命周期:LVGL可能在多个地方同时打开同一个文件(比如图片解码和字体加载)。确保你的open_cb每次返回独立的句柄,不要复用。
  • 缓冲区对齐:有些底层存储驱动(比如SDIO)要求读取缓冲区4字节对齐。如果LVGL传下来的buf不对齐,读出来的数据可能是错的。我遇到过图片花屏,查了两天才发现是缓冲区对齐问题。