第1章:LVGL 的崛起与本仓库解剖

一个 C 语言的 UI 库,凭什么成为嵌入式图形的事实标准?

🔬

本章导师:费曼

核心方法论:第一性原理,从"为什么"出发

「在嵌入式世界里,屏幕上像素到 MCU 之间隔着厚厚的一层抽象。学 LVGL 之前,先问:为什么嵌入式 UI 需要一套框架?它到底解决什么痛点?把这个问题想透,后面每一个 API 你都能猜个八九不离十。」

1.1 嵌入式 UI 的痛点

在动手敲任何一行 LVGL 代码之前,先回到第一性原理:没有框架时,在嵌入式屏幕上画界面到底是什么样的?绝大多数 MCU 开发板上,屏幕背后是一块帧缓冲(frame buffer)——一片按像素排列的内存。想画一个按钮,你要把按钮的颜色、边框、文字的每个像素手动写进缓冲区;按钮被按下要变色,你得先轮询触摸芯片,再决定重绘哪一块区域。

这不是"难",而是"全是体力活"。你写下的每一段绘制代码都只对这一块屏、这一个界面有效。可一旦界面多起来,问题就从体力升级为复杂度:两个界面之间如何切换?按下物理按键回到哪个界面?动画播放到一半时来了触摸怎么处理?没有框架时,答案几乎都是同一个——一个巨大的 switch-case 状态机,把所有界面的"当前状态"和"输入分发"揉在一起。每加一个界面,状态机就膨胀一圈;每改一处逻辑,都要担心会不会碰坏另一个界面的绘制。

抽象的意义:对象树与渲染引擎

框架的解法,是把屏幕组织成一棵对象树(object tree)。按钮不再是一堆散落的像素,而是一个拥有位置、尺寸、样式、事件的对象;整个界面就是这棵树的根对象(LVGL 里叫 screen)下挂着的若干子对象。你只声明"这里放一个按钮、那里放一个标签",至于它怎么被画出来、按下时怎么响应——全部交给库去处理。

渲染引擎遍历这棵对象树,把每个对象按自己的状态画到缓冲区。这一层抽象是所有图形框架的共同本质:无论是桌面上的 Qt、移动端的 Flutter,还是内存按 kB 计的 LVGL,核心都是同一件事。理解了这一点,你就理解了 LVGL 大部分 API 的设计意图。

费曼提示

当你第一次调用 lv_button_create() 时,别只把它当"一个创建按钮的函数"。它背后是"对象树 + 渲染引擎 + 事件分发"整套体系的一次落地。先想透这个体系,后面每一个 API 你都能从它推导出来。

1.2 LVGL 是什么

LVGL(Light and Versatile Graphics Library,轻量而多能的图形库)是一个免费开源的 UI 库,目标是为"任何厂商的 MCU 或 MPU、任何平台"提供图形界面。官方 README 把它定位得很清楚:无外部依赖,从几十 kB 内存的小 MCU,到带 3D 加速的多核 Linux MPU,都能编译运行。对典型的 UI,你只需要约 100 kB RAM、200–300 kB Flash,外加一块 1/10 屏幕大小的渲染缓冲。

它的"开箱即用"体现在一长串特性清单(README「Features」一节):30+ 个内置控件(按钮、滑块、图表、键盘、弧、表格……);一套灵活而强大的样式系统,100+ 样式属性;Flex 与 Grid 两种自动布局引擎;Observer 数据绑定,让 UI 与应用数据联动;多输入设备支持(触摸、鼠标、按键、编码器、外部按钮);以及多显示器同时驱动。

光看一个按钮就能直观感受它的风格。README 里"C 与 XML 两种写法"的例子就是这样——C 代码创建按钮并居中,注册点击回调,再在按钮上放一个标签:

/* 创建一个居中按钮,点击时打印消息 */
lv_obj_t * button = lv_button_create(lv_screen_active());
lv_obj_center(button);
lv_obj_add_event_cb(button, button_clicked_cb, LV_EVENT_CLICKED, NULL);

lv_obj_t * label = lv_label_create(button);
lv_label_set_text(label, "Hello from LVGL!");

注意第 1 行——lv_screen_active() 返回的就是 1.1 里说的"对象树的根"。你不需要关心缓冲区怎么分配、像素怎么刷,lv_obj_center() 会替你完成布局,LV_EVENT_CLICKED 替你完成事件分发。当然,这些便利也不是白来的,LVGL 有自己的资源占用清单:

