第14章:性能优化与内存管理

在有限的 RAM 里,运筹帷幄地榨出每一帧的流畅

🪶

本章导师:诸葛亮

核心方法论:运筹帷幄,先度量再动刀

「良将用兵,必先算而后战。调性能也是一样:先用量化工具把瓶颈测出来,再决定内存花在哪、功能砍哪些。不计量就动手,十次里有九次是在给错误的问题开药方。」

14.1 渲染引擎概览:draw 子系统与 draw buffer

LVGL 的渲染层集中在 src/draw/。打开这个目录你会发现,它不是一个"唯一的绘制实现",而是一组可以互相切换的后端(backend)。默认且最通用的,是纯 CPU 的软件渲染器 sw/;旁边还并排躺着 opengles/(OpenGL ES)、vg_lite/(NXP VG-Lite GPU)、nema_gfx/(ThinkSilicon NemaGFX)、nxp/(NXP PXP)、dma2d/(STM32 DMA2D)等硬件加速后端,以及 sdl/espressif/renesas/ 等平台相关后端。

后端的差异对你的 UI 代码是透明的:widgets 层调用统一的绘制接口,后端只负责"画得快一点还是慢一点"。切换后端几乎只动配置——LV_USE_DRAW_SW 控制软件渲染(默认 1,开启),LV_USE_DRAW_VG_LITE 控制 VG-Lite GPU(默认 0,需要硬件)。软件渲染内部还有若干可调项:LV_DRAW_SW_DRAW_UNIT_CNT 决定并行绘制单元数量,LV_USE_DRAW_SW_COMPLEX_GRADIENTS 决定是否启用复杂渐变,LV_DRAW_SW_SHADOW_CACHE_SIZE 缓存阴影计算。

/* lv_conf.h —— 选择渲染后端 */
#define LV_USE_DRAW_SW 1               /* 软件渲染,默认开启 */
#define LV_USE_DRAW_VG_LITE 0           /* VG-Lite GPU,需对应硬件 */
#if LV_USE_DRAW_SW
#  define LV_DRAW_SW_DRAW_UNIT_CNT 1  /* 并行绘制单元数量 */
#endif

渲染的"画布"叫 draw buffer。LVGL 9 里它由 lv_draw_buf_t 描述,配套 lv_draw_buf_create()lv_draw_buf_clear() 等 API。而真正交给屏幕的显示缓冲挂在 display 对象上:lv_display_create() 创建一块屏幕,lv_display_set_buffers() 把一个或两个缓冲区交给它。下面这段来自官方 README 的经典写法——分辨率、1/10 屏幕缓冲、16 位色深下每像素 2 字节——就是嵌入式端最常见的样子:

/* README 经典示例:320×240,1/10 屏缓冲 */
static uint8_t buf[TFT_HOR_RES * TFT_VER_RES / 10 * 2];
/* x2 是因为 16-bit 色深每像素占 2 字节 */

lv_display_t * disp = lv_display_create(TFT_HOR_RES, TFT_VER_RES);
lv_display_set_buffers(disp, buf, NULL, sizeof(buf),
                         LV_DISPLAY_RENDER_MODE_PARTIAL);

除了直接的屏幕缓冲,每个显示对象还有一层"layer"机制:复杂的透明、模糊、3D 变换先在离屏 layer 上画好,再合成回主缓冲。配置宏 LV_DRAW_LAYER_SIMPLE_BUF_SIZE(默认 24576 字节)决定简单 layer 的缓冲区大小——太小会被降级处理,太大则浪费 RAM,是"缓冲调优"最常见的入口之一。

后端目录说明
swsrc/draw/sw/纯 CPU 软件渲染,默认启用
openglessrc/draw/opengles/OpenGL ES 后端
vg_litesrc/draw/vg_lite/NXP VG-Lite GPU 加速
nema_gfxsrc/draw/nema_gfx/ThinkSilicon NemaGFX
nxp / dma2dsrc/draw/nxp/ / src/draw/dma2d/NXP PXP / STM32 DMA2D
诸葛亮提示

