3、flush_cb回调函数实现:刷新回调的原理、双缓冲与单缓冲模式、DMA加速技巧

好,咱们今天来聊聊 LVGL 里最核心的一个回调——flush_cb。说白了,它就是 LVGL 把画好的内容“扔”到屏幕上的最后一步。很多初学者觉得这玩意儿就是调个 memcpy 把显存拷贝过去,其实没那么简单。我早期做项目时就在这上面栽过跟头,画面撕裂、闪烁、卡顿,全跟它有关。

3.1 flush_cb 到底在干什么?

LVGL 是一个“软件渲染”的 GUI 框架。它先在内存里把按钮、文字、图标全部画好,画到一个叫 draw buffer 的区域。画完之后,它调用你注册的 flush_cb,说:“哥们儿,我把数据准备好了,你帮我送到屏幕去吧。”

这个回调的原型长这样:

static void my_flush_cb(lv_disp_drv_t * disp_drv, const lv_area_t * area, lv_color_t * color_p)
{
    // 将 color_p 指向的像素数据,刷新到 area 指定的屏幕区域
    // 刷新完成后,必须调用 lv_disp_flush_ready(disp_drv)
}

三个参数:disp_drv 是显示驱动结构体,area 告诉你要刷新的矩形区域(x1,y1,x2,y2),color_p 就是像素数据的首地址。

核心要点:flush_cb 执行完后,必须调用 lv_disp_flush_ready()。否则 LVGL 会认为刷新还没完成,不会继续渲染下一帧。我见过有人忘了这行代码,结果屏幕一直黑着,查了半天。

3.2 单缓冲模式:简单但危险

先说说最简单的模式——单缓冲。LVGL 只分配一个 draw buffer,画图、刷新都用它。

流程是这样的:

  1. LVGL 往 draw buffer 里画内容。
  2. 画完后调用 flush_cb,把 buffer 数据发送到屏幕。
  3. 在数据发送期间,LVGL 必须等待 flush_ready 被调用,才能开始画下一帧。

你想想看,如果屏幕刷新速度慢(比如 SPI 接口的屏幕,刷新一帧要 20ms),那 LVGL 这 20ms 就啥也干不了,CPU 空转。帧率直接掉到 50fps 以下。

避坑指南:单缓冲模式下,千万不要在 flush_cb 里用阻塞式延时等待传输完成。我曾经在一个项目里用 HAL_Delay() 等 SPI 传输结束,结果整个 UI 卡成幻灯片。后来改成 DMA + 中断才解决。

3.3 双缓冲模式:流畅的秘诀

双缓冲就聪明多了。LVGL 分配两个 draw buffer:buffer1 和 buffer2。一个用于渲染,一个用于刷新,交替使用。

流程变成:

  1. LVGL 往 buffer1 里画内容。
  2. 画完后,flush_cb 开始把 buffer1 的数据发送到屏幕。
  3. 同时,LVGL 立刻开始往 buffer2 里画下一帧。
  4. 等 buffer2 画完,buffer1 也发送完了,两者交换角色。

这样一来,CPU 和屏幕传输可以并行工作。帧率能翻倍,甚至更高。

模式 内存占用 帧率 CPU 利用率 适用场景
单缓冲 低(1个 buffer) 受限于传输速度 高(等待传输) 低分辨率、简单 UI
双缓冲 高(2个 buffer) 接近理论上限 低(并行工作) 高帧率、复杂动画

配置双缓冲很简单,在 lv_disp_drv_init() 之后,设置 disp_drv->draw_buf 时传入两个 buffer 即可:

static lv_disp_draw_buf_t draw_buf;
static lv_color_t buf1[MY_DISP_HOR_RES * 100];
static lv_color_t buf2[MY_DISP_HOR_RES * 100];

lv_disp_draw_buf_init(&draw_buf, buf1, buf2, MY_DISP_HOR_RES * 100);

注意第三个参数是第二个 buffer,如果传 NULL 就是单缓冲。

3.4 DMA 加速:让传输飞起来

