3、LVGL基础入门:LVGL架构概览、源码移植步骤、初始化流程、显示缓冲区配置、输入设备驱动注册

好,咱们进入第三章。这一章我打算把LVGL的底裤扒开给你看。说白了,就是让你知道LVGL到底怎么跑起来的,以及你该怎么把它塞进你的板子里。

很多初学者一上来就急着画界面,结果卡在移植上,一卡就是好几天。我当年也干过这事——对着官方文档翻来覆去,就是跑不出一个Hello World。后来才发现,其实就那么几个关键点,搞通了就通了。

3.1 LVGL架构概览:它到底长什么样?

先看一张图,把LVGL的骨架看清楚。

LVGL软件架构分层图 LVGL 软件架构分层图 应用层(Application) 你的UI界面、业务逻辑、事件处理 控件层(Widgets) 按钮、标签、滑块、图表、列表等内置控件 核心层(Core) 对象管理、事件系统、动画引擎、样式系统 驱动抽象层(Drivers) 显示驱动、输入设备驱动、文件系统接口 硬件层(Hardware) MCU、LCD屏、触摸屏、SPI/I2C总线 你 写 的 代 码 ↓ L V G L ↓ 硬 件

你看,LVGL不是一个大泥球。它分了好几层。你写应用代码,调用控件API,控件再依赖核心层去管理对象和事件。核心层往下,就是驱动抽象层——这一层是你需要动手的地方。

我个人习惯把LVGL比作一个「积木工厂」。核心层是生产积木的机器,控件层是各种形状的积木块,驱动层就是传送带,把你的积木送到屏幕上。你作为工程师,只需要把传送带接好就行。

3.2 源码移植步骤:怎么把它塞进你的工程?

移植LVGL,说白了就是三步:拿源码、配参数、写接口。别想复杂了。

3.2.1 获取源码

去GitHub上拉LVGL的官方仓库。我建议你直接用release版本,别追master。我在项目里吃过这个亏——追master追到一半,API变了,整个界面重写,那叫一个酸爽。

3.2.2 文件结构

把源码丢进你的工程后,目录结构大概长这样:

project/
├── lvgl/
│   ├── src/            # LVGL核心源码
│   │   ├── core/       # 核心对象、事件、动画
│   │   ├── draw/       # 绘图引擎
│   │   ├── fonts/      # 字体
│   │   ├── widgets/    # 控件
│   │   └── misc/       # 杂项
│   ├── examples/       # 示例(可删)
│   └── lv_conf.h       # 配置文件(关键!)
├── lv_port_disp.c      # 显示驱动接口(你写)
├── lv_port_indev.c     # 输入设备驱动接口(你写)
└── main.c              # 你的主程序
我的习惯:我会把 lv_conf.h 单独拎出来放在工程根目录,而不是藏在lvgl文件夹里。这样改配置的时候不用翻半天目录。

3.2.3 配置 lv_conf.h

这个文件是LVGL的「总开关」。里面几百个宏定义,但你别慌——大部分保持默认就行。你需要关注的就这几个:

宏定义 说明 我常用的值
LV_COLOR_DEPTH 颜色深度,跟你的屏幕有关 16(RGB565)或 32(ARGB8888)
LV_HOR_RES_MAX 屏幕水平分辨率 320、480、800 等
LV_VER_RES_MAX 屏幕垂直分辨率 240、320、480 等
LV_MEM_SIZE LVGL动态内存大小 32 * 1024(32KB起步)
LV_USE_DEMO_WIDGETS 是否启用内置demo 调试时开,发布时关
注意:LV_MEM_SIZE 别设太小。我曾经在一个STM32F103项目上只给了16KB,结果界面稍微复杂一点就分配失败,画面花屏。后来改成48KB才稳定。你想想看,一个按钮加个标签就吃掉好几KB,太小了真不行。

3.3 初始化流程:LVGL是怎么启动的?

初始化流程其实就五步,我把它写成了一个口诀:

  1. 调 lv_init() —— 初始化LVGL内部核心模块
  2. 调 lv_port_disp_init() —— 注册显示驱动
  3. 调 lv_port_indev_init() —— 注册输入设备驱动
  4. 调 lv_tick_inc(x) —— 告诉LVGL时间过去了多少毫秒(通常在定时器中断里调)
  5. 在while(1)里调 lv_task_handler() —— 让LVGL处理任务、刷新界面

代码写出来就是这样:

#include "lvgl.h"
#include "lv_port_disp.h"
#include "lv_port_indev.h"

int main(void)
{
    /* 硬件初始化:时钟、GPIO、SPI、定时器等 */
    hardware_init();

    /* 第一步:初始化LVGL核心 */
    lv_init();

    /* 第二步:注册显示驱动 */
    lv_port_disp_init();

    /* 第三步:注册输入设备驱动 */
    lv_port_indev_init();

    /* 第四步:创建一个简单的界面 */
    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);

    /* 主循环 */
    while(1)
    {
        /* 第五步:定期调用LVGL任务处理 */
        lv_task_handler();
        
        /* 延时,别让CPU空转 */
        delay_ms(5);
    }
}
关键点:lv_tick_inc() 必须在定时器中断里周期性调用,频率通常是1ms一次。LVGL内部靠这个时间戳来做动画、长按检测、闪烁光标等。如果你忘了调这个,动画会卡住,触摸长按也没反应。我刚开始就踩过这个坑,动画死活不动,查了半天才发现是定时器没开。

