第2章 LVGL基础框架移植:从源码到跑起来

好,咱们正式开始动手。上一章聊了LVGL和TouchGFX的整体差异,这一章我带你一步步把LVGL的底层框架搭起来。说白了,就是让LVGL能在你的硬件上“喘口气”——能显示、能触摸、能感知时间流逝。

我记得第一次移植LVGL时,对着那一堆文件真有点懵。后来摸清了门道,发现核心就三块:配置头文件、显示驱动、输入驱动。再加一个心跳时钟,齐活。

本章核心目标:让LVGL在裸机或RTOS环境下完成最基本的初始化,能在屏幕上画出一个像素点,并能响应触摸或按键输入。

2.1 源码获取与版本选择

LVGL的源码托管在GitHub上,搜索lvgl就能找到。我个人习惯直接从官方仓库的Release页面下载稳定版,而不是拉master分支——你想想看,master上可能正在开发新功能,说不定哪天就编译不过了。

目前主流版本是v8.x和v9.x。v9.x改动较大,API有调整。如果你刚入门,我建议先用v8.3.x,资料多、社区活跃、坑也少。我在项目里踩过v9早期版本的雷,有些驱动接口变了,文档还没跟上,折腾了好几天。

版本 特点 推荐场景
v8.3.x 稳定,资料丰富,API成熟 新手入门、产品量产
v9.x 新架构,性能优化,部分API不兼容 新项目评估、愿意尝鲜

下载后解压,你会看到这样的目录结构:

lvgl/
├── docs/            # 文档(英文为主)
├── examples/        # 示例代码
├── scripts/         # 构建脚本
├── src/             # 核心源码
│   ├── core/        # 核心对象、事件、动画
│   ├── draw/        # 绘图引擎
│   ├── extra/       # 额外组件(图表、表格等)
│   ├── fonts/       # 字体引擎
│   ├── hal/         # 硬件抽象层(显示、输入、时钟)
│   ├── misc/        # 杂项(内存、日志)
│   ├── layouts/     # 布局引擎
│   └── widgets/     # 基础控件(按钮、标签、滑块等)
├── lv_conf.h        # 配置文件(模板)
└── lvgl.h           # 主头文件

嗯,这里要注意:lv_conf.h 默认是模板,你需要复制一份到项目目录并重命名。别直接在源码目录里改,否则下次更新源码就覆盖了。

2.2 文件结构解析:哪些是必须的?

整个lvgl文件夹有几百个文件,但移植时你不需要全部编译。我一般只挑出核心部分:

  • src/core/ — 必须。对象系统、事件循环、动画框架都在这里。
  • src/hal/ — 必须。硬件抽象层,你要实现的显示和输入接口就在这里声明。
  • src/misc/ — 必须。内存管理、日志、定时器。
  • src/draw/ — 必须。绘图引擎,负责把像素刷到显存。
  • src/widgets/ — 可选但推荐。基础控件,没有它们你只能画原始图形。
  • src/extra/ — 按需添加。图表、表格、文件选择器等高级控件。
  • src/fonts/ — 按需添加。如果你用内置字体就编译,否则可以跳过。

我曾经犯过一个错误:为了省空间,把 src/draw 整个跳过了,结果LVGL初始化后屏幕一片黑。后来才发现绘图引擎是显示的基础,没有它,你连一个像素都画不出来。

小技巧:第一次移植时,先把所有源文件加进工程,编译通过后再逐步裁剪。这样能快速定位问题,避免一开始就陷入“这个文件要不要”的纠结。

2.3 移植核心文件之一:lv_conf.h

这是LVGL的“总开关”。几乎所有功能都可以在这里打开或关闭。我把常用的配置项列出来:

/* lv_conf.h 关键配置 */
#define LV_COLOR_DEPTH     16      /* 颜色深度:16位RGB565最常见 */
#define LV_HOR_RES_MAX     240     /* 屏幕宽度 */
#define LV_VER_RES_MAX     320     /* 屏幕高度 */
#define LV_DPI_DEF         100     /* 每英寸点数,影响控件大小 */
#define LV_MEM_SIZE        (32U * 1024U)  /* LVGL专用堆大小 */
#define LV_TICK_CUSTOM     1       /* 使用自定义心跳时钟 */
#define LV_USE_DEMO_WIDGETS 1      /* 打开示例控件,测试用 */

这里有个坑:LV_MEM_SIZE 设太小,控件一多就分配失败;设太大,MCU内存不够。我一般先设32KB,跑起来后观察内存使用,再调整。

另外,LV_TICK_CUSTOM 必须设为1。这样LVGL就不会自己产生心跳,而是等你从外部喂给它。后面会细说。

注意:lv_conf.h 必须被你的编译器找到。要么放在工程根目录,要么在包含路径里加上它的目录。否则编译时会报“lv_conf.h not found”。

2.4 移植核心文件之二:lv_port_disp.c/h

显示驱动是移植的重头戏。LVGL不知道你的屏幕怎么刷,你得告诉它。接口很简单,就一个函数:

/* lv_port_disp.c 核心实现 */
static void disp_flush(lv_disp_drv_t * disp_drv, const lv_area_t * area, lv_color_t * color_p)
{
    /* 将color_p指向的像素数据刷到屏幕的area区域 */
    /* 以ILI9341为例:设置窗口,然后批量写像素 */
    ili9341_set_window(area->x1, area->y1, area->x2, area->y2);
    ili9341_write_pixels(color_p, lv_area_get_width(area) * lv_area_get_height(area));
    
    /* 通知LVGL刷新完成 */
    lv_disp_flush_ready(disp_drv);
}

然后初始化时注册这个函数:

void lv_port_disp_init(void)
{
    lv_disp_drv_t disp_drv;
    lv_disp_drv_init(&disp_drv);
    disp_drv.flush_cb = disp_flush;
    disp_drv.draw_buf = &draw_buf;
    disp_drv.hor_res = LV_HOR_RES_MAX;
    disp_drv.ver_res = LV_VER_RES_MAX;
    lv_disp_drv_register(&disp_drv);
}

这里要注意 draw_buf。LVGL需要一个缓冲区来绘制内容。缓冲区大小至少是一行像素。如果内存够,可以用全屏双缓冲,效果最流畅。我一般先用一行缓冲,跑起来再优化。

缓冲模式 内存需求 刷新效果 推荐场景
单行缓冲 低(一行像素) 可能有撕裂 内存紧张
部分缓冲 中(1/10屏幕) 较好 大多数MCU
全屏双缓冲 高(两屏像素) 无撕裂,最流畅 内存充裕

2.5 移植核心文件之三:lv_port_indev.c/h

输入设备驱动,比如触摸屏、按键、编码器。接口同样简单:

/* lv_port_indev.c 核心实现 */
static void touchpad_read(lv_indev_drv_t * indev_drv, lv_indev_data_t * data)
{
    /* 读取触摸坐标 */
    if (touchpad_is_pressed()) {
        data->state = LV_INDEV_STATE_PR;
        touchpad_get_xy(&data->point.x, &data->point.y);
    } else {
        data->state = LV_INDEV_STATE_REL;
    }
}

初始化注册:

void lv_port_indev_init(void)
{
    lv_indev_drv_t indev_drv;
    lv_indev_drv_init(&indev_drv);
    indev_drv.type = LV_INDEV_TYPE_POINTER;  /* 触摸屏类型 */
    indev_drv.read_cb = touchpad_read;
    lv_indev_drv_register(&indev_drv);
}

如果你用的是按键,类型设为 LV_INDEV_TYPE_KEYPAD。编码器则用 LV_INDEV_TYPE_ENCODER。我做过一个项目,触摸屏坏了,临时用按键模拟触摸坐标,虽然体验差,但至少能调试界面布局。

调试技巧:在 touchpad_read 里加个串口打印,看看坐标值是否合理。我曾经遇到触摸屏X/Y反了,就是靠打印发现的。

2.6 心跳与时钟配置

LVGL需要知道时间流逝,才能做动画、长按检测、闪烁光标等。它不自己产生时钟,而是等你调用 lv_tick_inc() 来喂它。

通常的做法是用一个硬件定时器,每1ms中断一次,在中断里调用:

void TIM2_IRQHandler(void)
{
    if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET) {
        lv_tick_inc(1);  /* 告诉LVGL过去了1ms */
        TIM_ClearITPendingBit(TIM2, TIM_IT_Update);
    }
}

然后在主循环里调用 lv_timer_handler() 处理定时任务:

while(1) {
    lv_timer_handler();  /* 处理LVGL内部定时任务 */
    delay_ms(5);         /* 适当延时,避免CPU跑满 */
}

这里有个细节:lv_timer_handler() 不能放在中断里,它可能耗时较长。我见过有人把心跳和定时器处理都放中断,结果系统卡死。记住:心跳在中断喂,定时器在主循环处理。

常见错误:忘记调用 lv_tick_inc(),或者调用频率不对。如果动画不动、长按不触发,先检查心跳。我曾经排查了一整天,最后发现是定时器分频算错了,实际是2ms喂一次,LVGL以为时间过得很慢。

2.7 整体移植流程SVG图

下面这张图把整个移植流程串起来了。你可以对照着一步步走:

LVGL基础框架移植流程 1. 获取源码 2. 配置 lv_conf.h 3. lv_port_disp 4. lv_port_indev 5. 心跳与时钟 6. 主循环调用 提示:显示驱动和输入驱动可以并行开发,互不依赖。心跳是独立模块,但必须在主循环前启动。

2.8 验证:让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) {
        lv_timer_handler();
        delay_ms(5);
    }
}

如果屏幕上出现了“Hello LVGL!”,恭喜你,移植成功了。如果没显示,别急。先检查硬件连接,再确认 disp_flush 有没有被调用。我习惯在 disp_flush 里加个GPIO翻转,用示波器看波形,就知道LVGL有没有在刷屏。

移植成功标志:

  • 屏幕显示内容(哪怕只是一个像素点)
  • 触摸或按键能触发事件(比如点击按钮有反馈)
  • 动画能正常播放(比如进度条旋转)