好了,双缓冲解决了“等待”的问题,但传输本身还是占用 CPU 时间。如果屏幕接口是 SPI 或并行总线,每次 flush_cb 都要 CPU 一个个字节地搬数据,那 CPU 还是被拖累。

这时候就该 DMA 出场了。DMA 可以在后台自动搬运数据,CPU 可以继续做渲染或其他任务。

我个人的习惯是:在 flush_cb 里启动 DMA 传输,然后立即返回。等 DMA 传输完成中断里调用 lv_disp_flush_ready()。

代码骨架大概这样:

static void my_flush_cb(lv_disp_drv_t * disp_drv, const lv_area_t * area, lv_color_t * color_p)
{
    // 设置屏幕窗口区域(根据 area 设置)
    set_window(area->x1, area->y1, area->x2, area->y2);

    // 启动 DMA 传输
    // 参数:color_p 是源地址,屏幕数据寄存器是目标地址,数据长度是像素数 * 字节数
    HAL_DMA_Start(&hdma_spi_tx, (uint32_t)color_p, (uint32_t)&SPI->DR, 
                  lv_area_get_width(area) * lv_area_get_height(area) * sizeof(lv_color_t));

    // 注意:这里不调用 lv_disp_flush_ready()
    // 等 DMA 传输完成中断里再调用
}

// DMA 传输完成中断回调
void HAL_DMA_TC_Callback(DMA_HandleTypeDef * hdma)
{
    if(hdma->Instance == hdma_spi_tx.Instance) {
        lv_disp_flush_ready(&disp_drv);  // 告诉 LVGL 刷新完成
    }
}

小技巧:有些 MCU 支持 DMA 的“循环模式”或“链表模式”,可以提前配置好多个传输任务。但 LVGL 的 flush_cb 每次传入的 color_p 地址和 area 都可能变化,所以一般用单次传输模式就够了。

3.5 三种模式对比与选择

我把三种模式放在一起对比一下,方便你选型:

模式 内存开销 帧率表现 CPU 负载 实现复杂度
单缓冲(无 DMA) 低 差 高 低
双缓冲(无 DMA) 中 中 中 中
双缓冲 + DMA 中 优 低 高

我的建议是:

  • 如果屏幕分辨率低(比如 128x64),或者 UI 很简单,单缓冲就够了。
  • 如果要做动画、滑动、流畅的触摸交互,至少上双缓冲。
  • 如果 MCU 有 DMA 控制器,别犹豫,直接用双缓冲 + DMA。帧率提升非常明显。

3.6 核心流程图

下面这张图展示了双缓冲 + DMA 模式下,LVGL 渲染和刷新的并行流程:

双缓冲 + DMA 刷新流程图 LVGL 渲染到 Buffer A flush_cb 启动 DMA 传输 Buffer A DMA 后台传输(CPU 空闲) DMA 中断 → flush_ready() LVGL 同时渲染到 Buffer B Buffer B 渲染完成,等待传输 交换角色:传输 Buffer B,渲染 Buffer A 关键点:DMA 传输 Buffer A 的同时,CPU 可以渲染 Buffer B 两者互不干扰,帧率由传输速度和渲染速度中较慢者决定

3.7 一些实战经验

最后分享几个我踩过的坑:

  • DMA 缓存一致性问题:有些 MCU(比如 STM32F7/H7 系列)有 Cache,DMA 传输的数据可能和 Cache 里的数据不一致。记得在启动 DMA 前调用 SCB_CleanDCache() 或配置 MPU 关闭对应区域的 Cache。
  • 中断优先级:DMA 传输完成中断的优先级要设置得比 LVGL 的 tick 中断高,否则可能导致 flush_ready 被延迟,影响帧率。
  • buffer 大小:LVGL 的 draw buffer 不一定要覆盖全屏。可以只分配几行的高度,LVGL 会分块渲染。这样内存占用小,但刷新次数会增加。我一般分配屏幕高度 1/10 到 1/5 的 buffer。

嗯,关于 flush_cb 的实现,核心就是这些。记住:单缓冲保底,双缓冲提帧,DMA 解放 CPU。根据你的硬件资源,选一个合适的组合就好。