工欲善其事,必先利其器:把 lv_conf、CMake 与 SDL 模拟器串成一条链,跑起第一个界面
核心方法论:工欲善其事,必先利其器
「学 LVGL 的头一个小时,别急着写控件。先把三样家伙什儿摆齐:一份被激活的 lv_conf.h、一条能把库编进你工程的 CMake 链、一个能在电脑上画窗口的 SDL 模拟器。工具不顺,后面每一章都会在编译错误上浪费时间。工欲善其事,必先利其器——这一章,我们把『器』磨好。」
上一章提到过 lv_conf_template.h 是配置模板,这一章我们真正动手。流程正如 README「Porting manually」一节所说:把 lv_conf_template.h 复制成 lv_conf.h,再把开头的 #if 0 改成 #if 1 以启用整份配置。为什么是改这一行?因为整个配置文件的正文都被包在 #if 0 条件块里——不改这一行,后面几千行宏定义统统不生效,LVGL 会退回到内置默认值。
# 复制模板为 lv_conf.h(在 lvgl 目录旁)
cp lv_conf_template.h lv_conf.h
# 用编辑器打开,找到第一处条件编译,把 0 改成 1:
#if 0 /* Set this to "1" to enable content */ --> #if 1
配置生效后,LVGL 几乎所有行为都由这里的宏控制。它们大体分两类:LV_USE_* 功能开关(要不要编译某个模块)与资源参数(内存池多大、色深多少、用哪种渲染后端)。一个关键常量是 LV_COLOR_DEPTH,默认 16,它决定每个像素占几个字节(16 位 = 2 字节,32 位 = 4 字节),直接关系到 2.4 节渲染缓冲的字节数。
/* lv_conf.h(节选):功能开关都是 LV_USE_* 形式 */
#define LV_COLOR_DEPTH 16 /* 色深:每像素 2 字节 */
#define LV_USE_DRAW_SW 1 /* 软件渲染后端 */
#define LV_USE_BUTTON 1 /* button 控件 */
#define LV_USE_LABEL 1 /* label 控件 */
#define LV_USE_SDL 0 /* SDL 桌面模拟器:2.3 节再打开 */
模板里几乎每个宏都带注释说明用途,默认配置(#if 1 后原样保留)通常就能编译跑起来。新手最常见的两个误区:一是改了 lv_conf.h 却忘了重新编译(这些是编译期宏,不是运行期设置);二是盲目把一堆 LV_USE_* 全部置 1——每开一个模块就多一分 Flash 与 RAM 开销,按需裁剪才是正道。
| 配置宏 | 默认值 | 作用 |
|---|---|---|
LV_COLOR_DEPTH | 16 | 颜色位深,决定每像素字节数(16→2B,32→4B) |
LV_USE_DRAW_SW | 1 | 软件渲染后端(LVGL 内置,最通用) |
LV_USE_BUTTON | 1 | button 控件模块 |
LV_USE_LABEL | 1 | label 控件模块 |
LV_USE_SDL | 0 | SDL2 桌面模拟器驱动 |
lv_conf.h 是编译期配置,改完必须重新编译。想快速验证你的配置有没有语法问题,可以先把 #if 0 改成 #if 1 后单独编译 lvgl 库本身(见 2.2)——它比任何编辑器都更早告诉你错在哪。
LVGL 原生支持 CMake,集成方式极简。官方文档 docs/src/integration/building/cmake.mdx 给出的最小接入就是在你工程的 CMakeLists.txt 里加一句 add_subdirectory(lvgl),然后链接生成的 lvgl 目标即可。这个目标由 lvgl-master/env_support/cmake/main.cmake 用 add_library(lvgl ${SOURCES}) 定义(同时注册了别名 lvgl::lvgl),它已经把 src/ 下的源码按配置编译好、公共头文件路径也排好了。
# 你的 CMakeLists.txt:默认方式,lv_conf.h 放在 lvgl/ 目录旁
add_subdirectory(lvgl)
target_link_libraries(my_app lvgl)
# 如果你的 lv_conf.h 在别处,先告诉 CMake 它在哪里:
set(LV_BUILD_CONF_PATH /abs/path/to/lv_conf.h)
add_subdirectory(lvgl)
配置文件的定位方式不止一种。除了把 lv_conf.h 放在 lvgl 目录旁(默认查找),还可以用 LV_BUILD_CONF_PATH 直接指定文件路径,或用 LV_BUILD_CONF_DIR 指定它所在目录(需配合 LV_CONF_INCLUDE_SIMPLE)。这两种路径类变量也都能在命令行用 -D 传参——这是 CI 或脚本化构建最常用的方式,main.cmake 里甚至会把相对路径转换为绝对路径并打印一行 Using configuration: 方便确认。
# 命令行方式:配置 + 构建(Ninja 示例)
cmake -B build -DLV_BUILD_CONF_PATH=/abs/path/to/lv_conf.h
cmake --build build
| CMake 变量 | 作用 |
|---|---|
LV_BUILD_CONF_PATH | 直接指定 lv_conf.h 的文件路径(相对路径会转为绝对) |
LV_BUILD_CONF_DIR | 指定包含 lv_conf.h 的目录(配合 LV_CONF_INCLUDE_SIMPLE) |
LV_BUILD_USE_KCONFIG | 改用 Kconfig 作为配置系统(默认 OFF) |
LV_BUILD_CONF_PATH 与 LV_BUILD_CONF_DIR 不能同时使用——main.cmake 里对同时设置直接 FATAL_ERROR。二选一,优先用 LV_BUILD_CONF_PATH。
只想编译 lvgl 仓库自身来验证配置,也可以不进任何工程:cmake -B build && cmake --build build。仓库自带 CMakePresets.json(linux-base、windows-base 等预设),用来快速确认你的 lv_conf.h 能不能编过。
在真机屏幕上调试 UI 很麻烦:要烧固件、看串口、备一块开发板。LVGL 官方的解法是桌面模拟器——README「Integration」把 SDL 明确列在 Simulator / desktop 一类:在 PC 上开一个窗口当屏幕,鼠标当触摸。这套驱动的源码就在 lvgl-master/src/drivers/sdl/,包含 lv_sdl_window.c(窗口)、lv_sdl_mouse.c(鼠标)、lv_sdl_keyboard.c(键盘)等文件,对应公开头文件在 include/lvgl/drivers/sdl/。
启用方式分两步。第一步在 lv_conf.h 里把 LV_USE_SDL 从 0 改成 1,并按需配置 LV_SDL_INCLUDE_PATH(默认 "SDL2/SDL.h")等子选项;第二步保证本机装了 SDL2 开发库——CMake 在 env_support/cmake/dependencies.cmake 里检测到 CONFIG_LV_USE_SDL(即 lv_conf.h 中 LV_USE_SDL 预处理后的 CMake 内部变量)后,会走 find_package → pkg-config → FetchContent 的顺序自动解析 SDL2 依赖。
/* lv_conf.h:打开桌面模拟器 */
#define LV_USE_SDL 1
#define LV_SDL_INCLUDE_PATH "SDL2/SDL.h"
#define LV_SDL_RENDER_MODE LV_DISPLAY_RENDER_MODE_DIRECT
读一读 lv_sdl_window.c 的 lv_sdl_window_create() 你会发现一个关键事实:SDL 驱动替你完成了 2.4 节要手写的那一大半初始化。它内部会调用 SDL_Init()、lv_tick_set_cb(SDL_GetTicks)(自动把时基接到 SDL 的毫秒时钟)、lv_display_create(),还会建一个事件处理 timer 把 SDL 事件转成鼠标/键盘输入。所以模拟器代码里,你只需要开窗口、补一个输入设备,剩下的交给驱动。
/* 模拟器主程序:SDL 驱动已把 tick、display 都接好了 */
lv_init();
lv_display_t * disp = lv_sdl_window_create(480, 320);
lv_sdl_mouse_create(); /* 鼠标当触摸 */
lv_sdl_keyboard_create(); /* 键盘输入 */
/* UI 照常写在活跃屏上 */
lv_obj_t * btn = lv_button_create(lv_screen_active());
lv_obj_center(btn);
while(1) {
lv_timer_handler();
SDL_Delay(5);
}
模拟器只负责开发期调试,它替你写的 lv_tick_set_cb、flush 回调、触摸回调在真机上统统要自己实现。正因如此,2.4 的最小程序(不用 SDL、走原生 API)才是你移植到任何屏幕的通用模板——模拟器只是把同一套东西预组装好给你看。
现在抛开模拟器,看一个不依赖任何平台驱动的最小程序——它来自 README「Porting manually」的 C 示例,是所有 LVGL 移植的骨架。流程固定五步:lv_init() 初始化全局 → lv_tick_set_cb() 注册毫秒时基 → lv_display_create() 创建显示 → lv_display_set_buffers() 分配渲染缓冲 → lv_display_set_flush_cb() 注册刷屏回调,然后就能在 lv_screen_active() 上创建控件,最后进主循环不断调用 lv_timer_handler()。
/* 最小程序(裸机 + 任一屏):五步初始化 + UI + 主循环 */
#include "lvgl/lvgl.h"
#define TFT_HOR_RES 320
#define TFT_VER_RES 240
static uint32_t my_tick_cb(void) /* 返回系统毫秒数 */
{
return my_get_millisec();
}
void main(void)
{
my_hardware_init();
lv_init(); /* 1. 初始化 LVGL 全局状态 */
lv_tick_set_cb(my_tick_cb); /* 2. 注册毫秒时基 */
lv_display_t * disp = lv_display_create(TFT_HOR_RES, TFT_VER_RES);
static uint8_t buf[TFT_HOR_RES * TFT_VER_RES / 10 * 2];
lv_display_set_buffers(disp, buf, NULL, sizeof(buf),
LV_DISPLAY_RENDER_MODE_PARTIAL);
lv_display_set_flush_cb(disp, my_flush_cb);
lv_obj_t * btn = lv_button_create(lv_screen_active());
lv_obj_center(btn);
lv_obj_t * label = lv_label_create(btn);
lv_label_set_text(label, "Hello LVGL!");
while(1) { /* 3. 主循环驱动渲染/动画/事件 */
lv_timer_handler();
my_sleep_ms(5);
}
}
最难理解的一步是 flush 回调。lv_display_set_buffers 分配的只是 LVGL 内部画图用的内存;真正把图像送进屏幕(帧缓冲或 LCD 控制器)靠的是你注册的 my_flush_cb——它的签名是 void (*)(lv_display_t * disp, const lv_area_t * area, uint8_t * px_map),LVGL 每次渲染完一块区域就把坐标 area 和像素数据 px_map 交给你,你把它写进屏幕,然后务必调用 lv_display_flush_ready(disp) 告诉 LVGL"这块刷完了,可以继续画下一块"。
/* flush 回调:把 px_map 画到 area 区域,并回执 lv_display_flush_ready */
static void my_flush_cb(lv_display_t * disp, const lv_area_t * area,
uint8_t * px_map)
{
/* 写屏代码:把 px_map 拷贝到 area->x1..x2, y1..y2 对应的屏幕位置 */
lv_display_flush_ready(disp); /* 通知 LVGL 本区域已刷完 */
}
| 调用 | 职责 |
|---|---|
lv_init() | 初始化全局状态(内存、timer、draw 等子系统) |
lv_tick_set_cb() | 注册毫秒级时基,LVGL 借此计算时间/动画 |
lv_display_create(w, h) | 创建一块 display 并附带一个默认屏幕 |
lv_display_set_buffers(...) | 指定渲染缓冲与渲染模式(PARTIAL/DIRECT/FULL) |
lv_display_set_flush_cb(...) | 注册"把缓冲刷到屏幕"的回调 |
lv_timer_handler() | 驱动重绘、动画与事件处理(主循环每次调用) |
屏幕白屏或花屏,九成是缓冲尺寸与 LV_COLOR_DEPTH 不匹配:16 位色深每像素 2 字节,32 位是 4 字节。partial 模式的 1/10 屏只是"最少够用",LV_DISPLAY_RENDER_MODE_PARTIAL 会拿更小缓冲做局部刷新——先跑通,再谈性能。
把 lvgl-master/lv_conf_template.h 复制为你的 lv_conf.h,找到开头的 #if 0 并改成 #if 1。然后回答两个问题:当前 LV_COLOR_DEPTH 的值是多少?LV_USE_SDL 的值是多少?
在文件里搜索 #define LV_COLOR_DEPTH 和 #define LV_USE_SDL,注意它们各自的分隔注释块。
LV_COLOR_DEPTH 为 16(每像素 2 字节);LV_USE_SDL 为 0(默认关闭)。若打算在 PC 上跑模拟器,需改为 1 并确认 LV_SDL_INCLUDE_PATH 指向本机 SDL2 头文件。
写一个最小工程把 lvgl 集成进来:一个 CMakeLists.txt + 一个 main.c,链接 lvgl 目标;再把 lv_conf.h 从"lvgl 目录旁"挪到外部路径,改用 LV_BUILD_CONF_PATH 指向它。
接入只需 add_subdirectory(lvgl) + target_link_libraries(my_app lvgl);配置文件定位用 set(LV_BUILD_CONF_PATH ...) 或命令行 -DLV_BUILD_CONF_PATH=...。
最小 CMakeLists.txt:add_subdirectory(lvgl)、add_executable(my_app main.c)、target_link_libraries(my_app lvgl),并在前面加 set(LV_BUILD_CONF_PATH /path/outside/lv_conf.h)(若文件不在 lvgl 目录旁)。等价命令行写法:cmake -B build -DLV_BUILD_CONF_PATH=/path/outside/lv_conf.h。
把 2.4 最小程序的分辨率从 320×240 改成 480×320,并同步调整 buf 数组。请说明为什么 16 位色深下缓冲大小是 480 * 320 / 10 * 2,如果换成 LV_COLOR_DEPTH 32 又该是多少?
partial 模式只需 1/10 屏幕的缓冲;每像素字节数 = LV_COLOR_DEPTH / 8。想想 /10 和 *2 各代表什么。
static uint8_t buf[480 * 320 / 10 * 2]; —— 总面积 480×320,partial 模式取 1/10 为工作区,乘以每像素 2 字节(16 位色深)。若 LV_COLOR_DEPTH 为 32,则每像素 4 字节,改为 480 * 320 / 10 * 4。注意 sizeof(buf) 会随数组自动变化,别把常量写死。
打开 lvgl-master/src/drivers/sdl/lv_sdl_window.c,通读 lv_sdl_window_create():列出它替你自动完成的初始化步骤;然后反推——如果去掉 SDL、换成真实屏幕,你需要自己补齐的 checklist 是什么?
观察它内部调了哪些 lv_* API(tick、display、timer、indev);再对照 2.4 的五步流程和 README 里触摸设备的写法。
SDL 驱动自动完成:SDL_Init() 初始化 SDL;lv_tick_set_cb(SDL_GetTicks) 接入毫秒时基;lv_display_create(hor, ver) 创建显示;建事件处理 timer 把 SDL 事件分发为鼠标/键盘输入。真机 checklist:屏幕驱动初始化 → lv_tick_set_cb()(用系统毫秒)→ lv_display_create() → lv_display_set_buffers() → lv_display_set_flush_cb() → 触摸 lv_indev_create() + LV_INDEV_TYPE_POINTER 回调 → 主循环 lv_timer_handler()。