2. 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 核心架构与数据流 用户代码 对象模型 (lv_obj_t) 脏区域管理 渲染引擎 帧缓冲 显示驱动 输入设备驱动 物理屏幕 触摸/按键 说明: 用户代码操作对象 → 脏区域标记 → 渲染引擎生成像素 → 驱动刷屏 输入事件反向注入对象系统

嗯,这张图基本概括了 LVGL 的运行时全貌。你写代码时,其实就是在跟“对象模型”和“任务调度”打交道。而“脏区域管理”和“渲染引擎”是 LVGL 内部自动完成的,你不需要直接干预。

但理解它们的工作原理,能帮你写出更高效的界面。比如,你知道局部刷新的存在,就不会在动画里频繁调用 lv_obj_invalidate() 去强制刷新整个屏幕。

总结一下本章的核心:

  • 对象模型是树形结构,父子关系决定渲染顺序。
  • 显示驱动和输入设备驱动是移植的关键,只需要实现几个回调。
  • 部分缓冲是嵌入式设备的最佳选择,兼顾内存和性能。
  • 心跳 Tick 必须放在硬件定时器里,保证精度。
  • 任务调度是非阻塞的,主循环里定期调用 lv_task_handler() 即可。