找回密码
立即注册
搜索
发回帖 发新帖

6402

积分

0

好友

808

主题
发表于 10 小时前 | 查看: 1| 回复: 0

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 线程在自己的上下文里执行修改。这样采集任务完全不需要碰锁,职责也更清晰——业务只管产生数据,界面负责消费数据。




上一篇:Claude Code怎么用?从只会跑Demo到敢交付的实战路线
下一篇:LVGL文字渲染原理深度解析:从lv_label_set_text()到像素混合
您需要登录后才可以回帖 登录 | 立即注册

关闭

站长推荐上一条 /1 下一条

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-10-28 14:16 , Processed in 0.074000 second(s), 43 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表