3.4 显示缓冲区配置:画面流畅的关键

显示缓冲区,说白了就是一块内存,LVGL先把画面画到这块内存里,然后通过你的驱动刷到屏幕上。缓冲区怎么配,直接影响画面流畅度。

3.4.1 三种缓冲模式

模式 缓冲区大小 特点 适用场景
单缓冲 1个全屏 简单,但会有撕裂 低要求、低刷新率
双缓冲 2个全屏 无撕裂,流畅 大部分项目
部分缓冲 几行到半屏 省内存,但刷新慢 内存紧张时

我个人最推荐双缓冲。虽然多占一份内存,但画面是真的丝滑。你想想看,用户按个按钮,界面卡一下,那体验多糟糕。

3.4.2 配置代码示例

在 lv_port_disp.c 里,你会看到类似这样的配置:

/* 定义两个全屏缓冲区 */
static lv_disp_draw_buf_t draw_buf;
static lv_color_t buf_1[LV_HOR_RES_MAX * LV_VER_RES_MAX];
static lv_color_t buf_2[LV_HOR_RES_MAX * LV_VER_RES_MAX];

/* 初始化显示缓冲区 */
void lv_port_disp_init(void)
{
    /* 设置为双缓冲模式 */
    lv_disp_draw_buf_init(&draw_buf, buf_1, buf_2, 
                          LV_HOR_RES_MAX * LV_VER_RES_MAX);

    /* 注册显示驱动 */
    static lv_disp_drv_t disp_drv;
    lv_disp_drv_init(&disp_drv);
    disp_drv.draw_buf = &draw_buf;
    disp_drv.flush_cb = my_display_flush;  /* 你的刷屏函数 */
    disp_drv.hor_res = LV_HOR_RES_MAX;
    disp_drv.ver_res = LV_VER_RES_MAX;
    lv_disp_drv_register(&disp_drv);
}
省内存技巧:如果你的MCU内存实在紧张,可以用部分缓冲。比如只分配屏幕10行像素的缓冲区。代价是LVGL需要多次刷屏才能完成一帧,CPU占用会高一些。我在一个STM32F030项目上就这么干过——RAM只有8KB,硬是跑起来了。

3.5 输入设备驱动注册:让触摸屏和按键活起来

输入设备包括触摸屏、物理按键、编码器、鼠标等。LVGL把它们统一抽象成「输入设备」,你只需要注册一个读取函数就行。

3.5.1 注册流程

在 lv_port_indev.c 里,核心代码就这几行:

static lv_indev_drv_t indev_drv;

void lv_port_indev_init(void)
{
    /* 初始化输入设备驱动 */
    lv_indev_drv_init(&indev_drv);
    
    /* 指定设备类型:LV_INDEV_TYPE_POINTER 是触摸屏 */
    indev_drv.type = LV_INDEV_TYPE_POINTER;
    
    /* 注册读取函数——LVGL会周期性调用它 */
    indev_drv.read_cb = my_touchpad_read;
    
    /* 注册设备 */
    lv_indev_drv_register(&indev_drv);
}

/* 你的触摸读取函数 */
void my_touchpad_read(lv_indev_drv_t *drv, lv_indev_data_t *data)
{
    /* 读取触摸坐标 */
    int16_t x, y;
    uint8_t 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;
    }
}

3.5.2 设备类型一览

类型 说明 典型硬件
LV_INDEV_TYPE_POINTER 触摸屏/鼠标 电阻屏、电容屏
LV_INDEV_TYPE_KEYPAD 矩阵键盘 4x4按键矩阵
LV_INDEV_TYPE_ENCODER 旋转编码器 EC11旋钮
LV_INDEV_TYPE_BUTTON 独立按键 GPIO直连按键
避坑指南:我曾经在一个项目里,触摸屏的坐标读取函数里加了延时——因为觉得触摸芯片需要等一会儿才能读到稳定数据。结果LVGL的整个任务调度被拖慢,界面刷新掉到了10帧以下。后来才明白,read_cb 里绝对不能加阻塞延时。正确的做法是:在外部用定时器或DMA读取触摸数据,read_cb 只负责把最新数据交给LVGL。

3.6 小结:移植其实就这几件事

嗯,到这里,LVGL的移植骨架就搭完了。你回头看看,其实就干了三件事:

  • 配好 lv_conf.h —— 告诉LVGL你的屏幕多大、颜色多深、内存给多少
  • 写好显示驱动 —— 把LVGL画好的缓冲区刷到屏幕上
  • 写好输入驱动 —— 把触摸或按键数据喂给LVGL

剩下的,就是LVGL自己的事了。你只管调用API创建控件,它帮你画、帮你响应触摸、帮你做动画。说白了,LVGL就是个「高级打工仔」,你给它配好干活的环境,它就能帮你把界面撑起来。