LVGL 8.3.10单片机源码移植与GUI设计落地
简介:这份 lvgl-8.3.10 源码包面向嵌入式 GUI 开发者、STM32 单片机学习者,以及需要为资源受限设备搭建图形界面的工程师,提供从基础组件到高级视觉效果的一站式实现参考。压缩包共 1183 个文件、约 24.51MB,以 455 个 C 源文件和 213 个头文件为核心,辅以 150 个 Python 脚本、134 个 Markdown 文档及 RST 说明,并包含示例图片、字体与演示素材,目录层次较清晰,便于按模块查阅、移植和裁剪。已有 1824 人学习下载,适合作为 LVGL 入门、项目选型和界面开发的实践资料。包内覆盖按钮、图表、列表、滑动条、图片等模块化组件,以及动画、抗锯齿、透明度、平滑滚动和图层混合等图形引擎能力,并支持触摸屏、键盘、编码器、按键等输入方式;借助完整源码与示例资源,可较快完成界面搭建、性能验证和跨硬件平台适配,也能为组件定制和资源优化提供可复用的代码基础。
1. 三个按钮卡半秒:为什么在单片机上啃 LVGL 8.3.10 源码包
拿到 lvgl-8.3.10 源码包那天,我先没看界面代码,而是让一块 STM32F4 板子跑空屏。320×240 的 SPI 屏,三个按钮点一下要等半秒才变色,他怀疑屏幕时序,改了三天分频,问题还在。真正原因很反直觉:GUI 任务被主循环里的延时拖住,LVGL 的刷新周期根本没跑起来。嵌入式设计里这类现象太常见,界面 GUI 设计不是把控件摆上去就完事,底层心跳、刷新回调、输入读取任何一环掉链子,看起来都像屏的问题。
这套源码适合手里有 STM32 或其他单片机、要用 C 写界面、愿意自己掌控移植层的人。它给的是 LVGL 8.3.10 核心源码、配置模板和显示/输入移植模板,不是现成工程。下面按结构、移植、搭建、排查、验证五步走。
2. 拆开 lvgl-8.3.10:目录、配置与移植入口
源码包到手,最怕的是把整个目录拖进 Keil 然后点编译。LVGL 的目录里既有核心库,也有示例、演示和移植模板,全塞进去只会让编译时间变长、错误变多。先弄清楚哪些文件该进工程,再谈配置和心跳。
2.1 目录里哪几个文件夹决定成败
解压后先看根目录。src是核心源码,必须全部加入工程;examples是控件示例,按需取;demos是演示程序,占用 Flash 和 RAM 都大,前期不建议编进去;lv_conf_template.h是配置模板,要复制成lv_conf.h;lvgl.h是总入口;lv_port_disp_template.c/h和lv_port_indev_template.c/h是显示、输入移植模板,不是拿来做编译单元的,而是拷内容到自己的lv_port_disp.c、lv_port_indev.c。
| 路径 | 作用 | 处理建议 |
|---|---|---|
src/ | LVGL 核心控件、绘制、字体、驱动框架 | 全部加入工程 |
examples/ | 各控件用法示例 | 只挑需要的参考,不整包编译 |
demos/ | 综合演示界面 | 前期关闭,验证性能时再开 |
lv_conf_template.h | 配置模板 | 复制为lv_conf.h,改#if 1 |
lvgl.h | 对外总头文件 | 工程包含路径指向lvgl根目录 |
lv_port_disp_template.c/h | 显示移植模板 | 拷到自己工程后重命名,避免和模板重名 |
lv_port_indev_template.c/h | 输入移植模板 | 同上,触摸、按键都走这个入口 |
包含路径一般只加lvgl根目录,让lvgl.h能按相对路径找到src/...。lv_conf.h放在工程包含路径能搜到的地方,常见做法是放在lvgl同级目录,并在编译器里定义LV_CONF_INCLUDE_SIMPLE,让 LVGL 直接#include "lv_conf.h"。如果这个宏没定义,内部会按相对路径找配置,目录一挪就报找不到。
注意:
lv_port_disp_template.c和lv_port_indev_template.c不要原样加入编译。它们只是模板,原样编译容易和自己的lv_port_disp_init、lv_port_indev_init重名。
2.2 先把 lv_conf.h 改成能编译的最小配置
配置项很多,第一轮不要全改。先把颜色深度、内存池、日志和演示开关定下来,能点亮屏再逐项加。lv_conf_template.h顶部通常有#if 0,不改的话整个配置被跳过,编译会走默认值,现象就是“我明明改了配置却不生效”。
/* lv_conf.h:第一轮只动这几个,其他先保持模板默认 */ #if 1 /* 模板里是 #if 0,必须改成 1,否则配置整体失效 */ #define LV_COLOR_DEPTH 16 /* 大多数 SPI 屏是 RGB565 */ #define LV_COLOR_16_SWAP 0 /* SPI 字节序不对时再改 1 */ #define LV_MEM_CUSTOM 0 /* 用 LVGL 内部内存池 */ #define LV_MEM_SIZE (48U * 1024U) /* 控件和对象的内存池,RAM 紧可先降到 24KB */ #define LV_USE_LOG 0 /* 调试期可开 1,发布前关掉 */ #define LV_USE_DEMO_WIDGETS 0 /* 不编译综合演示 */ #define LV_USE_DEMO_BENCHMARK 0 #endifLV_COLOR_DEPTH必须和屏幕实际颜色格式一致,16 位色下lv_color_t占 2 字节,后面算绘图缓冲区大小都按这个来。LV_COLOR_16_SWAP只负责 16 位像素的高低位交换,不是颜色顺序校正;如果红蓝反了,先查屏幕初始化序列和 RGB/BGR 配置,不要指望这个宏解决所有颜色问题。
LV_MEM_SIZE是 LVGL 内部对象池,按钮、标签、样式、动画这些对象从这里分配。320×240 的界面,48KB 比较稳;如果单片机 RAM 只有 64KB,先降到 24KB,再通过减少页面对象、及时删除不用的页面来控制。绘图缓冲区是另外定义的静态数组,不走这个内存池,算 RAM 时别漏掉。
LV_USE_LOG调试期可以开,但要接一个lv_log_register_print_cb输出到串口,否则开了也看不到。发布前关掉,能省不少代码空间。LV_USE_DEMO_*前期一律关,等显示和输入都通了,再单独开一个 demo 做性能参考。
2.3 把源码包塞进 Keil/IAR/Makefile 的三种姿势
Keil 和 IAR 的做法类似:新建一个lvgl分组,把lvgl/src下所有.c加进去;包含路径加lvgl根目录和lv_conf.h所在目录;预定义宏加LV_CONF_INCLUDE_SIMPLE。不要只加lvgl/src/core,LVGL 8.3 的绘制、字体、控件、驱动框架分散在多个子目录,漏一个就在链接时报未定义。
Makefile 或 CMake 工程可以用通配收集,省得手工维护文件列表。下面这段 CMake 把lvgl/src下所有 C 文件编成静态库,并把包含路径和配置宏带出去:
set(LVGL_DIR ${CMAKE_CURRENT_SOURCE_DIR}/lvgl) file(GLOB_RECURSE LVGL_SRC ${LVGL_DIR}/src/*.c) add_library(lvgl STATIC ${LVGL_SRC}) target_include_directories(lvgl PUBLIC ${LVGL_DIR}) target_compile_definitions(lvgl PUBLIC LV_CONF_INCLUDE_SIMPLE)file(GLOB_RECURSE ...)的优点是新增源文件不用改脚本,缺点是新增文件后 CMake 不一定自动重跑,改完目录最好手动重新生成一次。target_include_directories只暴露lvgl根目录,不要把lvgl/src下每个子目录都加进去,头文件包含关系按 LVGL 自己的相对路径走,路径加多了反而容易和别的库撞名。
如果工程里已经有一份lv_conf.h,要确认编译器实际搜到的是哪一份。常见翻车是工程目录一份、lvgl目录一份,改了其中一份,编译用的是另一份。排查时在lv_conf.h里加一行#error "this lv_conf",编译一次就能看到实际路径,确认后再删掉。
2.4 让心跳先跑起来:SysTick 与 lv_task_handler
LVGL 需要两个时间源:一个是 1ms 心跳,用来跑动画、定时器和输入消抖;一个是主循环里的lv_task_handler,用来处理刷新、事件和定时任务。心跳不进lv_task_handler,界面就不会动;lv_task_handler调得太慢,按钮响应就像卡住。
#include "lvgl.h" void SysTick_Handler(void) { lv_tick_inc(1); /* SysTick 配成 1ms,这里就传 1;配成 2ms 就传 2 */ } int main(void) { board_init(); lv_init(); /* 先初始化 LVGL 内核 */ lv_port_disp_init(); /* 再注册显示驱动 */ lv_port_indev_init(); /* 再注册输入驱动 */ while (1) { lv_task_handler(); /* GUI 主循环,建议 5ms 左右调用一次 */ delay_ms(5); } }lv_tick_inc(1)放在 SysTick 中断里,参数是“这次经过了多少毫秒”,必须和 SysTick 周期一致。有人把 SysTick 配成 1ms,却在中断里写lv_tick_inc(10),动画会快十倍,触摸消抖也会乱。lv_task_handler不要放中断里,它内部会刷屏、跑事件回调,执行时间不固定。
如果跑 RTOS,常见做法是单独建一个 GUI 任务,任务里循环调用lv_task_handler(),外面加互斥锁。其他任务要改界面时,不要直接调lv_obj_set_text,而是发消息给 GUI 任务,或者拿锁再改。LVGL 8.3 默认不是线程安全的,两个任务同时碰同一个对象,轻则花屏,重则 HardFault。
注意:
lv_init()必须在lv_port_disp_init()之前。显示驱动注册时会用到内核里的显示链表,顺序反了可能注册不上。
3. 显示与输入移植:把 flush_cb 和 read_cb 写成可复现的驱动
移植层只做两件事:把 LVGL 画好的像素送到屏,把触摸状态送回去。听起来简单,但flush_cb里少调一次lv_disp_flush_ready,界面就只刷第一帧;触摸坐标没映射,按钮就永远点不中。下面按显示、输入、最小验证三步走。
3.1 显示驱动:单缓冲、双缓冲和 DMA 的取舍
先求通,再求快。第一版用单缓冲,缓冲区开 40 行,320×240 的屏大约 25KB,能明显降低撕裂,又不会把 RAM 吃光。lv_disp_draw_buf_init的第三个参数是缓冲区像素数,第四个参数给第二个缓冲区;单缓冲传NULL,双缓冲传第二块地址。
#define LCD_HOR_RES 320 #define LCD_VER_RES 240 static lv_disp_draw_buf_t draw_buf; static lv_color_t buf_1[LCD_HOR_RES * 40]; /* 40 行,16 位色下约 25KB */ static void disp_flush(lv_disp_drv_t *drv, const lv_area_t *area, lv_color_t *color_p) { uint16_t w = area->x2 - area->x1 + 1; uint16_t h = area->y2 - area->y1 + 1; LCD_SetWindow(area->x1, area->y1, area->x2, area->y2); LCD_WritePixels((uint16_t *)color_p, w * h); lv_disp_flush_ready(drv); /* 必须调用,且一次刷新只调一次 */ } void lv_port_disp_init(void) { lv_disp_draw_buf_init(&draw_buf, buf_1, NULL, LCD_HOR_RES * 40); static lv_disp_drv_t disp_drv; 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 = &draw_buf; lv_disp_drv_register(&disp_drv); }area的坐标是闭区间,算宽高要x2 - x1 + 1,少加一就会每行少一个像素,界面右边出现一条残影。color_p指向本次要刷的像素起始地址,LCD_WritePixels按uint16_t写,是因为 16 位色下每个像素两个字节;如果你的屏驱动接口是 8 位字节流,就按w * h * 2字节写,别把像素数当字节数。
用 DMA 时,disp_flush里只启动传输,不能在启动后立刻调lv_disp_flush_ready。正确做法是把disp_drv改成全局或文件级静态,在 DMA 完成中断里调:
static lv_disp_drv_t disp_drv; static void lcd_dma_done_cb(void) { lv_disp_flush_ready(&disp_drv); }DMA 完成回调里只调一次,传输启动失败时也要补调,否则 LVGL 会一直等这一帧,后面所有刷新都停住。双缓冲适合 DMA:一块缓冲在刷屏,另一块给 LVGL 画下一帧,但 RAM 要翻倍。320×240 全屏双缓冲是 320×240×2×2,约 300KB,多数单片机扛不住,所以更常见的是两块 40 行的小缓冲。
3.2 输入驱动:触摸坐标校准与按下释放状态
触摸驱动最容易犯的错,是把原始 ADC 值直接塞给 LVGL。原始值范围可能是 0~4095,屏幕坐标是 0~319,不映射当然点不中。先读原始坐标,再做线性映射,必要时交换 X/Y 或镜像。
static void touch_read(lv_indev_drv_t *drv, lv_indev_data_t *data) { uint16_t x, y; bool pressed = touch_read_xy(&x, &y); if (pressed) { /* TOUCH_X_MAX / TOUCH_Y_MAX 用实测原始最大值,下面只是示意 */ int32_t sx = (int32_t)x * LCD_HOR_RES / TOUCH_X_MAX; int32_t sy = (int32_t)y * LCD_VER_RES / TOUCH_Y_MAX; if (sx < 0) sx = 0; if (sx >= LCD_HOR_RES) sx = LCD_HOR_RES - 1; if (sy < 0) sy = 0; if (sy >= LCD_VER_RES) sy = LCD_VER_RES - 1; >static void btn_event_cb(lv_event_t *e) { lv_obj_t *btn = lv_event_get_target(e); lv_obj_t *label = lv_obj_get_child(btn, 0); lv_label_set_text(label, "clicked"); } void ui_test_create(void) { lv_obj_t *scr = lv_scr_act(); lv_obj_set_style_bg_color(scr, lv_color_hex(0x101418), 0); lv_obj_t *btn = lv_btn_create(scr); lv_obj_set_size(btn, 120, 48); lv_obj_align(btn, LV_ALIGN_CENTER, 0, 0); lv_obj_add_event_cb(btn, btn_event_cb, LV_EVENT_CLICKED, NULL); lv_obj_t *label = lv_label_create(btn); lv_label_set_text(label, "test"); lv_obj_center(label); lv_obj_t *bar = lv_bar_create(scr); lv_obj_set_size(bar, 160, 12); lv_obj_align(bar, LV_ALIGN_CENTER, 0, 60); lv_bar_set_value(bar, 60, LV_ANIM_OFF); }按钮文字变了但进度条不显示,先查flush_cb的区域计算;进度条显示但按钮点不动,查触摸坐标和LV_INDEV_STATE_REL;两者都正常但背景有花点,查 SPI 字节序和LV_COLOR_16_SWAP。这三步能把问题锁在显示、输入、配置三个格子里,比一上来调整个界面快得多。
4. 界面 GUI 设计落地:从控件树到事件回调的工程化写法
能跑 demo 不等于能交付界面。demo 里控件少、逻辑简单,真正做产品时,页面一多、回调一乱,代码会比驱动还难维护。我的习惯是先把控件树画在纸上,再写创建函数;样式统一抽出来;事件回调只做“发命令”,不在里面跑业务。
4.1 先画控件树再写代码:屏幕、容器、控件的层级
LVGL 的对象是树形结构,父对象删除时子对象一起删。页面切换不要用lv_obj_del(lv_scr_act())把屏幕删了,常见做法是在当前屏幕上放一个页面容器,切换时lv_obj_clean(page)清掉子控件,再建新页面。容器用lv_obj_create创建,大小用LV_PCT(100)跟随父对象,布局用 flex 排。
static lv_obj_t *ui_create_page(lv_obj_t *parent) { lv_obj_t *page = lv_obj_create(parent); lv_obj_set_size(page, LV_PCT(100), LV_PCT(100)); lv_obj_set_flex_flow(page, LV_FLEX_FLOW_COLUMN); lv_obj_set_flex_align(page, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_style_pad_all(page, 8, 0); lv_obj_set_style_border_width(page, 0, 0); return page; }LV_PCT(100)是百分比尺寸,父容器变宽变高时子对象跟着变,比写死 320×240 更适合不同屏。lv_obj_set_flex_flow(page, LV_FLEX_FLOW_COLUMN)让子控件从上到下排,lv_obj_set_flex_align三个参数分别管主轴、交叉轴、轨道对齐。lv_obj_set_style_pad_all给容器内边距,避免控件贴边。
控件树不要太深。每多一层容器,绘制和布局就多一层开销。一个页面通常“屏幕 → 顶部栏 → 内容区 → 控件”就够了。列表项多的时候,不要一次创建几百个按钮,用 LVGL 的列表或表格控件,或者只创建当前可见的几项,滚动时复用。
4.2 用样式和主题统一风格,别在每个控件上重复设置
同一个按钮颜色,如果在每个按钮创建后都写一遍lv_obj_set_style_bg_color,后面改配色要改几十处。把样式对象抽出来,初始化一次,创建控件时挂上去。样式对象必须是静态或全局生命周期,不能定义在函数里返回,函数栈一退,样式内存就没了。
static lv_style_t style_btn_primary; static lv_style_t style_btn_pressed; static void ui_style_init(void) { lv_style_init(&style_btn_primary); lv_style_set_bg_color(&style_btn_primary, lv_color_hex(0x2F6FED)); lv_style_set_bg_opa(&style_btn_primary, LV_OPA_COVER); lv_style_set_radius(&style_btn_primary, 8); lv_style_set_text_color(&style_btn_primary, lv_color_white()); lv_style_init(&style_btn_pressed); lv_style_set_bg_color(&style_btn_pressed, lv_color_hex(0x1B4FBF)); } static void ui_apply_primary(lv_obj_t *btn) { lv_obj_add_style(btn, &style_btn_primary, LV_STATE_DEFAULT); lv_obj_add_style(btn, &style_btn_pressed, LV_STATE_PRESSED); }lv_style_set_bg_opa设成LV_OPA_COVER才是实心背景,默认可能是透明,按钮看起来像只有文字。LV_STATE_PRESSED是按下状态,松开后自动回默认状态。样式可以叠加,后添加的优先级更高;不要给同一个状态重复加同属性样式,后期排查很痛苦。
字体、圆角、边框、阴影这些也放样式里。不同页面共用一套style_btn_primary,改配色只改一处。需要局部微调时,再用lv_obj_set_style_*覆盖单个控件,覆盖项越少越好。
4.3 事件回调与页面切换:把业务逻辑挡在 GUI 线程外
按钮回调里最忌讳三件事:长延时、阻塞式等待、直接操作硬件。回调
本文还有配套的精品资源,点击获取