(五)LVGL 8.3.11 移植:FreeRTOS 操作系统适配

目标:把 LVGL 安全地放进 FreeRTOS 多任务环境,做到界面流畅、业务并行、互不踩内存。


1. 原理篇:LVGL 的线程模型约束

LVGL 8.3 不是线程安全的。其内部的对象树、样式缓存、定时器链表都没有锁保护。因此只有两条合法路线:

  1. 单上下文路线:所有 lv_* API(包括 lv_timer_handler())都在同一个任务里调用;
  2. 互斥锁路线:任何任务调用 lv_* 前都先抢同一把互斥锁,调用完释放。

实际工程几乎都用路线 2:UI 渲染在专用任务,业务任务(传感器、通信)要更新界面时持锁调 API。

另外两个硬性约束:

  • lv_timer_handler() 禁止在中断里调用;
  • lv_tick_inc() 可以在中断里调用(它只做一个计数累加,是中断安全的),但要保证调度器启动后 SysTick 仍然正常工作。

任务模型总览

在这里插入图片描述


2. 应用篇:完整适配代码

2.1 时基:SysTick 与 RTOS 共用

FreeRTOS 接管 SysTick 后,在 HAL 的回调中补喂 LVGL 节拍:

/* stm32f4xx_hal_timebase 或 it.c */
void HAL_SYSTICK_Callback(void)
{
    lv_tick_inc(1);    /* 调度器运行后该回调每 1ms 被调一次 */
}

或者干脆用做法 B(lv_conf.h):

#define LV_TICK_CUSTOM 1
#define LV_TICK_CUSTOM_INCLUDE "FreeRTOS.h"
#define LV_TICK_CUSTOM_SYS_TIME_EXPR (xTaskGetTickCount() * portTICK_PERIOD_MS)

2.2 GUI 互斥锁与封装宏

#include "FreeRTOS.h"
#include "semphr.h"

static SemaphoreHandle_t gui_mutex;

void gui_lock(void)   { xSemaphoreTake(gui_mutex, portMAX_DELAY); }
void gui_unlock(void) { xSemaphoreGive(gui_mutex); }

业务任务中更新界面:

void sensor_task(void *arg)
{
    float temp;
    char buf[32];
    for (;;) {
        temp = ADC_ReadTemperature();
        snprintf(buf, sizeof(buf), "温度: %.1f ℃", temp);

        gui_lock();
        lv_label_set_text(label_temp, buf);
        lv_bar_set_value(bar_temp, (int)temp, LV_ANIM_ON);
        gui_unlock();

        vTaskDelay(pdMS_TO_TICKS(500));
    }
}

2.3 LVGL 任务

void lvgl_task(void *arg)
{
    lv_init();
    lv_port_disp_init();
    lv_port_indev_init();
    /* lv_port_fs_init(); */
    gui_mutex = xSemaphoreCreateMutex();

    /* UI 初始化也在本任务完成,天然单上下文 */
    ui_create_all();

    for (;;) {
        gui_lock();
        uint32_t next = lv_timer_handler();   /* 返回距下次需唤醒的 ms */
        gui_unlock();
        next = next < 5 ? 5 : (next > 20 ? 20 : next);
        vTaskDelay(pdMS_TO_TICKS(next));
    }
}

利用 lv_timer_handler() 的返回值动态调整周期:空闲时 20ms 省 CPU,动画时 5ms 保流畅。

关键细节解析

① LVGL 任务自己也抢 gui_mutex,是不是多此一举?

不是。锁的语义是"同一时刻只有一个上下文在碰 LVGL",而不是"保护 LVGL 任务自己"。如果 LVGL 任务不持锁就跑 lv_timer_handler(),业务任务就可能在渲染进行到一半时持锁进来改控件——对象树正被遍历却被修改,这是比"忘加锁"更隐蔽的崩溃源。规则必须无例外:任何 lv_* 调用(含 lv_timer_handler)都在锁内。UI 初始化放在本任务内、其他任务启动前完成,则是利用时序把初始化期天然变成单上下文。

② 为什么用 lv_timer_handler() 的返回值做延时,而不是固定 vTaskDelay(5)

返回值是 LVGL 算出的"距下一个待办事项还有多少 ms":有动画跑时它返回 1~5ms,完全空闲时可能返回几百 ms。固定 5ms 轮询意味着界面静止时 LVGL 任务仍以 200Hz 空转唤醒;用返回值动态延时,空闲期唤醒频率降一个数量级,整机功耗和 CPU 占用都受益。代码里钳位到 [5, 20]ms 是防止极端值:动画最密时不低于 5ms(保帧率上限 200FPS 无意义),空闲时不超过 20ms(保触摸响应)。

lv_tick_inc() 为什么能放中断里,lv_timer_handler() 却不行?

