4、外部Flash/SD卡缓存:文件系统图像加载、lv_fs驱动集成、缓存预加载策略

好,咱们接着聊。前面几章讲的都是内部RAM和显存里的那点事,说白了就是「内存里怎么倒腾」。但实际项目里,尤其是做复杂UI或者高清图片显示时,你会发现——内存根本不够用。

我做过一个智能家居中控屏的项目,分辨率1280x720,全屏背景图一张就2MB多。MCU内部RAM总共才512KB,你想想看,连一张图都塞不下。怎么办?只能把图放在外部Flash或者SD卡里,用的时候再读进来。

这一章,我就把外部存储缓存这件事给你讲透。包括文件系统怎么挂载、lv_fs驱动怎么集成、以及缓存预加载的那些坑。

4.1 为什么需要外部存储缓存?

先问个问题:LVGL默认的图像加载方式是什么?

答案是——全量加载。也就是调用 lv_img_set_src() 时,它会一次性把整张图片的像素数据解压到内存里。对于小图标(几十KB)没问题,但换成大图(几百KB甚至几MB),内存直接爆掉。

所以,外部存储缓存的核心思路就两条:

  • 存不下:把图片放在外部Flash/SD卡里,不占内部RAM
  • 读得慢:用缓存策略减少读取次数,提升显示流畅度

嗯,这里要注意:外部存储的读取速度通常比内部RAM慢几十倍甚至上百倍。所以「怎么读」比「存哪里」更重要。

4.2 文件系统集成:lv_fs驱动

LVGL本身不直接操作SD卡或Flash芯片。它通过一个抽象层——lv_fs(文件系统接口)——来对接底层的存储介质。

说白了,你只需要实现几个回调函数,LVGL就能像读本地文件一样读外部存储。

4.2.1 驱动注册流程

我习惯把驱动注册放在系统初始化阶段。大致步骤是这样的:

// 1. 定义一个文件系统驱动结构体
static lv_fs_drv_t fs_drv;

// 2. 初始化驱动
lv_fs_drv_init(&fs_drv);

// 3. 设置驱动参数
fs_drv.letter = 'S';          // 盘符,比如 'S' 代表 SD 卡
fs_drv.cache_size = 4096;     // 缓存大小,单位字节
fs_drv.open_cb = sd_open_cb;  // 打开文件回调
fs_drv.close_cb = sd_close_cb;
fs_drv.read_cb = sd_read_cb;
fs_drv.seek_cb = sd_seek_cb;
fs_drv.tell_cb = sd_tell_cb;

// 4. 注册驱动
lv_fs_drv_register(&fs_drv);

注册完成后,你就可以用 "S:/images/bg.png" 这样的路径来访问SD卡里的文件了。

小提示:盘符字母可以随便选,但别跟其他驱动冲突。我一般用 'S' 表示SD卡,'F' 表示Flash,'U' 表示U盘。好记。

4.2.2 回调函数实现要点

这几个回调函数里,read_cbseek_cb 是最关键的。我踩过一个大坑:

我曾经在实现 read_cb 时,每次只读一个字节。结果加载一张100KB的图片,要调用10万次读取函数。界面卡得跟幻灯片一样。

正确的做法是:尽量一次读取大块数据。比如:

static lv_fs_res_t sd_read_cb(lv_fs_drv_t *drv, void *buf, uint32_t btr, uint32_t *br) {
    // 一次读取 btr 字节,而不是循环单字节读取
    if (f_read(&file, buf, btr, br) == FR_OK) {
        return LV_FS_RES_OK;
    }
    return LV_FS_RES_UNKNOWN;
}

另外,seek_cb 也要注意:LVGL在解码图片时,会频繁地前后跳转读取数据。如果每次seek都重新打开文件,性能会非常差。最好用 f_lseek 直接移动文件指针。

4.3 缓存预加载策略

驱动搞定了,文件能读了。但问题来了——每次显示图片都从SD卡读,速度还是慢。尤其是切换界面时,图片一张一张地加载,用户能明显看到「刷图」的过程。

怎么办?预加载

4.3.1 什么是预加载?

预加载,就是在用户还没看到这张图之前,先把数据读到内存缓存里。等真正需要显示时,直接从缓存拿,不用再读SD卡。