后端不是越"高级"越好:LV_USE_DRAW_VG_LITE 这类 GPU 后端只在有对应硬件时才成立,否则开了一样的代码反而多一层空转。先确认板子上有什么,再决定换不换后端——这就是"运筹帷幄"。

14.2 内存管理:从 lv_malloc 到 LV_MEM_SIZE

LVGL 有自己的内存分配层,位于 src/stdlib/lv_mem.*。它把库里的所有动态分配统一收敛到 lv_malloc()lv_calloc()lv_zalloc()lv_realloc()lv_free() 这几个函数上,而不是直接调用 C 标准库的 malloc。这样做的两个直接好处:一是没有 libc 的裸机也能跑,二是内存可以精确统计、精确管控。

分配后端由 LV_USE_STDLIB_MALLOC 选择,可选值包括 LV_STDLIB_BUILTIN(内置内存池,默认)、LV_STDLIB_CLIB(标准库 malloc/realloc/free)、LV_STDLIB_RTTHREAD(RT-Thread)与 LV_STDLIB_CUSTOM(完全自己实现)。这里要提醒你:v9 里已经找不到 LV_MEM_CUSTOM 这个宏了——v8 时代的它在 v9 被拆成了更细的 LV_USE_STDLIB_* 系列。选 BUILTIN 时,LV_MEM_SIZE(默认 65536,即 64 kB,注释明确写着下限 2048)决定内置池的大小,LV_MEM_ADR 可以把池子固定到某个地址。

/* v8 的老名字 LV_MEM_CUSTOM 已废弃,v9 改用 LV_USE_STDLIB_MALLOC */
#define LV_USE_STDLIB_MALLOC LV_STDLIB_BUILTIN
#if LV_USE_STDLIB_MALLOC == LV_STDLIB_BUILTIN
#  define LV_MEM_SIZE 65536   /* 内置内存池:64 kB,下限 2048 */
#  define LV_MEM_ADR 0x0    /* 0:池子作为普通数组分配 */
#endif

每种后端都有独立的实现文件:内置池在 src/stdlib/builtin/lv_mem_core_builtin.c,其余分别放在 clib/rtthread/micropython/uefi/ 子目录下。切到 LV_STDLIB_CLIB 时,lv_malloc 就只是标准 malloc 的一层薄封装,内存由系统堆统一管理——适合已经有 OS 和堆的 MPU 平台。

要"算清楚账",用 lv_mem_monitor()。它把堆的统计信息填进 lv_mem_monitor_t 结构体:总大小、可用大小、最大连续空闲块、使用率、碎片率等等。这些数字是判断"内存够不够、碎不碎"的第一手证据:

lv_mem_monitor_t mon;
lv_mem_monitor(&mon);
LV_LOG_USER("used %d%% , frag %d%% , biggest free %d B",
             mon.used_pct, mon.frag_pct, mon.free_biggest_size);
字段含义
total_size堆总大小(字节)
free_size / free_biggest_size可用内存 / 最大连续空闲块(碎片敏感指标)
used_cnt / max_used已用块数 / 历史峰值占用
used_pct / frag_pct使用率 / 碎片率

除了监控,lv_mem_test() 会做一次分配-释放自检来验证分配器健康;lv_mem_add_pool() / lv_mem_remove_pool() 则允许你向内置分配器追加或移除内存池——当某块内存区域(比如片外 SDRAM)不想被默认池覆盖时很有用。

注意

网上的 v8 教程经常写 #define LV_MEM_CUSTOM 1,这在 v9(本仓库 9.6.0-dev)里会编译失败。请改用 LV_USE_STDLIB_MALLOC 系列宏,并留意 src/stdlib/ 下的实现文件。

14.3 调优手段:缓冲、色深与按需裁剪

第一类手段是缓冲模式与大小。回顾 14.1 的 lv_display_set_buffers(),它的最后一个参数 render_mode 有三种取值:LV_DISPLAY_RENDER_MODE_PARTIAL 只重绘被标记为"脏"的区域,缓冲可以小到屏幕的 1/10;LV_DISPLAY_RENDER_MODE_DIRECT 直接画进屏幕自身的帧缓冲;LV_DISPLAY_RENDER_MODE_FULL 则需要一整块完整帧缓冲。传两个缓冲(buf1 + buf2)开启双缓冲,能缓解绘制过程中的画面撕裂——代价是 RAM 翻倍,需要权衡。

