4. rounder_cb 与 set_px_cb:像素级操作回调

这一章我们聊点底层的。说白了,就是 LVGL 怎么把一个个像素点画到屏幕上。

你可能会想:「不就是画点吗?驱动层写个 draw_pixel 不就行了?」嗯,没那么简单。尤其是在低内存设备上,每一字节都金贵。LVGL 给了我们两个回调接口——rounder_cb 和 set_px_cb,让你能在像素级别上做文章。

我个人习惯把这两个回调称为「显示驱动的最后一道防线」。为什么?因为它们直接决定了渲染引擎怎么跟你的显示缓冲区打交道。

4.1 rounder_cb:对齐的艺术

先看 rounder_cb。这个回调的名字有点绕,其实它的作用很简单:告诉 LVGL,你的显示缓冲区在 X 和 Y 方向上的对齐粒度是多少。

举个例子。我早年做过一个项目,用的是一块 320x240 的 ST7789 屏,但显存只有 64KB。为了省内存,我把缓冲区宽度设成了 320,但高度只开了 40 行——也就是所谓的「部分缓冲区」。这时候问题来了:LVGL 默认是按全屏刷新的,它不知道你的缓冲区只有 40 行高。

rounder_cb 就是用来解决这个问题的。它会在每次刷新前被调用,把 LVGL 要刷新的区域「对齐」到你的缓冲区边界上。

核心作用:rounder_cb 让 LVGL 的刷新区域与你的显示缓冲区布局对齐,避免越界访问。

来看一个典型的实现:

static void my_rounder_cb(lv_disp_drv_t * disp_drv, lv_area_t * area)
{
    // 假设缓冲区高度只有 40 行
    // 把刷新区域的高度对齐到 40 的整数倍
    uint16_t buf_h = 40;
    
    // 对齐起始 Y
    area->y1 = (area->y1 / buf_h) * buf_h;
    // 对齐结束 Y
    area->y2 = (area->y2 / buf_h + 1) * buf_h - 1;
    
    // 别忘了边界检查
    if(area->y2 >= disp_drv->ver_res) {
        area->y2 = disp_drv->ver_res - 1;
    }
}

这段代码做了什么?它把 LVGL 要刷新的 Y 范围,强制对齐到 40 的整数倍。这样每次刷新都正好落在缓冲区覆盖的行范围内。

我的经验:rounder_cb 里一定要做边界检查。我曾经忘了加,结果刷新区域越界,把缓冲区后面的内存给踩了——那个 bug 查了我整整两天。

4.2 set_px_cb:像素级的精细控制

如果说 rounder_cb 是「宏观对齐」,那 set_px_cb 就是「微观绘制」。它让你能控制每一个像素怎么写到缓冲区里。

为什么需要这个?因为不是所有显示设备都支持标准的 RGB565 或 RGB888 格式。我遇到过一些奇葩的屏,像素格式是 BGR 排列的,还有的是 3 位灰度屏。这时候 set_px_cb 就派上用场了。

它的原型长这样:

static void my_set_px_cb(lv_disp_drv_t * disp_drv, 
                         uint8_t * buf, lv_coord_t buf_w, 
                         lv_coord_t x, lv_coord_t y,
                         lv_color_t color, lv_opa_t opa)

参数看着多,其实就几件事:

  • buf:当前要写入的缓冲区指针
  • buf_w:缓冲区的宽度(像素数)
  • x, y:像素坐标(相对于缓冲区左上角)
  • color:LVGL 传过来的颜色值
  • opa:透明度(0-255)

一个典型的 BGR 格式转换实现:

static void my_set_px_cb(lv_disp_drv_t * disp_drv, 
                         uint8_t * buf, lv_coord_t buf_w, 
                         lv_coord_t x, lv_coord_t y,
                         lv_color_t color, lv_opa_t opa)
{
    // 计算像素在缓冲区中的偏移
    uint32_t offset = (y * buf_w + x) * 2;  // 每个像素 2 字节
    
    // 提取 RGB 分量
    uint8_t r = color.ch.red;
    uint8_t g = color.ch.green;
    uint8_t b = color.ch.blue;
    
    // 转换为 BGR 格式(低字节在前)
    buf[offset] = (b >> 3) | ((g & 0x07) << 5);
    buf[offset + 1] = (g >> 3) | ((r & 0x1F) << 3);
}

