2. LVGL基础框架剖析:对象树、显示驱动与输入设备接口深度解析

LVGL 的架构,说白了就是一套「对象管理 + 硬件抽象」的体系。你想想看,一个嵌入式 GUI 要跑起来,无非就是三件事:创建界面元素、把画面刷到屏幕上、处理用户的触摸或按键。LVGL 把这三点拆成了对象树、显示驱动接口和输入设备接口。今天我们就把这三大块掰开揉碎了讲。

2.1 对象树:LVGL 的骨骼

LVGL 里所有的可见元素——按钮、标签、滑块、图表——都是 lv_obj_t 对象。它们不是散兵游勇,而是以树形结构组织在一起的。每个对象都有一个父对象,可以有零个或多个子对象。

核心概念: 屏幕(Screen)是根节点,所有对象都挂在某个屏幕下。一个系统可以有多个屏幕,但同一时刻只有一个活动屏幕。

我在项目里见过有人把所有控件都直接挂在屏幕下,结果屏幕对象下面挂了几十个子节点。这样做不是不行,但维护起来很痛苦。我建议你养成「容器化」的习惯——用 lv_cont 或 lv_obj 做中间容器,把逻辑相关的控件分组。

// 创建一个屏幕
lv_obj_t * scr = lv_obj_create(NULL);
lv_scr_load(scr);

// 创建一个容器作为面板
lv_obj_t * panel = lv_obj_create(scr);
lv_obj_set_size(panel, 200, 150);
lv_obj_align(panel, LV_ALIGN_CENTER, 0, 0);

// 在面板里放按钮
lv_obj_t * btn = lv_btn_create(panel);
lv_obj_set_pos(btn, 10, 10);

对象树的好处是什么?事件冒泡、样式继承、自动销毁。你删掉父对象,所有子对象自动被清理。你给父对象设一个样式,子对象默认继承。事件从子对象向上冒泡到父对象——这个机制在实现「点击空白区域关闭弹窗」时特别好用。

我的习惯: 在复杂界面里,我会给每个容器对象设置 lv_obj_set_user_data(),存一个结构体指针,里面放这个容器的业务数据。这样事件回调里拿到对象就能直接取数据,不用全局变量。

2.2 显示驱动接口:lv_disp_drv_t 深度解析

显示驱动是 LVGL 和硬件屏幕之间的桥梁。核心结构体是 lv_disp_drv_t。你只需要填好这个结构体,注册进去,LVGL 就知道怎么往你的屏幕上画东西了。

成员 类型 说明
flush_cb 函数指针 核心回调,LVGL 把要刷新的像素数据传给你
draw_buf lv_disp_draw_buf_t * 绘制缓冲区,至少 1/10 屏幕大小
hor_res lv_coord_t 屏幕水平分辨率
ver_res lv_coord_t 屏幕垂直分辨率
user_data void * 用户自定义数据,比如 SPI 句柄

最关键的还是 flush_cb。LVGL 渲染完成后,会调用这个回调,把一块矩形区域的像素数据交给你。你的任务就是把这些数据通过 SPI、I2C 或者并行总线送到屏幕驱动 IC 里。

static void my_flush_cb(lv_disp_drv_t * disp_drv, 
                        const lv_area_t * area, 
                        lv_color_t * color_p)
{
    // 设置屏幕的写入窗口
    lcd_set_window(area->x1, area->y1, area->x2, area->y2);
    
    // 发送像素数据(假设是 SPI 接口)
    lcd_write_data((uint8_t *)color_p, 
                   (area->x2 - area->x1 + 1) * 
                   (area->y2 - area->y1 + 1) * 
                   sizeof(lv_color_t));
    
    // 通知 LVGL 刷新完成
    lv_disp_flush_ready(disp_drv);
}

