2. LVGL 图像架构:缓存、解码器与文件系统

好,咱们进入正题。LVGL 显示一张图片,背后其实有一套完整的流水线。我刚开始接触 LVGL 时,以为就是调个 lv_img_set_src() 完事。后来发现,图片加载慢、内存爆、花屏……各种问题全来了。

说白了,你得理解 LVGL 是怎么“吃”进一张图片的。它分三层:图像缓存机制、解码器框架、文件系统接口。这三层配合不好,你的 UI 就卡成 PPT。

2.1 图像缓存机制:LVGL 怎么存图?

LVGL 的图片缓存,我习惯叫它“三级缓存”。不是 CPU 那种 L1/L2/L3,而是针对不同场景设计的三种存储方式。

缓存类型 存储位置 特点 典型场景
内部 RAM 缓存 MCU 内部 SRAM 速度快,容量小(几十 KB ~ 几百 KB) 小图标、按钮背景
外部 RAM 缓存 SDRAM / PSRAM 速度中等,容量大(几 MB ~ 几十 MB) 全屏背景图、大尺寸图片
Flash 缓存 内部 Flash / 外部 SPI Flash 速度慢,容量大,掉电不丢失 开机 Logo、固定图标

嗯,这里要注意:LVGL 默认会把解码后的图片数据放在 内部 RAM。如果你加载一张 800×480 的 32 位图,光像素数据就 1.5MB。很多 MCU 内部 RAM 总共才 512KB,直接爆掉。

我曾经踩过的坑: 用 STM32F429 加载一张 320×240 的 JPEG 图片,LVGL 解码后直接分配了 300KB 的缓存。当时内部 RAM 还剩 200KB,程序直接 HardFault。后来改用外部 SDRAM 做解码缓存才解决。

LVGL 的缓存分配策略,核心是 lv_img_cache.h 里的几个宏:

// 内部 RAM 最大缓存图片数量
#define LV_IMG_CACHE_DEF_SIZE       1

// 是否启用外部 RAM 缓存
#define LV_IMG_CACHE_DEF_EXT_RAM    0

// 单张图片最大缓存大小(字节)
#define LV_IMG_CACHE_MAX_SIZE       (32 * 1024)

我个人习惯把 LV_IMG_CACHE_DEF_SIZE 设成 2~3,这样 UI 切换时能缓存最近几张图,避免反复解码。但别设太大,内部 RAM 扛不住。

2.2 解码器框架:LVGL 怎么“看懂”图片?

LVGL 支持多种图片格式:BMP、PNG、JPEG、GIF,还有它自己的 LVGL 原生格式(.bin 或 .c 数组)。每种格式对应一个解码器。

解码器的注册机制,说白了就是一个链表。每个解码器是一个 lv_img_decoder_t 结构体:

typedef struct _lv_img_decoder_t {
    lv_img_decoder_info_cb_t info_cb;
    lv_img_decoder_open_cb_t open_cb;
    lv_img_decoder_read_cb_t read_cb;
    lv_img_decoder_close_cb_t close_cb;
    struct _lv_img_decoder_t * next;
} lv_img_decoder_t;

你想想看,LVGL 加载图片时,会遍历这个链表。先调用 info_cb 判断文件头是否匹配,匹配了就调用 open_cb 开始解码。解码过程中,read_cb 负责逐行读取像素数据。

我遇到过一个问题:同时注册了 PNG 和 JPEG 解码器,但 PNG 解码器总是先被调用,导致 JPEG 图片也被当成 PNG 去解析,结果花屏。后来发现是解码器注册顺序的问题。LVGL 按注册顺序遍历,先注册的先匹配。

建议: 把最常用的解码器(比如 LVGL 原生格式)注册在前面,不常用的(比如 GIF)放后面。这样可以减少不必要的格式匹配尝试。

解码器的核心流程,我画了张图:

LVGL 图片解码流程 图片源 文件系统接口 解码器链表遍历 格式匹配 解码 & 缓存 关键说明 1. 图片源可以是文件、数组、 或 LVGL 原生格式 2. 文件系统接口负责读取 原始数据 3. 解码器按注册顺序遍历 4. 格式匹配通过文件头判断 5. 解码后的数据存入缓存 供显示使用

2.3 文件系统接口:LVGL 怎么“读”文件?

