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卡里的文件了。
4.2.2 回调函数实现要点
这几个回调函数里,read_cb 和 seek_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);
4.3.3 分块预加载(适合大图)
对于全屏背景图这种大块头,全量加载不现实。我的做法是——按需加载,分块缓存。
思路是这样的:
- 把一张大图分成多个小块(比如每块64x64像素)
- 只加载当前屏幕可见区域对应的块
- 当用户滑动屏幕时,提前加载即将进入视野的块
代码实现上,需要自己管理一个块缓存池:
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加速,到时候咱们再聊。