4、LVGL移植第一步:裸机移植——创建工程模板、添加LVGL源码、配置lv_conf.h

好,咱们正式开始动手了。

前面三章我们把LVGL的架构、显示原理和输入设备机制都捋了一遍。说实话,光看理论容易犯困。我当年学的时候也是这样,看了三天文档,脑子里全是概念,手上一行代码没写。后来我换了个思路——先搭个能跑起来的工程,哪怕屏幕上只亮一个像素,也比看一百页文档强。

这一章,我们就来干这件事。目标很明确:在裸机环境下,把LVGL跑起来。不涉及RTOS,不涉及复杂的外设驱动,就是最纯粹的“裸奔”移植。

4.1 裸机移植到底在做什么?

说白了,LVGL的裸机移植就三件事:

  1. 把LVGL的源码放进你的工程里
  2. 告诉LVGL你的屏幕有多大、用什么颜色格式
  3. 给LVGL提供两个函数:一个画点,一个读时间

没了。真的就这么多。

你可能会问:“那触摸屏呢?按键呢?”别急,输入设备我们下一章单独讲。这一章先把显示搞定。屏幕能亮起来,你就成功了一半。

核心原则:裸机移植的本质是让LVGL的绘图引擎能往你的显存里写数据。至于这个显存是SRAM、SDRAM还是LCD控制器自带的,LVGL不关心。它只关心你给它的那个“画点”函数能不能把颜色填对位置。

4.2 创建工程模板

我个人习惯,每个项目都从零搭一个干净的模板。不依赖厂商的例程包,因为那些例程里经常塞了一堆用不上的东西。

4.2.1 工程目录结构

先建个文件夹,我一般这么组织:

lvgl_port_project/
├── lvgl/                  # LVGL源码
│   ├── src/               # 核心源码
│   ├── examples/          # 官方示例(可选)
│   └── lv_conf.h          # 配置文件
├── port/                  # 移植文件
│   ├── lv_port_disp.c     # 显示接口
│   └── lv_port_disp.h
├── driver/                # 硬件驱动
│   ├── lcd_driver.c
│   └── lcd_driver.h
├── main.c                 # 主函数
├── main.h
└── Makefile / .uvprojx    # 构建文件

这个结构很清晰:lvgl/ 放官方源码,不动它;port/ 放我们自己写的移植层;driver/ 放LCD、触摸等硬件驱动。各司其职,后期维护起来不头疼。

小技巧:我建议把 lv_conf.h 放在 lvgl/ 目录下,因为LVGL源码里默认用相对路径 #include "lv_conf.h" 来找它。如果你放别处,就得改源码或者加额外的包含路径,容易出问题。

4.2.2 主函数框架

主函数怎么写?先看个最简单的骨架:

#include "lvgl.h"
#include "lcd_driver.h"

int main(void)
{
    /* 1. 硬件初始化 */
    lcd_init();                 // 初始化LCD
    lcd_set_backlight(100);     // 开背光

    /* 2. LVGL初始化 */
    lv_init();

    /* 3. 显示驱动注册 */
    lv_port_disp_init();

    /* 4. 主循环 */
    while(1)
    {
        lv_timer_handler();     // LVGL心跳处理
        lv_tick_inc(5);         // 喂时间(5ms一次)
        delay_ms(5);            // 简单延时
    }
}

嗯,这里有个关键点:lv_tick_inc() 是给LVGL提供系统心跳的。LVGL内部所有动画、定时器都依赖这个函数。你喂的间隔越均匀,动画就越流畅。我在项目里一般用硬件定时器中断来调用它,精度更高。

4.3 添加LVGL源码

从GitHub把LVGL v8.3.x(我推荐这个版本,稳定)下载下来,解压。然后把 src/ 目录整个复制到你的工程里。

这里有个坑——文件太多了。LVGL的源码有几百个.c文件,如果你用Keil或者IAR,一个个加进去能加到手抽筋。我教你个办法:

  • Keil:在工程里建一个Group,然后把 lvgl/src/ 下的所有.c文件拖进去。Keil支持多选,按住Shift一次选完。
  • Makefile:用通配符 $(wildcard lvgl/src/**/*.c) 自动扫描。
  • CMake:用 file(GLOB_RECURSE) 命令。

注意:不要复制 lvgl/examples/ 目录下的文件,除非你确定要用。那些示例代码会额外占用大量Flash,而且很多初学者搞不清哪些是示例、哪些是核心,编译报错了都不知道去哪找。

4.4 配置lv_conf.h——这是最关键的一步

LVGL的灵活性,很大程度上体现在 lv_conf.h 这个配置文件上。它就像一个总开关面板,你想开什么功能、用什么颜色深度、分配多少内存,都在这里设。

4.4.1 找到并启用配置文件

在 lvgl/ 目录下,有一个 lv_conf_template.h。把它复制一份,重命名为 lv_conf.h。然后打开文件,找到第一行:

#if 0 /* 把这个改成 1 来启用配置 */

改成:

#if 1 /* 已启用 */

这一步不做,LVGL会全部用默认值,而默认值通常不适合你的硬件。

4.4.2 核心配置项

下面这几个配置,是裸机移植必须调的。我列个表,你对着改就行:

配置宏 推荐值 说明
LV_COLOR_DEPTH 16 颜色深度。大部分RGB565屏幕用16位,RGB888用32位
LV_COLOR_16_SWAP 1 如果你的MCU是大端模式,或者LCD控制器要求字节交换,就开这个
LV_MEM_SIZE (32U * 1024U) LVGL动态内存池大小。裸机至少32KB,复杂UI建议64KB以上
LV_HOR_RES_MAX 320 屏幕水平分辨率,改成你实际的值
LV_VER_RES_MAX 240 屏幕垂直分辨率
LV_DPI_DEF 130 每英寸像素数,影响触摸校准和控件大小。拿不准就设130
LV_TICK_CUSTOM 0 裸机下关掉,我们自己手动调用 lv_tick_inc()

