3. LVGL核心架构

好,我们直接进入正题。这一章,我们要让LVGL真正“跑”起来。

前面两章,你装好了环境,也跑了个模拟器。但模拟器终究是模拟器。真正的嵌入式开发,你得跟硬件打交道。说白了,就是让LVGL知道:你的屏幕长什么样,怎么往上面画点,以及用户用手指戳屏幕时,坐标是多少。

这就是显示驱动和触摸驱动的活儿。

我个人习惯,先把这两个接口搞定,再谈别的。因为画面出不来,后面全是白搭。我记得刚入行那会儿,有一次调一个4.3寸屏,折腾了两天,最后发现是像素格式配错了。嗯,这种坑,咱们今天一并填上。

核心要点: LVGL的驱动层是硬件和UI引擎之间的“翻译官”。显示驱动负责把内存里的像素数据“搬运”到屏幕上;触摸驱动负责把物理触摸信号“翻译”成LVGL能理解的坐标事件。

3.1 显示驱动接口:lv_disp_drv_t 的结构与配置

先看显示驱动。LVGL里,所有跟显示相关的配置,都封装在一个结构体里——lv_disp_drv_t。你可以把它想象成一张“显示器身份证”。

这张身份证上要填什么?主要就三样:

  • 屏幕尺寸:宽和高,单位是像素。
  • 像素格式:比如RGB565、RGB888。这决定了每个像素占几个字节。
  • 绘制回调函数:这是核心。LVGL画图时,最终会调用你提供的这个函数,把数据刷到屏幕上。

来看一段典型的初始化代码:

/* 定义一个显示缓冲区 */
static lv_disp_draw_buf_t draw_buf;
static lv_color_t buf[LV_HOR_RES_MAX * 10];  /* 10行像素的缓冲区 */

/* 定义一个显示驱动结构体 */
static lv_disp_drv_t disp_drv;

void my_display_init(void)
{
    /* 1. 初始化显示缓冲区 */
    lv_disp_draw_buf_init(&draw_buf, buf, NULL, LV_HOR_RES_MAX * 10);

    /* 2. 初始化显示驱动 */
    lv_disp_drv_init(&disp_drv);

    /* 3. 填写关键参数 */
    disp_drv.hor_res = 480;   /* 屏幕宽度 */
    disp_drv.ver_res = 272;   /* 屏幕高度 */
    disp_drv.flush_cb = my_display_flush;  /* 绘制回调函数 */
    disp_drv.draw_buf = &draw_buf;         /* 绑定缓冲区 */

    /* 4. 注册驱动到LVGL */
    lv_disp_drv_register(&disp_drv);
}

这里有个细节:draw_buf。LVGL不是直接往屏幕硬件写像素的。它先画到内存缓冲区里,画满一行或几行后,再一次性通过flush_cb刷出去。这种“双缓冲”机制,能有效避免屏幕闪烁。

我的经验: 缓冲区大小,建议至少是屏幕一行像素的10倍。太小了,LVGL频繁刷屏,CPU占用高;太大了,内存吃不消。对于320x240的屏,用10行缓冲区,也就是320*10*2=6400字节(RGB565),刚刚好。

3.2 实现像素点绘制函数:从缓冲区到屏幕

好,接下来是实现my_display_flush这个回调。这是整个驱动里最核心、也最容易出错的地方。

这个函数的原型是固定的:

void my_display_flush(lv_disp_drv_t * drv, const lv_area_t * area, lv_color_t * color_p);

参数解释一下:

  • drv:指向当前显示驱动结构体的指针。
  • area:需要刷新的矩形区域。包含左上角(x1, y1)和右下角(x2, y2)坐标。
  • color_p:指向像素数据缓冲区的指针。数据是按行排列的。

说白了,LVGL告诉你:“从坐标(x1,y1)到(x2,y2)这个矩形,像素数据已经放在color_p里了,你帮我写到屏幕对应的位置上去。”

实现起来,通常就是两层循环:

void my_display_flush(lv_disp_drv_t * drv, const lv_area_t * area, lv_color_t * color_p)
{
    int32_t x, y;
    uint32_t width = lv_area_get_width(area);
    uint32_t height = lv_area_get_height(area);

    /* 假设你的屏幕驱动芯片是ILI9341,通过SPI接口写入 */
    ili9341_set_window(area->x1, area->y1, area->x2, area->y2);

    for (y = area->y1; y <= area->y2; y++) {
        for (x = area->x1; x <= area->x2; x++) {
            /* 从缓冲区取出一个像素,写入屏幕 */
            ili9341_write_pixel(color_p->full);
            color_p++;
        }
    }

    /* 重要!通知LVGL刷新完成 */
    lv_disp_flush_ready(drv);
}

注意: 最后一定要调用 lv_disp_flush_ready(drv)。如果不调,LVGL会认为屏幕还在忙,不会再发新的绘制任务。我曾经因为这个函数漏掉了,画面卡死了一整天,排查得头都大了。

另外,如果你的屏幕控制器支持“批量写像素”指令(比如ILI9341的0x2C命令),千万别用逐像素写入。改成DMA或连续写入,速度能快10倍以上。我一般这么优化:

void my_display_flush(lv_disp_drv_t * drv, const lv_area_t * area, lv_color_t * color_p)
{
    uint32_t size = lv_area_get_width(area) * lv_area_get_height(area);

    /* 设置窗口 */
    ili9341_set_window(area->x1, area->y1, area->x2, area->y2);

    /* 批量写入,一次发完所有像素数据 */
    ili9341_write_pixels((uint16_t *)color_p, size);

    lv_disp_flush_ready(drv);
}

你看,代码简洁多了,效率也高。

3.3 触摸驱动接口:lv_indev_drv_t 的实现

画面能显示了,接下来得让用户能交互。触摸驱动,就是干这个的。

LVGL里,输入设备(触摸屏、按键、编码器)都统一用lv_indev_drv_t来管理。触摸屏只是其中一种。

初始化流程跟显示驱动很像:

static lv_indev_drv_t indev_drv;

void my_touch_init(void)
{
    /* 初始化触摸芯片(比如FT6236、GT911) */
    touch_hw_init();

    /* 初始化输入设备驱动 */
    lv_indev_drv_init(&indev_drv);

    /* 类型:触摸屏 */
    indev_drv.type = LV_INDEV_TYPE_POINTER;

    /* 读取回调:LVGL会周期性调用这个函数获取触摸状态 */
    indev_drv.read_cb = my_touch_read;

    /* 注册驱动 */
    lv_indev_drv_register(&indev_drv);
}

关键在my_touch_read这个回调里。它的任务是:告诉LVGL“当前有没有手指按下?按在哪里?”

void my_touch_read(lv_indev_drv_t * drv, lv_indev_data_t * data)
{
    static int16_t last_x = 0;
    static int16_t last_y = 0;
    static lv_indev_state_t last_state = LV_INDEV_STATE_REL;

    /* 从硬件读取触摸坐标和状态 */
    uint8_t touch_points = touch_get_point(&last_x, &last_y);

    if (touch_points > 0) {
        data->point.x = last_x;
        data->point.y = last_y;
        data->state = LV_INDEV_STATE_PR;   /* 按下 */
    } else {
        data->state = LV_INDEV_STATE_REL;   /* 释放 */
    }
}

这里有个小坑:触摸屏的坐标原点,不一定跟屏幕显示的原点一致。有的触摸屏X轴是反的,有的Y轴是反的。你需要根据实际情况做坐标变换。

我曾经遇到过一块屏,触摸X轴跟显示X轴是镜像关系。调试时点左上角,光标跑到右下角去了。解决办法很简单:data->point.x = 479 - last_x;。嗯,一行代码搞定。

3.4 调试驱动:确保画面正常

驱动写完了,怎么验证对不对?别急着写UI。先跑几个“裸”测试。