void lvgl_disp_init(void)
{
    static lv_disp_draw_buf_t draw_buf;
    static lv_color_t buf[LV_HOR_RES_MAX * 40];  // 40 行缓冲区
    
    lv_disp_draw_buf_init(&draw_buf, buf, NULL, LV_HOR_RES_MAX * 40);
    
    static lv_disp_drv_t disp_drv;
    lv_disp_drv_init(&disp_drv);
    disp_drv.flush_cb = my_flush_cb;
    disp_drv.draw_buf = &draw_buf;
    disp_drv.hor_res = 320;
    disp_drv.ver_res = 240;
    
    lv_disp_drv_register(&disp_drv);
}

我曾经踩过的坑: 忘记调用 lv_disp_flush_ready()。LVGL 是单线程的,你不告诉它刷新完成,它就一直等着,整个界面卡死。还有一个坑——缓冲区大小不够。如果屏幕分辨率是 320x240,颜色深度 16bit,一帧就是 150KB。你单片机 RAM 可能只有 64KB。所以要用部分缓冲区,LVGL 会分块渲染。

2.3 输入设备接口:lv_indev_drv_t 深度解析

输入设备驱动负责把物理输入(触摸、按键、编码器)转换成 LVGL 能理解的事件。核心结构体是 lv_indev_drv_t。

成员 类型 说明
type lv_indev_type_t 设备类型:触摸、按键、编码器、鼠标
read_cb 函数指针 读取输入状态的回调
user_data void * 用户数据,比如 I2C 地址

对于触摸屏,read_cb 里你要返回触摸点的坐标和按压状态。LVGL 会轮询这个回调——默认每 5 毫秒调用一次。

static void my_touch_read_cb(lv_indev_drv_t * indev_drv, lv_indev_data_t * data)
{
    static int16_t last_x, last_y;
    static bool pressed = false;
    
    // 读取触摸控制器(比如 FT6x36)
    pressed = touch_read(&last_x, &last_y);
    
    if (pressed) {
        data->point.x = last_x;
        data->point.y = last_y;
        data->state = LV_INDEV_STATE_PR;
    } else {
        data->state = LV_INDEV_STATE_REL;
    }
}

void lvgl_indev_init(void)
{
    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_touch_read_cb;
    lv_indev_drv_register(&indev_drv);
}

性能小技巧: 如果你的触摸 IC 支持中断,不要在中断里直接调用 read_cb。我习惯在中断里只设一个标志位,然后在主循环或者定时器里调用 LVGL 的 lv_timer_handler(),它会自动轮询输入设备。这样不会打断 LVGL 的渲染流程。

2.4 三者如何协同工作?

对象树、显示驱动、输入设备驱动不是各自为政的。它们通过 LVGL 的主循环串联起来:

  1. 输入阶段: lv_timer_handler() 调用所有注册的 read_cb,获取输入状态
  2. 事件处理: 输入状态被转换成事件,沿着对象树冒泡
  3. 渲染阶段: LVGL 遍历对象树,计算需要重绘的区域,调用 flush_cb 输出像素
  4. 循环: 回到步骤 1,周而复始

下面这张图把整个流程串起来了——

LVGL 显示与输入驱动协同工作流 lv_timer_handler() 输入设备 read_cb 轮询 事件沿对象树冒泡处理 渲染引擎遍历对象树 显示驱动 flush_cb 输出像素 说明:整个流程在 lv_timer_handler() 中循环执行,默认每 5ms 触发一次

嗯,这里要注意一个细节:显示驱动和输入设备驱动是解耦的。你换屏幕只需要改 flush_cb,换触摸 IC 只需要改 read_cb。我在一个项目里把 ST7789 换成 ILI9341,只改了 20 行代码。这就是 LVGL 硬件抽象层设计的精髓。

总结一下: 对象树管界面结构,显示驱动管像素输出,输入设备管用户交互。三者通过 LVGL 主循环串联,互不干扰。你只要把三个接口实现好,LVGL 帮你搞定剩下的一切。