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 的刷新流程里,它们配合得相当紧密。我画了一张图,帮你理清关系:
流程其实很清晰:
- LVGL 计算出需要刷新的脏区域
- 调用
rounder_cb对齐区域边界 - 遍历区域内每个像素,调用
set_px_cb写入缓冲区 - 缓冲区填满后,通过
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 卡死。