lv_tick_inc(1) 只做一件事:给一个全局计数器加 1。无链表、无堆操作、无可重入风险,中断安全。而 lv_timer_handler() 内部要遍历对象树、分配/释放堆内存、触发用户回调——任何一步被中断打断后重入都会破坏内部状态。这是"LVGL API 只有极少数能进 ISR"的根本原因,同类例外只有 lv_disp_flush_ready()(见第 02 篇)。

④ 栈 4096 字(16KB)的依据是什么?

lv_timer_handler() 的调用链很深:控件渲染 → 样式计算 → 字体渲染 → 图片解码层层嵌套,每层的局部变量(lv_area_tlv_draw_*_dsc_t 结构体都有几十字节)累计可观;lv_demo_widgets 这类复杂界面实测峰值在 4~8KB。再叠加 FreeType 或 PNG 解码会更高。4096 字(=16KB,注意 FreeRTOS 栈单位是不是字节)是留足余量的起点值,定型前务必用 uxTaskGetStackHighWaterMark() 实测——它返回的是"历史最低剩余量",是最可靠的栈水位证据。

2.4 任务创建参数(推荐起点)

xTaskCreate(lvgl_task,   "lvgl",   4096, NULL, tskIDLE_PRIORITY + 2, NULL);
xTaskCreate(sensor_task, "sensor", 1024, NULL, tskIDLE_PRIORITY + 1, NULL);
xTaskCreate(comm_task,   "comm",   2048, NULL, tskIDLE_PRIORITY + 1, NULL);
参数 建议 依据
LVGL 任务栈 2048~4096 字(8~16KB) lv_demo_widgets 的复杂控件嵌套很深;FreeType/图片解码另算
LVGL 任务优先级 高于普通业务任务 保证触摸响应及时
业务任务优先级 同级或更低 防止高优先业务任务饿死 UI
FreeRTOS 堆 ≥ 30KB configTOTAL_HEAP_SIZE,任务栈 + 信号量都从这里出

栈用量验证:uxTaskGetStackHighWaterMark() 打印剩余栈,低于 15% 就该加大。LVGL 函数局部数组较多,栈溢出是本篇场景最高频的玄学故障来源


3. 实践篇:并发陷阱与对策

3.1 典型踩坑场景

场景 后果 对策
业务任务忘加锁直接 lv_label_set_text 偶发花屏/HardFault,难复现 统一封装 gui_lock/unlock,代码审查
持锁期间做长耗时操作(如 SD 读大图) UI 卡顿 文件操作放锁外,只有 lv_img_set_src 这一步持锁
中断里调 lv_* 立即或随机崩溃 中断只发信号量,任务里再调 LVGL
互斥锁在 LVGL 任务内也抢 无问题但多一次切换;注意不可重入,持锁后再 lock 会死锁 LVGL 任务内部连续操作持一次锁即可
DMA2D 中断回调里调 lv_disp_flush_ready 合法(8.3 允许),但该回调优先级要低于 RTOS 最高中断优先级门限 LV_DISP_DEF_REFR_PERIOD 内不再并发其他 LVGL 调用即可

3.2 优先级反转提示

GUI 互斥锁用 FreeRTOS 的 xSemaphoreCreateMutex自带优先级继承),不要用二值信号量代替——二值信号量没有优先级继承,高优先的 LVGL 任务可能被持锁的低优先业务任务长时间阻塞。

3.3 与文件系统的叠加

FatFS 开了 FF_FS_REENTRANT 后,LVGL 任务读图与日志任务写日志可以并发;但 LVGL API 调用仍需 gui_mutex——两把锁保护的层级不同,不冲突。注意加锁顺序固定(先 gui_mutex 后 FatFS 锁),避免交叉死锁。


4. 裸机对照方案

int main(void)
{
    /* 初始化同上 */
    uint32_t last = HAL_GetTick();
    for (;;) {
        if (HAL_GetTick() - last >= 5) {
            last = HAL_GetTick();
            lv_timer_handler();
        }
        /* 其他业务:状态机形式,禁止阻塞 */
    }
}

裸机的本质是"单任务",天然满足 LVGL 的单上下文约束;代价是所有业务必须写成非阻塞状态机。界面复杂度高(多页面 + 动画 + 文件 IO)时,RTOS 的工程量反而更小。


5. 验证清单

  1. lv_demo_widgets() + 后台业务任务同时跑,10 分钟无卡顿、无崩溃;
  2. 示波器/日志确认 lv_timer_handler 平均周期 ≤ 10ms;
  3. uxTaskGetStackHighWaterMark(lvgl_handle) 余量 > 15%;
  4. LV_USE_MEM_MONITOR 显示堆占用曲线平稳;
  5. 业务高频更新(10Hz 刷图表)时触摸响应无迟滞。
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