3、LVGL显示驱动架构:从结构体到双缓冲的实战拆解
显示驱动,说白了就是LVGL和底层硬件之间的翻译官。你画了一个按钮,LVGL把它变成像素数据,然后通过驱动送给屏幕。这个流程听起来简单,但实际做起来坑不少。我最早接触LVGL时,以为只要把画点函数填进去就行,结果屏幕要么闪成雪花,要么刷新慢得像PPT。
这一章,我们就把显示驱动的几个核心零件拆开看:display驱动结构体、flush回调函数、双缓冲机制,还有输入设备集成。搞懂这些,你就能自己写一个稳定、流畅的显示驱动。
3.1 display驱动结构体:lv_disp_drv_t
LVGL里每个显示设备都对应一个 lv_disp_drv_t 结构体。你可以把它理解成一张“设备登记表”,告诉LVGL你的屏幕长什么样、怎么刷新、用什么内存。
lv_disp_drv_t disp_drv;
lv_disp_drv_init(&disp_drv); // 先初始化,填默认值
disp_drv.hor_res = 480; // 水平分辨率
disp_drv.ver_res = 320; // 垂直分辨率
disp_drv.flush_cb = my_flush_cb; // 刷新回调,核心函数
disp_drv.draw_buf = &draw_buf; // 绑定绘制缓冲区
disp_drv.user_data = &my_lcd_handle; // 私有数据,比如SPI句柄
lv_disp_drv_register(&disp_drv); // 注册到LVGL内核
这里有几个字段我特别想提醒你:
- flush_cb:必须实现,否则屏幕永远是黑的。
- draw_buf:缓冲区指针,决定了你用单缓冲还是双缓冲。
- user_data:这个很实用。我在项目里经常用它传递LCD的SPI设备句柄或者I2C地址,这样回调函数里就能直接拿到硬件资源。
lv_disp_drv_init() 清一遍结构体。别小看这一步,它会把所有字段设成安全默认值,避免你漏掉某个字段导致诡异问题。
3.2 flush回调函数:真正的搬运工
flush_cb 是显示驱动的灵魂。LVGL每画好一块区域,就会调用这个函数,把像素数据送到屏幕。原型长这样:
void my_flush_cb(lv_disp_drv_t * disp_drv, const lv_area_t * area, lv_color_t * color_p)
{
// area->x1, area->y1, area->x2, area->y2 是脏矩形范围
// color_p 指向像素数据,按行排列
// 1. 设置屏幕的写入窗口(set_window)
lcd_set_window(area->x1, area->y1, area->x2, area->y2);
// 2. 发送像素数据(通常用SPI/DMA)
lcd_write_data((uint8_t *)color_p,
(area->x2 - area->x1 + 1) *
(area->y2 - area->y1 + 1) *
sizeof(lv_color_t));
// 3. 通知LVGL刷新完成
lv_disp_flush_ready(disp_drv);
}
注意最后一行 lv_disp_flush_ready(),这个调用绝对不能少。LVGL靠它知道缓冲区可以复用了。如果你忘了调用,LVGL会一直等,整个界面卡死。
lv_disp_flush_ready 迟迟没调用。LVGL以为还没刷完,就不给我新的绘制任务。屏幕看起来就像死机了一样。后来我把日志去掉,问题立刻解决。
3.3 双缓冲机制:告别屏幕撕裂
单缓冲模式下,LVGL一边往缓冲区画图,flush回调一边往外送数据。这就像一个人一边往桶里倒水,一边从桶里舀水——很容易乱套,出现屏幕撕裂(tearing)。
双缓冲就是准备两个桶。一个桶让LVGL画,另一个桶让flush往外送。画完就交换,互不干扰。
配置双缓冲很简单:
static lv_color_t buf_1[480 * 10]; // 第一个缓冲区
static lv_color_t buf_2[480 * 10]; // 第二个缓冲区
lv_disp_draw_buf_t draw_buf;
lv_disp_draw_buf_init(&draw_buf, buf_1, buf_2, 480 * 10); // 两个缓冲区
第三个参数传 buf_2 就是双缓冲,传 NULL 就是单缓冲。缓冲区大小一般是 屏幕一行像素 × 10行,这个值可以根据内存调整。
| 模式 | 内存占用 | 撕裂风险 | 适用场景 |
|---|---|---|---|
| 单缓冲 | 低 | 高 | 内存极小的MCU,对显示质量要求不高 |
| 双缓冲 | 中 | 低 | 大部分嵌入式场景,推荐使用 |
| 全屏双缓冲 | 高 | 无 | Linux + 大内存,追求极致流畅 |
3.4 输入设备集成:让屏幕“活”起来
显示驱动搞定了,但屏幕只能看不能点,那跟一张图片有什么区别?输入设备集成就是让触摸屏、按键、编码器能和LVGL交互。
LVGL的输入设备驱动结构体是 lv_indev_drv_t,用法和display驱动很像:
lv_indev_drv_t indev_drv;
lv_indev_drv_init(&indev_drv);
indev_drv.type = LV_INDEV_TYPE_POINTER; // 触摸屏类型
indev_drv.read_cb = my_touchpad_read; // 读取回调
lv_indev_drv_register(&indev_drv);
read_cb 回调函数需要把触摸坐标填到 lv_indev_data_t 结构体里:
void my_touchpad_read(lv_indev_drv_t * indev_drv, lv_indev_data_t * data)
{
// 从触摸芯片读取坐标
int16_t x, y;
bool pressed = touchpad_read(&x, &y);
if(pressed) {
data->point.x = x;
data->point.y = y;
data->state = LV_INDEV_STATE_PRESSED;
} else {
data->state = LV_INDEV_STATE_RELEASED;
}
}
输入设备类型有几种:
- LV_INDEV_TYPE_POINTER:触摸屏、鼠标,需要坐标。
- LV_INDEV_TYPE_KEYPAD:矩阵键盘,需要键值。
- LV_INDEV_TYPE_ENCODER:旋转编码器,需要方向和按键。
- LV_INDEV_TYPE_BUTTON:独立按键,需要ID和状态。
read_cb 里做一次坐标映射:data->point.x = disp_drv->hor_res - x;。
3.5 整体架构:一张图看懂
下面这张图把display驱动、flush回调、双缓冲和输入设备的关系画清楚了。你可以看到数据是怎么流动的:
从这张图你能看到,LVGL内核是大脑,display驱动是调度中心,flush回调是执行者,双缓冲是数据中转站。输入设备则是另一条独立的链路,通过 read_cb 把用户操作反馈给内核。
3.6 实战要点总结
写到这里,我把几个关键点再拎一下:
- flush回调里一定要调用
lv_disp_flush_ready,这是新手最容易忘的。 - 双缓冲不是必须的,但如果你希望画面流畅、没有撕裂,建议用。内存不够就缩小缓冲区行数。
- 输入设备的坐标可能需要做映射,尤其是触摸屏方向跟屏幕方向不一致时。
- user_data 是个好东西,别浪费。把硬件句柄、配置参数都塞进去,回调函数里直接取用。
flush_cb 里填充一个纯色(比如红色),看看屏幕能不能正常显示一整屏颜色。如果能,说明SPI通信和窗口设置没问题。然后再逐步调试LVGL的绘制内容。这样分层验证,问题定位快很多。