跳转至

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 的两个必踩点

  1. XShmPutImage(..., send_event=False) 是异步的:下一帧渲染会覆盖 X server 正在读的共享内存 ⇒ 必须补 XSync。控制消息是百微秒级,相比省掉的 3.7 MB/帧完全值得。
  2. 换之前先探测 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 那一栏)。

判「板子是不是假死」用两个客观证据,不要用「画面不动」:

  1. 主循环 CPU 时间在涨(75 秒内 +6.88 秒);
  2. 跨分钟时钟像素差(两帧 1958 px 差异落在状态栏 x 508–751, y 42–65,即时钟从 10:34 跳到 10:35)。

画面静止本身不是证据 —— 时钟只精确到分钟、已急停无升温动画时,画面本来就该静止。

九、验收:探针 > 字符画 > 像素 > 视觉模型

这一节最贵,因为踩过太多「看起来验证过了」。

取证手段 可靠度 坑
探针(refresh 里 lv_obj_get_coords 写文件) 最高,是对象树的真实值 需要预留开关,别留在量产固件里
字符画(区域降采样成 ASCII) 高,一眼看布局 分辨率有限,宽度差近一倍时能看出来
像素统计 中 阈值必须标定
视觉模型 只当提示,不当结论 同一版界面先说「偏方」再说「胶囊」,两轮自相矛盾

四个具体陷阱:

  1. XWD 的 header_size 不是常量。硬编码 100,而板子上是 123 ⇒ 整体错位 23 字节(约 5.75 px/行), 颜色全乱。这正是视觉模型对同一张图反复给出矛盾描述、把字读错的真正原因。
  2. 板端抓图可能与屏幕不同步(字符画里块的宽度与探针报告差近一倍)⇒ 那几轮「像素验证通过」的结论本身不可靠。
  3. 不要用 lv_snapshot_take 代替抓屏:它是 LVGL 自己渲染,不走 flush 路径,测不到 flush 层的 bug。 反例:一次改动漏了「行推进」,屏幕乱成横条,而截图模式差异只有 9.4%(只是时钟与状态文字)—— 完全看不出。
  4. 阈值要拿「已知改前帧」标定。数「上屏文本行数」时,原判据是「非背景像素 > 3 算一行」, 而板端泛光渐变让每行都有 18–22 个非背景像素 ⇒ 恒判 1 行的假绿;阈值改 ≥ 40 才回到 2 行。 同理,量测窗口要限定(用环境变量给左右边界),否则会把 WiFi / 时钟那一行量进来。

部署判据(板上验证容易被自己骗):传输与构建分开执行、每步校验字节数(例:本地与板上都是 9401 字节), 比一串 && 可靠 —— 曾出现传输失败、板上文件不存在,构建却「成功」地编了旧代码。 测试用例少写一个 -I 会编译失败,于是跑的是上一次的陈旧二进制,看着像 PASS。 最终验收清单:服务 active、重启次数 0、无崩溃、测试痕迹已清(改完文件要重启服务,进程内存里还是旧值)、截图回读。

十、设计取舍:一个视觉效果的代价

为了「图标 + 名称骑在边框线上」这一个效果,付出的代价是:隐形 outline + border_post=0 + OVERFLOW_VISIBLE 两个 hack + 3 px 遮线块 + 一个手动量宽函数及其缓存 —— 每一件都在制造新坑(描边显形、折行、裁切)。

看过实际效果后整体回退成「标题 + 温度」的普通卡片,整套实现删掉。

教训:为一个视觉小效果引入多个 hack,收益抵不过风险。先在没有 hack 的版本上把「能不能看」定下来,再谈花活。

相关阅读