我常用的策略有三种:

策略名称 适用场景 内存开销 实现复杂度
全量预加载 小图片、图标
分块预加载 大图片、背景图
LRU缓存 多图片轮播、图库 可控

4.3.2 全量预加载(适合小图)

对于小于64KB的图标或按钮图片,我建议直接全量读到内存里。代码很简单:

// 在界面切换前,预加载下一屏的图标
lv_fs_file_t file;
lv_fs_open(&file, "S:/icons/next_icon.bin", LV_FS_MODE_RD);

uint8_t *buf = lv_mem_alloc(64 * 1024);
uint32_t bytes_read;
lv_fs_read(&file, buf, 64 * 1024, &bytes_read);

// 把数据传给LVGL图像对象
lv_img_dsc_t img_dsc;
img_dsc.data = buf;
img_dsc.data_size = bytes_read;
// ... 其他参数设置

lv_img_set_src(img_obj, &img_dsc);
注意:全量预加载虽然快,但会占用大量内存。如果图片超过128KB,我建议你慎重考虑。我曾经在一个项目里预加载了10张100KB的图片,结果内存直接溢出,系统反复重启。

4.3.3 分块预加载(适合大图)

对于全屏背景图这种大块头,全量加载不现实。我的做法是——按需加载,分块缓存

思路是这样的:

  1. 把一张大图分成多个小块(比如每块64x64像素)
  2. 只加载当前屏幕可见区域对应的块
  3. 当用户滑动屏幕时,提前加载即将进入视野的块

代码实现上,需要自己管理一个块缓存池:

typedef struct {
    uint32_t block_id;   // 块编号
    uint8_t *data;       // 块数据
    uint8_t valid;       // 是否有效
} block_cache_t;

#define CACHE_BLOCK_NUM 8
static block_cache_t cache_pool[CACHE_BLOCK_NUM];

// 获取指定块的函数
uint8_t* get_block(uint32_t block_id) {
    // 先查缓存
    for (int i = 0; i < CACHE_BLOCK_NUM; i++) {
        if (cache_pool[i].block_id == block_id && cache_pool[i].valid) {
            return cache_pool[i].data;  // 缓存命中
        }
    }
    // 缓存未命中,从SD卡读取
    // ... 读取并替换最旧的缓存块
}

嗯,这里要注意:分块大小和缓存块数量需要根据你的内存和图片尺寸来调。我一般把单块大小控制在4KB以内,缓存池8~16块,这样总内存开销也就32~64KB。

4.3.4 LRU缓存(适合图库场景)

如果你做的是图片浏览器或者轮播图,用户会频繁切换图片。这时候用LRU(最近最少使用)缓存最合适。

LVGL其实自带了一个简单的LRU缓存机制——lv_img_cache。你只需要设置缓存大小:

// 设置图像缓存为 10 张图片
lv_img_cache_set_size(10);

之后,LVGL会自动缓存最近使用过的图片。当缓存满了,它会淘汰最久没用的那张。

不过,这个内置缓存有个限制:它缓存的是解码后的图像数据,而不是原始文件数据。所以对于大图,缓存几张就可能把内存吃光。

我个人更推荐自己实现一个文件级LRU缓存:只缓存原始文件数据(比如PNG或JPEG的压缩数据),解码工作交给LVGL实时做。这样内存占用小很多。

4.4 实战中的避坑指南

最后,分享几个我踩过的坑:

  • 文件系统挂载失败:SD卡初始化时,一定要检查返回值。我遇到过卡没插好,但程序没检查错误,结果一直卡在读取循环里。
  • 缓存一致性:如果外部Flash支持OTA升级,升级后图片文件变了,但缓存里还是旧数据。记得在升级后清空缓存。
  • DMA传输:如果MCU支持DMA,尽量用DMA来读取外部存储。这样CPU可以继续处理UI事件,不会卡界面。
  • 文件路径:LVGL的路径分隔符是 /,不是 \。我刚开始从Windows开发转过来时,经常写错。

好了,这一章的内容就这些。外部存储缓存说白了就是「用空间换时间,用策略换流畅度」。下一章我会讲更高级的——双缓冲与DMA2D加速,到时候咱们再聊。