LVGL 本身不直接操作文件系统。它通过一个抽象层——lv_fs 模块——来对接各种文件系统。你想想看,MCU 上常见的文件系统有 FatFS、LittleFS、SPIFFS,甚至 Linux 上的 VFS。LVGL 不能为每种都写一套代码,所以它定义了一套回调接口。

核心结构体是 lv_fs_drv_t:

typedef struct _lv_fs_drv_t {
    char letter;                // 驱动器盘符,如 'C', 'D', 'S'
    uint16_t cache_size;        // 文件缓存大小
    lv_fs_open_cb_t open_cb;
    lv_fs_close_cb_t close_cb;
    lv_fs_read_cb_t read_cb;
    lv_fs_write_cb_t write_cb;
    lv_fs_seek_cb_t seek_cb;
    lv_fs_tell_cb_t tell_cb;
    lv_fs_dir_open_cb_t dir_open_cb;
    lv_fs_dir_read_cb_t dir_read_cb;
    lv_fs_dir_close_cb_t dir_close_cb;
} lv_fs_drv_t;

每个文件系统驱动注册时,需要指定一个 盘符。比如 FatFS 注册为 'F',那么 LVGL 中访问文件路径就是 "F:/images/logo.bmp"。这个设计很巧妙,不同存储介质可以共用一套文件访问代码。

实际项目中的配置:

  • 内部 Flash:注册为 'I',只读,用于存放固定图标
  • SD 卡(FatFS):注册为 'S',读写,用于存放用户图片
  • SPI Flash(LittleFS):注册为 'L',读写,用于存放缓存文件

我做过一个项目,需要从 SD 卡加载图片,同时从内部 Flash 加载字体。当时注册了两个驱动:

// 注册 SD 卡驱动
lv_fs_drv_t sd_drv;
lv_fs_drv_init(&sd_drv);
sd_drv.letter = 'S';
sd_drv.open_cb = sd_open_cb;
sd_drv.close_cb = sd_close_cb;
sd_drv.read_cb = sd_read_cb;
sd_drv.seek_cb = sd_seek_cb;
sd_drv.tell_cb = sd_tell_cb;
lv_fs_drv_register(&sd_drv);

// 注册 Flash 驱动
lv_fs_drv_t flash_drv;
lv_fs_drv_init(&flash_drv);
flash_drv.letter = 'I';
flash_drv.open_cb = flash_open_cb;
flash_drv.close_cb = flash_close_cb;
flash_drv.read_cb = flash_read_cb;
flash_drv.seek_cb = flash_seek_cb;
flash_drv.tell_cb = flash_tell_cb;
lv_fs_drv_register(&flash_drv);

然后 LVGL 加载图片时,路径写 "S:/images/bg.bmp" 就会自动走 SD 卡驱动,写 "I:/logo.bin" 就走 Flash 驱动。嗯,这个设计真的很方便。

注意: 文件系统的 cache_size 字段容易被忽略。它决定了 LVGL 在读取文件时,一次缓存多少字节。设太小(比如 128 字节),读取大图片时会频繁调用 read_cb,性能很差。我一般设成 4096 字节,跟 SD 卡的扇区大小对齐。

2.4 三者如何配合?

咱们把这三层串起来看:

  1. 应用层 调用 lv_img_set_src(img, "S:/bg.bmp")
  2. 文件系统接口 根据盘符 'S' 找到对应驱动,打开文件
  3. 解码器框架 遍历解码器链表,找到能处理 BMP 格式的解码器
  4. 解码器 通过文件系统接口读取文件头,确认格式,开始解码
  5. 缓存机制 分配内存,存放解码后的像素数据
  6. 显示驱动 从缓存中读取像素,刷新到屏幕

这个流程中,任何一个环节出问题,图片都显示不出来。我遇到过最典型的问题:文件系统接口的 read_cb 返回数据太慢,解码器等超时,直接报错。后来在 lv_conf.h 里把 LV_IMG_DECODER_TIMEOUT 从默认的 100ms 改成了 500ms,问题解决。

调试技巧: 如果图片加载失败,先检查文件系统接口是否正常。可以单独写个测试函数,用 lv_fs_open() 和 lv_fs_read() 读取文件前几个字节,看返回值。文件系统通了,再查解码器。