工欲善其事,必先利其器:显示、输入与渲染缓冲,是 LVGL 引擎室里三只最重要的阀门
核心方法论:工欲善其事,必先利其器
「上一章我们跑起了第一个界面,但那套代码里藏着三个你还没拆开看的'黑箱':像素先在渲染缓冲里画好、再由 flush 回调搬上屏幕、触摸通过 indev 回调变成数据。工具要趁手,就得先弄清每一段链条怎么转。这一章,我们把引擎室的门打开,一个一个阀门拧给你看。」
LVGL 从来不是"直接往屏幕上画"。它先在内存里的一块渲染缓冲(render buffer)中把界面画好,再一次性把这块缓冲交给屏幕。为什么绕这一圈?因为 MCU 写内存比写 LCD 控制器快得多,而且"先画缓冲、再整体刷屏"天然避免了闪烁。缓冲划多大、怎么和屏幕对齐,就是本章的核心——也就是 lv_display_set_buffers() 的第四个参数:渲染模式。
渲染模式是 lv_display_render_mode_t 枚举,定义在 lvgl-master/include/lvgl/display/lv_display.h 里。三种模式的差别可以用一句话概括:缓冲多大、每次重绘多少。LV_DISPLAY_RENDER_MODE_PARTIAL 用"至少 1/10 屏幕"的小缓冲,一次只渲染一小块区域再拼到屏幕;LV_DISPLAY_RENDER_MODE_DIRECT 需要整屏大小的缓冲,LVGL 始终在正确的位置作画,但只有发生变化的部分会被更新;LV_DISPLAY_RENDER_MODE_FULL 同样要整屏缓冲,但哪怕只改了一个像素,也把整屏重新画一遍。
/* include/lvgl/display/lv_display.h(节选) */
typedef enum {
LV_DISPLAY_RENDER_MODE_PARTIAL, /* 小缓冲,分区域渲染(推荐至少 1/10 屏) */
LV_DISPLAY_RENDER_MODE_DIRECT, /* 整屏缓冲,只更新变化区域 */
LV_DISPLAY_RENDER_MODE_FULL, /* 整屏缓冲,每次都全屏重绘 */
} lv_display_render_mode_t;
选哪种模式,本质是拿 RAM 换刷新效率。同样的 320×240、16 位色深屏幕:partial 模式 320 * 240 / 10 * 2 = 15360 字节就够;direct/full 模式则要 320 * 240 * 2 = 153600 字节——整整差 10 倍。所以小 RAM 的 MCU 几乎都选 partial,带外部 RAM 的中端芯片才有底气上 direct。注意 lv_display_set_buffers() 还接受第二个缓冲 buf2:传入两块缓冲即为双缓冲,渲染与刷屏可以并行流水,是消除撕裂与卡顿的常用手段。
/* partial:一块 1/10 屏的缓冲就够(16 位色深,每像素 2 字节) */
static uint8_t buf[TFT_HOR_RES * TFT_VER_RES / 10 * 2];
lv_display_set_buffers(disp, buf, NULL, sizeof(buf),
LV_DISPLAY_RENDER_MODE_PARTIAL);
/* full / direct:需要整屏缓冲,可给两块做双缓冲 */
static uint8_t buf1[TFT_HOR_RES * TFT_VER_RES * 2];
static uint8_t buf2[TFT_HOR_RES * TFT_VER_RES * 2];
lv_display_set_buffers(disp, buf1, buf2, sizeof(buf1),
LV_DISPLAY_RENDER_MODE_FULL);
| 渲染模式 | 缓冲要求 | 刷新行为 | 典型场景 |
|---|---|---|---|
PARTIAL | 至少 1/10 屏 | 分小块渲染后搬上屏幕 | RAM 紧张的小 MCU |
DIRECT | 整屏大小 | 只在正确位置作画,仅更新变化区域 | 有足够 RAM 的中端 MCU |
FULL | 整屏大小 | 无论改多少都全屏重绘 | 重绘开销可忽略,或双缓冲换地址 |
缓冲字节数 = 宽 × 高 ×(LV_COLOR_DEPTH / 8)×(1 或 1/10)。16 位色深每像素 2 字节,32 位是 4 字节;partial 模式先按 1/10 屏估,内存够用再往上加。先跑通,再谈性能——这是移植屏幕的通用顺序。
渲染缓冲只是 LVGL 的"画板",真正把像素搬进屏幕(帧缓冲或 LCD 控制器)的是你注册的 flush 回调。lv_display_set_flush_cb(disp, cb) 负责注册它,回调签名是固定的:void (*)(lv_display_t * disp, const lv_area_t * area, uint8_t * px_map)。每当 LVGL 渲染完一块区域,就会带着这块区域的坐标 area 和像素数据 px_map 来调用你的回调——area 告诉你"画哪",px_map 告诉你"画什么"。
回调里你必须做两件事:把 px_map 的数据写到 area->x1..x2, y1..y2 对应的屏幕坐标(具体取决于你的 LCD 驱动),然后务必调用 lv_display_flush_ready(disp) 通知 LVGL"这块刷完了"。漏掉这一句,LVGL 会一直以为上一块还没刷完,界面要么卡住不动、要么闪烁——这是新手移植屏幕遇到的最常见"白屏/花屏"原因之一。README「Porting manually」里的最小 flush 回调就是这样。
/* README「Porting manually」的 flush 回调(节选) */
static void my_flush_cb(lv_display_t * disp, const lv_area_t * area,
uint8_t * px_map)
{
/* 把 px_map 写到屏幕 area->x1..x2, y1..y2 对应的位置 */
lv_display_flush_ready(disp); /* 通知 LVGL 本区域已刷完 */
}
如果你用双缓冲,flush 还能进一步简化。在 direct/full 模式下,LVGL 始终把整幅画面渲染进某块缓冲,刷屏只是"通知硬件换一块地址"——README 说的"在 flush_cb 里只需要换地址"就是这个意思。若你的屏幕硬件有 DMA,你还可以注册 lv_display_set_flush_wait_cb() 作为"同步闸门":等 DMA 真正把数据搬完再让 LVGL 复用这块缓冲,避免画到一半被覆盖。整套 flush 相关的 API 就四五个,职责非常清晰。
/* 双缓冲 + DMA 的 flush:地址切换 + 可选同步闸门 */
static void my_flush_cb(lv_display_t * disp, const lv_area_t * area,
uint8_t * px_map)
{
/* LCD 控制器通常只需切换 RAM 地址即可显示新缓冲 */
set_lcd_frame_address(px_map);
lv_display_flush_ready(disp); /* 立即回执:下次渲染交给另一块缓冲 */
}
/* 若担心 DMA 还没搬完:注册 flush_wait 回调等待传输完成 */
static void my_flush_wait_cb(lv_display_t * disp)
{
/* 阻塞直到上一次 DMA 传输结束,再让 LVGL 复用缓冲 */
}
lv_display_set_flush_cb(disp, my_flush_cb);
lv_display_set_flush_wait_cb(disp, my_flush_wait_cb);
| API | 职责 |
|---|---|
lv_display_set_flush_cb() | 注册"把缓冲写入屏幕"的回调 |
lv_display_flush_ready() | 通知 LVGL 当前区域已刷完,可继续渲染下一块 |
lv_display_set_flush_wait_cb() | (可选)同步闸门:等硬件/DMA 搬完再复用缓冲 |
lv_display_set_buffers() | 绑定渲染缓冲(buf1 + 可选 buf2 双缓冲)与渲染模式 |
判断"是不是 flush 的问题"有个捷径:把 my_flush_cb 里临时写死成"把整块 px_map 填成纯色",如果界面能正常刷新颜色、只是内容不对,那问题在渲染侧;如果连颜色都不动,问题基本就在 flush 或缓冲配置上。
动画要算时间、长按要计时、闪烁要定时——LVGL 需要知道"现在是第几毫秒",这个来源就是 tick。在 v9 中 tick 被独立成 lvgl-master/src/tick/ 子系统,核心 API 只有三个:lv_tick_set_cb() 注册一个"返回当前毫秒数"的函数,lv_tick_get() 随时读取它,lv_tick_inc() 则是裸机环境下手动累加的入口。README 里 lv_tick_set_cb(my_tick_cb) 正是第一步——没有它,动画和计时器全部停摆。
tick 回调的签名是 uint32_t (*)(void),返回系统毫秒数即可。不同平台的接法不同:裸机上,如果 OS 提供毫秒时钟(如 SysTick),就直接把读取函数传进去;如果只有周期性中断,就在中断里每 1ms 调一次 lv_tick_inc(1);Linux 或 SDL 模拟器则直接接系统时钟——2.3 节里 SDL 驱动调用的就是 lv_tick_set_cb(SDL_GetTicks),把时基接到 SDL 的毫秒计时上。
/* README:注册毫秒时基回调 */
static uint32_t my_tick_cb(void)
{
return my_get_millisec(); /* 返回自启动以来的毫秒数 */
}
lv_tick_set_cb(my_tick_cb); /* 让 LVGL 知道"现在几点" */
/* 裸机方案 B:只有周期性定时器,就手动累加 */
void SysTick_Handler(void) /* 每 1ms 进一次中断 */
{
lv_tick_inc(1); /* 累加 1ms */
}
/* 需要当前时间时 */
uint32_t now = lv_tick_get();
忘注册 tick,表现通常是"界面能画出来、但动画不动、按钮长按没反应"。排查时先确认 lv_tick_set_cb() 有没有被调用、返回的值是不是真的在递增——用 lv_tick_get() 连续打印两次对比即可。
显示是"输出",输入靠 indev(input device,输入设备)。LVGL 不直接感知硬件,它把所有输入抽象成一个统一的回调:你负责把物理状态(触点坐标、按键、编码器转了几格)填进一个结构体,LVGL 负责把它翻译成"谁被按下了"。创建输入设备的套路固定三步:lv_indev_create() 建对象 → lv_indev_set_type() 指定类型 → lv_indev_set_read_cb() 注册读取回调。类型用 LV_INDEV_TYPE_POINTER(触摸/鼠标)、LV_INDEV_TYPE_KEYPAD(键盘)、LV_INDEV_TYPE_ENCODER(旋钮)、LV_INDEV_TYPE_BUTTON(映射到固定坐标的物理按键)来区分。
回调和显示侧的 flush 是镜像关系:LVGL 周期性调用你的读取回调,你往它传进来的 lv_indev_data_t * data 里填数据。lv_indev_data_t 定义在 lvgl-master/include/lvgl/indev/lv_indev.h,对 pointer 型设备,关键是 point(触点坐标)和 state(按下/抬起);对 keypad 是 key,对 encoder 是 enc_diff。README 的 my_touch_read_cb 就是这个模式的教科书写法。
/* README「Porting manually」的触摸读取回调 */
static void my_touch_read_cb(lv_indev_t * indev, lv_indev_data_t * data)
{
if(my_touch_is_pressed()) {
data->point.x = touchpad_x;
data->point.y = touchpad_y;
data->state = LV_INDEV_STATE_PRESSED;
}
else {
data->state = LV_INDEV_STATE_RELEASED;
}
}
/* 注册:创建 + 类型 + 回调 */
lv_indev_t * indev = lv_indev_create();
lv_indev_set_type(indev, LV_INDEV_TYPE_POINTER);
lv_indev_set_read_cb(indev, my_touch_read_cb);
/* include/lvgl/indev/lv_indev.h(节选):回调与数据 */
typedef void (*lv_indev_read_cb_t)(lv_indev_t * indev, lv_indev_data_t * data);
typedef struct {
lv_indev_state_t state; /* LV_INDEV_STATE_PRESSED / RELEASED */
lv_point_t point; /* 指针型:当前触点坐标 */
uint32_t key; /* 键盘型:当前按键 */
int16_t enc_diff; /* 编码器型:距上次读取的步数 */
uint32_t timestamp; /* 事件时间戳 */
bool continue_reading; /* 置 true 会再次调用读取回调 */
} lv_indev_data_t;
| indev 类型 | 物理设备示例 | 填写的字段 |
|---|---|---|
LV_INDEV_TYPE_POINTER | 触摸屏、鼠标 | point + state |
LV_INDEV_TYPE_KEYPAD | 键盘、矩阵按键 | key |
LV_INDEV_TYPE_ENCODER | 旋转编码器 | enc_diff |
LV_INDEV_TYPE_BUTTON | 映射到屏幕坐标的物理按键 | btn_id |
指针型回调里,state 务必每次都给——即使没有按下也要填 LV_INDEV_STATE_RELEASED。LVGL 靠"上一次按下、这一次抬起"来判定一次完整的点击,漏报一次抬起,你的按钮就会"卡在按下的样子"。
把前三节串起来,就是一个完整的移植模板——它正是 README「Porting manually」的 C 示例。初始化顺序固定:lv_init() 先初始化全局 → lv_tick_set_cb() 给 LVGL 上弦 → lv_display_create() 建显示 → lv_display_set_buffers() 绑缓冲 → lv_display_set_flush_cb() 接刷屏 → lv_indev_create() 建触摸。之后主循环每次调 lv_timer_handler(),LVGL 就在这一帧里完成"读输入 → 处理事件 → 渲染变化区域 → 回调 flush"的完整闭环。
读懂这条链的价值在于:当问题发生时你能精准定位是哪一环。屏幕没反应,先查 flush;点了没反应,先查 indev;动画不动,先查 tick;内容错乱,先查缓冲大小与色深。显示是输出通道、indev 是输入通道、tick 是时钟、缓冲是画板——四者各司其职,组合起来就是 LVGL 在裸机上的全部"引擎室"。
/* README「Porting manually」最小程序(合并本片):tick + display + buffer + flush + indev */
void main(void)
{
my_hardware_init();
lv_init();
lv_tick_set_cb(my_tick_cb); /* 时基 */
lv_display_t * display = lv_display_create(TFT_HOR_RES, TFT_VER_RES);
static uint8_t buf[TFT_HOR_RES * TFT_VER_RES / 10 * 2];
lv_display_set_buffers(display, buf, NULL, sizeof(buf),
LV_DISPLAY_RENDER_MODE_PARTIAL);
lv_display_set_flush_cb(display, my_flush_cb);
lv_indev_t * indev = lv_indev_create();
lv_indev_set_type(indev, LV_INDEV_TYPE_POINTER);
lv_indev_set_read_cb(indev, my_touch_read_cb);
/* 驱动就绪,现在可以创建 UI 了 */
lv_obj_t * label = lv_label_create(lv_screen_active());
lv_label_set_text(label, "Hello world");
lv_obj_center(label);
while(1) {
lv_timer_handler(); /* 一帧:读输入 → 渲染 → flush */
my_sleep_ms(5);
}
}
/* 移植到新屏幕的 checklist(对照排查用) */
/* 1. lv_init() 全局初始化 */
/* 2. lv_tick_set_cb() tick 递增了吗? */
/* 3. lv_display_create() 分辨率对得上屏幕? */
/* 4. lv_display_set_buffers() 缓冲大小含色深因子? */
/* 5. lv_display_set_flush_cb() 有没有 flush_ready?*/
/* 6. lv_indev_*() 触摸 state 每次都给?*/
/* 7. lv_timer_handler() 主循环有在调吗? */
| 环节 | 负责 API | 排查信号 |
|---|---|---|
| 输出 | lv_display_* + flush 回调 | 白屏/花屏 → 缓冲与 flush |
| 输入 | lv_indev_create/set_type/set_read_cb | 点了没反应 → indev 回调 |
| 时钟 | lv_tick_set_cb / lv_tick_inc | 动画不动 → tick 未递增 |
| 驱动 | lv_timer_handler() | 全部不动 → 主循环没跑 |
这套"四件套"(tick / display / buffer / indev)在任何平台——裸机、FreeRTOS、Linux、SDL 模拟器——都是同一个骨架。模拟器只是把四件套预组装好了。吃透本章,你移植任何屏幕都只剩"填四个回调"的机械劳动。
一块 480×320、LV_COLOR_DEPTH = 16 的屏幕。分别计算 partial(按 1/10 屏)、direct、full 三种模式下渲染缓冲至少需要多少字节?各模式刷屏行为有什么差别?
缓冲字节 = 宽 × 高 ×(色深/8),partial 模式再乘以 1/10。对照 4.1 的表格回忆每种模式"重绘多少"。
每像素 16 位 = 2 字节。partial:480 * 320 / 10 * 2 = 30720 字节(1/10 屏,分块渲染);direct 与 full 都要整屏:480 * 320 * 2 = 307200 字节。差别:partial 只渲染变化的小块区域,direct 渲染到正确位置但仅更新变化区,full 无论改多少都整屏重绘。
写出一个完整的 flush 回调骨架:签名是什么?回调里除了"把 px_map 写到 area 对应屏幕区域"之外,最后必须调用哪个函数?如果漏掉它,现象会是什么?
对照 4.2 的代码块;想想 LVGL 怎么知道"这块缓冲可以复用了"。area 的边界字段是 x1/x2/y1/y2。
签名 void my_flush_cb(lv_display_t * disp, const lv_area_t * area, uint8_t * px_map);写完像素后调用 lv_display_flush_ready(disp)。漏掉它 LVGL 会认为当前缓冲仍在使用中,不再把新渲染交给屏幕——表现为界面卡住、刷新停滞甚至花屏。
假设你的触摸芯片只在按下时才有坐标,读取函数是 bool my_touch_is_pressed(void) 与 int my_touch_get_x(void) / my_touch_get_y(void)。用 lv_indev_create + lv_indev_set_type + lv_indev_set_read_cb 接好它,并写出读取回调(别忘了没有按下时也要给 LV_INDEV_STATE_RELEASED)。
直接套 4.4 里 README 的 my_touch_read_cb 结构,把坐标来源换成你自己的读取函数。
lv_indev_t * indev = lv_indev_create(); lv_indev_set_type(indev, LV_INDEV_TYPE_POINTER); lv_indev_set_read_cb(indev, my_touch_read_cb);。回调内:if(my_touch_is_pressed()) { data->point.x = my_touch_get_x(); data->point.y = my_touch_get_y(); data->state = LV_INDEV_STATE_PRESSED; } else { data->state = LV_INDEV_STATE_RELEASED; }。
4.2 提到 direct/full 模式配双缓冲时,flush 回调"只需要换地址"。请解释:为什么 direct 模式用两块整屏缓冲就能做到?而 partial 模式下为什么做不到"只换地址"?提示:回想三块缓冲各自内容的语义(谁画、谁在显示)。
direct 模式缓冲始终保存整幅画面且 LVGL 只在正确位置作画;想想"屏幕上正在显示哪块、LVGL 在画哪块"——换地址后显示的是不是完整的下一帧?partial 模式缓冲只有 1/10 屏,屏幕其余部分由谁维护?
direct(及 full)模式每块缓冲都承载整屏画面:一块在显示、一块在渲染,二者内容互补为一帧。刷新时只需把"显示地址"指向刚渲染完的那块(也就是"只换地址"),下次渲染再用另一块——天然消除撕裂。partial 模式缓冲只含 1/10 屏,屏幕其余部分是 LVGL 靠多次 flush 逐块拼出来的,任何时刻缓冲都不代表完整画面,所以必须真地把数据搬进屏幕,无法只换地址。