简介:LVGL V9在嵌入式图形库中正式加入RLE(行程长度编码)无损压缩能力,专门应对单片机等资源受限设备的图片存储难题;这套工具包面向已经接触过LVGL、正在为图像占用大量Flash而苦恼的嵌入式开发者,既可用于学习RLE压缩原理,也能直接集成到项目中。包内共3个文件,包含Python脚本、Windows批处理脚本和说明文档,分别负责ARGB8888格式图像的RLE编码与格式转换、自动化压缩流程的执行,以及依赖环境和使用步骤的详细说明;整体仅11KB,轻量且不增加开发负担。目前已有400人浏览学习;借助这套脚本,开发者可针对单色背景、渐变色块等连续像素密集的图像自动完成压缩,减少冗余数据,显著降低存储占用并加快UI加载速度。随附的示例图像还能帮助直观对比压缩前后效果,配合LVGL V9原生RLE解码机制,可在保持图像质量的同时节省内存,是低配MCU与中小型GUI项目的实用工具。
1. LVGL V9 RLE压缩:小图标的体积焦虑,一次解码器就解决
LVGL V9 RLE压缩,是在V9新图像框架里内置的一种图片压缩方案:它把带透明通道的 RGBA 位图按行拆成游程编码,专门收拾 UI 图标这类“大面积同色 + 大片透明”的资源。一张 48×48 的 ARGB8888 图标,裸数据 9216 字节;压成 RLE 之后,常见图标能缩到几百字节,解码时不需要额外的字典表和解压缓冲。这带来的直接收益是:同样一片 Flash,能塞下多一倍的图标资源;同样一段渲染路径,解码耗时基本可控。适合Flash和RAM都紧巴巴的 MCU 界面项目,也适合那些想摆脱“一张图吃掉一半内存”的资源焦虑的开发者。下文不聊抽象概念,直接拆原理、给转换步骤、讲坑,最后给一套能抄的验证方法。
2. 先搞懂 RLE 在 LVGL V9 里的定位:逐行游程解码与图片解码器的分工
RLE 不是什么新东西,但在 LVGL V9 里它被设计成了一种“嵌入式友好”的压缩:按行切、按需解、不依赖第三方库。要判断它适不适合你的项目,得先看清楚它在整套图像链路里的位置。
2.1 RLE 为什么比 PNG 更适合嵌入式:字节级角度看图标
先看一块屏幕上的图标数据长什么样。ARGB8888 格式下每个像素 4 个字节:Alpha、Red、Green、Blue。一个 48×48 的图标就是 9216 字节,如果界面上一屏有 20 个图标,光是这堆小图就接近 180KB,很多 MCU 的 RAM 总共也就这么多。但实际数据里,图标的绝大部分区域是同一个颜色,或者干脆是完全透明的,也就是说数据里存在大量连续重复的字节串。
RLE 的思路就是把“连续相同的像素”记录成一条游程:长度 + 像素值。比如一行里有 40 个像素全是透明的,就记成“长度 40 + 透明色”,而不是写 40 组 RGBA。这是 RLE 压缩率高的根源,也是它在嵌入式里适合做图标压缩的根源。
那为什么不直接用 PNG?PNG 的压缩率通常更高,因为它后面接的是 DEFLATE,带 LZ77 字典和哈夫曼编码,能处理更复杂的重复模式。但代价也在那里:解码需要维护滑动窗口、哈夫曼表,一次解码的内存峰值和 CPU 开销在 MCU 上都比较可观。LVGL 的 PNG 解码器在 V9 里确实有,但它的资源占用是“完整解码器”级别,不是所有项目都扛得住。RLE 的解码循环要朴素得多:读游程、填像素、换下一行。它几乎不用动态分配,也几乎没有解码状态。所以在“CPU 主频几十到几百 MHz、RAM 以 KB 计”的场景里,RLE 是那个少花钱多办事的选项。
还有一个工程上的细节:LVGL V9 的 RLE 不是把整张图当作一个二维块来编码,而是逐行编码,并且额外维护一张“行索引表”。这张表记录每一行压缩数据在整体数据里的起始位置。它的意义在于配合 LVGL 的局部重绘机制:屏幕只更新一个区域时,解码器只解该区域涉及的那几行,不需要把整张图全部还原。这一点比整图压缩方案在交互式 GUI 里友好得多,滚动列表、按钮按下高亮这类局部刷新场景不会被迫做全图解码。
2.2 LVGL V9 里 RLE 解码器在哪个环节工作:decoder_open 到 draw
V9 的图像子系统相比 V8 做了一次彻底重构,API 上最大的变化是lv_img全部换成了lv_image,底层解码框架也统一走lv_image_decoder_*接口。RLE 解码器是这条链路上一个标准解码器,它是否注册进系统,取决于lv_conf.h里的LV_USE_RLE宏。
当绘制一张带 RLE 压缩标记的图片时,实际路径大概是:draw 引擎发现这个lv_image_dsc_t的 header 里带压缩标志位,于是调用解码器链上的 open 接口;解码器 open 时解析头部信息,拿到宽度、高度、颜色格式和压缩数据起始位置;接着根据 draw 请求的裁剪区域,计算需要哪几行;随后逐行把游程还原成目标颜色格式的像素;最后交给 draw unit 做混合和输出。
这里要注意一个关键点:LVGL 的 RLE 解码器不会直接把整张图解成一块完整位图。它的输出粒度是“行”。所以正确理解它的工作方式是:它只在有绘制需求时、按需解码可见行。这也意味着,如果你的代码里去手动调用解码接口并试图拿一整张解码结果,是不符合它的设计预期的——它会按你传的裁剪区域返回对应行数据。
2.3 什么图适合 RLE:颜色格式与透明度的取舍
RLE 不是万能压缩,它的压缩率完全取决于素材本身。合理的做法是在转换前先判断图片类型,再决定是否启用 RLE、用哪种颜色格式。
| 图片类型 | 推荐格式 | 说明 |
|---|---|---|
| 全不透明、颜色数少的图标 | RGB565 + RLE | 没有透明度负担,数据量减半,游程长 |
| 带透明通道的 Logo/装饰图 | ARGB8888 + RLE | 透明区域形成超长游程,压缩收益最大 |
| 纯 alpha 通道的遮罩图 | A8 + RLE | 单通道重复度高,体积极小 |
| 照片、复杂渐变、噪点纹理 | 不压缩 / LZ4 | RLE 大概率膨胀,行索引表反而成负担 |
颜色格式的选择会直接影响压缩效果。ARGB8888 每个像素 4 字节,好处是透明边缘的抗锯齿信息能完整保留,坏处是原始数据大;RGB565 每个像素 2 字节,适合完全不透明的素材,但如果硬把带透明信息的图标压成 RGB565,边缘会丢失透明度,显得发毛。V9 还支持 RGB565A8 这类分离格式,颜色和 alpha 分开存放,alpha 通道本身重复度通常很高,RLE 对它的压缩效果往往不错。
判断标准其实很简单:一行数据里连续相同像素越多,RLE 越划算。可以用一个粗略的经验值——如果一行 48 像素里,纯色/透明区域超过一半,RLE 基本稳赚;如果一行里每个像素颜色都在跳,那 RLE 大概率要翻车。这也是后面转换和选素材时最重要的判断依据。
3. 把 PNG 换成 LVGL V9 RLE 格式:转换器选项、工程接入与批量脚本
原理部分确认能用之后,剩下的就是动手。这一章讲最常见的做法:用 LVGL 官方图片转换工具生成 RLE 压缩的 C 数组,再把生成的 C 文件接进工程,最后给出我在批量场景下替代手工操作的小脚本。
3.1 在转换器里勾上 RLE:颜色格式和压缩选项怎么选
常见做法是把素材先处理成 PNG,然后在 LVGL 官方图片转换工具里上传并配置输出参数。需要留意四个选项:Color format、Compression、Output format,以及是否需要预乘 alpha。我的建议是:
- 有透明通道的 UI 图标选
ARGB8888,不要因为它体积大就换成 RGB565,边缘质量会明显下降; - 完全不透明的纯色图标选
RGB565,体积直接砍半,RLE 压缩率依然可观; - Compression 选择
RLE; - Output format 选
C array,方便直接编进固件; - 预乘 alpha 的选项默认不勾,除非你的渲染管线明确要求预乘,否则会让颜色看起来偏暗。
转换工具会生成一个.c文件,内容结构大致长下面这样。注意这里的字段是 LVGL V9 的lv_image_dsc_t结构,和 V8 的lv_img_dsc_t已经不兼容。
/* logo_rle.c - 转换器生成内容的结构示意 */ #include "lvgl.h" static const uint8_t logo_rle_data[] = { 0x10, 0x00, 0x00, 0xFF, /* 游程编码后的数据,具体字节由转换器生成 */ /* ... */ }; const lv_image_dsc_t logo_rle = { .header = { .magic = LV_IMAGE_HEADER_MAGIC, .cf = LV_COLOR_FORMAT_ARGB8888, .flags = 0x01, /* 最低位为压缩标记 */ .w = 48, .h = 48, }, .data_size = 612, .data = logo_rle_data, };这里要说明两点。data_size是压缩后的字节数,不是宽乘高乘字节深度;不要拿它去跟图片原始分辨率做加减。flags里的压缩标记位由转换器自动填入,不需要手工维护,但你要知道它的存在,以便后面排查时判断资源有没有真的走 RLE 路径。生成的文件一般会直接用图片名命名,里面已经带好const声明,编译后会存放在只读数据段,不会吃掉 RAM。
3.2 生成的 C 文件接进 LVGL V9 工程:最小三处改动
第一步,把生成的.c文件放进工程源码目录,加入编译。很多开发者习惯直接在 UI 文件里#include "logo_rle.c",这能跑,但会让编译依赖变乱,我一般建议按常规源文件加入构建,然后在需要的地方用LV_IMAGE_DECLARE引入外部声明。
第二步,打开lv_conf.h,找到LV_USE_RLE这个宏,确保它是 1。这个宏控制内置 RLE 解码器的注册,不打开的话,图片资源可能显示成空白或花屏。
/* lv_conf.h */ #define LV_USE_RLE 1第三步,在界面代码里创建一个图片控件,并把生成的lv_image_dsc_t赋值给控件。
#include "lvgl.h" LV_IMAGE_DECLARE(logo_rle); void ui_add_logo(lv_obj_t *parent) { lv_obj_t *img = lv_image_create(parent); lv_image_set_src(img, &logo_rle); lv_obj_center(img); }代码里的LV_IMAGE_DECLARE(logo_rle)展开后是一个 extern 声明,用来引用转换器生成的全局结构体。lv_image_set_src是 V9 的设置图像源接口,它接受const lv_image_dsc_t *指针,所以前面要加&。如果你在 V8 的习惯是lv_img_set_src,V9 里已经改名,编译报错搜一下改名就能定位。另外注意lv_conf.h的改动是编译期生效,LVGL 初始化时会自动把 RLE 解码器挂到解码器链上,不需要你在lv_init之后手动注册。
3.3 批量转换的工程化:一个扫描生成 extern 声明的小脚本
项目里的图标通常不止一个,一个个手动把生成文件的 extern 声明写进头文件,既无聊也容易漏。我一般会写一个小脚本,扫描资源目录下所有转换好的.c文件,自动提取里面的lv_image_dsc_t变量名,生成统一的assets_images.h。这样 UI 代码里只需要 include 一个头文件,所有图标资源就都可见了。
#!/usr/bin/env python3 # 扫描资源目录下所有 .c,生成 assets_images.h import os import re import sys src_dir = sys.argv[1] if len(sys.argv) > 1 else "./src/assets" out_h = sys.argv[2] if len(sys.argv) > 2 else "./src/assets/assets_images.h" names = [] for root, _, files in os.walk(src_dir): for f in sorted(files): if not f.endswith(".c"): continue path = os.path.join(root, f) text = open(path, encoding="utf-8").read() m = re.search(r"const\s+(?:lv_)?image_dsc_t\s+(\w+)\s*=", text) if m: names.append(m.group(1)) with open(out_h, "w", encoding="utf-8") as fh: fh.write("#ifndef ASSETS_IMAGES_H\n#define ASSETS_IMAGES_H\n\n") fh.write('#include "lvgl.h"\n\n') for n in names: fh.write(f"LV_IMAGE_DECLARE({n});\n") fh.write("\n#endif\n") print(f"declared {len(names)} images -> {out_h}")脚本的正则同时兼容 V9 的lv_image_dsc_t和 V8 的lv_img_dsc_t,所以老工程临时也能用。跑完之后,UI 文件里#include "assets_images.h",然后就能直接引用logo_rle这类变量名。参数说明:src_dir是存放转换结果的目录,out_h是生成头文件的路径。如果某个.c文件里改了变量名,重新跑一次脚本即可,不需要手工维护头文件。
在此基础上,我建议把压缩率检查也加进这个工作流。可以在脚本里顺带读一下data_size和header.w/h,估算一下是不是有图在 RLE 后反而膨胀,早发现早换方案。检查逻辑不复杂,关键是养成“转完先看体积比”的习惯,别等烧到板子上才发现内存吃紧。
4. LVGL V9 RLE 压缩常见问题与排查:从花屏到内存爆掉
资源转换和接入本身不难,真正耽误时间的是运行期那些“看着不对但不知道哪里不对”的问题。这一章把我在排查中反复遇到的五类典型问题列出来,每条按现象、原因、解决来讲。
4.1 图片区域空白,日志里没明显报错
现象是图片控件存在,但对应区域什么都不画,串口日志没有致命错误,顶多有一条关于未知图像格式的提示。
原因大概率是LV_USE_RLE没开。V9 的 RLE 解码器只有在宏打开时才注册进解码器链,否则系统碰到带压缩标记的图片数据时,找不到能处理它的解码器,直接放弃绘制。这个属于配置类问题,和资源本身无关。
解决方法是检查lv_conf.h,确认LV_USE_RLE被置为 1,然后全量重新编译。有一点容易踩坑:只改了头文件但增量编译没重新触发,尤其在使用预编译库的工程里,改完配置要 clean 一次再 build。
4.2 图片能出来,但颜色发青发紫
现象是图片轮廓和位置都对,但整体颜色错乱,白色图标偏紫偏绿,或者某些颜色像被交换了通道。
原因是转换器里选的颜色格式和运行环境不一致。RLE 游程记录的是像素的原始字节序列,LVGL 解码时按头部的颜色格式字段来解释这些字节。如果图片按 ARGB8888 转出来,但屏幕或 draw unit 最终按 RGB565 去取数据,一个像素的 4 个字节会被错位解析成两个半像素,颜色自然全乱套。
解决方法是把转换时的颜色格式和项目的LV_COLOR_FORMAT设置对齐。如果目标设备屏幕是 RGB565 且没有透明需求,就选 RGB565;有透明需求才选 ARGB8888。还有一个跨平台陷阱:如果转换器里有个“跟随当前平台颜色格式”的选项,生成的资源换到另一块不同深度的屏上就会翻车,建议锁定具体的颜色格式而不是用平台自适应。
4.3 压缩后 data_size 比原图还大
现象是转换完的.c文件里,data_size接近甚至超过w * h * bpp,也就是压缩了个寂寞。
原因是素材本身不适合 RLE。最典型的是照片、烟灰类半透明特效、或者带锯齿和渐变阴影的图标。RLE 的有效性建立在“连续相同像素”上,当一行 48 个像素几乎每个都不一样时,每条游程长度都是 1,编码结果反而比原始数据多出一截行表和游程头部的开销。
解决方法是先判断素材再决定压缩策略。可以在转换前用 Python 打开图片,统计一下每行像素变化的次数,如果平均每行变化次数超过宽度的一半,就不要用 RLE。对这种图,改用 LZ4 压缩方案,或者干脆不压缩,直接扔 Flash。还有一个折中办法:对 alpha 边缘做阈值化处理,让半透明像素归并到少数几个固定级别,把游程长度拉长,这个技巧在第 6 章展开。
4.4 大图解码时 malloc 失败,系统卡死
现象是小图标都正常,切到一张稍微大点的 RLE 图时系统进入内存断言或直接死机,反复复现都是同一张图触发。
原因是 RLE 解码虽然是按行进行的,但它依然需要为输出行分配缓冲。如果图片的原始颜色格式和渲染请求的目标格式不一致,解码器还需要额外的转换缓冲。更隐蔽的是,LVGL 的 draw unit 在绘制带旋转、缩放属性的图片时,可能在解码之外再开一层目标位图缓冲。几张 RLE 图同时存在,内存池就被榨干了。
解决方法是先量化再调参。RLE 图的运行期峰值内存大致由三部分构成:行索引表(按行数乘以 4 字节估算)、最长一行的游程数据缓冲、解码输出行缓冲(一行像素的目标格式大小)。如果图片的目标格式和原始格式一致,转换缓冲可以省掉。另一个有效手段是调大LV_MEM_SIZE,或者检查工程是否开起了 image cache,避免同一张图在每帧重复解码时反复分配释放。
4.5 从 V8 升级 V9,资源全部黑块
现象是老工程从 V8 升级到 V9 之后,原来能显示的图片资源全部失效,编译报错或者运行黑块,选择困难症直接发作。
原因是两个版本之间图像子系统做了破坏性重构。lv_img_dsc_t改名为lv_image_dsc_t,LV_IMG_CF_*枚举改名为LV_COLOR_FORMAT_*,解码器接口也完全不同。老的 V8 转换工具生成的资源文件,头部字段和 V9 的解析逻辑对不上,RLE 的实现也重新写过,无法直接复用。
解决方法是做一次彻底的版本迁移:头文件里的lv_img_*接口全部换成lv_image_*,声明宏换成LV_IMAGE_DECLARE,所有图片资源用 V9 对应的转换工具重新生成一遍。不要试图在代码里做兼容层,那个工作量比重新转换资源大得多,而且会埋下运行期隐患。
5. 跑起来之后怎么验证:内存、耗时和解码路径三件事
接到工程能显示,只算成功了一半。RLE 值不值得用,要看运行时的三个数据:它确实走了 RLE 解码路径吗?解码一次要多久?Flash 省下来的内存换来的运行期开销是多少?这一章给三个可以照着用的验证手段。
5.1 先确认加载的真是 RLE 路径:打印 header 信息
资源来源一多,或者接手别人的工程时,最容易出现的问题是你以为它在用 RLE,实际上显示的还是转换前那份原始数据。好在 V9 的lv_image_dsc_t结构体里可以直接读到所有关键信息,写一个小的调试函数打出来就行。
#include "lvgl.h" LV_IMAGE_DECLARE(logo_rle); void check_image_header(void) { LV_LOG_USER("w=%d h=%d cf=%d data_size=%d flags=0x%02X", logo_rle.header.w, logo_rle.header.h, logo_rle.header.cf, logo_rle.data_size, logo_rle.header.flags); }flags里的压缩标记位如果被置位,说明这张资源确实带压缩属性;data_size则直接反映压缩后的体积。判断是否有效压缩,不只看标记位,还要看data_size是否远小于w * h * bpp。如果data_size和原始大小差不多,说明这张图转换时就没压动。把这个函数放到初始化流程里跑一次,串口日志里就能看到资源的真实状态,避免对着黑屏猜原因。
5.2 用 lv_tick 量化解码耗时:单次 vs 连续滚动
解码耗时的传统测法是打点计时。LVGL 提供lv_tick_get()返回当前 tick,在解码动作前后各取一次,差值就是耗时。但这里有一个容易测不准的地方:LVGL 的重绘是异步的,lv_image_set_src只是标记控件需要刷新,真正的解码发生在lv_timer_handler或同步刷新时才执行。所以要测真实耗时,需要强制同步刷新。
#include "lvgl.h" void bench_image(lv_obj_t *img, uint32_t loops) { uint32_t t0 = lv_tick_get(); for (uint32_t i = 0; i < loops; i++) { lv_obj_invalidate(img); lv_refr_now(NULL); /* 强制同步刷新,确保解码实际发生 */ } uint32_t t1 = lv_tick_get(); LV_LOG_USER("loops=%d use=%d ms avg=%.3f ms", loops, t1 - t0, (t1 - t0) / (float)loops); }lv_refr_now(NULL)会同步执行一次完整的刷新周期,解码和绘制都在这次调用中完成。loops建议至少在 100 以上,取平均才有意义。比较典型的做法是测三组:单张 RLE 图反复刷新、多张 RLE 图同时显示、以及开启旋转缩放后的 RLE 图。旋转缩放会改变解码输出路径,耗时可能成倍上升,这部分开销只有实测才会暴露。
5.3 算内存账:flash 占用和运行期峰值
运行期内存的开销不像 Flash 体积那么直观。下面这张表是按常见图标素材估算的数量级,实际值取决于具体图片内容,但可以用来做项目早期的粗略判断。
| 图片类型 | 尺寸 | 原始 ARGB8888 | RLE 后 | 行索引表估算 | 运行期峰值 RAM 估算 |
|---|---|---|---|---|---|
| 纯色图标 | 32×32 | 4KB | 0.1–0.4KB | 128B | 约 0.3–1KB |
| 透明 Logo | 48×48 | 9KB | 0.5–1.5KB | 192B | 约 1–2KB |
| 按钮背景 | 128×32 | 16KB | 4–8KB | 128B | 约 4–9KB |
估算公式不复杂:运行期峰值主要看“行索引表 + 最长一行的游程数据 + 解码输出行缓冲”。如果目标颜色格式和图片原始格式一致,输出行缓冲就是一行像素;不一致时还要加上格式转换缓冲。把这些数值加进整个 UI 工程的内存预算里,就能回答“RLE 到底给我省了多少”这个问题。通常结论是:Flash 省得很明显,RAM 的开销则取决于按需解码的设计,只要不把整张图一次性解出来,它就把成本控制在了行的粒度上。
6. 进阶:图源预处理让 RLE 压缩率再翻倍
RLE 压缩率的瓶颈在素材,不在解码器。掌握一个小技巧,可以让很多原本“压不动”的图标重新变得合适:对透明通道做阈值化归并。绝大多数 UI 图标的边缘抗锯齿会产生大量半透明像素,这些像素的 alpha 值从 0 到 255 连续分布,导致相邻像素几乎都不一样,游程长度被切得很碎。如果把 alpha 通道接近 0 的值压到 0,接近 255 的值抬到 255,中间的过渡值保留少数几个级别,游程长度会显著变长。
from PIL import Image img = Image.open("icon.png").convert("RGBA") r, g, b, a = img.split() a = a.point(lambda x: 255 if x > 180 else (0 if x < 60 else x)) Image.merge("RGBA", (r, g, b, a)).save("icon_rle_ready.png")阈值 60 和 180 是常用的起步值,具体要看素材本身的边缘质量。硬边风格的图标可以激进一些,直接二值化;带柔和阴影的图标需要保守,否则边缘会出现明显锯齿。处理完再重新转换,很多原来压缩率只有 60% 的图能压到 20% 以下。
第二个技巧是颜色格式往下沉。能用 A8 的素材绝不用 ARGB8888。A8 只存 alpha 通道,每像素 1 字节,如果图标本身是纯色渲染,那么 A8 通道的重复率极高,配合 RLE 可以做到极小的体积。LVGL V9 的图片体系支持 A8 作为颜色格式,这类资源在透明遮罩场景里效果很好。RGB565A8 也是一个值得试的折中格式,颜色和透明度分离存储,RLE 对两个通道分别压缩的效果通常优于混合排布。
我自己的习惯是,在素材进转换器之前先跑一个直方图脚本,统计透明像素占比和每行颜色变化次数,先把不适合 RLE 的图筛出来。这个方法帮我避开了好几次“转完发现压缩后更大”的尴尬。总的原则是:RLE 不拯救素材,只放大素材本身的规律性。把素材整理得越规整,压缩收益越明显。希望这些经验能帮你在自己的项目里少走一段弯路。
本文还有配套的精品资源,点击获取