4. LVGL基础入门:LVGL架构与核心概念

好,咱们进入正题。LVGL这个库,说白了就是给嵌入式设备用的图形界面框架。我最早接触它是在一个智能家居面板项目上,那时候屏幕小、资源紧,Qt跑不动,裸机画点又太累。LVGL正好卡在中间——轻量、开源、功能还全。

这一章,我会带你拆开LVGL看看里面到底怎么组织的。然后手把手移植到你的板子上,最后跑一个“Hello World”。

4.1 LVGL的整体架构

先看一张图,把LVGL的层次关系搞清楚。

LVGL 软件架构分层图 应用层(Application) 你的业务逻辑:按钮回调、页面切换、数据刷新 LVGL 核心库(Core Library) 对象系统 | 事件驱动 | 样式管理 | 动画引擎 布局系统 | 字体渲染 | 输入设备抽象 控件库(Button, Label, Slider, Chart ...) 硬件抽象层(HAL / Porting Layer) 显示驱动(flush_cb) | 触摸/按键驱动(read_cb) 系统时钟(tick) | 文件系统接口(可选) 硬件层(Hardware) MCU / MPU | LCD / OLED 屏幕 | 触摸屏 / 按键 移植工作主要集中在对齐 HAL 层:实现显示刷新和输入读取两个回调

从上往下看:你的应用代码调用LVGL的控件API;核心库负责管理所有对象、处理事件、渲染画面;HAL层是桥梁,把LVGL的绘图指令转成屏幕驱动能懂的像素数据;最底下是真实的硬件。

我习惯把LVGL比作一个“微型的操作系统”——它有自己的内存管理、任务调度(lv_timer)、消息传递(事件)。你不需要自己写循环去轮询触摸,LVGL帮你干了。

4.2 核心概念:对象、图层、事件

这三个概念你搞懂了,LVGL就算入门了。咱们一个一个说。

4.2.1 对象(Object)

LVGL里所有可见的东西都是对象。按钮是对象,标签是对象,进度条也是对象。每个对象都是一个 lv_obj_t 结构体。

对象之间有父子关系。父对象移动,子对象跟着动;父对象被隐藏,子对象也看不见。这跟HTML的DOM树很像。

关键点:创建对象时一定要指定父对象。最常见的父对象是 lv_scr_act() —— 当前活动屏幕。

// 创建一个标签,父对象是当前屏幕
lv_obj_t *label = lv_label_create(lv_scr_act());
lv_label_set_text(label, "Hello LVGL!");
lv_obj_align(label, LV_ALIGN_CENTER, 0, 0);

对象可以设置大小、位置、样式、透明度、旋转角度……几乎所有UI属性都能调。我在项目里踩过一个坑:忘了给对象设置大小,结果按钮显示不出来。后来养成习惯,创建完对象立刻设尺寸。

4.2.2 图层(Layer)

LVGL内部维护了多个图层。你不需要手动管理,但理解图层能帮你避免很多显示问题。

图层名称 用途 说明
活动屏幕层 显示当前页面内容 你创建的对象默认都在这里
顶层(Top Layer) 全局覆盖层 比如状态栏、弹窗提示
系统层(System Layer) 鼠标光标、调试信息 一般用不到

图层顺序决定了谁盖住谁。系统层在最上面,然后是顶层,最后是活动屏幕层。如果你想做一个“永远在最前面”的悬浮按钮,把它放到顶层就行了。

// 把对象放到顶层
lv_obj_t *top_btn = lv_btn_create(lv_layer_top());
lv_obj_set_size(top_btn, 60, 60);
lv_obj_align(top_btn, LV_ALIGN_TOP_RIGHT, -10, 10);

小技巧:调试时可以用 lv_layer_sys() 显示FPS和内存占用,方便性能调优。

4.2.3 事件(Event)

事件是LVGL的交互核心。用户点击按钮、滑动列表、键盘输入……都会触发事件。你只需要注册回调函数,LVGL会在事件发生时自动调用它。

// 事件回调函数
static void btn_event_cb(lv_event_t *e) {
    lv_event_code_t code = lv_event_get_code(e);
    if (code == LV_EVENT_CLICKED) {
        printf("按钮被点击了!\n");
    }
}

// 创建按钮并注册事件
lv_obj_t *btn = lv_btn_create(lv_scr_act());
lv_obj_add_event_cb(btn, btn_event_cb, LV_EVENT_CLICKED, NULL);

常用的事件类型有这些:

  • LV_EVENT_CLICKED —— 点击完成(按下+抬起)
  • LV_EVENT_PRESSING —— 正在按下
  • LV_EVENT_VALUE_CHANGED —— 值改变(比如滑块拖动)
  • LV_EVENT_RELEASED —— 手指抬起

我曾经在一个产品里遇到“按钮反应迟钝”的问题。排查了半天,发现是事件回调里做了太多计算,阻塞了LVGL的渲染循环。解决方案很简单:耗时操作扔到 lv_timer 里异步执行。

4.3 移植LVGL到裸机/RTOS

移植听起来高大上,其实就三步:改配置、写驱动、调心跳。我带你走一遍。

4.3.1 第一步:复制源码并配置

