简介:面向嵌入式 Linux 与 ARM 平台开发者的 LVGL 图形界面移植文档,聚焦在开发板上完成图形库适配、交叉编译与上板运行这一常见任务。内容围绕 Gitee 打包源码的工程改造展开,涉及 custom 与 generated 目录接入、CMake 工具链配置、多余库裁剪、主函数与屏幕参数调整、构建命令组织以及显示驱动兼容性和资源占用等排错方向,适合具备一定 Linux 运维与嵌入式硬件基础、希望把 LVGL 界面应用到实际项目的读者。压缩包共 1 个 docx 文件,约 449KB,篇幅集中,便于查阅。已有 3381 人学习,说明该主题在嵌入式 GUI 实践中关注度较高。读者可从中获得从源码准备到编译产物上板验证的整体思路,以及交叉编译链设置、工程裁剪和常见问题定位的参考,用于搭建自己的移植流程与检查清单。
1. 一块 800×480 的屏和一棵 cmake 树:LVGL 8.1 上 ARM Linux 板子在移什么
手上有块 800×480 的屏,接到跑 Linux 的 ARM 核心板上,最省事的 GUI 路线不是 Qt,也不是塞一整套桌面环境进去,而是让 LVGL 8.1 直连 framebuffer。嵌入式硬件资源紧,一个几十兆常驻内存的轻量图形栈,比拖着一整套窗口系统更适合现场调试和日常运维。真正卡人的从来不是lv_conf.h怎么写,而是交叉编译链在 cmake_lvgl8.1 这棵 CMake 树上没改干净:PC 模拟时留下的 SDL2、X11 目标还挂着,编译一路绿灯,上板一跑就是黑屏或者段错误。下面按一条能复现的路径走:把 GUI Guider 导出的 custom 与 generated 并进工程、改掉三处工具链开关、砍掉模拟专用库、把lv_ui接进 main,最后交出build/bin里那个能在板子上直接起的可执行文件。
2. 工程骨架拆解:cmake_lvgl8.1 里的目录、顶层 CMake 与三处工具链开关
拿到打包文件,很多人第一反应是直接cmake ..试一把,看能不能过。这一步在宿主机上基本都会过,因为默认工具链就是本机的 gcc。问题在于过了也没用——产出的 x86_64 程序在 ARM 板子上根本起不来。所以顺序要反过来:先把这棵树的形状看清楚,知道哪一层在管工具链、哪一层在管源码收集,再动手改。
2.1 解包第一件事:看清 src、lvgl、lv_drivers 三层
解包后先别急着进 build,用tree看两层就够,重点是确认 lvgl 本体、驱动层、应用层三者的边界在哪。常见做法是下面这种布局,具体文件名以你手上的包为准,但层次不会差太多:
| 路径 | 职责 | 移植时动不动 |
|---|---|---|
顶层CMakeLists.txt | 定义工程名、C 标准、编译选项、链接库、最终产物输出到bin/ | 必须改 |
lv_conf.h | LVGL 的全局配置,颜色深度、内存池、刷新周期全在这里 | 必须改 |
src/lvgl/ | LVGL 8.1 内核源码,只读,不要在里面动刀 | 不动 |
src/lv_drivers/ | fbdev、evdev、SDL 等驱动适配层 | 按需裁 |
src/下的子目录 | 各自由add_subdirectory拉进来,每个子目录一个CMakeLists.txt | 逐个检查 |
src/main.c(或同级入口) | 程序入口,初始化显示、输入、UI | 必须改 |
build/与bin/ | 构建中间产物与最终可执行文件 | 生成,不提交 |
这里有个容易忽略的点:src/lv_drivers/下面往往同时存在display/fbdev.c、display/SDL.c、indev/evdev.c、indev/mouse.c这些。它们在源码层面共存没问题,真正决定编不编进去的是各个CMakeLists.txt里的add_library/target_sources列表。也就是说,你要裁的是构建脚本,不是源码文件——删源码文件反而容易把别处的引用搞断。
确认完结构,先在宿主机上完整编一遍。这一步不是为了上板,是为了拿到一个「已知能跑」的基线:宿主机编译通过、SDL 模拟窗口能出界面,说明工程本身和 GUI Guider 的代码没问题;后面交叉编译出任何错,都能确定是工具链引入的,而不是应用代码本身的问题。这个基线意识在排错阶段能省掉大量来回。
# 宿主机基线编译,确认工程自洽 cd cmake_lvgl8.1 rm -rf build && mkdir build && cd build cmake .. && make -j$(nproc) file bin/*make -j$(nproc)换成-j12也行,核数比线程数略小一点更稳,尤其在小内存机器上并行编译容易把内存打满。file bin/*的作用是记住基线产物的架构,多半是ELF 64-bit LSB pie executable, x86-64,后面交叉编译后要拿它对比。
2.2 顶层 CMakeLists 的交叉编译三件套
交叉编译在 CMake 里最稳的做法是写一个工具链文件,而不是在命令行上-DCMAKE_C_COMPILER=...一路怼。工具链文件的好处是可复用、可进版本管理,换板子只改一个文件。在工程根目录建toolchain-arm.cmake:
# toolchain-arm.cmake —— 交叉编译描述文件 set(CMAKE_SYSTEM_NAME Linux) # 目标系统是 Linux,不是裸机 set(CMAKE_SYSTEM_PROCESSOR arm) # 目标架构,arm / aarch64 按板子改 # 三件套之一:C 编译器绝对路径 set(CMAKE_C_COMPILER /opt/toolchain/bin/arm-linux-gnueabihf-gcc) # 三件套之二:C++ 编译器,工程里若有 .cpp 就必须给 set(CMAKE_CXX_COMPILER /opt/toolchain/bin/arm-linux-gnueabihf-g++) # 三件套之三:sysroot,交叉编译器自带的头文件和库根目录 set(CMAKE_SYSROOT /opt/toolchain/arm-linux-gnueabihf/sysroot) # 查找规则:可执行程序在本机找,库和头文件只在 sysroot 里找 set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)路径必须写绝对路径,写相对路径cmake会去 PATH 里找,找到本机 gcc 就前功尽弃。CMAKE_SYSROOT这一行不是每个工具链都有对应目录,如果你的编译器是arm-linux-gnueabihf-前缀但没带 sysroot,把这一行注释掉,改用编译器的--sysroot内置默认值,也就是不显式指定。
然后在顶层CMakeLists.txt的project()之后补编译选项。这一步很关键,因为交叉编译器默认的-mcpu往往是最保守的通用 ARM,跑起来性能会明显偏软:
# 顶层 CMakeLists.txt,project() 之后 if(CMAKE_SYSTEM_PROCESSOR STREQUAL "arm") add_compile_options( -O2 # 发布级别优化,LVGL 渲染对 -O2 敏感 -mcpu=cortex-a7 # 按你板子的核心改,a7/a53/a9 各不相同 -mfpu=neon-vfpv4 # 有 NEON 就开,LVGL 的部分填充会用到 -mfloat-abi=hard # 与工具链的 abi 保持一致,混用会链接失败 ) endif()-mcpu写错的表现很典型:编译能过,运行时报Illegal instruction。如果板子核心型号不确定,把它去掉只留-O2,宁可慢一点也别一上来就翻车。-mfloat-abi必须和工具链、和板子上的 C 库一致,hard 和 softfp 混用会在链接阶段报 VFP register arguments 不匹配。
改完工具链文件,配置命令变成:
cd cmake_lvgl8.1 rm -rf build && mkdir build && cd build cmake -DCMAKE_TOOLCHAIN_FILE=../toolchain-arm.cmake -DCMAKE_BUILD_TYPE=Release .. make -j12rm -rf build不是洁癖,是必须。CMake 会把上一次的编译器路径缓存进CMakeCache.txt,你改了工具链文件但不删缓存,它照样用旧编译器,然后你对着一个「明明改了却没生效」的工程怀疑人生——这种玄学问题我见过太多次。
2.3 子目录 CMakeLists 与 add_subdirectory 的连带修改
打包工程里通常不止顶层一个CMakeLists.txt。src/下每个被add_subdirectory()拉进来的目录,都可能自己写死了编译器相关的东西,或者自己写了include_directories()。逐个grep一遍最省事:
# 把所有 CMakeLists.txt 里跟编译器、链接库、头文件路径相关的行捞出来 grep -rn --include=CMakeLists.txt -E "CMAKE_C_COMPILER|CMAKE_CXX_COMPILER|target_link_libraries|include_directories|link_directories" .输出会比你想的长。重点看三类:
第一类是子目录里重复设置的CMAKE_C_COMPILER。这种写法会覆盖工具链文件里的设置,必须删掉或者注释,统一由工具链文件管。
第二类是include_directories()里写死的绝对路径,比如指向某个 PC 上的第三方库目录。这种在交叉编译环境里必然找不到,要么改成相对路径,要么整行去掉——LVGL 本身对第三方依赖很少,lvgl/和lv_drivers/用相对包含就够。
第三类是target_link_libraries()里的SDL2、X11、m、pthread。m和pthread在 ARM Linux 上一般也要留(数学库和线程库,LVGL 的lv_timer_handler单线程跑不需要 pthread,但 lv_drivers 某些实现里会用到),SDL2和X11属于 PC 模拟专用,必须去掉,下一章展开讲。
改完子目录,还有一处最容易漏:add_subdirectory()的顺序。有些工程把 PC 模拟的入口和板子的入口放在不同子目录,靠一个option()切换。如果你的包里存在这种开关,正确姿势是把它关掉而不是删代码:
# 顶层 CMakeLists.txt 里若存在模拟器开关,显式关掉 option(BUILD_SIMULATOR "Build PC simulator with SDL2" OFF)用option关比删源码安全,因为源码之间的头文件引用关系还在,删了反而要连带修一堆#include。
2.4 产物自检:file、readelf 与 ldd 三道关
编译过了不等于能上板。每次交叉编译完,我固定跑三条命令,三十秒换来的是不用抱着板子反复插拔:
# 第一关:确认架构,必须是 ARM 而不是 x86-64 file build/bin/* # 第二关:确认指令集和浮点 ABI,跟板子对得上 arm-linux-gnueabihf-readelf -A build/bin/your_app | head -20 # 第三关:确认动态库依赖,不能出现宿主机的 .so arm-linux-gnueabihf-readelf -d build/bin/your_app | grep NEEDED第一关输出ELF 32-bit LSB executable, ARM, EABI5才算对,出现x86-64就是工具链没生效,回去删 build 重来。第二关看Tag_CPU_arch和Tag_ABI_VFP_args,前者要跟你-mcpu写的一致,后者VFP registers表示 hard float。第三关是排错的钥匙:如果NEEDED里出现libSDL2-2.0.so.0或libX11.so.6,说明模拟库还没裁干净,板子上绝对没有这两个东西,程序起来就是error while loading shared libraries。
ldd在交叉编译场景下不能用,它加载的是宿主机的动态链接器,输出会误导你。一律用readelf -d看NEEDED段,这是血泪经验。
3. 接上 GUI Guider 的产出:custom 与 generated 怎么放、lv_ui 怎么起
界面这块,多数人的做法是先用 GUI Guider 拖出布局、生成代码,再把生成的代码并进工程。这一步的坑不在代码本身,在于生成产物的目录约定和工程的包含路径对不上。GUI Guider 生成的代码默认期望custom/和generated/是平级的兄弟目录,而打包工程期望它们在src/下面,中间差一层路径,所有#include就全断了。
3.1 custom 与 generated 两个文件夹各自负责什么
先把两个文件夹的职责分清楚,改路径的时候才知道哪些能动、哪些不能动。
| 文件夹 | 典型内容 | 谁生成的 | 能不能手改 |
|---|---|---|---|
generated/ | gui_guider.h、gui_guider.c、events_init.c/h、setup_scr_*.c、图片与字体资源 | GUI Guider 导出 | 尽量不动,重新生成会被覆盖 |
custom/ | custom.c、custom.h,放用户自己的回调、业务逻辑、定时器 | 一半生成一半手写 | 可以改,是给你留的口子 |
generated/gui_guider.h里定义了lv_ui这个结构体,它把所有界面上需要被外部访问的控件指针打包在一起,比如guider_ui.screen_1、guider_ui.screen_1_btn_1。事件回调里想改某个控件的状态,就是从这个结构体进去。这也是为什么 main 里必须先有一个lv_ui全局实例,再调setup_ui(&guider_ui)——所有屏幕的创建过程都往这个结构体里写指针。
按输入里的做法,把两个文件夹整体拷到src/下面:
# 从 GUI Guider 的导出目录往工程里搬 cp -r /path/to/guider_export/custom cmake_lvgl8.1/src/ cp -r /path/to/guider_export/generated cmake_lvgl8.1/src/ # 确认结构对了 ls src/custom src/generatedsrc/generated/下面应该能看到gui_guider.c、gui_guider.h、events_init.c、events_init.h,以及若干setup_scr_xxx.c。src/custom/下面至少要有custom.c和custom.h。少一个就在包含路径上补,别去改#include的相对层级,改生成代码的相对包含是给自己挖坑,下次重新导出又要来一遍。
搬完之后,把这两个目录加进构建。顶层CMakeLists.txt里找到收集源码的地方,常见写法是file(GLOB ...)或者显式add_executable:
# 收集 GUI Guider 生成的源码 file(GLOB GUIDER_SOURCES ${CMAKE_CURRENT_SOURCE_DIR}/src/generated/*.c ${CMAKE_CURRENT_SOURCE_DIR}/src/custom/*.c ) # 加进最终可执行文件 add_executable(your_app ${APP_SOURCES} ${GUIDER_SOURCES}) # 头文件搜索路径:把 src 加进去,这样 #include "src/generated/gui_guider.h" 才成立 target_include_directories(your_app PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_CURRENT_SOURCE_DIR}/src )${CMAKE_CURRENT_SOURCE_DIR}指向顶层 CMakeLists 所在目录,加它是因为 main 里的包含路径写的是"src/generated/gui_guider.h",从工程根开始算。加了src目录则是因为generated/内部的#include "gui_guider.h"这种同级引用需要能解析到。两个都加上,能覆盖绝大多数包含路径问题。
3.2 main 函数的四段改造:头文件、lv_ui 实例、setup_ui、事件初始化
main 的改造是整套移植里唯一必须手写代码的地方,其余都是配置。按输入给的四行关键调用,展开成能跑的完整骨架:
#include "lvgl/lvgl.h" #include "lv_drivers/display/fbdev.h" #include "lv_drivers/indev/evdev.h" #include "src/generated/gui_guider.h" /* lv_ui 结构体定义 */ #include "src/generated/events_init.h" /* 事件回调注册 */ #include <unistd.h> lv_ui guider_ui; /* 全局实例,setup_ui 往里写控件指针 */ int main(void) { lv_init(); /* 显示:framebuffer */ fbdev_init(); static lv_color_t buf1[800 * 60]; /* 60 行的局部缓冲 */ static lv_disp_draw_buf_t draw_buf; lv_disp_draw_buf_init(&draw_buf, buf1, NULL, 800 * 60); static lv_disp_drv_t disp_drv; lv_disp_drv_init(&disp_drv); disp_drv.draw_buf = &draw_buf; disp_drv.flush_cb = fbdev_flush; disp_drv.hor_res = 800; /* 屏幕宽,必须和实际一致 */ disp_drv.ver_res = 480; /* 屏幕高 */ lv_disp_drv_register(&disp_drv); /* 输入:触摸屏 evdev */ evdev_init(); static lv_indev_drv_t indev_drv; lv_indev_drv_init(&indev_drv); indev_drv.type = LV_INDEV_TYPE_POINTER; indev_drv.read_cb = evdev_read; lv_indev_drv_register(&indev_drv); /* GUI Guider 的两步:先建界面,再挂事件 */ setup_ui(&guider_ui); events_init(&guider_ui); while (1) { lv_timer_handler(); usleep(5000); /* 5ms,避免空转吃满 CPU */ } }四段要说清楚。第一段是头文件,gui_guider.h提供lv_ui类型和setup_ui()声明,events_init.h提供events_init()。这两个头文件的相对路径按你实际的目录层级写,路径错了报的是fatal error: src/generated/gui_guider.h: No such file or directory,一眼能定位。
第二段是lv_ui guider_ui;全局实例。必须是全局,不能是 main 里的局部变量。原因在于事件回调函数散落在generated/的各文件里,它们通过extern lv_ui guider_ui;访问这个结构体,如果实例在栈上,回调一执行读到的就是野指针,典型症状是点击控件随机崩溃或者界面错乱。
第三段是setup_ui(&guider_ui),把所有屏幕和控件建出来。第四段events_init(&guider_ui)挂事件回调,缺了它界面能显示但点不动。
disp_drv.hor_res和ver_res就是输入里说的「修改屏幕像素」。LVGL 8.x 已经不用LV_HOR_RES_MAX那种编译期宏来定屏幕尺寸了,改成在驱动结构体里给,所以改分辨率改这里,改lv_conf.h里那些LV_HOR_RES是无效的——这一点在 8.x 上坑过不少人。
3.3 lv_conf.h 里跟这块屏对齐的六个参数
lv_conf.h是 LVGL 的行为总开关,改错一个可能整个界面都是花的。移植场景下真正需要动的就六项:
| 参数 | 典型值 | 说明 |
|---|---|---|
LV_COLOR_DEPTH | 32或16 | 必须和/dev/fb0的bits_per_pixel一致,不一致画面颜色全错 |
LV_MEM_SIZE | (1024U * 1024U)起 | LVGL 内部内存池,界面控件多就往上加 |
LV_DISP_DEF_REFR_PERIOD | 30 | 刷新周期 ms,帧率不够就调小,CPU 吃紧就调大 |
LV_INDEV_DEF_READ_PERIOD | 30 | 触摸扫描周期 ms,跟手性差就调小 |
LV_TICK_CUSTOM | 1 | 用系统时钟做 tick,比手动lv_tick_inc稳 |
LV_USE_LOG | 1(调试期) | 打开日志才能在板子上看到 LVGL 内部报错 |
LV_COLOR_DEPTH是最容易翻车的一个。板子上先查清楚:
# 在板子上执行,确认 framebuffer 的色深和分辨率 cat /sys/class/graphics/fb0/bits_per_pixel cat /sys/class/graphics/fb0/virtual_size输出32就配LV_COLOR_DEPTH 32,输出16就配16。配错了不会崩,就是颜色变成负片或者一片惨绿,很多人第一反应是驱动有问题,其实是这里差了一行。
tick 这一项值得多说一句。LVGL 的所有动画、定时器、超时都依赖一个毫秒级的 tick 源。裸机上靠定时器中断里调lv_tick_inc(1),Linux 上这么干反而麻烦,用LV_TICK_CUSTOM直接读系统时钟更省事:
/* lv_conf.h 里打开 */ #define LV_TICK_CUSTOM 1 #define LV_TICK_CUSTOM_INCLUDE "time.h" #define LV_TICK_CUSTOM_SYS_TIME_EXPR ((uint32_t)(clock() / (CLOCKS_PER_SEC / 1000)))clock()返回的是进程占用的 CPU 时间而不是墙钟时间,在程序大部分时间在usleep睡着的场景下,tick 会走得比实际慢。这是这个写法的固有毛病,表现是动画看着偏慢。要求严格的话改用gettimeofday包一个函数,或者在独立的线程里每毫秒调一次lv_tick_inc(1)。我一般先用clock()版本把界面跑通,确认渲染没问题,再换成时间更准的实现。
3.4 编译期报错定位:undefined reference 与头文件搜索路径
GUI Guider 的代码并进来之后,最常见的两类报错,处理思路完全不同。
第一类是fatal error: xxx.h: No such file or directory。这是包含路径问题,去target_include_directories里补目录,不要改#include那一行。判断补哪个目录的方法很机械:看报错的头文件名,去工程里find . -name "xxx.h",找到它的父目录,把父目录加进包含路径。
第二类是undefined reference to 'setup_ui'或undefined reference to 'events_init'。这是链接期找不到符号,说明generated/*.c没被编进去。检查file(GLOB ...)是否在add_executable之前执行、GUIDER_SOURCES是否真的传给了add_executable。这里有个 CMake 的经典陷阱:file(GLOB)在配置阶段求值,如果配置完之后你才把新文件拷进src/generated/,CMake 不会自动感知,必须重新cmake ..。改了文件却一直报未定义符号,八成就是这个——删 build 重配一次立刻好。
如果链接报的是undefined reference to 'lv_...'这类 LVGL 内核符号,方向就变了:说明lvgl这个静态库没链上,或者链接顺序不对。CMake 里用target_link_libraries(your_app PRIVATE lvgl)声明依赖,CMake 会自动处理顺序,别手工在CMAKE_C_FLAGS里塞-llvgl。
4. 裁剪与显存对接:去掉模拟库、接上 fbdev 和 evdev
交叉编译能过、架构也对,程序上板还是起不来,八成卡在运行期依赖和驱动对接上。这一章处理三件事:把 PC 模拟专用的库彻底摘掉、把 framebuffer 的缓冲算对、把触摸设备对上号。
4.1 找出并剔除 PC 模拟专用库
判断标准很简单:板子上没有的东西就是模拟专用。SDL2、X11、Wayland 客户端库、libpng(如果图片已经转成 C 数组)都属于这一类。查的方法还是readelf -d:
# 看最终产物到底依赖了什么 arm-linux-gnueabihf-readelf -d build/bin/your_app | grep NEEDED出现libSDL2-2.0.so.0,就去CMakeLists.txt里找target_link_libraries和find_package(SDL2),把相关行注掉。出现libX11.so.6,同理找X11。还有一种更隐蔽的情况:链接行里没有 SDL2,但lv_drivers的monitor.c(SDL 模拟窗口的显示驱动)被编进去了,它会间接拉进 SDL。解决办法是把monitor.c、mouse.c、keyboard.c这些模拟输入输出从源码列表里去掉。
工程里有模拟器开关的(option(BUILD_SIMULATOR ...)),关掉它比重链接库干净。我一般的做法是保留 PC 分支的代码不删,只在交叉编译时关开关——这样在 PC 上还能用 SDL 快速验证 UI 布局,改一版界面不用每次都上板,迭代效率差好几倍。等界面定稿了,再彻底交叉编译上板验证交互和性能。这个双轨流程在新手眼里像多此一举,实际用起来能省掉大量插拔串口线的时间。
裁完之后重新编一次,再跑readelf -d,NEEDED里应该只剩下libc.so.6、libm.so.6这类板子上肯定有的基础库,加上libpthread.so.0(如果有线程)。剩下这几个在 ARM Linux 的 rootfs 里都是标配,不用操心。
4.2 framebuffer 驱动注册与缓冲区大小计算
framebuffer 这条路子简单粗暴:/dev/fb0是一块内存映射,往里面写像素,屏幕就变。lv_drivers的fbdev.c已经把这套封装好了,你要做的是初始化、给缓冲、注册驱动。
缓冲大小是这里唯一需要动脑的地方。LVGL 支持三种缓冲策略:
| 策略 | 缓冲大小 | 内存占用 | 适用 |
|---|---|---|---|
| 单缓冲全屏 | hor_res * ver_res | 800×480×4 ≈ 1.5 MB | 内存充裕,无撕裂 |
| 单缓冲局部 | hor_res * N(N 为行数) | 800×60×4 ≈ 192 KB | 内存紧张,推荐起点 |
| 双缓冲 | 两块局部缓冲 | 翻倍 | 有 DMA 或能并行刷屏时减少撕裂 |
static lv_color_t buf1[800 * 60]就是局部缓冲,60 行。行数越多,一次 flush 的数据量越大,fbdev_flush的调用次数越少,整体吞吐越好;但行数多到超过 CPU cache 就没什么收益了。经验值是在 10 到 100 行之间试,找到你这块板子上 CPU 占用和流畅度的平衡点。内存特别紧的板子(比如只有 64 MB DDR)从 20 行起步。
fbdev_flush是lv_drivers提供的回调,内部做的是memcpy到映射好的 framebuffer 地址。如果屏幕有撕裂感(快速滑动列表时上半屏和下半屏不同步),说明刷屏和扫描不同步,解法是开双缓冲或者把缓冲行数调小、让每次刷新的区域变窄。
还有一点:disp_drv.hor_res和ver_res必须和/dev/fb0的实际分辨率一致。不一致的表现是画面只占屏幕一角、或者被拉伸、或者直接越界写内存把程序搞崩。上板第一件事就是cat /sys/class/graphics/fb0/virtual_size,把这个数字抄进代码。
4.3 触摸与按键:evdev 设备名和权限
触摸屏在 Linux 下就是一个 input 设备,读/dev/input/eventX拿坐标。麻烦在于X这个编号不固定,插拔一次可能就从event0变成event1。
# 板子上列出所有输入设备,找触摸屏那一个 cat /proc/bus/input/devices # 输出片段里关注这两行 # N: Name="goodix-ts" # H: Handlers=event1lv_drivers的evdev.c里有一个编译期宏指定设备路径,通常在文件头部或者lv_drv_conf.h里:
/* lv_drv_conf.h 中的 evdev 段 */ #define EVDEV_NAME "/dev/input/event1"把这里改成你板子上实际的那个,或者更稳的做法是改成按名字查找,避免编号漂移。改完还要确认权限:/dev/input/eventX一般是root:input属组、0660权限,程序用普通用户跑会读不到,表现为界面能显示但完全点不动。
# 板子上确认权限,确认当前用户是否在 input 组 ls -l /dev/input/event1 id要么用 root 跑(调试期最省事),要么把用户加进input组,要么写 udev 规则。运维视角下更推荐后两种,长期跑 root 不是个好习惯。
校准这块,电容屏一般不需要,电阻屏必须做。evdev.c里如果有EVDEV_CALIBRATE之类的开关,打开后按提示点四个角就行。坐标反了的典型症状是左右镜像或上下颠倒,那是evdev里 X/Y 交换或取反的宏没配对,改一行就好。
4.4 内存和刷新率:三个能立竿见影的参数
板子跑起来之后,界面能显示但卡顿、或者运行几分钟就 OOM,通常是这三个参数的问题。
LV_MEM_SIZE太小,表现是控件创建到一半返回 NULL,界面上少一块或者直接白屏,日志里能看到lv_mem_alloc失败。1 MB 是起步值,界面复杂、图片多就往上加到 2 MB、4 MB。LVGL 的内存池是静态数组,编译期就占了这块 BSS,所以也不能无脑往大了写,内存紧张的板子要算着来。
LV_DISP_DEF_REFR_PERIOD控制lv_timer_handler里刷新的时间片。默认 30 ms 对应约 33 fps,对嵌入式够用。调到 16 ms 追求 60 fps,前提是你的刷屏速度跟得上,跟不上反而因为频繁 flush 把 CPU 吃满,界面更卡。反过来板子 CPU 弱、又要跑别的业务,调到 50 ms 甚至 100 ms,界面迟钝但系统稳。
-O2这个编译选项对 LVGL 的影响比想象中大。LVGL 的渲染核心是大量小函数调用和像素级操作,-O0和-O2在 ARM 上能差出两三倍的填充速度。调试期用-O0 -g方便定位问题,上板前一定要切-O2,别把调试版本直接发出去。
5. 避坑排查:交叉编译到上板运行的五类翻车现场
前面几章是正向路径,这一章是反向清单。下面五条都是我实际踩过或者帮人定位过的,按「现象 → 原因 → 解决」写,对着症状查就行。
5.1 编译期三个坑
坑一:改了工具链文件但产物还是 x86
现象:file build/bin/your_app输出x86-64,明明toolchain-arm.cmake里写的是 ARM 编译器。原因:build/目录里的CMakeCache.txt缓存了上一次的CMAKE_C_COMPILER,CMake 认为工具链没变,直接复用。解决:rm -rf build重来,别用cmake ..覆盖。判断有没有删干净,看build/CMakeCache.txt里CMAKE_C_COMPILER:FILEPATH这一行,必须是 ARM 编译器路径。
坑二:链接报cannot find -lxxx,但 PC 上编得好好的
现象:交叉编译时报cannot find -lSDL2或者cannot find -lX11。原因:target_link_libraries里还挂着 PC 专用库,交叉编译器的 sysroot 里当然没有。解决:按 4.1 的方法把这些库从链接列表里摘掉。这里有个变体:报的是cannot find -lm这种基础库,那就不是裁库的问题,是工具链的 sysroot 配错了,检查CMAKE_SYSROOT路径下有没有lib/libm.so。
坑三:编过了,但上板报Illegal instruction
现象:程序在板子上刚启动就挂,dmesg里有Illegal instruction或者SIGILL。原因:-mcpu或-mfpu写得比板子实际能力强,编译出了板子不支持的指令。解决:把-mcpu、-mfpu、-mfloat-abi全去掉,只留-O2重编一版,能跑就说明是指令集问题,再逐步加回去定位。板子的 CPU 型号不确定时,cat /proc/cpuinfo看Processor和Features两行最靠谱,Features里没有neon就别开-mfpu=neon。
5.2 运行期两个坑
坑四:界面能显示,但点哪儿都没反应
现象:图形正常,触摸完全无响应,串口也没报错。原因有三个层次:一是EVDEV_NAME指向的/dev/input/eventX不对,打开失败但代码没检查返回值;二是权限不够,普通用户读不了 input 设备;三是indev_drv根本没注册,main 里漏了lv_indev_drv_register。解决:按顺序查——先在板子上cat /dev/input/event1 | hexdump -C,手指按屏幕看有没有数据出来,有数据说明设备和权限都对,问题在 LVGL 侧的注册;没数据就往设备和权限方向查。
坑五:跑几分钟后白屏或者直接退出
现象:界面正常显示一段时间,然后某次操作后白屏、花屏,或者进程被杀。原因:内存池耗尽或者野指针。LV_MEM_SIZE不够时,控件创建失败返回 NULL,后续代码拿着 NULL 用就崩;lv_ui实例不是全局的,回调里读到野指针也会崩。解决:把LV_USE_LOG打开,LV_LOG_LEVEL设成LV_LOG_LEVEL_WARN,重编上板跑,日志里会明确打出内存分配失败的记录。同时检查lv_ui guider_ui;是不是在文件作用域(全局),而不是 main 的局部变量。
6. 上板验证与迭代:把 build/bin 里的程序跑成能用的一版
编译完的程序在build/bin/下面,接下来是搬上板子跑。这一步没什么技术含量,但顺序错了会浪费很多时间。我一般先确认三件事再传:产物架构对、NEEDED列表干净、lv_conf.h的色深跟板子一致。这三条在宿主机上花两分钟就能查完,比传上去再排查快得多。
传输用scp最省事:
# 传到板子的应用目录,IP 和路径按实际替换 scp build/bin/your_app root@192.168.1.100:/opt/app/ # 板子上执行 ssh root@192.168.1.100 cd /opt/app && chmod +x your_app ./your_app程序起来如果黑屏,先别改代码,按这个顺序看:/dev/fb0存不存在、cat /sys/class/graphics/fb0/bits_per_pixel跟LV_COLOR_DEPTH对不对、disp_drv.hor_res跟virtual_size对不对。这三条对上了还是黑屏,就在lv_timer_handler前后加打印,确认循环真的在跑、fbdev_flush真的被调到了。多数黑屏都停在这三步里的某一步。
界面上电正常之后,真正的验证才开始:手指顺着列表快速滑动,看有没有明显撕裂;连续点按钮二十次,看会不会崩;让界面挂着跑半小时,top看 CPU 占用和 RSS 有没有缓慢上涨。RSS 一直涨就是内存泄漏,多半是某个lv_obj_create出来的对象没在切屏时删掉,或者事件回调里反复malloc没free。
有个习惯我保持了挺久:每次改完lv_conf.h或者 CMake 里的编译选项,一定先删build/重配重编,而不是直接make。CMake 的缓存和 LVGL 的配置头文件之间的依赖关系不是每次都可靠,改了一个宏但没触发重编,跑出来的行为跟配置对不上,排查半天最后发现是增量编译的锅——这种亏吃过一次就够了。从那以后我每次动配置都强制走一遍完整重建,宁可多等两分钟。
后续要加新界面,流程是回 GUI Guider 改布局、重新导出、覆盖src/generated/、重编。覆盖的时候注意别把src/custom/里的手写逻辑冲掉,如果 GUI Guider 也重新生成了custom.c,把里面你自己加的回调手动挑出来再合一次。这个合并动作容易出错,所以业务逻辑尽量往独立文件里放,custom.c只留最薄的一层转发。希望这套流程帮到你。
本文还有配套的精品资源,点击获取