3、LVGL显示驱动架构:从结构体到双缓冲的实战拆解

显示驱动,说白了就是LVGL和底层硬件之间的翻译官。你画了一个按钮,LVGL把它变成像素数据,然后通过驱动送给屏幕。这个流程听起来简单,但实际做起来坑不少。我最早接触LVGL时,以为只要把画点函数填进去就行,结果屏幕要么闪成雪花,要么刷新慢得像PPT。

这一章,我们就把显示驱动的几个核心零件拆开看:display驱动结构体、flush回调函数、双缓冲机制,还有输入设备集成。搞懂这些,你就能自己写一个稳定、流畅的显示驱动。

3.1 display驱动结构体:lv_disp_drv_t

LVGL里每个显示设备都对应一个 lv_disp_drv_t 结构体。你可以把它理解成一张“设备登记表”,告诉LVGL你的屏幕长什么样、怎么刷新、用什么内存。

lv_disp_drv_t disp_drv;
lv_disp_drv_init(&disp_drv);          // 先初始化,填默认值
disp_drv.hor_res = 480;               // 水平分辨率
disp_drv.ver_res = 320;               // 垂直分辨率
disp_drv.flush_cb = my_flush_cb;      // 刷新回调,核心函数
disp_drv.draw_buf = &draw_buf;        // 绑定绘制缓冲区
disp_drv.user_data = &my_lcd_handle;  // 私有数据,比如SPI句柄
lv_disp_drv_register(&disp_drv);      // 注册到LVGL内核

这里有几个字段我特别想提醒你:

  • flush_cb:必须实现,否则屏幕永远是黑的。
  • draw_buf:缓冲区指针,决定了你用单缓冲还是双缓冲。
  • user_data:这个很实用。我在项目里经常用它传递LCD的SPI设备句柄或者I2C地址,这样回调函数里就能直接拿到硬件资源。
我的习惯:每次注册display驱动前,我都会用 lv_disp_drv_init() 清一遍结构体。别小看这一步,它会把所有字段设成安全默认值,避免你漏掉某个字段导致诡异问题。

3.2 flush回调函数:真正的搬运工

flush_cb 是显示驱动的灵魂。LVGL每画好一块区域,就会调用这个函数,把像素数据送到屏幕。原型长这样:

void my_flush_cb(lv_disp_drv_t * disp_drv, const lv_area_t * area, lv_color_t * color_p)
{
    // area->x1, area->y1, area->x2, area->y2 是脏矩形范围
    // color_p 指向像素数据,按行排列

    // 1. 设置屏幕的写入窗口(set_window)
    lcd_set_window(area->x1, area->y1, area->x2, area->y2);

    // 2. 发送像素数据(通常用SPI/DMA)
    lcd_write_data((uint8_t *)color_p, 
                   (area->x2 - area->x1 + 1) * 
                   (area->y2 - area->y1 + 1) * 
                   sizeof(lv_color_t));

    // 3. 通知LVGL刷新完成
    lv_disp_flush_ready(disp_drv);
}

注意最后一行 lv_disp_flush_ready(),这个调用绝对不能少。LVGL靠它知道缓冲区可以复用了。如果你忘了调用,LVGL会一直等,整个界面卡死。

我曾经踩过的坑:有一次我在flush回调里加了日志打印,结果打印太慢,导致 lv_disp_flush_ready 迟迟没调用。LVGL以为还没刷完,就不给我新的绘制任务。屏幕看起来就像死机了一样。后来我把日志去掉,问题立刻解决。

3.3 双缓冲机制:告别屏幕撕裂

单缓冲模式下,LVGL一边往缓冲区画图,flush回调一边往外送数据。这就像一个人一边往桶里倒水,一边从桶里舀水——很容易乱套,出现屏幕撕裂(tearing)。

双缓冲就是准备两个桶。一个桶让LVGL画,另一个桶让flush往外送。画完就交换,互不干扰。

配置双缓冲很简单:

static lv_color_t buf_1[480 * 10];   // 第一个缓冲区
static lv_color_t buf_2[480 * 10];   // 第二个缓冲区

