LVGL 这类轻量级 GUI 框架把控件、布局、渲染和输入事件一层层收拢起来。对开发者来说,真正的难点其实不在画控件,而在移植层那几条通道是否打通。
让屏幕先跑起来
引入 LVGL 的第一步不是画界面,而是把三条最基本的通道打通:像素怎么出到屏上、时间心跳从哪来、周期性任务在哪里调用。这三层打通之后,框架才开始真正运转。
三条通道里最容易出错的是像素输出。LVGL 负责把自己要画的内容渲染进你提供的缓冲区,然后调用注册进来的 flush 回调,剩下的搬运工作由移植层完成:
// 显示移植:以 LVGL v8 + SPI 屏为例。重点是"通知刷新的时机"
static lv_disp_draw_buf_t disp_buf;
static lv_disp_drv_t disp_drv;
// 行缓冲:大小为若干行像素。行数是 RAM 与刷新效率之间的取舍,后面会聊
#define DRAW_BUF_LINES 20
static lv_color_t buf_lines[LCD_HOR_RES * DRAW_BUF_LINES];
// 把 LVGL 渲染好的像素推到屏幕;color_p 是待搬运的像素首地址
static void disp_flush(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_p)
{
uint32_t w = (uint32_t)(area->x2 - area->x1 + 1);
uint32_t h = (uint32_t)(area->y2 - area->y1 + 1);
lcd_set_address_window(area->x1, area->y1, area->x2, area->y2);
// 启动 DMA 搬运,函数立即返回,传输在后台进行
lcd_write_pixels_dma((uint16_t *)color_p, w * h);
// 关键点:这里不能立刻调用 lv_disp_flush_ready()!
// 必须等到 DMA 真正传完再通知 LVGL,否则 LVGL 会在搬运途中复用这块缓冲区,
// 表现为画面撕裂或局部花屏。通知放在 DMA 传输完成回调里,见下。
}
// DMA 传输完成回调(在中断或回调上下文)
void lcd_dma_complete_callback(void)
{
// 实际项目中要把 drv 通过全局变量或句柄带过来
lv_disp_flush_ready(&disp_drv);
}
void lv_port_disp_init(void)
{
lcd_hw_init(); // 底层 SPI/并口初始化、屏寄存器配置、开显示
lv_disp_draw_buf_init(&disp_buf, buf_lines, NULL, LCD_HOR_RES * DRAW_BUF_LINES);
lv_disp_drv_init(&disp_drv);
disp_drv.hor_res = LCD_HOR_RES;
disp_drv.ver_res = LCD_VER_RES;
disp_drv.flush_cb = disp_flush;
disp_drv.draw_buf = &disp_buf;
// 若需要软件旋转屏幕或色彩格式不是 RGB565,按需设置 sw_rotate / set_px_cb 等回调
lv_disp_drv_register(&disp_drv);
}
上面那段注释里点出了一个移植期最常见也最隐蔽的 bug:在 flush 回调里同步调用 lv_disp_flush_ready()。如果此时像素搬运是同步完成的(比如轮询方式写 SPI),这样写没问题;但一旦用 DMA 异步搬运,立刻通知会让 LVGL 认为缓冲区已经空闲,于是开始渲染下一帧并覆写同一块内存,屏幕上就会出现撕裂、错位、花屏。稳妥的做法是在 DMA 真正完成的回调里再通知。
时间心跳与周期任务则是另外两件小事:
// 心跳:在 1ms 的 SysTick 或硬件定时器中断里递增,LVGL 用它计算动画与超时
void SysTick_Handler(void)
{
HAL_IncTick();
lv_tick_inc(1);
}
// 周期任务:在主循环或独立任务里调用,间隔通常 5ms 左右即可
// 它负责实际执行 Layout、重绘脏区域、处理动画与事件
void gui_task_entry(void *arg)
{
lv_init();
lv_port_disp_init();
lv_port_indev_init(); // 输入设备移植
for (;;) {
lv_timer_handler(); // 内含重绘与事件派发
osDelay(5); // 5ms 调用一次,屏幕复杂时可缩短
}
}
lv_tick_inc() 必须在中断里以稳定的毫秒节拍调用,不能跳也不能停。lv_timer_handler() 的调用间隔决定界面响应的上限,太快会让 MCU 忙于重绘、太慢会让触摸和动画变得迟滞,一般取 5ms 上下,再根据实测拖动/动画的流畅度微调。
显示缓冲区怎么给
缓冲区策略直接决定了 RAM 占用和刷新表现。三种典型配置各有位置:
| 策略 |
缓冲区 |
特点 |
| 单行/少行缓冲 |
几行至几十行像素 |
RAM 占用最小(几十 KB 内),LVGL 需要多次分片渲染+多次 flush,整体帧率较低;RAM 紧张的 MCU 只能这样 |
| 半屏/多行缓冲 |
屏幕高度的 1/2 或 1/4 |
折中方案,RAM 与帧率都比较可接受,多数项目的选择 |
| 全屏单缓冲 |
整屏像素 |
一次 flush 完成整帧,效率最高,但 480×320 的 RGB565 屏就需要 300KB,片内通常放不下,需外部 SRAM/SDRAM |
| 全屏双缓冲 |
两整屏像素 |
一边渲染一边传输,彻底避免等待;代价是 RAM 翻倍,且要有 DMA 配合 |
选型时的判断依据先看 RAM:显示屏常见 RGB565 占两字节一个像素,用分辨率乘 2 再乘行数就能估算。若片内 RAM 只剩几十 KB,那选少行缓冲几乎是必然,再用 SPI 时钟提上去补偿帧率。若有外部 SDRAM 且总线够快(RGB 并口、8080 高速),全屏缓冲配 DMA 会让界面流畅得多。
还有一个折中做法:lv_disp_draw_buf_init() 支持传入第二个缓冲区,配上 DMA 就能让 LVGL 在传输第一块的同时渲染第二块,渲染与搬运重叠起来。双缓冲能明显提升帧率,代价仍是 RAM 翻倍,且同样必须坚持"传完才通知"的时机约定。
触摸怎么接
输入设备的移植比显示简单。LVGL 周期调用注册的 read 回调询问当前状态,只需如实汇报是否按下和坐标位置。
// 触摸移植:read 回调被 LVGL 周期性调用,要求返回瞬时状态
static void touchpad_read(lv_indev_drv_t *drv, lv_indev_data_t *data)
{
static int16_t last_x = 0;
static int16_t last_y = 0;
if (touch_is_pressed()) { // 读触摸芯片 INT 脚或轮询寄存器
touch_get_xy(&last_x, &last_y);
data->point.x = last_x;
data->point.y = last_y;
data->state = LV_INDEV_STATE_PRESSED;
} else {
data->point.x = last_x;
data->point.y = last_y;
data->state = LV_INDEV_STATE_RELEASED;
}
// data->continue_reading 保持默认 false,表示本次读取完成
}
void lv_port_indev_init(void)
{
static lv_indev_drv_t indev_drv;
lv_indev_drv_init(&indev_drv);
indev_drv.type = LV_INDEV_TYPE_POINTER;
indev_drv.read_cb = touchpad_read;
lv_indev_drv_register(&indev_drv);
}
这里有两个工程上的注意点:
坐标要做方向与增益校准。 触摸芯片返回的原始 ADC 值既有旋转方向问题,也有缩放比例问题,直接给 LVGL 会出现"点这里跳那里"。通常支持 screen calibrate:在启动时提示用户点几个十字标记,记录对应的原始值,算出 x'=(x-xmin)*LCD_W/(xmax-xmin) 这样的映射参数,再把参数固化到 Flash。
read 回调要快。 它是在 lv_timer_handler() 的上下文里被同步调用的,在回调里做一次 I²C 读还行,但若加上 HAL_Delay() 式等待或者重试循环,整个 GUI 任务就会卡在这里。带数据就绪引脚的触摸芯片,更适合在中断里取数、回调只读缓存。
界面怎么组织与切换
LVGL 里一切可见元素都是对象(obj),有父子层级。lv_scr_act() 拿到当前屏幕,屏幕本质上也是一个顶层容器。控件、布局、样式、事件这四件事搞清之后,界面组织就很自然。
下面是一段典型的界面构建与切换代码:
// 界面构建与事件处理:按钮点击触发翻页,标签定期刷新数据
static lv_obj_t *g_label_power; // 主界面上的功率标签
static void create_main_screen(void)
{
lv_obj_t *scr = lv_scr_act();
// 标题:居中对齐,使用默认字体放大
lv_obj_t *title = lv_label_create(scr);
lv_label_set_text(title, "并网逆变器");
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 8);
// 功率显示标签,建好后保留句柄,由刷新函数定期更新文本
g_label_power = lv_label_create(scr);
lv_label_set_text(g_label_power, "P: --- W");
lv_obj_align(g_label_power, LV_ALIGN_CENTER, 0, -20);
// 设置按钮,注册点击事件回调
lv_obj_t *btn = lv_btn_create(scr);
lv_obj_align(btn, LV_ALIGN_BOTTOM_MID, 0, -10);
lv_obj_add_event_cb(btn, btn_event_cb, LV_EVENT_CLICKED, NULL);
lv_obj_t *btn_label = lv_label_create(btn);
lv_label_set_text(btn_label, "设置");
lv_obj_center(btn_label);
}
// 事件回调:所有控件事件都进这一类函数,用 e->code 区分事件类型
static void btn_event_cb(lv_event_t *e)
{
lv_event_code_t code = lv_event_get_code(e);
if (code == LV_EVENT_CLICKED) {
lv_scr_load(g_settings_screen); // 切换到此前已创建好的设置页
}
}
// 数据刷新:由定时器回调周期性更新文本内容
static void update_power_label(lv_timer_t *timer)
{
char buf[32];
snprintf(buf, sizeof(buf), "P: %.0f W", get_current_power());
lv_label_set_text(g_label_power, buf); // 注意:LVGL 内部会拷贝字符串
}
理解这段的关键是:所有涉及创建/修改/删除控件的操作,都应该在调用 lv_timer_handler() 的那个上下文(也就是 GUI 线程)里发生。 LVGL 并没有对自己的对象树加锁,跨任务直接操作对象就是典型的数据竞争来源。
多线程下怎么安全地更新界面
RTOS 环境里,数据采集任务拿到最新功率想更新标签,这是最平常不过的需求,却也是新手最容易制造偶发死机的地方。LVGL 的设计前提一直是单线程访问,因此 RTOS 下要显式加互斥保护。
// RTOS 下的互斥封装:所有 GUI 操作都要在锁的保护范围内
static SemaphoreHandle_t g_lvgl_mutex;
void lvgl_port_lock_init(void)
{
g_lvgl_mutex = xSemaphoreCreateMutex();
}
// 封装 LVGL 的 tick 与 handler 调用,进出都要加锁
void lvgl_tick_thread(void *arg)
{
(void)arg;
for (;;) {
lv_tick_inc(1);
vTaskDelay(pdMS_TO_TICKS(1));
}
}
void gui_task_entry(void *arg)
{
for (;;) {
if (xSemaphoreTake(g_lvgl_mutex, portMAX_DELAY) == pdTRUE) {
lv_timer_handler(); // 所有渲染与事件派发都在锁内
xSemaphoreGive(g_lvgl_mutex);
}
vTaskDelay(pdMS_TO_TICKS(5));
}
}
// 其他任务需要更新 UI 时的标准做法
void other_task_update_ui(void)
{
if (xSemaphoreTake(g_lvgl_mutex, pdMS_TO_TICKS(50)) == pdTRUE) {
lv_label_set_text(g_label_power, "P: 3500 W"); // 短时间、无阻塞的 GUI 操作
xSemaphoreGive(g_lvgl_mutex);
}
}
锁的方案能用,但有两点约束要守住:持锁期间不许调用任何会导致阻塞的 API(延时、等待信号量、慢速 I²C 读写都不行),否则 GUI 任务会被拖死;另一个任务等待锁的超时必须有,别用 portMAX_DELAY,万一 GUI 侧卡住还能有退路。
更干净的做法是用 LVGL 自带的消息机制(lv_msg)或自建队列,把"更新 UI"的请求投递给 GUI 任务,由 GUI 线程在自己的上下文里执行修改。这样采集任务完全不需要碰锁,职责也更清晰——业务只管产生数据,界面负责消费数据。