需求最低配置
RAM~100 kB(典型 UI)
Flash~200–300 kB
渲染缓冲1/10 屏幕(支持 partial 渲染模式)
极致精简32 kB RAM / 128 kB Flash(裸机极限)

1.3 解剖本仓库 lvgl-sample

先明确一点:这个 lvgl-sample 工作区并不是一个"应用工程",而是一个「库 + 工具」的组合。就学习 LVGL 而言,仓库顶层有两个核心目录,分工极其清晰——lvgl-master/ 是 LVGL 的完整源码仓库(本教程所有代码都建立在这份源码之上);LVGL_Pro_CLI-2.0.2-rc1-darwin/ 是官方设计工具 LVGL Pro 的命令行版本,负责把界面描述(XML)翻译成纯 C 代码。

/* lvgl-sample 工作区结构 */
lvgl-master/                   LVGL v9.6.0 库源码
LVGL_Pro_CLI-2.0.2-rc1-darwin/ 设计工具 CLI(XML→C)

先看 lvgl-master/。顶层文件可以分成几类:lv_conf_template.h 是配置模板;CMakeLists.txt 是构建入口(配套还有 lvgl.mkSConscript 等,README 说 Make、CMake、简单通配符编译都支持);src/ 是库的全部实现;examples/demos/ 提供可直接运行的示例与演示工程;docs/ 存放文档(含中文版 README);include/ 是面向使用者的公共头文件目录。你日常打交道最多的,就是 src/include/

lv_conf_template.h:一切的开关

在这些文件里,lv_conf_template.h 值得单独说。LVGL 几乎所有行为都由配置宏控制——内存分配方式、功能模块开关、渲染缓冲策略……使用流程正如 README「Porting manually」一节所说:把它复制为 lv_conf.h,把开头的 #if 0 改成 #if 1 以启用配置,再按需裁剪。默认配置通常就能跑起来,这也是新手最该记住的一步。

这个文件还有一个用处:它的头部注释写着 Configuration file for v9.6.0,是判断当前仓库版本最直接的线索之一。我们下一节就来系统地读版本。

1.4 src 子系统地图

打开 lvgl-master/src/,你看到的是 19 个子系统目录。别被数量吓到——它们不是杂乱堆放的,而是按职责分层:core 在最中心,widgets 建立在 core 之上,draw 负责把对象画出来,displayindev 是连接物理世界的两个"口",其余子系统则围绕它们提供通用能力。读这份目录,就像在读一张地图。

举几个例子理解分工。core 是对象模型与渲染管线的核心——lv_obj 的生命周期、布局、事件都从这里出发,是整个库的"地基";widgets 是建立在地基上的控件库;draw 是一组渲染后端(sw 软件渲染、openglesvg-lite 等),切换后端不影响你的 UI 代码;display 封装屏幕与渲染缓冲(还记得 1/10 缓冲吗?部分渲染模式就在这里定义);indev 抽象各类输入设备,lv_indev_type 里定义了 pointer / keypad / encoder / button 等类型。

子系统职责
core对象模型(lv_obj)与渲染管线核心
widgets30+ 内置控件(button、label、slider、chart…)
draw渲染引擎(sw / opengles / vg-lite…)
display / indev屏幕与输入设备抽象
layoutsFlex / Grid 自动布局引擎
font / image字体加载与图像解码
themes / libs / misc主题、第三方库集成、通用工具

还有几个"隐藏"子系统值得知道。debugging 提供调试辅助;osal 是对操作系统原语(互斥锁、信号量、线程)的抽象,配合 tick 时基,让 LVGL 能在裸机、FreeRTOS、Linux 等不同宿主上运行——这正是它"无外部依赖"的底气来源。

费曼提示

读源码别从 drawwidgets 开始,那是"结果"而不是"原因"。先看 corelv_obj.c 的创建与销毁,再看 misc 里的计时器与动画,你就能拼出 LVGL 的运行主循环(README 里的 lv_timer_handler())。地图越清晰,后面每一章都越省力。

1.5 版本与 API 兼容

先确认你手上是哪个版本。lvgl-master/include/lvgl/lv_version.h 用三个宏定义主次修订号,外加一个信息串:主版本 9、次版本 6、修订 0、信息 "dev"——也就是说这是一份 9.6.0-dev 的源码,与 lv_conf_template.h 头部注释完全吻合。

