2. LVGL 缓存架构总览:L1 / L2 / L3 三级缓存设计思想
大家好,我是你们的老朋友。上一章我们聊了为什么要给 LVGL 做图像缓存,说白了就是「别让屏幕等你」。这一章我们深入骨架——LVGL 的缓存架构到底长什么样?
我个人习惯把 LVGL 的缓存体系想象成一个「三级弹药库」。最靠近 CPU 的缓存最快但最小,离 CPU 越远的缓存越大但越慢。你想想看,这和计算机体系结构里的 L1/L2/L3 cache 是不是一个道理?LVGL 的设计者显然借鉴了这个思想。
2.1 整体设计思想:分层解耦,按需流动
LVGL 的图像缓存不是一个大池子,而是分了三层。每一层都有自己的职责和生命周期。数据从底层存储(比如 Flash 或 SD 卡)一路向上,最终到达屏幕像素。
为什么要分层?我在项目中遇到过一个问题:如果只用一个大缓存,一旦缓存被刷掉,所有图像都得重新解码。那画面卡顿得让人崩溃。分层之后,每一层只负责自己那一亩三分地,上层缓存失效了,下层还能顶一顶。
核心设计原则:
- 就近访问:越靠近渲染管线的缓存,访问延迟越低
- 容量递增:L1 最小,L3 最大,平衡速度和内存占用
- 逐级回退:上层 miss 了,自动向下层查找,直到原始数据源
- 透明替换:开发者不需要手动管理缓存替换,LVGL 内部自动处理
下面这张图是我手绘的三级缓存架构,你可以把它当作本章的「地图」。
2.2 L1 缓存:最前线的像素快递员
L1 缓存是离渲染管线最近的一层。它缓存的是已经解码好的像素数据,也就是 ARGB8888 或 RGB565 格式的原始像素。
你想想看,如果每次画一个图标都要重新解码 PNG,那 CPU 得累死。L1 缓存就是为了解决这个问题——它把最近用过的图像小块(比如 32x32 的图标)直接以像素形式存着,渲染时直接 memcpy 就行。
我的经验:L1 缓存的大小通常由 LV_IMG_CACHE_DEF_SIZE 宏控制,默认是 0(不启用)。我建议至少设成 8~16 个条目,对图标密集型的 UI 效果立竿见影。
L1 缓存的替换策略是 LRU(最近最少使用)。当一个图像块被渲染后,它会被放到 L1 的头部;如果 L1 满了,尾部的条目就会被踢出去。嗯,这里要注意:L1 缓存只缓存图像的一部分,不是整张图。比如一张 800x480 的背景图,L1 可能只缓存当前屏幕可见的那一块。
2.3 L2 缓存:完整的图像帧仓库
L2 缓存比 L1 大一个量级。它缓存的是完整解码后的图像帧。也就是说,一张 PNG 图片被解码成位图后,整个位图数据会放在 L2 里。
我曾经在一个智能家居面板项目上吃过亏:界面有 20 多个图标,每个图标 64x64,PNG 格式。一开始没开 L2 缓存,每次切换页面都要重新解码,那个卡顿啊……后来把 L2 缓存开到 512KB,所有图标解码一次后直接命中,切换页面丝般顺滑。
| 特性 | L1 缓存 | L2 缓存 |
|---|---|---|
| 缓存粒度 | 图像块(tile) | 完整图像帧 |
| 数据格式 | 解码后像素 | 解码后位图 |
| 典型大小 | 几 KB ~ 几十 KB | 几百 KB ~ 几 MB |
| 替换策略 | LRU | LRU + 引用计数 |
| 适用场景 | 频繁变化的小图标 | 重复使用的大背景/图片 |
L2 缓存的替换策略除了 LRU,还加了一个引用计数。如果一个图像被多个对象引用(比如多个按钮共用同一个图标),它的引用计数会增加,被踢出的优先级会降低。这个设计很聪明,避免了「刚解码完就被踢出去」的尴尬。
2.4 L3 缓存:原始编码数据的压缩仓库
L3 缓存是最底层的一级缓存。它缓存的是原始编码数据——也就是 PNG、JPEG、BMP 这些还没解码的文件内容。
你可能会问:为什么不直接解码?因为解码很慢啊!L3 缓存的存在意义是:避免重复从文件系统读取。文件 I/O 的速度比内存访问慢几个数量级,尤其是从 Flash 或 SD 卡读取时。
避坑指南:我曾经在一个项目里把 L3 缓存开得太大,结果内存不够,系统频繁 OOM。L3 缓存的大小要根据你的硬件来定——如果 Flash 读取速度还行(比如 40MB/s),L3 可以小一点;如果用的是低速 SPI Flash,L3 就得大一些。
L3 缓存的数据是压缩的,所以占用的内存比 L2 小得多。一张 100KB 的 PNG 图片,解码后可能变成 600KB 的位图。在 L3 里它只占 100KB,在 L2 里却要占 600KB。这就是为什么 L3 可以做得比 L2 大很多。
2.5 三级缓存的协作流程
当 LVGL 需要渲染一张图片时,它会按照下面的顺序查找:
- 查 L1 缓存:看当前要渲染的图像块是否已经在 L1 里。如果命中,直接 memcpy 到帧缓冲,完事。
- 查 L2 缓存:L1 miss,去 L2 找完整的解码后位图。如果命中,从位图中裁剪出需要的块,同时把这块数据放到 L1 里。
- 查 L3 缓存:L2 也 miss,去 L3 找原始编码数据。如果命中,解码成位图,放入 L2,同时把需要的块放入 L1。
- 从文件系统加载:L3 也 miss,只能从 Flash/SD 卡读取原始文件。读取后放入 L3,然后解码、放入 L2、裁剪块放入 L1。
这个流程说白了就是「逐级回退」。每一级 miss 了,就往下一级找,直到找到原始数据源。数据一旦被加载,会同时填充所有经过的缓存层级——这叫缓存填充策略。
关键点:LVGL 的缓存架构是「写穿透」的。也就是说,当图像数据被修改时(比如动态生成的图像),它会直接写入 L2 和 L3,同时使 L1 中对应的缓存行失效。这样可以保证数据一致性。
2.6 缓存配置的实用建议
根据我的项目经验,不同场景下的缓存配置可以参考下表:
| 场景 | L1 条目数 | L2 大小 | L3 大小 | 备注 |
|---|---|---|---|---|
| 低端 MCU(< 256KB RAM) | 4~8 | 32~64 KB | 不启用 | 内存紧张,优先保证 L2 |
| 中端 MCU(256KB~1MB RAM) | 8~16 | 128~256 KB | 64~128 KB | 均衡配置,图标类 UI 首选 |
| 高端 MCU / MPU(> 1MB RAM) | 16~32 | 512 KB ~ 2 MB | 256 KB ~ 1 MB | 大背景图多的场景,L2 优先 |
嗯,这里要注意:L3 缓存不是必须的。如果你的文件系统读取速度很快(比如用内存映射的 Flash),或者你的图片都是小尺寸的,完全可以关掉 L3,把内存留给 L2。
我个人习惯在项目初期先把 L2 缓存打开,设一个中等大小,然后跑一下 UI 看看命中率。如果命中率低于 80%,就加大 L2;如果内存还够,再考虑开 L3。说白了,缓存配置是个「试出来」的过程,没有银弹。