注意:set_px_cb 会被频繁调用(每帧可能数万次),所以一定要优化。避免除法、浮点运算,能用移位就别用乘除。我见过有人在里面用 malloc——那画面太美我不敢看。

4.3 两个回调的协作流程

这两个回调不是孤立的。LVGL 的刷新流程里,它们配合得相当紧密。我画了一张图,帮你理清关系:

rounder_cb 与 set_px_cb 协作流程 LVGL 渲染引擎 计算脏区域 (dirty area) rounder_cb:对齐区域到缓冲区边界 逐像素绘制:调用 set_px_cb 写入显示缓冲区 / 发送到屏幕 rounder_cb 确保区域对齐 → set_px_cb 确保像素格式正确 → 两者配合实现低内存适配

流程其实很清晰:

  1. LVGL 计算出需要刷新的脏区域
  2. 调用 rounder_cb 对齐区域边界
  3. 遍历区域内每个像素,调用 set_px_cb 写入缓冲区
  4. 缓冲区填满后,通过 flush_cb 发送到屏幕

4.4 低内存设备的适配策略

说到低内存设备,我踩过的坑可不少。最常见的情况是:MCU 只有 32KB RAM,但你要驱动一块 320x240 的屏。全屏缓冲区需要 320x240x2 = 150KB,显然不够。

这时候 rounder_cb 和 set_px_cb 的组合拳就派上用场了。我总结了几种常用策略:

策略 缓冲区大小 rounder_cb 对齐方式 set_px_cb 优化点
水平条带 宽度×N行(N=10~40) Y 方向对齐到 N 无特殊处理
垂直条带 M列×全高(M=40~80) X 方向对齐到 M 注意列偏移计算
像素格式压缩 全屏但每像素1字节 无需对齐 RGB565→RGB332 转换
双缓冲乒乓 两个小缓冲区交替 Y 方向对齐到缓冲区高度 切换缓冲区时清空

我个人最常用的是「水平条带 + 像素格式压缩」的组合。举个例子:

// 配置显示驱动
lv_disp_drv_t disp_drv;
lv_disp_drv_init(&disp_drv);

// 缓冲区:320x20,共 6400 字节
static lv_color_t buf1[320 * 20];
lv_disp_buf_t disp_buf;
lv_disp_buf_init(&disp_buf, buf1, NULL, 320 * 20);

disp_drv.buffer = &disp_buf;
disp_drv.rounder_cb = my_rounder_cb;  // 对齐到 20 行
disp_drv.set_px_cb = my_set_px_cb;    // BGR 格式转换
disp_drv.flush_cb = my_flush_cb;

lv_disp_drv_register(&disp_drv);

避坑指南:设置 rounder_cb 后,一定要把 disp_drv->full_refresh 设为 0。否则 LVGL 会强制全屏刷新,你的对齐就白做了。

4.5 性能优化要点

最后聊点性能相关的。这两个回调是高频调用路径,优化空间很大:

  • 减少函数调用开销:如果不需要格式转换,直接把 set_px_cb 设为 NULL,LVGL 会用默认的快速路径
  • 批量写入:在 set_px_cb 里尽量用 16 位或 32 位写入,别一个字节一个字节地写
  • 缓存行对齐:如果 MCU 有缓存,让缓冲区地址对齐到缓存行大小(通常是 32 字节)
  • 避免分支预测失败:set_px_cb 里不要有复杂的 if-else 链,能用查表就用查表

我记得有一次优化一个 8 位 MCU 上的 LVGL 项目,set_px_cb 里有个 switch-case 判断像素格式。我把那个 switch 换成了查表,帧率直接从 12fps 提到了 28fps。你想想看,就改了一行代码。

重要提醒:不要在 set_px_cb 里调用任何可能阻塞的函数,比如延时、打印、互斥锁。这个函数是在 LVGL 的渲染线程里调用的,阻塞它会导致整个 UI 卡死。