第一步:纯色填充测试

直接调用你的flush_cb,往全屏填红、绿、蓝、白、黑。看看颜色对不对,有没有花屏。

/* 手动构造一个全屏区域 */
lv_area_t area;
area.x1 = 0; area.y1 = 0;
area.x2 = 479; area.y2 = 271;

/* 构造一个全红的缓冲区 */
lv_color_t red_buf[480 * 272];
for (int i = 0; i < 480 * 272; i++) {
    red_buf[i].full = 0xF800;  /* RGB565 红色 */
}

/* 调用flush */
my_display_flush(&disp_drv, &area, red_buf);

如果屏幕全红,说明像素写入没问题。如果出现条纹、错位,检查你的窗口设置和坐标计算。

第二步:LVGL内置测试

LVGL自带了一个lv_demo_widgets(),能显示各种控件。跑一下这个demo,看看按钮、滑块、图表是否正常显示。如果某个控件显示不全,可能是缓冲区太小,或者flush_cb里区域计算有误。

第三步:触摸坐标打印

在my_touch_read里,把读取到的坐标通过串口打印出来。用手指在屏幕上划动,看打印的坐标是否连续、是否跟手指位置对应。如果坐标跳变,检查触摸芯片的滤波配置;如果坐标反向,做坐标变换。

调试口诀: 先显示,后触摸;先纯色,后UI;先单点,后手势。每一步都确认无误了,再往下走。别想着一步到位,那只会让你花更多时间排查。

3.5 知识体系:显示与触摸驱动核心逻辑

为了让你更直观地理解整个流程,我画了一张图。它展示了从LVGL内核到硬件屏幕和触摸芯片的数据流向。

显示与触摸驱动核心数据流 LVGL 内核 显示驱动 (lv_disp_drv_t) 触摸驱动 (lv_indev_drv_t) 显示缓冲区 (draw_buf) 硬件屏幕 (LCD) 触摸芯片 (Touch IC) 绘制指令 轮询读取 写入像素 flush_cb 读取坐标 返回状态 说明:LVGL内核不直接操作硬件。 所有硬件交互都通过驱动回调完成。

从这张图可以看得很清楚:LVGL内核只跟驱动结构体打交道。显示驱动负责把像素从缓冲区搬到屏幕;触摸驱动负责把物理坐标翻译成逻辑事件。两者独立,互不干扰。

3.6 常见问题与避坑指南

最后,我把这些年遇到过的典型问题列出来。你调试时如果碰到类似现象,直接对照排查。

现象 可能原因 解决方案
屏幕全白/全黑,无任何显示 1. 硬件初始化失败
2. 像素格式不匹配
1. 检查SPI/I2C通信是否正常
2. 确认lv_color_t格式与屏幕一致
画面闪烁 显示缓冲区太小 增大缓冲区,至少10行像素
画面撕裂(上半部分和下半部分不同步) 没有使用双缓冲,或刷新频率不匹配 启用LVGL的双缓冲模式,或降低刷新率
触摸无反应 1. 触摸芯片I2C地址错误
2. read_cb未正确返回状态
1. 用逻辑分析仪确认I2C通信
2. 确保data->state正确赋值
触摸坐标偏移 触摸屏与显示屏坐标原点不一致 在read_cb中做坐标变换
触摸坐标跳变 触摸芯片未做滤波,或电源噪声大 1. 启用触摸芯片内部滤波
2. 在软件中做均值滤波

一个小技巧: 调试触摸时,可以在屏幕上画一个十字光标,显示当前触摸点位置。这样你点哪里,光标就跟到哪里,坐标对不对一目了然。LVGL的lv_demo_benchmark()里就有这个功能,你可以直接参考。

嗯,这一章的内容就到这里。显示驱动和触摸驱动,是LVGL和硬件之间的桥梁。桥搭稳了,后面的路就好走了。记住:驱动调试,耐心比技术更重要。每改一个参数,就验证一次。别偷懒,别跳步。