关于LV_MEM_SIZE:这个值不是越大越好。LVGL的内存池是在编译时静态分配的,你设64KB,它真的就吃掉64KB RAM。如果你的MCU只有128KB RAM,设个32KB比较稳妥。我在一个STM32F103项目里,32KB跑一个带仪表盘和列表的UI,完全够用。

4.4.3 关闭不需要的功能

LVGL默认开了很多功能,比如动画、抗锯齿、主题、字体等等。裸机移植阶段,我建议全部关掉,只保留最核心的绘图引擎。等跑起来了,再一个一个打开。

把下面这些宏设为 0:

  • LV_USE_ANIMATION —— 动画
  • LV_USE_SHADOW —— 阴影
  • LV_USE_ANTIALIAS —— 抗锯齿
  • LV_USE_THEME_DEFAULT —— 默认主题
  • LV_FONT_MONTSERRAT_* —— 所有内置字体,只留一个 LV_FONT_MONTSERRAT_12 用于调试

这样做的好处是:编译速度快、占用Flash小、内存开销低。等你把“Hello World”显示出来之后,再慢慢加功能,心里有底。

4.5 显示接口实现——lv_port_disp.c

这是移植的核心文件。LVGL通过这个文件里的三个回调函数,把绘图数据送到你的屏幕上。

4.5.1 注册显示驱动

在 lv_port_disp_init() 函数里,我们要做这几件事:

void lv_port_disp_init(void)
{
    /* 1. 创建一个显示缓冲区 */
    static lv_disp_draw_buf_t draw_buf;
    static lv_color_t buf_1[LV_HOR_RES_MAX * 40];  // 一行40像素高
    lv_disp_draw_buf_init(&draw_buf, buf_1, NULL, LV_HOR_RES_MAX * 40);

    /* 2. 注册显示驱动 */
    static lv_disp_drv_t disp_drv;
    lv_disp_drv_init(&disp_drv);
    disp_drv.draw_buf = &draw_buf;
    disp_drv.flush_cb = my_disp_flush;   // 刷新回调
    disp_drv.hor_res = LV_HOR_RES_MAX;
    disp_drv.ver_res = LV_VER_RES_MAX;

    /* 3. 注册到LVGL */
    lv_disp_drv_register(&disp_drv);
}

这里有个细节:缓冲区大小。我用了 LV_HOR_RES_MAX * 40,也就是一行40像素高。这个值可以根据你的RAM调整。缓冲区越大,刷新效率越高,但占RAM也多。10行到40行是比较常见的范围。

4.5.2 实现刷新回调

my_disp_flush 函数是真正往LCD写像素的地方:

static void my_disp_flush(lv_disp_drv_t * disp_drv, const lv_area_t * area, lv_color_t * color_p)
{
    int32_t x, y;
    for(y = area->y1; y <= area->y2; y++) {
        for(x = area->x1; x <= area->x2; x++) {
            lcd_draw_pixel(x, y, color_p->full);
            color_p++;
        }
    }
    lv_disp_flush_ready(disp_drv);  // 通知LVGL刷新完成
}

注意最后一行 lv_disp_flush_ready(),这个必须调用。否则LVGL会认为刷新还没完成,一直等着,整个UI就卡死了。我曾经在这个坑里爬了整整一个下午,最后发现是忘了加这一句。

4.6 整体流程梳理

说了这么多,咱们用一张图把整个流程串起来:

LVGL裸机移植流程 LVGL裸机移植核心流程 1. 创建工程模板 2. 添加LVGL源码 3. 配置lv_conf.h 4. 实现显示接口 5. 编写主循环 创建缓冲区 注册驱动结构体 实现flush回调 调用flush_ready ✅ 编译 → 下载 → 屏幕点亮 💡 裸机移植不涉及RTOS,主循环中只需调用 lv_timer_handler() 和 lv_tick_inc() 显示接口是唯一必须实现的硬件相关代码

4.7 验证与调试

代码写完了,编译通过,下载到板子上。然后呢?

屏幕上应该还是黑的。别慌,LVGL默认不画任何东西。你需要在主循环里加一行测试代码:

/* 创建一个标签,显示 "Hello LVGL!" */
lv_obj_t * label = lv_label_create(lv_scr_act());
lv_label_set_text(label, "Hello LVGL!");
lv_obj_center(label);

把这行代码放在 lv_port_disp_init() 之后、主循环之前。重新编译下载,如果屏幕上出现了文字,恭喜你——移植成功了。

常见问题:

  • 屏幕白屏或花屏:检查 LV_COLOR_DEPTH 和 LV_COLOR_16_SWAP 是否匹配你的LCD。我遇到过一块屏幕,明明是RGB565,但必须开字节交换才能正常显示。
  • 编译报错,提示 undefined reference:多半是 lv_port_disp.c 没加到编译列表里,或者 lv_conf.h 没被找到。
  • 屏幕有显示但卡死:检查 lv_disp_flush_ready() 是否在flush回调末尾调用了。没调的话,LVGL会一直等待。

4.8 小结

这一章我们走完了裸机移植的完整流程。从创建工程目录,到添加源码,再到配置 lv_conf.h 和实现显示接口,每一步我都把当年踩过的坑指给你看了。

说实话,第一次移植成功时,屏幕上那个小小的“Hello LVGL!”让我兴奋了好一阵。虽然现在看来那只是个开始,但那种“我能控制每一个像素”的感觉,真的很棒。