LVGL 9 触屏 HMI 实现坑
结论先行:LVGL 在嵌入式屏上的问题,八成不在 LVGL 本身,而在三处—— 显示后端选错(软件光栅 + 每帧全推)、对象生命周期没管住(野指针与重入)、 以及把「默认值」当成了「合理值」(
SCROLLABLE默认开、长按 400 ms、滚动阈值 10 px)。 本文按「渲染 → 生命周期 → 事件 → 量测」四层给判据与修法,每条都带实测数字。
问题背景
同一块板子、同一个界面需求,用 GTK3 的老方案(KlipperScreen)反而比自研的 LVGL 9 界面流畅。 最初判断是「LVGL 体量小、预编译,应该更快」——实测正好相反。
结论是:「体量小 + 预编译」省的是 flash 与内存,跟每秒能画多少像素无关。 流畅度完全由渲染管线决定:谁在画、画多少、怎么送到屏幕。
一、后端矩阵:先选对管线,再谈优化
LVGL 9.3 自带全部后端,不需要自研:
| 需求 | 驱动 | 特点 |
|---|---|---|
| X11 | drivers/x11/lv_x11_display.c |
依赖 X server;可用 xrandr 转屏 |
| fbdev | drivers/display/fb/lv_linux_fbdev.c |
零外部依赖,最适合 kiosk / 无 X 系统 |
| DRM/KMS | drivers/display/drm/lv_linux_drm.c |
支持 page-flip,无撕裂 |
| 触摸(不依赖 X) | drivers/evdev/lv_evdev.c |
直读 /dev/input/eventN |
| 桌面调试 | SDL | 开发机用 |
为什么 GTK3 在小机上反而更流畅:GTK/cairo 走 pixman 的 NEON 汇编 + 服务端渲染, 而且按需重绘(静态界面不重画);LVGL 默认是软件光栅化 + 持续 60 fps 主动刷新 + 每帧把整帧像素推给显示服务。 两条管线的差别是数量级的:一个只在变化时画几块区域,一个每秒画 60 张全屏。
所以后端选择不是「哪个更现代」,而是「谁承担光栅化」:
- 有 X、且不愿意改系统 ⇒ X11 后端,但必须解决逐像素转换与共享内存两件事(见下)。
- 要 kiosk、要最省 ⇒ fbdev 或 DRM/KMS 直出,甩掉整个 X server。
- 有 GPU 且能用 EGL/GLES2 ⇒ 上 GPU,这是最后一步。
二、三个真瓶颈(都有数据)
| # | 瓶颈 | 证据 | 修法 |
|---|---|---|---|
| ① | CPU 被钉在最低档 | governor=powersave, cur=200000, max=998400(只跑到 1/5 频率) |
抬 scaling_min_freq + performance ⇒ 17.7 → 31.2 fps |
| ② | 每帧 92 万次逐像素转换 | X11 flush 里 get_px() 把 RGB565 转 32bpp,1280×720 逐像素函数调用 |
改纯整数位运算,让编译器自动向量化 |
| ③ | 每帧整屏走 X 协议 | 3.7 MB/帧,Xorg 占 23~35% CPU | 换 XShmPutImage ⇒ Xorg 5.9% |
第 ① 条最便宜:只改调频,fps 直接翻近一倍。任何性能排查都应该先看 scaling_cur_freq 是不是落在最低档,
否则后面所有优化都是在 1/5 的算力上做。
第 ②③ 条是「框架默认写法挡住了优化」,两条都有可复用的判据:
# 确认 CPU 是否被钉在最低频
cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor
cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq
cat /sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq
# 确认共享内存段真的建起来了:shmid / 段数 / 字节数
ipcs -m
ipcs -m 里段大小应当正好等于 宽 × 高 × 4(1280×720×4 = 3,686,400 B)。这条比查字符串可靠得多:
strings 查不到 "MIT-SHM" 是正常的,日志字符串在 LV_USE_LOG=0 时被编译掉了,不代表没用上。
NEON 汇编这条路在 aarch64 上是死的
lv_blend_neon.S 是 armv7 专用汇编(.fpu neon / .arch armv7a / r0 寄存器 / @ 注释),
aarch64 汇编器直接报 unknown pseudo-op: .fpu。⇒ 不要开 LV_USE_DRAW_SW_ASM=NEON。
省钱技巧:开宏之前先单独试编那一个 .S 文件(30 秒),否则可能在一小时编译的末尾才链接失败。
真正的病灶是「代码写法挡住了自动向量化」:
| 写法 | 结果 |
|---|---|
纯整数位运算 + -O3 |
GCC 9.4 在 aarch64 上生成 12 条 NEON 指令(ushr/uxtl/shl,一轮 8 像素) |
加 -mcpu=cortex-a53 |
没有额外收益,不必改构建系统 |
| 返回结构体 + 逐字段写位域(原写法) | 无法向量化,退化成 92 万次标量操作 |
XShm 的两个必踩点
XShmPutImage(..., send_event=False)是异步的:下一帧渲染会覆盖 X server 正在读的共享内存 ⇒ 必须补XSync。控制消息是百微秒级,相比省掉的 3.7 MB/帧完全值得。- 换之前先探测
MIT-SHM是否可用,不支持要能自动回退,否则白改一场。
还有一条可以整段免掉的工作:显示服务支持 depth 16 / 16bpp 时直接建 16bpp 图像,
根本不需要转 32bpp —— 省掉的是整个转换循环,不是把它优化快。
三、fps 实测对比
同一类 720p 级小机、同一版界面、同一套测量方法(跑固定交互序列后统计帧数):
| 场景 | 后端 | fps | 说明 |
|---|---|---|---|
| 默认 governor(powersave) | X11 | 17.7 | 基线,CPU 只有 1/5 算力 |
| governor=performance | X11 | 31.2 | 只改调频,+76% |
| fbdev 直出(横屏) | fbdev | 39.5 | 无 X server,无整帧协议开销 |
| fbdev + 软件旋转 270° | fbdev | 33.9 | 旋转代价 -14%,竖屏设备必付 |
两条结论:
- 调频是第一步,收益/成本比最高,且不影响任何代码。
- 旋转是要付费的。X11 下可以用
xrandr --rotate交给显示服务转(不占应用 CPU); fbdev 直出没有这一层,只能用 LVGL 软件旋转,每帧多一次全屏搬运,39.5 → 33.9 fps。
四、fbdev 路径特有的两个坑
| 坑 | 现象 | 修法 |
|---|---|---|
| 方向 | X11 靠 xrandr --rotate 转屏;fbdev 没有这层 ⇒ 竖屏设备画面是竖的(720×1280 而非 1280×720) |
用 LVGL 软件旋转(270° 后逻辑分辨率 1280×720 正常) |
| 触摸权限 | /dev/input/eventN 属 input 组,普通用户读不了 ⇒ 报「无可用触摸设备」,其实设备找对了 |
安装时把用户加入 input / video 组(服务以 root 跑时没这问题,但别依赖它) |
触摸节点必须自动探测(按设备名 / 能力扫 /dev/input/event*),不要写死 event4 ——
换机、换内核、插拔顺序变化都会失效。
两条 fbdev 通用纪律:服务启动前必须等显示设备就绪(等 /tmp/.X11-unix/X0 或 /dev/fb0 出现,
不要用 sleep 猜时间);后端要能在启动时探测失败后回退(X11 不可用 → fbdev),
否则「显示服务没起来」会表现成「界面代码崩了」。
五、移植层与对象生命周期
切页后崩溃:全量 refresh × lv_obj_clean()
症状:点开某个页面就闪退,然后服务被 Restart=always 拉起来 —— 看起来像「偶发闪退重启」。
根因:主循环每个 tick 无条件调用全部 10 个面板的 refresh(),而 render() 里的
lv_obj_clean(body) 会把上一页的对象全删掉 ⇒ 上一页攥着的静态指针全成野指针,下一 tick 就踩上去。
为什么长期没暴露:平时都用命令行直接渲染单页做验证 —— 其他页从未被创建(指针是 NULL,被 NULL 检查挡着)。 只有「真的切页」才触发。验证方法本身漏掉了这条路径,这是本文最值得记住的一条。
修法:refresh 只跑当前正在显示的那一页(加一个 ui_nav_current() 判断)。
复现判据:修前同一路径 18 秒 SEGV(退出码 139),修后稳定 25 秒不崩。
浮层挂在 lv_layer_top() ⇒ 重建时被漏掉
症状:切主题 / 切语言后,各页面配色都跟着变,只有文件详情弹窗还是老配色。
两截原因:① 弹窗挂在 lv_layer_top(),不在导航树里,而重建函数只重建导航树中的页面 ⇒ 没人去重做它;
② 弹窗的全部样式(令牌色、圆角、描边、字体)都是 build 那一刻从令牌读出并写死到对象上的,
之后再没人重读 —— 里面还有两处写死的颜色。
修法:给重建流程加一个浮层重建钩子(ui_rebuild_hook_set()),重建末尾回调已注册的浮层函数;
弹窗在 open 时注册「按原参数重开一次」的回调(复用已验证的构建路径,没开则 no-op),写死色改回令牌。
判据(像素计数,不靠眼睛):同一命令只换代码,弹窗开着时切到强调色主题 —— 旧代码该颜色像素 0 / 对色 10177,修后 5423 / 1749。数字落地的判据才可复核。
通用教训:凡是不在导航树里的对象(浮层、弹层、常驻覆盖层)都要显式进重建流程, 并优先用令牌 + 标准控件,而不是 build 时写死。
判重只比「名字」不比「图源」⇒ 重建后图永远挂不回来
缩略图缓存的判断是 strcmp(名字, 新文件名) == 0 就 return。重建之后对象是新的、src 为空,
而记录的名字还是旧的 ⇒ 直接 return,图再也挂不上。
修法:判重要比 (名字, 图源) 两元组;重建后必须显式失效缓存。
布局回调重入:一个挂死 300 秒,一个直接 SIGABRT
| 位置 | 结果 | 调用链 |
|---|---|---|
lv_obj_update_layout() 放进 refresh() |
本机挂死 300 秒 | 重排 → SIZE_CHANGED → 回调 → 又回到 refresh |
| 同一个调用放在重建路径上 | SIGABRT(exit 134),无截图 | 重建 → 更新布局 → SIZE_CHANGED → refresh → 同步控件值 → 访问已释放对象 ⇒ malloc: pointer being freed was not allocated |
两者同族:布局回调里绝不做重活;重建期间的对象状态必须整体失效(一次性把缓存的指针/索引清空, 而不是指望「空闲态能侥幸走过去」—— 空闲态确实能过去,打印态会崩)。
六、并行事件层:手势与重绘的竞态
长按松手后 LVGL 会补发一次 CLICKED
症状:拖拽温度块排序,松手后误弹数字键盘。
根因:LVGL 长按之后松手仍会补发一次 CLICKED。原本靠「拖拽索引 >= 0」判断这次点击来自拖拽,
但落位逻辑在松手时已经把索引清回 -1 —— 补发的 CLICKED 到达时标志早没了,检查形同虚设。
修法:加一个「本轮按下进过拖拽态」的抑制标记:进入拖拽时置位,由点击回调吃掉那一次补发。
像素判据:弹层带遮罩会把整屏压暗 ⇒ 量屏幕角落亮度即可判定有无弹层 (拖拽后 13.7 无遮罩 ⇒ 没弹键盘;正常点击 5.1 被压暗 ⇒ 弹了)。
LV_OBJ_FLAG_SCROLLABLE 默认开启 ⇒ 长按 + 点击双双失效
症状:温度块「长按抓不住、轻点要按好几次才弹键盘」。两个手势同时坏 = 同一个东西把它们一起吃掉了。
根因:lv_obj 默认带 LV_OBJ_FLAG_SCROLLABLE。只要手指产生位移,LVGL 就把这次触摸判成「滚动」,
LONG_PRESSED 与 CLICKED 一起被取消。项目里其它交互对象都显式去了这个标志,唯独温度块漏了。
lv_obj_remove_flag(o, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_add_flag(o, LV_OBJ_FLAG_PRESS_LOCK); /* 可选:按下期间锁住滚动 */
⚠️ 祖先链上任何一层可滚动都会抢走手势,要逐层清。
教训:先怀疑「默认值」,再调参数。在长按时长 / 滚动阈值上先调过一轮(400→250 ms、10→22 px), 方向沾边但不是根因,白花半天。
触摸默认参数对屏太「钝」
| 参数 | 默认 | 实测合适 | 症状 |
|---|---|---|---|
| 长按判定 | 400 ms | 250 ms | 拖拽要按半天 |
| 滚动阈值 | 10 px | 22 px | 手指自然抖动就被判滚动 ⇒ CLICKED 丢 |
lv_indev_set_long_press_time(250);
lv_indev_set_scroll_limit(22);
再按 LV_STATE_PRESSED 给按下即时反馈(边框立刻变色),手一碰就知道按到了。
手感只能上真机试 —— 开发机没有触摸设备,无法自测,这条不能靠「本机看起来对」。
snprintf 目标就是源缓冲 ⇒ 自重叠(UB),板子上拷成空串
现象:通知「发出去但看不见」—— 泛光常亮、标签是空的;开发机不复现、板子上必现。
根因:把同一个缓冲区既当入参又当落点传给 snprintf ⇒ dst == src 自重叠,属 UB。
板子的 libc 把它拷成空串,而通知仲裁器仍认为这条通知「当前」(泛光不灭)⇒ 视觉上就是「有光无字」。
板端探针实证:[silent] len=0 keep_arb_gen=1 文案='' → wrapped=''。
修法(+8 行):入参先 snprintf 拷一份断开别名;重画守卫改成与「换行后文本」比较
(原来对多行文案永远不等 ⇒ 每秒重画一遍,白烧 CPU)。
像素实证:板端通知区墨量 2 → 2582(与本地一致),条形区 1240 → 8635。
教训:本地跑得通 ≠ 板上跑得通。 同一段代码、不同 libc 对 UB 的行为可以完全相反; 这类「板端特有」的差异要先去板上取证(探针 / 像素),别在本机反复复现。
长文本滚动 ⇒ 忙等卡死
现象:来一条长通知,HMI CPU 打满、RSS 增长。
入口是「每拍推进滚动」的那条动画逻辑。收口:长文本一律静态换行,不做滚动动效, 守两条硬约束 —— 零动态分配、每拍约 6 µs 预算。 任何「每拍推进」的动效都要先算每拍预算;文本长度是外部输入(错误正文可以很长),不能假设它短。
七、布局与量测:if (x > 0) 会把时序错误变成静默错误
首帧 lv_obj_get_content_width() 返回 0
症状:「从其他页面返回主屏时按钮和加热宽度会变化」。
实测:300 ms 时按钮宽 129 px(错),700 ms 后 225 px(对) —— 不是稳态差异,是第一帧算错、约半秒后被修正。
根因:按钮等分与槽位在 refresh() 里按 lv_obj_get_content_width() 现算,而页面函数末尾立刻调了一次 refresh()
—— 那一刻 LVGL 还没跑布局,函数返回 0,被 if (w > 0) 保护静默跳过 ⇒ 控件停在「内容固有宽度」。
修法:容器尺寸一确定就重排。if (x > 0) 这种「保护」会把时序错误变成静默错误,必须回读验证第一帧。
其余四条高频坑
| 坑 | 实测 | 修法 |
|---|---|---|
flex_grow 是在内容固有宽度上加余量 |
三个「等分」按钮 227 / 234 / 228 px | 按列宽精确算 ⇒ 226 / 226 / 227,间隙 13/13 |
lv_text_get_size() 对 CJK 少算约 5 px/字 |
喷嘴 量 32、实渲 42;加热段二 量 64、实渲 84 |
别用它给 label 设死宽;要自适应用 LV_SIZE_CONTENT + LV_LABEL_LONG_MODE_CLIP |
| 给 label 设的宽正好等于文字宽 ⇒ 折行 | 探针实测 h=42 = 2×21(两行),第二行被父对象裁 |
显式禁折行,宽度交给内容自适应 |
flex + LV_SIZE_CONTENT 循环依赖 |
算出的宽只有内容一半,图标被挤到 x = −18 | 手动量宽 + 手动摆位;受 CJK 少算影响仍要靠 LV_SIZE_CONTENT 兜住渲染宽 |
⚠️ 量 CJK 时要数清字数:曾把 4 个字当成 5 个,得出「API 对中文不准」的错误结论,把整个排查方向带偏。
flex 容器还有三条规矩:用 lv_obj_set_x() 给子项定位,下一轮布局就抹掉(拖拽排序容器不能用 flex);
子项排到容器外,content_height 不增长(滚动条失效);容器宽依赖子项、子项宽又依赖容器时不要指望自动布局。
圆角观感取决于元素高度,不是一个数值
弹窗按钮渲染成「胶囊」,与主屏的「圆角矩形」完全不是一回事。
设置其实生效了(actual radius=4 → 缩放后 26 px),问题是同一数值配不同高度的元素观感不同:
弹窗按钮高约 50 px,26 px 已接近半高 ⇒ 胶囊;主屏按钮高 90 px,同样 26 px 仍是圆角矩形。
全站只用一档圆角才真正统一。收敛为三档:BRAND_RADIUS(10) 卡片/列表行/按钮/弹窗、
BRAND_RADIUS_SM(6) 温度块/缩略图、LV_RADIUS_CIRCLE 状态点。扫尾要 grep 复查「写死的圆角值」= 0 处。
outline 用错底色 ⇒ 不可见的描边真被画出来
为让「骑边框标题」露出卡片外,给容器加 outline_width=16px, outline_opa=COVER 来撑开 ext_draw_size
(OVERFLOW_VISIBLE 只外扩裁剪区,扩多少由 shadow/outline 决定)。颜色错设成页面底色,
而块实际坐在卡片底色上 ⇒ 这条 16 px 不透明描边真的被画出来,绕块一圈深色粗带。
判据不靠眼睛:本机 diff 集中在 y=150–260、每行恒定 52 个像素;真机 diff 同样 y=150–270 每行 52 px
—— 正是左右两条侧带的宽度。修法:outline_color 与标题底色都改成父容器的真实底色。
图表刻度要自己算,顺序不能反
LVGL chart 不算刻度。参考成熟前端库的规则:
yAxis: { min: 0, max: v => Math.ceil((v.max + 5) / 20) * 20, minInterval: 20, maxInterval: 100 }
关键不是「画几条」,而是先挑一个整齐步长,再让上限去凑该步长的整数倍 ⇒ 自然铺出 4~6 档。 顺序搞反(先定上限再分)会得到 160 / 134 / 108 这种不整齐的刻度。
字体档位:别名堆叠要收口
原 9 个档位实际只有 5 种字号(三个别名全是 48,两个同号),小屏还有两个档位同为 14(层次拉不开)。 收敛为 6 档(大屏 16/22/26/44/48/32,小屏 14/16/22/28/32/24),在一处定义、全项目引用。 ⚠️ 字号文件缺失时会静默回退(小屏没有 18 号 ⇒ 回退 22),改完要确认字体文件真的存在。
八、崩溃判据:先分流,再看代码
服务被 Restart=always 拉起时,现场只看到「闪退重启」,日志里往往只剩最后一次。必须先拿到退出码再分流:
| 退出码 | 特征 | 指向 |
|---|---|---|
| 139(SIGSEGV) | 启动后 N 秒崩、只在某个交互序列后崩、能画出界面 | 野指针:lv_obj_clean() 后旧指针被 refresh 访问 |
134(SIGABRT,Abort trap 6) |
重建/切主题时崩,malloc: pointer being freed was not allocated |
堆损坏 / 重复释放:布局回调重入、访问已释放对象 |
启动即崩、从未画出任何帧、DISPLAY 或 X socket 不存在 |
151/139 且无任何窗口期 | 显示后端没有可用目标:X11 后端依赖 X server,显示服务没就绪就直接进渲染 ⇒ 空指针解引用 |
| 进程活着、画面静止 | 无崩溃 | 可能只是「无事可画」,不是假死 |
「X11 不可达 ⇒ 直接段错误」这一型的判据是时间位置而不是堆栈:崩溃发生在创建窗口之前,
窗口树里从来没有过目标窗口,同时 /tmp/.X11-unix/X0(或 DISPLAY 指向的 socket)不存在。
这时不要去读界面代码 —— 先确认显示服务就绪,再把后端做启动探测 + 回退。
反过来,如果窗口树里曾出现过目标窗口、且崩溃固定在某个交互之后,那才是对象生命周期问题(139/134 那一栏)。
判「板子是不是假死」用两个客观证据,不要用「画面不动」:
- 主循环 CPU 时间在涨(75 秒内 +6.88 秒);
- 跨分钟时钟像素差(两帧 1958 px 差异落在状态栏
x 508–751, y 42–65,即时钟从 10:34 跳到 10:35)。
画面静止本身不是证据 —— 时钟只精确到分钟、已急停无升温动画时,画面本来就该静止。
九、验收:探针 > 字符画 > 像素 > 视觉模型
这一节最贵,因为踩过太多「看起来验证过了」。
| 取证手段 | 可靠度 | 坑 |
|---|---|---|
探针(refresh 里 lv_obj_get_coords 写文件) |
最高,是对象树的真实值 | 需要预留开关,别留在量产固件里 |
| 字符画(区域降采样成 ASCII) | 高,一眼看布局 | 分辨率有限,宽度差近一倍时能看出来 |
| 像素统计 | 中 | 阈值必须标定 |
| 视觉模型 | 只当提示,不当结论 | 同一版界面先说「偏方」再说「胶囊」,两轮自相矛盾 |
四个具体陷阱:
- XWD 的
header_size不是常量。硬编码 100,而板子上是 123 ⇒ 整体错位 23 字节(约 5.75 px/行), 颜色全乱。这正是视觉模型对同一张图反复给出矛盾描述、把字读错的真正原因。 - 板端抓图可能与屏幕不同步(字符画里块的宽度与探针报告差近一倍)⇒ 那几轮「像素验证通过」的结论本身不可靠。
- 不要用
lv_snapshot_take代替抓屏:它是 LVGL 自己渲染,不走 flush 路径,测不到 flush 层的 bug。 反例:一次改动漏了「行推进」,屏幕乱成横条,而截图模式差异只有 9.4%(只是时钟与状态文字)—— 完全看不出。 - 阈值要拿「已知改前帧」标定。数「上屏文本行数」时,原判据是「非背景像素 > 3 算一行」, 而板端泛光渐变让每行都有 18–22 个非背景像素 ⇒ 恒判 1 行的假绿;阈值改 ≥ 40 才回到 2 行。 同理,量测窗口要限定(用环境变量给左右边界),否则会把 WiFi / 时钟那一行量进来。
部署判据(板上验证容易被自己骗):传输与构建分开执行、每步校验字节数(例:本地与板上都是 9401 字节),
比一串 && 可靠 —— 曾出现传输失败、板上文件不存在,构建却「成功」地编了旧代码。
测试用例少写一个 -I 会编译失败,于是跑的是上一次的陈旧二进制,看着像 PASS。
最终验收清单:服务 active、重启次数 0、无崩溃、测试痕迹已清(改完文件要重启服务,进程内存里还是旧值)、截图回读。
十、设计取舍:一个视觉效果的代价
为了「图标 + 名称骑在边框线上」这一个效果,付出的代价是:隐形 outline + border_post=0 +
OVERFLOW_VISIBLE 两个 hack + 3 px 遮线块 + 一个手动量宽函数及其缓存 ——
每一件都在制造新坑(描边显形、折行、裁切)。
看过实际效果后整体回退成「标题 + 温度」的普通卡片,整套实现删掉。
教训:为一个视觉小效果引入多个 hack,收益抵不过风险。先在没有 hack 的版本上把「能不能看」定下来,再谈花活。