lv_disp_draw_buf_t draw_buf;
lv_disp_draw_buf_init(&draw_buf, buf_1, buf_2, 480 * 10);  // 两个缓冲区

第三个参数传 buf_2 就是双缓冲,传 NULL 就是单缓冲。缓冲区大小一般是 屏幕一行像素 × 10行,这个值可以根据内存调整。

模式 内存占用 撕裂风险 适用场景
单缓冲 低 高 内存极小的MCU,对显示质量要求不高
双缓冲 中 低 大部分嵌入式场景,推荐使用
全屏双缓冲 高 无 Linux + 大内存,追求极致流畅
核心原则:双缓冲不是银弹。如果你的MCU只有几十KB内存,硬上双缓冲反而会因为内存不足导致系统崩溃。我建议:先评估可用内存,再决定缓冲区大小。宁可缓冲区小一点,也要保证系统稳定。

3.4 输入设备集成:让屏幕“活”起来

显示驱动搞定了,但屏幕只能看不能点,那跟一张图片有什么区别?输入设备集成就是让触摸屏、按键、编码器能和LVGL交互。

LVGL的输入设备驱动结构体是 lv_indev_drv_t,用法和display驱动很像:

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;     // 读取回调
lv_indev_drv_register(&indev_drv);

read_cb 回调函数需要把触摸坐标填到 lv_indev_data_t 结构体里:

void my_touchpad_read(lv_indev_drv_t * indev_drv, lv_indev_data_t * data)
{
    // 从触摸芯片读取坐标
    int16_t x, y;
    bool pressed = touchpad_read(&x, &y);

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

输入设备类型有几种:

  • LV_INDEV_TYPE_POINTER:触摸屏、鼠标,需要坐标。
  • LV_INDEV_TYPE_KEYPAD:矩阵键盘,需要键值。
  • LV_INDEV_TYPE_ENCODER:旋转编码器,需要方向和按键。
  • LV_INDEV_TYPE_BUTTON:独立按键,需要ID和状态。
避坑指南:触摸屏的坐标可能需要做校准。我曾经遇到过触摸屏的X轴和屏幕显示方向相反,点左边却触发右边。解决办法是在 read_cb 里做一次坐标映射:data->point.x = disp_drv->hor_res - x;。

3.5 整体架构:一张图看懂

下面这张图把display驱动、flush回调、双缓冲和输入设备的关系画清楚了。你可以看到数据是怎么流动的:

LVGL 显示驱动与输入设备架构图 LVGL 内核 lv_disp_drv_t (显示驱动) flush_cb (刷新回调) lv_disp_draw_buf_t (双缓冲) 缓冲区1 ←→ 缓冲区2 交替使用 LCD 硬件 (SPI/8080/RGB) lv_indev_drv_t read_cb (读取回调) 触摸屏/按键/编码器 说明:LVGL内核通过display驱动调用flush回调,将双缓冲中的数据送到LCD硬件;同时通过input设备驱动读取用户输入。

从这张图你能看到,LVGL内核是大脑,display驱动是调度中心,flush回调是执行者,双缓冲是数据中转站。输入设备则是另一条独立的链路,通过 read_cb 把用户操作反馈给内核。

3.6 实战要点总结

写到这里,我把几个关键点再拎一下:

  1. flush回调里一定要调用 lv_disp_flush_ready,这是新手最容易忘的。
  2. 双缓冲不是必须的,但如果你希望画面流畅、没有撕裂,建议用。内存不够就缩小缓冲区行数。
  3. 输入设备的坐标可能需要做映射,尤其是触摸屏方向跟屏幕方向不一致时。
  4. user_data 是个好东西,别浪费。把硬件句柄、配置参数都塞进去,回调函数里直接取用。
我的经验:调试显示驱动时,我习惯先在 flush_cb 里填充一个纯色(比如红色),看看屏幕能不能正常显示一整屏颜色。如果能,说明SPI通信和窗口设置没问题。然后再逐步调试LVGL的绘制内容。这样分层验证,问题定位快很多。