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,画图、刷新都用它。
流程是这样的:
- LVGL 往 draw buffer 里画内容。
- 画完后调用
flush_cb,把 buffer 数据发送到屏幕。 - 在数据发送期间,LVGL 必须等待
flush_ready被调用,才能开始画下一帧。
你想想看,如果屏幕刷新速度慢(比如 SPI 接口的屏幕,刷新一帧要 20ms),那 LVGL 这 20ms 就啥也干不了,CPU 空转。帧率直接掉到 50fps 以下。
避坑指南:单缓冲模式下,千万不要在 flush_cb 里用阻塞式延时等待传输完成。我曾经在一个项目里用 HAL_Delay() 等 SPI 传输结束,结果整个 UI 卡成幻灯片。后来改成 DMA + 中断才解决。
3.3 双缓冲模式:流畅的秘诀
双缓冲就聪明多了。LVGL 分配两个 draw buffer:buffer1 和 buffer2。一个用于渲染,一个用于刷新,交替使用。
流程变成:
- LVGL 往 buffer1 里画内容。
- 画完后,
flush_cb开始把 buffer1 的数据发送到屏幕。 - 同时,LVGL 立刻开始往 buffer2 里画下一帧。
- 等 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 渲染和刷新的并行流程:
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。根据你的硬件资源,选一个合适的组合就好。