LVGL基础框架与显示驱动移植

LVGL的移植,说白了就是让这个图形库能在你的硬件上跑起来。我刚开始接触LVGL时,以为移植就是改几个宏定义,结果折腾了两天才把画面点亮。今天咱们就把这块彻底讲透。

LVGL架构概览

先看看LVGL的整体结构。它分三层:

  • 应用层:你写的UI代码,按钮、标签、动画都在这里
  • 核心层:对象管理、事件分发、绘图引擎
  • 硬件抽象层:显示驱动、输入设备驱动、内存管理

移植工作主要集中在下两层。核心层基本不用动,除非你要改内存分配策略。

关键点:LVGL的硬件抽象层设计得很干净。你只需要实现几个回调函数,剩下的框架帮你搞定。

LVGL 三层架构 应用层 你的UI代码:按钮、标签、列表、动画 核心层 对象管理 · 事件分发 · 绘图引擎 样式系统 · 动画框架 · 字体渲染 硬件抽象层 显示驱动 · 输入设备驱动 · 内存管理 文件系统接口 · 系统时钟 移植工作集中在硬件抽象层,核心层和应用层基本不动

显示缓冲区配置

显示缓冲区是LVGL性能的关键。配置不对,画面要么撕裂,要么卡顿。

LVGL支持三种缓冲区模式:

模式 缓冲区大小 适用场景 性能
单缓冲区 1 × 屏幕像素 低端MCU,内存紧张 低,可能撕裂
部分缓冲区 1/10 ~ 1/2 屏幕 大多数嵌入式设备 中等
双缓冲区 2 × 屏幕像素 高性能设备,需要防撕裂 高

我的建议:如果内存够用,优先用双缓冲区。我在一个项目里用了部分缓冲区,结果滑动列表时总有残影,换成双缓冲区后问题立刻消失。

配置代码长这样:

/* 单缓冲区配置 */
static lv_disp_draw_buf_t draw_buf;
static lv_color_t buf[LV_HOR_RES_MAX * LV_VER_RES_MAX];
lv_disp_draw_buf_init(&draw_buf, buf, NULL, LV_HOR_RES_MAX * LV_VER_RES_MAX);

/* 双缓冲区配置 */
static lv_color_t buf1[LV_HOR_RES_MAX * LV_VER_RES_MAX];
static lv_color_t buf2[LV_HOR_RES_MAX * LV_VER_RES_MAX];
lv_disp_draw_buf_init(&draw_buf, buf1, buf2, LV_HOR_RES_MAX * LV_VER_RES_MAX);

注意第二个参数是buf1,第三个是buf2。双缓冲区时两个都要传,单缓冲区时第二个传NULL。

Framebuffer 与 Double Buffer

这两个概念容易搞混。我简单解释一下:

  • Framebuffer:硬件层面的概念,是显存中一块固定区域,LCD控制器直接从这里读取数据刷新屏幕
  • Double Buffer:软件层面的技术,用两个缓冲区交替绘制和显示

在实际项目中,它们经常配合使用。比如:

  1. LVGL往buf1里画图
  2. 画完后,把buf1的内容拷贝到Framebuffer
  3. 同时LVGL开始往buf2里画下一帧
  4. 如此循环

注意:如果Framebuffer和LVGL的缓冲区是同一块内存,要小心同步问题。我曾经在STM32上遇到过,LVGL正在写缓冲区时LCD控制器突然读取,结果画面出现撕裂。解决办法是用双缓冲区,或者等垂直消隐期再切换。

Flush回调函数实现

flush回调是移植的核心。LVGL画完一帧后,会调用这个函数把数据送到显示器。

标准实现模板:

static void my_flush_cb(lv_disp_drv_t * disp_drv, 
                        const lv_area_t * area, 
                        lv_color_t * color_p)
{
    /* 获取刷新区域 */
    int32_t x1 = area->x1;
    int32_t y1 = area->y1;
    int32_t x2 = area->x2;
    int32_t y2 = area->y2;
    
    /* 计算像素数量 */
    int32_t w = x2 - x1 + 1;
    int32_t h = y2 - y1 + 1;
    
    /* 调用底层驱动,把color_p数据写到LCD */
    lcd_draw_bitmap(x1, y1, w, h, (uint16_t *)color_p);
    
    /* 通知LVGL刷新完成 */
    lv_disp_flush_ready(disp_drv);
}

这里有几个坑:

  • 一定要调用lv_disp_flush_ready,否则LVGL会一直等
  • 如果用了DMA传输,要在DMA完成中断里调用lv_disp_flush_ready
  • 颜色格式要匹配,LVGL默认是RGB565,如果你的LCD是RGB888需要转换

性能优化技巧:如果LCD支持窗口模式,尽量只刷新脏区域。我见过有人每次都刷新全屏,结果60fps的屏只能跑到20fps。改成局部刷新后直接拉满。

带DMA的flush实现:

static void my_flush_cb(lv_disp_drv_t * disp_drv, 
                        const lv_area_t * area, 
                        lv_color_t * color_p)
{
    /* 设置LCD窗口 */
    lcd_set_window(area->x1, area->y1, area->x2, area->y2);
    
    /* 启动DMA传输 */
    dma_start((uint32_t)color_p, LCD_DATA_ADDR, 
              (area->x2 - area->x1 + 1) * (area->y2 - area->y1 + 1) * 2);
    
    /* DMA传输完成后在中断中调用 lv_disp_flush_ready */
}

void DMA_IRQHandler(void)
{
    if(dma_is_transfer_complete()) {
        lv_disp_flush_ready(&disp_drv);
    }
}

嗯,这里要注意:DMA中断里调用lv_disp_flush_ready时,要确保中断优先级别太高,否则可能影响LVGL的定时器。

最后,注册显示驱动的代码:

static lv_disp_drv_t disp_drv;
lv_disp_drv_init(&disp_drv);
disp_drv.draw_buf = &draw_buf;
disp_drv.flush_cb = my_flush_cb;
disp_drv.hor_res = 320;
disp_drv.ver_res = 240;
lv_disp_drv_register(&disp_drv);

到这里,显示驱动就移植完成了。你想想看,其实就三步:配缓冲区、写flush回调、注册驱动。但每一步都有细节,踩过坑才能记得牢。