第二类手段是颜色深度。配置宏 LV_COLOR_DEPTH 支持 1(I1 单色)、8(L8)、16(RGB565)、24(RGB888)、32(XRGB8888)五档。从 32 降到 16,每像素内存直接减半——对一块 800×480 的屏幕,整帧从约 1.5 MB 降到约 0.75 MB,效果立竿见影。大多数 TFT LCD 面板就用 RGB565。

/* lv_conf.h —— 色深与双缓冲 */
#define LV_COLOR_DEPTH 16   /* 1/8/16/24/32 可选 */

static uint8_t buf1[HOR * VER / 10 * 2];
static uint8_t buf2[HOR * VER / 10 * 2];
lv_display_set_buffers(disp, buf1, buf2, sizeof(buf1),
                         LV_DISPLAY_RENDER_MODE_PARTIAL);

第三类手段是按需裁剪 LV_USE_* 开关。这是 LVGL 最大的开关族:LV_USE_LOG(日志,默认 0)、LV_USE_OBSERVERLV_USE_FLEX / LV_USE_GRID(布局引擎)、各种第三方库与字体……用不到的模块一律关掉,既省 Flash 也减运行时开销。想精确控制编译内容,就用 Kconfig:仓库根的 Kconfig 提供了完整配置菜单,而 configs/defconfigs/ 目录里还躺着一批现成的"配方"——sdl2.defconfigwayland.defconfiglinux.defconfigdrm.defconfig 以及各自的 -3d 变体。

# configs/defconfigs/sdl2.defconfig(节选)
CONFIG_LV_USE_SDL=y
CONFIG_LV_COLOR_DEPTH_32=y
CONFIG_LV_USE_LOG=y
CONFIG_LV_DRAW_SW_SHADOW_CACHE_SIZE=1024
CONFIG_LV_OBJ_STYLE_CACHE=y

还要留意样式与动画的开销。运行时的大头往往不是绘制本身,而是样式对象的数量与动画对象:样式尽量用静态样式(lv_style_init() 一次、全局复用),动画避免在渲染路径里频繁创建销毁。像 LV_DRAW_SW_SHADOW_CACHE_SIZE 这类缓存宏,就是让重复的阴影计算只做一次——用"缓存"换"时间",是低端 MCU 上最常见也最有效的调法。

诸葛亮提示

裁剪的顺序很重要:先关"整块功能"(LV_USE_*),再缩"单一资源"(色深、缓冲),最后才上"缓存"这种局部优化。整块关掉的收益是线性的、可控的,缓存则是"优化局部、动全身"——切莫本末倒置。

14.4 用 benchmark demo 量化你的优化

没有量化就没有优化。LVGL 在 demos/benchmark/ 目录里放了一个专门测性能的 demo:调用 lv_demo_benchmark(),它会把圆角矩形、阴影、文字、图像、渐变等典型绘制场景依次跑一遍,逐个测量每场景的渲染耗时与帧率,最后汇总成一张结果表。这是评估"板子到底能跑到多少帧"的最快路径。

启用它只需两步:在配置里打开 LV_USE_DEMO_BENCHMARK,然后在 main 里调用 lv_demo_benchmark()。官方 demos/README.md 还提到一种更省事的启动方式:如果把主程序命名为 lv_demos,那么直接运行 lv_demos benchmark 1 就能跑基准——不用改任何代码。

/* lv_conf.h */
#define LV_USE_DEMO_BENCHMARK 1

/* main.c */
lv_demo_benchmark();   /* 跑完整基准并打印汇总 */
# 主程序命名为 lv_demos 时,可直接运行(demos/README.md)
lv_demos widgets
lv_demos benchmark 1

基准结果里有个关键字段:lv_demo_benchmark_summary_ttotal_avg_fps(平均帧率)、total_avg_cpu(平均 CPU 占用)、total_avg_render_time(平均渲染耗时)。你可以在优化前后各跑一次,把两个数字摆在一起对比。更严谨的做法是用 lv_demo_benchmark_set_end_cb() 挂一个"结束回调",让每次构建自动记录成绩,方便回归时比对:

static void on_bench_end(const lv_demo_benchmark_summary_t * s)
{
    LV_LOG_USER("avg fps: %d , render: %d ms",
                s->total_avg_fps, s->total_avg_render_time);
}
lv_demo_benchmark_set_end_cb(on_bench_end);

还有一个容易被忽略的联动:跑 demo 时内存不够,demos/README.md 对 widgets demo 的注释写得直白——"Show some widget. It might be required to increase LV_MEM_SIZE"。也就是说,benchmark 与内存配置是配套的两件事:先用 lv_demo_benchmark() 看帧率、用 lv_mem_monitor() 看占用,再决定把内存花在哪。这一章从头到尾的闭环,就是"度量 → 裁剪 → 复测"。

章末练习

练习 1:内存体检 入门

写一个小函数,调用 lv_mem_monitor() 把堆的使用率、碎片率、最大连续空闲块打印出来。解释一下 frag_pct(碎片率)代表什么,以及为什么 free_biggest_size 往往比 free_size 更能说明"还能不能塞下一块大缓冲"。

提示

参考 14.2 的示例:先声明 lv_mem_monitor_t,再 lv_mem_monitor(&mon),字段就绪后用 LV_LOG_USER 打印。碎片率高的系统,即使"可用内存"还很多,也常常分配不出大块。

参考答案

示例实现:lv_mem_monitor_t mon; lv_mem_monitor(&mon); LV_LOG_USER("used=%d%% frag=%d%% biggest=%d", mon.used_pct, mon.frag_pct, mon.free_biggest_size);frag_pct 反映内存被切碎的程度:多次分配/释放不同大小的对象后,空闲块之间夹杂着已占用块,导致大对象无法连续放置。free_biggest_size 是最大连续空闲块的字节数,它才真正决定"能不能再塞下一块 LV_DRAW_LAYER_SIMPLE_BUF_SIZE 大小的缓冲"。

练习 2:色深对比实验 进阶

在配置里把 LV_COLOR_DEPTH32 改到 16,用 14.4 的 benchmark 跑一次,对比两次的 total_avg_fps 与渲染耗时。再想一想:为什么对一块很小的屏幕(比如 128×64),这个收益可能不明显?

提示

色深影响的是"每像素字节数",进而影响缓冲大小与带宽。小屏总像素本来就少,数据量差异就小;同时部分渲染模式下瓶颈未必在拷贝带宽。

参考答案

32→16 后每像素从 4 字节降到 2 字节,缓冲减半、写屏带宽减半,大屏(如 800×480)上帧率提升明显。但对 128×64 的小屏,整帧只有 8192 像素,32 位时约 32 kB、16 位约 16 kB——绝对数据量小,带宽不再是瓶颈,帧率提升有限甚至没有。结论:色深降档是小屏优先压 RAM、大屏优先提帧率的工具,要结合屏幕实际分辨率权衡。

练习 3:裁剪前后复测 挑战

你的产品只需要按钮、标签、滑块与单张图片,不需要网格布局、图表、动画和日志。请列出你会在 lv_conf.h 里关闭的一组 LV_USE_* 开关,用 lv_mem_monitor() 和 benchmark 量化裁剪前后的内存与帧率差异。解释为什么"整块关功能"通常比"一味加缓冲"更有效。

提示

参考 14.3:日志 LV_USE_LOG、布局 LV_USE_GRID、图表等用不到的控件、以及动画相关的 LV_USE_* 都在候选清单里。跑基准前先用 lv_mem_monitor 记录基线。

参考答案

可以关闭的开关示例:LV_USE_LOG(省去日志字符串与开销)、LV_USE_GRID(只用 Flex)、图表/圆弧等不用的控件宏、用不到的第三方库宏。关闭后 Flash 显著下降,内置池 LV_MEM_SIZE 的占用与碎片率通常也会回落。为什么更有效:裁剪消除的是"整个子系统",是乘法级别的收益——每个 widget 都有构造函数、样式、事件处理代码;而加缓冲只是"多买一块地",不减少已有的开销。这也是 LVGL 用 LV_USE_* 做模块化、官方提供 configs/defconfigs 配方的原因。