/* include/lvgl/lv_version.h(节选) */
#define LVGL_VERSION_MAJOR 9
#define LVGL_VERSION_MINOR 6
#define LVGL_VERSION_PATCH 0
#define LVGL_VERSION_INFO "dev"

版本号背后是 API 的演进史。LVGL 9 相对 8 做过一次大规模改名:lv_btn_create() 改名为 lv_button_create(),对象家族也全面重命名。为了让存量代码不至于一夜失效,仓库在 src/ 根下放了一串"兼容垫片":lv_api_map_v8.hlv_api_map_v9_0.h……一直到 lv_api_map_v9_5.hinclude/lvgl/api_map/ 下也有一份)。它们用宏把旧名字映射到新 API——这就是为什么很多 v8 时代的示例拿到 v9 也能编译通过。

最后是使用上的一个坑:include 路径。仓库根部有一个 lv_version.h,但它是"历史遗留"的转发头文件,顶部就带着一行 #warning 提示它已废弃。正确做法是包含 include/lvgl/lv_version.h,或者更省事地直接 #include "lvgl/lvgl.h",它会替你引入版本信息与全部公共 API。

注意

仓库内 lvgl-master/lv_version.h 顶部有 #warning This file is deprecated,请包含 include/lvgl/lv_version.h,或直接 #include "lvgl/lvgl.h"

章末练习

练习 1:仓库解剖 入门

打开 lvgl-master/,列出 3 个顶层文件(或目录)并说明它们的用途。不要只写名字,试着说说它们各自解决什么问题。

提示

lv_conf_template.hCMakeLists.txtsrc/ 入手;想得更深一点,include/examples/ 也可以算进来。

参考答案

示例:lv_conf_template.h——配置模板,复制为 lv_conf.h 并启用开头 #if 0 后,可裁剪功能与资源占用;CMakeLists.txt——构建入口,把 src/ 下的源码组织成库(还支持 lvgl.mk、SConscript 等方式);src/——库的全部实现,按子系统分目录。此外 include/ 是面向使用者的公共头文件,examples/demos/ 提供可直接运行的示例与演示。

练习 2:子系统职责判断 进阶

假设你要做下面四件事,分别应该查阅 / 修改 src/ 下的哪个子系统?(1) 适配一块新分辨率的屏幕并调整渲染缓冲;(2) 从软件渲染切换到 OpenGL 后端;(3) 让文字正确显示中文与阿拉伯文;(4) 实现一个自动居中、随窗口伸缩的 Flex 布局。

提示

对照 1.4 的子系统地图:显示、绘制、字形、排版,四件事恰好对应四个子系统。

参考答案

(1) display——屏幕与渲染缓冲抽象,lv_display_set_buffers()、部分渲染模式都在这里;(2) draw——渲染后端切换(sw / opengles / vg-lite);(3) font——字体加载与文本渲染,支持 UTF-8 及 CJK、阿拉伯文等书写系统;(4) layouts——Flex / Grid 自动布局引擎。核心启示:子系统边界就是职责边界,这也正是 src/ 划分目录的依据。

练习 3:第一性原理推演 API 兼容 挑战

LVGL 9 做了一个看似矛盾的决定:一边大改 API(lv_btn_createlv_button_create),一边又提供 lv_api_map_v8.h 等垫片让旧代码照常编译。请用第一性原理分析:这两个决定各自的动机是什么?它们为什么能同时成立?如果你来设计,会如何平衡"干净的 API"与"存量代码的兼容"?

提示

想想 v9 内部为什么还需要 v9_0v9_5 的逐版本垫片;如果完全不兼容,社区要付出什么代价;如果永远兼容,新 API 会不会被旧名字拖累。

参考答案

大规模改名(v8→v9)是为了让 API 更统一、更可维护——例如控件名统一为 button 而非缩写 btn——这是"面向未来"的必要代价;而 api_map 垫片用宏把旧名映射到新名,让迁移可以渐进完成:先能编译、再逐步替换。二者能共存的关键是 C 预处理器的机制:改名发生在"源码头",垫片是"兼容层",互不干扰。v9_0~v9_5 的逐版本垫片,则是为了让同主版本内的升级"小步快跑"、平滑过渡。平衡之道:主版本可以激进,小版本必须平滑——这也解释了为什么未来版本大概率还会延续"主版本换名 + 垫片过渡"的模式。