LVGL 核心架构与渲染机制
这一章,我们来拆解 LVGL 的“心脏”。
很多初学者把 LVGL 当成一个“画图库”,调调 API 就能出界面。但真到了项目里,你会发现:为什么刷新这么慢?为什么触摸没反应?为什么内存爆了?
说白了,这些问题都出在对核心架构的理解上。我当年做第一个量产项目时,就因为没搞懂帧缓冲机制,导致界面卡成幻灯片,被硬件同事追着骂了一周。嗯,从那以后,我老老实实把源码里的 lv_disp.c 和 lv_indev.c 翻了个底朝天。
2.1 对象模型:一切皆对象
LVGL 里,屏幕上能看到的一切——按钮、标签、滑块、图表——都是 对象(lv_obj_t)。
这个设计思路跟很多 GUI 框架类似,但 LVGL 做得更轻量。每个对象本质上是一个结构体,里面存了坐标、大小、样式、父子关系等属性。
核心要点:LVGL 的对象模型是树形结构。一个屏幕(screen)是根节点,上面挂载各种容器和控件。子对象的位置是相对于父对象的,移动父对象,所有子对象跟着动。
我习惯把这种结构想象成“俄罗斯套娃”。你创建一个 lv_btn,它内部其实是一个 lv_obj 加上按钮特有的行为。这种继承关系在代码里是通过 lv_obj_create() 和 lv_obj_set_parent() 实现的。
// 创建一个屏幕
lv_obj_t *scr = lv_obj_create(NULL);
lv_scr_load(scr);
// 在屏幕上创建一个按钮
lv_obj_t *btn = lv_btn_create(scr);
lv_obj_set_size(btn, 100, 50);
lv_obj_center(btn);
// 在按钮上创建一个标签
lv_obj_t *label = lv_label_create(btn);
lv_label_set_text(label, "点击我");
lv_obj_center(label);
你看,lv_btn_create(scr) 里的 scr 就是父对象。这个父子关系决定了渲染顺序——子对象永远在父对象之上。
个人经验:我在项目中遇到过一个问题:动态创建了几十个对象后,内存碎片化严重。后来发现,尽量复用对象,而不是频繁创建和删除。LVGL 提供了 lv_obj_clean() 清空子对象,但不会释放父对象本身,这个接口在列表刷新时特别好用。
2.2 显示驱动与输入设备驱动
LVGL 本身不直接操作硬件。它通过两个抽象层来对接你的屏幕和触摸/按键:
- 显示驱动(Display Driver):负责把帧缓冲区的内容刷到物理屏幕上。
- 输入设备驱动(Input Device Driver):负责把触摸、按键、编码器等输入事件传给 LVGL。
这两个驱动是 LVGL 移植到新硬件时的核心工作。说白了,你只需要实现几个回调函数。
2.2.1 显示驱动注册
显示驱动的核心是 lv_disp_drv_t 结构体。你需要告诉 LVGL:屏幕分辨率多大、像素格式是什么、怎么刷数据。
static lv_disp_drv_t disp_drv;
static lv_disp_buf_t disp_buf;
static lv_color_t buf[LV_HOR_RES_MAX * 10]; // 10 行缓冲区
void my_display_init(void) {
lv_disp_buf_init(&disp_buf, buf, NULL, LV_HOR_RES_MAX * 10);
lv_disp_drv_init(&disp_drv);
disp_drv.buffer = &disp_buf;
disp_drv.flush_cb = my_disp_flush; // 你的刷屏函数
disp_drv.hor_res = 320;
disp_drv.ver_res = 240;
lv_disp_drv_register(&disp_drv);
}
static void my_disp_flush(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_p) {
// 把 color_p 指向的数据刷到 area 指定的矩形区域
// 调用硬件 SPI/并行接口发送数据
lv_disp_flush_ready(drv); // 通知 LVGL 刷完了
}
避坑指南:我曾经在 flush_cb 里忘记调用 lv_disp_flush_ready(),结果屏幕刷了一次就再也不动了。LVGL 的刷新机制是“等上一帧刷完才发下一帧”,你不告诉它刷完了,它就一直等着。这个坑我踩了整整一个下午。
2.2.2 输入设备驱动注册
输入设备驱动类似。你需要实现一个 read_cb 函数,把硬件读取到的坐标或按键值传给 LVGL。
static lv_indev_drv_t indev_drv;
void my_touch_init(void) {
lv_indev_drv_init(&indev_drv);
indev_drv.type = LV_INDEV_TYPE_POINTER; // 触摸屏
indev_drv.read_cb = my_touch_read;
lv_indev_drv_register(&indev_drv);
}
static bool my_touch_read(lv_indev_drv_t *drv, lv_indev_data_t *data) {
// 读取触摸控制器,获取坐标
data->point.x = touch_x;
data->point.y = touch_y;
data->state = touched ? LV_INDEV_STATE_PR : LV_INDEV_STATE_REL;
return false; // 没有更多数据了
}
输入设备类型有几种:LV_INDEV_TYPE_POINTER(触摸/鼠标)、LV_INDEV_TYPE_KEYPAD(键盘)、LV_INDEV_TYPE_ENCODER(旋转编码器)。每种类型的 read_cb 返回的数据结构略有不同。
2.3 刷新机制与帧缓冲
这是 LVGL 性能的关键。很多人觉得 LVGL 慢,其实不是 LVGL 慢,是刷新策略没选对。
LVGL 支持三种刷新模式:
| 模式 | 缓冲区大小 | 特点 | 适用场景 |
|---|---|---|---|
| 单缓冲 | 1 个全屏缓冲区 | 简单,但刷新时会有撕裂 | 低端 MCU,不介意撕裂 |
| 双缓冲 | 2 个全屏缓冲区 | 无撕裂,但内存占用大 | 有足够 RAM 的设备 |
| 部分缓冲 | 几行到半屏 | 内存占用小,刷新效率高 | 绝大多数嵌入式设备 |
我个人最推荐 部分缓冲。为什么?因为嵌入式设备的 RAM 通常很宝贵。一个 320x240 的 16 位色全屏缓冲区需要 150KB,很多 MCU 根本拿不出来。而部分缓冲只需要几行,比如 10 行,才 6.4KB。
刷新流程:LVGL 内部维护一个“脏区域”(dirty area)列表。当某个对象发生变化(比如按钮按下变色),LVGL 不会重绘整个屏幕,只标记这个对象所在的区域为“脏”。在下一个刷新周期,只把这些脏区域重新渲染到缓冲区,然后通过 flush_cb 刷到屏幕。
这个机制叫 局部刷新。我见过有人写了个动画,每秒 60 帧全屏刷新,结果 CPU 占用率飙到 90%。改成局部刷新后,直接降到 20%。
2.4 任务调度与心跳 Tick
LVGL 不是实时操作系统,但它有自己的“心跳”——lv_tick_inc()。这个函数需要你每隔 1 毫秒调用一次,告诉 LVGL 时间过去了。
// 在定时器中断里调用
void SysTick_Handler(void) {
lv_tick_inc(1);
}
LVGL 内部维护了一个任务链表。这些任务包括:
- 动画更新:比如一个按钮从红色渐变到蓝色,每帧需要更新颜色值。
- 对象刷新:检查哪些对象需要重绘。
- 输入设备轮询:定期调用
read_cb读取输入状态。 - 用户自定义任务:通过
lv_task_create()注册的回调。
所有这些任务都在 lv_task_handler() 里被调度执行。你需要在主循环里周期性地调用它:
while(1) {
lv_task_handler(); // 处理所有 LVGL 任务
my_delay(5); // 延时 5ms,避免 CPU 跑满
}
我的习惯:把 lv_task_handler() 放在一个 5ms 周期的定时器里调用,而不是在主循环里。这样主循环可以去做其他事情,比如处理通信协议。LVGL 的任务调度是非阻塞的,每次调用只会处理一小部分任务,不会卡死。
这里有个关键点:lv_tick_inc() 和 lv_task_handler() 是分开的。前者提供时间基准,后者执行任务。如果你发现动画跑得忽快忽慢,八成是 lv_tick_inc() 的调用间隔不稳定。
我曾经踩过的坑:在 FreeRTOS 里,我把 lv_tick_inc() 放在了一个低优先级任务里,结果系统忙的时候,这个任务被长时间挂起,LVGL 的心跳就停了。动画直接卡死。正确的做法是:放在硬件定时器中断里,保证毫秒级精度。
2.5 整体架构图
下面这张图展示了 LVGL 的核心架构和数据流。你可以看到,用户代码通过 API 操作对象,对象的变化触发脏区域标记,渲染引擎根据脏区域生成像素数据,最终通过显示驱动刷到屏幕。输入设备驱动则把外部事件反向注入到对象系统。
嗯,这张图基本概括了 LVGL 的运行时全貌。你写代码时,其实就是在跟“对象模型”和“任务调度”打交道。而“脏区域管理”和“渲染引擎”是 LVGL 内部自动完成的,你不需要直接干预。
但理解它们的工作原理,能帮你写出更高效的界面。比如,你知道局部刷新的存在,就不会在动画里频繁调用 lv_obj_invalidate() 去强制刷新整个屏幕。
总结一下本章的核心:
- 对象模型是树形结构,父子关系决定渲染顺序。
- 显示驱动和输入设备驱动是移植的关键,只需要实现几个回调。
- 部分缓冲是嵌入式设备的最佳选择,兼顾内存和性能。
- 心跳 Tick 必须放在硬件定时器里,保证精度。
- 任务调度是非阻塞的,主循环里定期调用
lv_task_handler()即可。