3. 文件系统接口

好,咱们进入第三章。前两章我们把外部存储的硬件选型和底层驱动搞定了,现在要解决一个关键问题:LVGL怎么跟你的存储设备对话?

说白了,LVGL本身不认识什么SD卡、SPI Flash、U盘。它只认一个东西——文件系统接口。这个接口就是 lv_fs 模块。你只要给它注册一个驱动,它就能用统一的方式读写你的存储设备。

我刚开始用LVGL做产品时,就踩过这个坑。当时图省事,直接把图片数组编译进固件里。结果客户要换一套皮肤,我得重新编译整个固件……那叫一个痛苦。后来老老实实把文件系统接上,世界清净了。

3.1 lv_fs模块:LVGL的文件抽象层

lv_fs 是LVGL内置的一个轻量级文件系统抽象层。它不实现具体的文件操作,而是定义了一套标准接口。你想想看,就像USB协议一样,不管插的是U盘还是键盘,只要符合协议就能通信。lv_fs 也是这个道理。

它的核心结构体是 lv_fs_drv_t。这个结构体里定义了所有你需要实现的回调函数:

typedef struct _lv_fs_drv_t {
    char letter;                /* 驱动器盘符,比如 'S' 代表SD卡 */
    uint16_t cache_size;        /* 文件缓存大小,字节 */
    bool (*ready_cb)(lv_fs_drv_t * drv);
    lv_fs_res_t (*open_cb)(lv_fs_drv_t * drv, void * file_p, const char * path, lv_fs_mode_t mode);
    lv_fs_res_t (*close_cb)(lv_fs_drv_t * drv, void * file_p);
    lv_fs_res_t (*read_cb)(lv_fs_drv_t * drv, void * file_p, void * buf, uint32_t btr, uint32_t * br);
    lv_fs_res_t (*write_cb)(lv_fs_drv_t * drv, void * file_p, const void * buf, uint32_t btw, uint32_t * bw);
    lv_fs_res_t (*seek_cb)(lv_fs_drv_t * drv, void * file_p, uint32_t pos, lv_fs_whence_t whence);
    lv_fs_res_t (*tell_cb)(lv_fs_drv_t * drv, void * file_p, uint32_t * pos_p);
    lv_fs_res_t (*trunc_cb)(lv_fs_drv_t * drv, void * file_p);
    lv_fs_res_t (*size_cb)(lv_fs_drv_t * drv, void * file_p, uint32_t * size_p);
    lv_fs_res_t (*rename_cb)(lv_fs_drv_t * drv, const char * old_name, const char * new_name);
    lv_fs_res_t (*free_space_cb)(lv_fs_drv_t * drv, uint64_t * total_p, uint64_t * free_p);
    lv_fs_res_t (*dir_open_cb)(lv_fs_drv_t * drv, void * rddir_p, const char * path);
    lv_fs_res_t (*dir_read_cb)(lv_fs_drv_t * drv, void * rddir_p, char * fn, uint32_t fn_len);
    lv_fs_res_t (*dir_close_cb)(lv_fs_drv_t * drv, void * rddir_p);
} lv_fs_drv_t;

嗯,看着挺长,但别怕。实际项目中,你只需要实现几个核心回调就够了。我一般只实现 ready_cb、open_cb、close_cb、read_cb、seek_cb 这五个,剩下的用默认值就行。

关键点:盘符 letter 是LVGL识别文件路径的第一个字符。比如你注册了 'S',那LVGL里访问文件就写成 "S:/images/bg.png"。这个盘符是硬编码的,选一个不冲突的字母就行。

3.2 注册自定义文件系统驱动

好,理论说完了,咱们直接上手。我以FatFS + SD卡为例,给你演示怎么注册一个完整的驱动。FatFS是嵌入式里最常用的文件系统库,LVGL官方也推荐用它。

3.2.1 第一步:实现回调函数

先写一个 fs_sd.c 文件,把回调函数填上:

#include "lvgl.h"
#include "ff.h"        /* FatFS 头文件 */
#include "diskio.h"    /* 底层磁盘I/O */

/* 检查SD卡是否就绪 */
static bool fs_ready_cb(lv_fs_drv_t * drv) {
    (void)drv;
    /* 调用FatFS的磁盘状态检查函数 */
    if (disk_status(0) & STA_NOINIT) {
        return false;
    }
    return true;
}