从GitHub下载LVGL源码,把 src 文件夹和 lvgl.h 拷到你的工程里。然后找到 lv_conf_template.h,重命名为 lv_conf.h,打开它。

你需要改几个关键宏:

宏定义 推荐值 说明
LV_COLOR_DEPTH 16 大多数屏幕是RGB565,16位色
LV_MEM_SIZE 32 * 1024 至少32KB,复杂UI需要更大
LV_HOR_RES_MAX 320 你的屏幕宽度
LV_VER_RES_MAX 240 你的屏幕高度
LV_USE_DEMO_WIDGETS 1 先打开,跑通后关掉省空间

注意:LV_MEM_SIZE 设太小会导致对象创建失败,设太大又浪费RAM。我一般先设64KB,跑起来后用 lv_mem_monitor() 看实际用量,再调到一个合适的值。

4.3.2 第二步:实现显示驱动

LVGL需要你提供一个“刷屏函数”。说白了就是把一块内存里的像素数据,整块拷贝到屏幕上。

// 显示刷新回调
static void my_flush_cb(lv_disp_drv_t *disp_drv, const lv_area_t *area, lv_color_t *color_p) {
    int32_t x, y;
    for (y = area->y1; y <= area->y2; y++) {
        for (x = area->x1; x <= area->x2; x++) {
            // 调用你的屏幕驱动,设置 (x, y) 处的像素颜色
            set_pixel(x, y, color_p->full);
            color_p++;
        }
    }
    // 通知LVGL刷新完成
    lv_disp_flush_ready(disp_drv);
}

// 注册显示驱动
static lv_disp_drv_t disp_drv;
lv_disp_drv_init(&disp_drv);
disp_drv.flush_cb = my_flush_cb;
disp_drv.hor_res = 320;
disp_drv.ver_res = 240;
lv_disp_drv_register(&disp_drv);

如果你的屏幕支持DMA或硬件加速,可以在 my_flush_cb 里启动DMA传输,然后立即返回。等DMA传输完成中断里再调用 lv_disp_flush_ready。这样CPU不用傻等,效率高很多。

4.3.3 第三步:实现输入驱动

触摸屏或者按键,都需要告诉LVGL“用户按了哪里”。

// 触摸读取回调
static void my_touchpad_read_cb(lv_indev_drv_t *indev_drv, lv_indev_data_t *data) {
    // 从你的触摸驱动读取坐标
    int16_t x, y;
    bool pressed = touchpad_get_xy(&x, &y);

    if (pressed) {
        data->state = LV_INDEV_STATE_PR;
        data->point.x = x;
        data->point.y = y;
    } else {
        data->state = LV_INDEV_STATE_REL;
    }
}

// 注册输入设备
static lv_indev_drv_t indev_drv;
lv_indev_drv_init(&indev_drv);
indev_drv.type = LV_INDEV_TYPE_POINTER;
indev_drv.read_cb = my_touchpad_read_cb;
lv_indev_drv_register(&indev_drv);

4.3.4 第四步:提供系统心跳

LVGL需要一个1ms的时钟节拍。裸机下用定时器中断,RTOS下用软件定时器。

// 在定时器中断或RTOS定时器回调中调用
void timer_isr(void) {
    lv_tick_inc(1);
}

然后在主循环里调用 lv_timer_handler() 让LVGL处理任务。

while (1) {
    lv_timer_handler();
    delay_ms(5);  // 5ms调用一次,不要用死等
}

经验之谈:在RTOS里,我会单独创建一个任务跑 lv_timer_handler(),优先级设中等。这样UI响应不会被其他任务完全阻塞。

4.4 第一个“Hello World”界面

移植完成,咱们写个最简单的界面验证一下。

#include "lvgl.h"

void hello_world_init(void) {
    // 创建一个标签
    lv_obj_t *label = lv_label_create(lv_scr_act());
    lv_label_set_text(label, "Hello World!");
    lv_obj_align(label, LV_ALIGN_CENTER, 0, -20);

    // 创建一个按钮
    lv_obj_t *btn = lv_btn_create(lv_scr_act());
    lv_obj_set_size(btn, 100, 40);
    lv_obj_align(btn, LV_ALIGN_CENTER, 0, 30);

    // 按钮上的文字
    lv_obj_t *btn_label = lv_label_create(btn);
    lv_label_set_text(btn_label, "点击我");
    lv_obj_center(btn_label);

    // 按钮点击事件
    lv_obj_add_event_cb(btn, btn_event_cb, LV_EVENT_CLICKED, NULL);
}

static void btn_event_cb(lv_event_t *e) {
    printf("Hello World 按钮被点击了!\n");
}

把 hello_world_init() 放在初始化最后调用。编译下载,如果屏幕上出现了“Hello World!”文字和一个按钮,点击按钮串口打印出消息——恭喜你,移植成功了。

检查清单:

  • 屏幕显示是否正常?有没有花屏、偏移?
  • 触摸点击位置是否准确?
  • 按钮点击后事件是否触发?
  • 长时间运行会不会内存泄漏?

嗯,到这里LVGL的基础入门就讲完了。你有了对象、图层、事件的概念,也亲手移植了LVGL并跑通了第一个界面。后面几章我们会深入控件、样式和动画,把界面做得更漂亮。但今天先把基础打牢——地基稳了,楼才能盖得高。