一张图、一个字,MCU 如何把它们变成像素画上屏幕?
核心方法论:排除不可能,剩下的再离奇也是真相
「在嵌入式世界里,'显示一张图片'这句话掩盖了大量工作:图片是压缩编码的,屏幕要的是裸像素,中间隔着一道解码的坎。排查图像、字体问题的正确姿势,不是瞎猜,而是逐条排除——先确认格式有没有被支持,再确认配置宏有没有打开,最后才轮到你的代码。把'不可能'逐条划掉,答案自然浮出来。」
屏幕上显示一张图片,最终都要落成一片像素——每个像素一个颜色值。可图片在存储里几乎从不以裸像素存在:PNG 做了无损压缩,JPEG 做了有损压缩,WebP、GIF 各有各的编码。把"压缩格式"还原成"像素阵列"这件事,在 LVGL 里由图像解码器(image decoder)负责。打开 lvgl-master/src/image/,你看到的就是整套解码框架:lv_image_decoder.c 是核心调度,lv_bin_decoder.c 解码 LVGL 自定义的 BIN 格式,其余文件是各种格式的接入点,svg/ 子目录则处理矢量图。
/* src/image/ 图像解码框架(节选) */
lv_image_decoder.c 解码器核心:open / get_info / close 调度
lv_bin_decoder.c LVGL 自定义 BIN 二进制格式解码器
lv_libpng.c lv_tjpgd.c PNG、JPEG 解码器的封装层
lv_lodepng.c lv_bmp.c lodepng(纯软件 PNG)、BMP 解码
lv_libwebp.c lv_libjpeg_turbo.c WebP、libjpeg-turbo 接入
svg/ SVG 解析与渲染(lv_svg_decoder.c 等)
真正干活的解码器,大部分被 LVGL"收编"进 src/libs/:这是第三方库的集成目录。PNG 有两条路——libpng(功能全)与 lodepng(纯软件、体积小);JPEG 也有两条——libjpeg_turbo(快)与 tjpgd(极致轻量);再加上 libwebp、bmp、gif 等。这些库默认全部关闭,使用前必须在 lv_conf.h 里打开对应开关。这正是一条福尔摩斯式的经验:图像显示不出来,八成不是你的代码错,而是格式根本没被编译进固件。
/* lv_conf_template.h:图像库开关(默认全为 0) */
#define LV_USE_LIBPNG 0 /* 打开后支持 PNG 文件解码 */
#define LV_USE_LIBJPEG_TURBO 0 /* 打开后支持 JPEG 文件解码 */
#define LV_USE_TJPGD 0 /* 轻量 JPEG 解码器 */
#define LV_USE_LODEPNG 0 /* 纯软件 PNG 解码器 */
#define LV_USE_LIBWEBP 0 /* WebP 支持 */
对应用而言,解码器是"透明"的:你只管把源交给 LVGL,它会调用 lv_image_decoder_open() 沿内置解码器链逐个尝试,直到某个能打开这个源。这套底层 API 定义在 include/lvgl/image/lv_image_decoder.h:lv_image_decoder_open() 打开并产出像素,lv_image_decoder_get_info() 只取宽高与色彩格式,lv_image_decoder_close() 释放。绝大多数时候你不会直接碰它们——但读懂这张地图,排查问题才有据可依。
lv_image_decoder_dsc_t dsc;
lv_result_t res = lv_image_decoder_open(&dsc, "S:/photo.png", NULL);
if(res == LV_RESULT_OK) {
/* dsc.img_data 已指向解码出的像素阵列,可直接使用 */
}
lv_image_decoder_close(&dsc);
上面整套解码框架,服务的目标控件只有一个:lv_image。创建它用 lv_image_create(lv_screen_active()),然后用 lv_image_set_src() 告诉它"显示哪张图"。这个 src 参数在 lv_image.h 的文档注释里写得很明白,合法取值有三种:① 文件路径,例如 "S:/dir/img.bin",配合文件系统驱动(FS)从存储读图;② 编译期图像描述符,即转换工具生成的 lv_image_dsc_t 结构体指针,图像数据被烧进固件,无需文件系统;③ 内置符号,一个字符串宏如 LV_SYMBOL_OK——它本质上是字体里的一个字形,"图标"在 LVGL 里就是"图"。
lv_obj_t * img = lv_image_create(lv_screen_active());
lv_image_set_src(img, "S:/dir/img.bin"); /* ① 文件路径(需 FS 驱动) */
lv_image_set_src(img, &img_example_lvgl_logo); /* ② 编译期描述符 */
lv_image_set_src(img, LV_SYMBOL_SETTINGS); /* ③ 内置符号图标 */
第二种来源最常用,仓库里现成参考遍地都是:examples/assets/ 下 img_example_lvgl_logo.png 旁边就躺着由它转换来的 img_example_lvgl_logo.c——里面就是一个 const lv_image_dsc_t 加上一大段像素数组。把这个 .c 加入工程,#include 之后用 &img_example_lvgl_logo 就能显示。官方在 XML 工作流里复用它的名字(见第 12 章 globals.xml),因为这份描述符是预编译好的,不依赖任何转换管线。
图像还支持不重新转换资源的"后期处理":lv_image_set_scale() 缩放(128 为 100%)、lv_image_set_rotation() 旋转(单位 0.1°,900 即 90°)、lv_image_set_antialias() 打开抗锯齿。这意味着同一张 1 倍图可以放大成 2 倍 UI,代价是渲染时多一点计算。
lv_image_set_scale(img, 200); /* 放大到 200% */
lv_image_set_rotation(img, 900); /* 顺时针旋转 90° */
lv_image_set_antialias(img, true); /* 缩放/旋转时抗锯齿 */
| src 形式 | 数据从哪来 | 典型场景 |
|---|---|---|
① 文件路径 "S:/a.png" | 文件系统(lv_fs)实时解码 | 图片较大、可运行时更换 |
② 描述符 &my_img | 编译进固件的 C 数组 | 图标、固定图片,无外部依赖 |
③ 符号 LV_SYMBOL_OK | 内置符号字体字形 | 按钮图标、状态提示 |
想把一张 PNG 变成"能编译进固件的 C 数组"或"LVGL 可直接解码的 .bin",官方脚本 scripts/LVGLImage.py 就是干这个的。它把 PNG 转成 LVGL 的二进制图像格式,并通过 --ofmt 选择输出为 C 源文件(C)、二进制文件(BIN)或 PNG(PNG)。脚本需要 pypng 与 lz4 两个 Python 包(pip3 install pypng lz4)。
# 转成 C 数组,I8 索引色格式(适合图标/小图)
python3 scripts/LVGLImage.py --ofmt C --cf I8 -o out/ logo.png
# 转成二进制 .bin,ARGB8888 真彩色,RLE 压缩
python3 scripts/LVGLImage.py --ofmt BIN --cf ARGB8888 \
--compress RLE -o out/ photo.png
--cf(色彩格式)的选择直接决定内存占用,这是图像优化最重要的一步。同一张 100×100 的图:ARGB8888 需要 40 kB;RGB565(无透明通道)只要 20 kB;而 I8 索引色约 10 kB 加一张 256 色的小调色板。颜色种类少的 UI 图(图标、logo、边框)用 AUTO 让工具自动挑选 I1/I2/I4/I8,往往最省。转换产物是 lv_image_dsc_t 描述符,头部注释会写明它用的颜色格式。
/* LVGLImage.py --ofmt C 生成的描述符(节选) */
const lv_image_dsc_t logo = {
.header = { .cf = LV_COLOR_FORMAT_I8, .w = 100, .h = 100 },
.data = logo_map,
.data_size = sizeof(logo_map),
};
另一个工具 scripts/filetohex.py 更朴素:把任意文件的内容逐字节转成一个 C 十六进制数组。它不在乎文件是图像还是字体、音频——原样搬进固件。常用参数有两个:--filter-character 过滤掉非 0x00–0xFF 的字符,--null-terminate 在末尾补一个 \x00(便于当 C 字符串用)。它输出的数组适合粘贴进源文件,或配合第 10.5 节的二进制字体加载。
# 把二进制字体原样转成字节数组,末尾补 0 终止符
python3 scripts/filetohex.py --null-terminate font.bin > font_bin.h
# 输出形如 0x00, 0x01, ... 的数组,可直接 #include 使用
排查"图显示不出来"按三步排除:① 格式是否被支持(PNG/JPEG 等宏是否打开);② 源路径对不对(文件系统有没有挂载、描述符符号有没有链接进来);③ 再回头查自己的 lv_image_set_src。绝大多数问题卡在前两步——先用"排除不可能"缩小范围,别一上来就怀疑 API 用法。
字体决定文字怎么画。LVGL 里每个字体是一个 lv_font_t(include/lvgl/font/lv_font.h),但它不是"一个文件句柄",而是一个带函数指针的结构体:通过 get_glyph_dsc() 查询某个字符(Unicode 码点)的度量信息,再取字形位图绘制。结构里还有 fallback 后备字体指针——当当前字体缺这个字时,会递归地到后备字体里找,这是"中英混排"能成立的结构基础。核心字段如下:
struct _lv_font_t {
const void * dsc; /* 实现相关的运行数据或缓存 */
const lv_font_t * fallback; /* 缺字时的后备字体,递归解析 */
void * user_data; /* 自由使用的用户数据 */
bool (*get_glyph_dsc)(const lv_font_t * font,
lv_font_glyph_dsc_t * dsc_out,
uint32_t letter,
uint32_t letter_next);
/* ... 取字形绘制数据的函数指针 get_glyph_draw_data */
};
内置字体在 src/font/ 下一目了然:lv_font_montserrat_*.c 从 8 到 48 px 覆盖十几个字号(Montserrat 是 LVGL 的默认西文字体);lv_font_unscii_*.c 是 8/16 px 的点阵风格;lv_font_dejavu_16_persian_hebrew.c 支持波斯文与希伯来文;lv_font_source_han_sans_sc_*_cjk.c 是思源黑体的简体中文(CJK)版。它们默认只开了 LV_FONT_MONTSERRAT_14(14 px),其余要在 lv_conf.h 打开对应宏——每个打开的字体会增加几 kB 到几十 kB 的 Flash 占用,中文整字集尤其贵。
/* lv_conf_template.h:内置字体开关(节选) */
#define LV_FONT_MONTSERRAT_14 1 /* 默认唯一开启的西文字体 */
#define LV_FONT_MONTSERRAT_28 0
#define LV_FONT_SOURCE_HAN_SANS_SC_14_CJK 0 /* 简体中文 14px */
#define LV_FONT_SOURCE_HAN_SANS_SC_16_CJK 0 /* 简体中文 16px */
使用上,把字体指针塞进样式即可:lv_obj_set_style_text_font(obj, &lv_font_montserrat_14, 0) 让该控件的文字用这个字体渲染(第三个参数 0 表示默认状态的选择器)。全局默认字体由 LV_FONT_DEFAULT 宏决定,可用 lv_font_get_default() 取回。注意 LVGL 9 里取默认字体的函数是 lv_font_get_default(),别按旧文档写成 lv_font_default()。
lv_obj_t * label = lv_label_create(lv_screen_active());
lv_obj_set_style_text_font(label, &lv_font_montserrat_14, 0);
lv_label_set_text(label, "Hello LVGL");
/* 中文界面示例:给同一控件设置思源黑体 */
lv_obj_set_style_text_font(label, &lv_font_source_han_sans_sc_14_cjk, 0);
| 内置字体 | 特点 | 默认开关 |
|---|---|---|
lv_font_montserrat_14 | 默认西文,拉丁字符 + FontAwesome 符号 | 开 |
lv_font_montserrat_28 … 48 | 更大字号(标题、大字) | 关 |
lv_font_unscii_8 / 16 | 点阵风格,极省资源 | 关 |
lv_font_source_han_sans_sc_14_cjk | 简体中文 CJK(思源黑体) | 关 |
lv_font_dejavu_16_persian_hebrew | 波斯文 / 希伯来文 | 关 |
产品往往要用自己的字体——尤其是中文,内置的两个 CJK 字号常常不够。转换入口在 scripts/built_in_font/:built_in_font_gen.py 把 TTF/OTF/WOFF 转成 C 源文件,generate_all.py 一次性重新生成仓库里的全部内置字体。内置字体 .c 文件头部就留着一行"生成配方"(Opts),既记录了参数,也是学习命令行格式的最佳范本。
# lv_font_montserrat_14.c 头部的"生成配方",可改写成你自己的字体
python3 scripts/built_in_font/built_in_font_gen.py \
--no-compress --no-prefilter --bpp 4 --size 14 \
--font Montserrat-Medium.ttf -r 0x20-0x7F,0xB0,0x2022 \
--font FontAwesome5-Solid+Brands+Regular.woff \
-r 61441,61448,61451 /* ...FontAwesome 的私有区码点 */ \
--format lvgl -o lv_font_montserrat_14.c --force-fast-kern-format
关键参数:-s/--size 字号(px)、--bpp 每个像素的位深(1/2/4/8,越小越省但越糙)、-r/--range 要包含的字符范围、--symbols 直接列字符、-o 输出文件。对中文,把需要的字枚举进 --range 或 --symbols("只收录用到的字"),就能把整字集的体积砍掉一个量级。
网上大量教程会写 lv_font_load("S:/xx.bin")——这是 LVGL 8 的 API,在 9.6.0-dev 里已经被移除,仓库里搜不到任何 lv_font_load。v9 的运行时加载改用 src/font/binfont_loader/ 的 lv_binfont_create(),或字体管理器 lv_font_manager_create() + lv_font_manager_create_font()。遇到"API 不存在"时,先在源码里搜一下,别盲目套老例子的函数名。
二进制字体(.fnt)可以把"转字体"从编译期挪到运行时:文件存在存储里,启动时用 lv_binfont_create(path) 读入,得到 lv_font_t*,再塞进样式。官方 examples/assets/font/ 目录就放着几份现成的 .fnt 可供试验(montserrat-16.fnt、lv_font_simsun_16_cjk.fnt 等)。用完后 lv_binfont_destroy() 释放。
const lv_font_t * f = lv_binfont_create("S:/fonts/montserrat-16.fnt");
if(f) {
lv_obj_set_style_text_font(label, f, 0);
lv_label_set_text(label, "runtime font");
/* 不再需要时:lv_binfont_destroy(f); */
}
判断一个字体问题,先问"这个字符在不在这个字体里"。内置 Montserrat 只覆盖拉丁字符与 FontAwesome 符号区,LV_SYMBOL_* 图标之所以能用,正是因为符号被并进了 Montserrat 的字形表。中文、emoji、特殊符号各自属于不同字体——这解释了为什么"英文正常、中文方块"是最常见的字体 bug:不是编码问题,是字体里根本没有那个字形。
用 scripts/LVGLImage.py 把 examples/assets/img_cogwheel_rgb.png 转成 C 数组(--ofmt C,色彩格式先用 AUTO),把生成的 .c 加入工程,用 lv_image_create() + lv_image_set_src() 显示出来。回答:生成的描述符头部里 .cf 被自动选成了哪个颜色格式?为什么它对这张图最合适?
AUTO 会在 I1/I2/I4/I8 里挑最省的一种;齿轮图颜色有限、是索引色友好的素材。对比同一命令用 --cf ARGB8888 生成的 .data_size 差异。
命令大致为 python3 scripts/LVGLImage.py --ofmt C --cf AUTO -o out/ img_cogwheel_rgb.png。对这张调色板有限的图,AUTO 通常选中 I8 甚至更低的 I 系列格式,.header.cf 对应 LV_COLOR_FORMAT_I8 等;.data_size 明显小于 ARGB8888(每像素 1 字节 vs 4 字节)。启示:图标类资源默认交给 AUTO,别迷信真彩色。
在 lv_conf.h 里把 LV_FONT_SOURCE_HAN_SANS_SC_14_CJK 设为 1,重编译后用 lv_obj_set_style_text_font 让一个 label 显示"你好,LVGL"。然后分别观察打开 _14 与 _16 两个中文字体后固件体积的变化。思考并回答:为什么内置中文比内置西文大这么多?
看 lv_font_source_han_sans_sc_14_cjk.c 覆盖的码点范围;中文常用字至少 3 千个,而 Montserrat 只有约 200 个字符。每个字形都要存位图,量级差 10 倍以上。
原因在于"覆盖的字符数":Montserrat 14px 只含 ASCII(0x20–0x7F)+ 少数符号,约 200 个字形;思源黑体 CJK 要覆盖常用汉字数千个字形,每个字形的位图随字符数线性累积。所以 10.5 的做法(用 --range/--symbols 只收录实际用到的字)是嵌入式中文 UI 的标准优化——让生成的字体文件只含界面上出现的汉字。
把 examples/assets/font/montserrat-16.fnt 用 filetohex.py --null-terminate 转成头文件数组(或直接放文件系统),再用 v9 的 lv_binfont_create() 从该二进制加载并设置到一个 label。写出完整代码,并说明:为什么 v8 的 lv_font_load() 在本仓库编译不过?
lv_binfont_create(path) 接受文件路径;从内存数组加载用 lv_binfont_create_from_buffer()(需 LV_USE_FS_MEMFS)。"为什么编译不过"可以用 10.5 的排除法在源码里搜一下。
路径方式:const lv_font_t * f = lv_binfont_create("S:/fonts/montserrat-16.fnt"); if(f) lv_obj_set_style_text_font(label, f, 0);;用完 lv_binfont_destroy(f)。编译不过的原因:lv_font_load 是 v8 API,9.6.0-dev 已整体移除(在 src/ 与 include/ 全仓库搜索无任何定义),也不在 lv_api_map_v8.h 的映射清单里,因此链接期直接报未定义符号。
你在一台板子上显示一张 JPEG 照片:lv_image_set_src(img, "S:/photo.jpg"),但屏幕上空白。请按"排除不可能"列出排查清单,至少覆盖:解码器宏、文件系统挂载、源路径、描述符/符号区别,并说明每一项的验证方法。若最终查到是 LV_USE_LIBJPEG_TURBO 0,修复要改哪里?
JPEG 在 v9 有 LV_USE_TJPGD 与 LV_USE_LIBJPEG_TURBO 两个解码入口;文件路径要求先初始化 FS 驱动(lv_fs_drv_init + lv_fs_drv_register 注册一个驱动器字母,如 "S:")。可以对照 10.1 的配置块逐项核对。
清单:① 解码器宏——确认 LV_USE_TJPGD 或 LV_USE_LIBJPEG_TURBO 至少一个为 1,改 lv_conf.h 后重编译;② FS 挂载——"S:" 前缀对应已注册的驱动器,lv_fs_open 能成功打开该文件;③ 源路径——文件确实存在且名字/大小写正确;④ src 语义——若传的是 C 数组符号名则必须带 &(描述符指针),若传文件路径则必须是字符串。修复:把 LV_USE_TJPGD 或 LV_USE_LIBJPEG_TURBO 改成 1 并重编译。教训:先排除"不可能",再怀疑自己的代码。