/* 打开文件 */
static lv_fs_res_t fs_open_cb(lv_fs_drv_t * drv, void * file_p, 
                               const char * path, lv_fs_mode_t mode) {
    (void)drv;
    FIL * fp = (FIL *)file_p;  /* FatFS的文件对象 */
    BYTE fat_mode;
    
    /* 转换LVGL的mode到FatFS的mode */
    if (mode == LV_FS_MODE_WR) {
        fat_mode = FA_WRITE | FA_CREATE_ALWAYS;
    } else if (mode == LV_FS_MODE_RD) {
        fat_mode = FA_READ;
    } else if (mode == (LV_FS_MODE_WR | LV_FS_MODE_RD)) {
        fat_mode = FA_READ | FA_WRITE | FA_CREATE_ALWAYS;
    } else {
        return LV_FS_RES_UNKNOWN;
    }
    
    FRESULT res = f_open(fp, path, fat_mode);
    if (res == FR_OK) {
        return LV_FS_RES_OK;
    } else {
        return LV_FS_RES_FS_ERR;
    }
}

/* 读取文件 */
static lv_fs_res_t fs_read_cb(lv_fs_drv_t * drv, void * file_p,
                               void * buf, uint32_t btr, uint32_t * br) {
    (void)drv;
    FIL * fp = (FIL *)file_p;
    UINT read_bytes;
    FRESULT res = f_read(fp, buf, btr, &read_bytes);
    *br = read_bytes;
    if (res == FR_OK) {
        return LV_FS_RES_OK;
    } else {
        return LV_FS_RES_FS_ERR;
    }
}

/* 定位文件指针 */
static lv_fs_res_t fs_seek_cb(lv_fs_drv_t * drv, void * file_p,
                               uint32_t pos, lv_fs_whence_t whence) {
    (void)drv;
    FIL * fp = (FIL *)file_p;
    FRESULT res;
    
    switch (whence) {
        case LV_FS_SEEK_SET:
            res = f_lseek(fp, pos);
            break;
        case LV_FS_SEEK_CUR:
            res = f_lseek(fp, f_tell(fp) + pos);
            break;
        case LV_FS_SEEK_END:
            res = f_lseek(fp, f_size(fp) + pos);
            break;
        default:
            return LV_FS_RES_UNKNOWN;
    }
    
    if (res == FR_OK) {
        return LV_FS_RES_OK;
    } else {
        return LV_FS_RES_FS_ERR;
    }
}

/* 关闭文件 */
static lv_fs_res_t fs_close_cb(lv_fs_drv_t * drv, void * file_p) {
    (void)drv;
    FIL * fp = (FIL *)file_p;
    FRESULT res = f_close(fp);
    if (res == FR_OK) {
        return LV_FS_RES_OK;
    } else {
        return LV_FS_RES_FS_ERR;
    }
}
我的习惯:每个回调函数里都加上 (void)drv; 来消除未使用参数的警告。虽然是小细节,但代码审查时能少很多麻烦。

3.2.2 第二步:注册驱动

回调写好了,接下来在系统初始化时注册它:

void lv_fs_sd_init(void) {
    lv_fs_drv_t fs_drv;
    lv_fs_drv_init(&fs_drv);   /* 先初始化结构体 */
    
    /* 配置驱动参数 */
    fs_drv.letter = 'S';        /* 盘符:S: */
    fs_drv.cache_size = 256;    /* 读缓存256字节,够用 */
    
    /* 挂载回调函数 */
    fs_drv.ready_cb  = fs_ready_cb;
    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;
    
    /* 注册到LVGL */
    lv_fs_drv_register(&fs_drv);
    
    LV_LOG_INFO("SD card file system driver registered.");
}

然后在你主程序的初始化里调用它:

int main(void) {
    /* 硬件初始化 */
    hal_init();
    
    /* 初始化FatFS */
    f_mount(&fatfs, "", 1);
    
    /* 初始化LVGL */
    lv_init();
    
    /* 注册SD卡文件系统驱动 */
    lv_fs_sd_init();
    
    /* 其他初始化... */
    while(1) {
        lv_task_handler();
        delay(5);
    }
}

3.2.3 第三步:在LVGL中使用

注册完成后,你就可以用 "S:" 盘符访问SD卡里的文件了:

/* 加载SD卡里的图片 */
lv_img_dsc_t img_dsc;
lv_fs_file_t file;
lv_fs_res_t res;

/* 打开文件 */
res = lv_fs_open(&file, "S:/images/background.bin", LV_FS_MODE_RD);
if (res != LV_FS_RES_OK) {
    LV_LOG_ERROR("Failed to open file!");
    return;
}

/* 读取文件头,获取图片信息 */
uint8_t header[16];
uint32_t br;
lv_fs_read(&file, header, 16, &br);

/* 解析BIN文件头,填充 img_dsc... */

/* 关闭文件 */
lv_fs_close(&file);

/* 创建图片对象 */
lv_obj_t * img = lv_img_create(lv_scr_act());
lv_img_set_src(img, &img_dsc);

不过说实话,实际项目中我很少手动这么操作。LVGL提供了更高级的API,比如 lv_img_set_src(img, "S:/images/bg.png"),它会自动调用文件系统接口去加载。但底层原理就是上面这套,你得心里有数。

3.3 文件系统驱动的性能考量

驱动写好了,能跑了,但性能怎么样?我在项目里遇到过一个问题:加载一张200KB的PNG图片,花了将近3秒。用户都以为死机了。

后来一查,问题出在 cache_size 上。这个参数决定了每次文件读取的缓存大小。默认值是0,意味着每次读一个字节都去访问磁盘。你想想看,SD卡的随机读取速度本来就慢,这么搞不卡才怪。

cache_size 值 读取200KB文件耗时 内存占用 适用场景
0(无缓存) ~2800ms 0字节 几乎不用,除非内存极度紧张
128字节 ~1200ms 128字节 小文件、文本文件
256字节 ~600ms 256字节 一般图片加载,推荐
512字节 ~350ms 512字节 大图片、连续读取
1024字节 ~200ms 1024字节 性能优先,内存充裕
注意:cache_size 不是越大越好。它是在LVGL堆上动态分配的,每个打开的文件都会占用一份。如果你同时打开多个文件,内存消耗会线性增长。我一般设256字节,兼顾性能和内存。

3.4 多文件系统共存

有些项目会同时用SD卡和SPI Flash。比如SD卡存用户数据,SPI Flash存固件和字体。这时候就需要注册多个驱动:

/* 注册SD卡驱动,盘符 'S' */
lv_fs_drv_t sd_drv;
lv_fs_drv_init(&sd_drv);
sd_drv.letter = 'S';
/* ... 配置SD卡回调 ... */
lv_fs_drv_register(&sd_drv);

/* 注册SPI Flash驱动,盘符 'F' */
lv_fs_drv_t flash_drv;
lv_fs_drv_init(&flash_drv);
flash_drv.letter = 'F';
/* ... 配置Flash回调 ... */
lv_fs_drv_register(&flash_drv);

使用时通过盘符区分:

  • "S:/userdata/config.ini" → SD卡
  • "F:/fonts/NotoSansSC.bin" → SPI Flash

这个设计很巧妙,LVGL内部完全不用关心底层是什么存储介质,它只认盘符和路径。你想想看,如果以后要换存储方案,只需要改驱动注册那部分代码,上层应用完全不用动。

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

最后分享几个实战中容易翻车的地方:

  1. 路径分隔符:LVGL统一用 /,但FatFS在Windows上习惯用 \。我刚开始写路径时写成 "S:\images\bg.png",结果死活打不开文件。记住,在LVGL里永远用 /。
  2. 文件指针生命周期:lv_fs_open 返回的文件指针,用完后必须 lv_fs_close。我曾经在一个循环里反复打开文件但不关闭,最后内存泄漏导致系统崩溃。嗯,血的教训。
  3. 并发访问:LVGL的事件处理是单线程的,但如果你在多个任务里同时访问文件系统,一定要加互斥锁。FatFS本身支持重入,但需要你配置 FF_FS_REENTRANT 宏。
调试小技巧:注册驱动后,可以用 lv_fs_get_letter 检查盘符是否被占用。如果返回非0值,说明这个盘符已经被注册了,换个字母就行。

好,文件系统接口这部分就讲到这里。记住一个核心思想:LVGL不关心你用什么存储,只关心你有没有注册驱动。把驱动写好了,后面加载图片、字体、动画文件就都水到渠成了。

LVGL文件系统驱动架构 LVGL 应用层 lv_img_set_src("S:/bg.png") lv_fs 文件系统抽象层 盘符路由 + 回调分发 驱动实例 1:SD卡 (盘符 S:) open/read/seek/close 回调 驱动实例 2 SPI Flash (盘符 F:) SD卡 (FatFS) SPI Flash 核心流程: 应用层调用 "S:/bg.png" → lv_fs 根据盘符 'S' 路由到 SD卡驱动 → 驱动调用 FatFS 读写 SD卡