上位机「越用越卡」:根因与防劣化
结论先行:工业上位机的「越用越卡」几乎都不是内存泄漏或过热,而是某个进程被反复拉起、孤儿不回收, 以及一堆 D 状态内核线程把 loadavg 顶起来造成的假高负载。 判断顺序应当是:先数实例个数,再数 D 状态任务,最后才看 loadavg —— 反过来看一定误判。
问题背景
一台跑了大半年的触屏上位机,客户反馈「触摸越来越迟滞、翻页要等」。这类反馈通常伴随三种猜测: 发热降频、内存泄漏、SD 卡写坏。实测把这三条全部排除之后才找到真因,而这恰恰是现场排查最容易走反的方向。
本页给的是可复制的诊断顺序、实测数字,以及在真机上落地的修复配置与守护策略。
一、先排除三个「常见嫌疑」
| 嫌疑 | 实测数据 | 结论 |
|---|---|---|
| 过热降频 | 47 ℃,四核稳在 2016 MHz | 排除 |
| 内存泄漏(单进程) | 5 分钟 RSS 平稳,波动 ±550 KB | 排除 |
| 磁盘写满 | 12 GB 用了 57% | 排除 |
三项都否掉之后,问题只剩一种可能:进程数量在增长。
二、真根因:孤儿 GUI 进程,每天 432 个
| 环节 | 实测 |
|---|---|
| 每个触屏 UI 实例内存 | 127 MB(还挂着 2 条 websocket 长连接) |
| 修复前一天内的实例初始化次数 | 432 次/天(≈ 每 3.3 分钟一次) |
| 整机内存 | 1.9 GB |
| 推算 | 约 12–14 个实例就能把内存吃光 |
机制很朴素:xinit 退出后,它拉起的 screen.py 不被回收,变成孤儿继续跑(父进程变成 init);
每次重启再拉一个新实例,旧的不退。内存一点点被吃掉,page cache 被挤掉,触摸就越来越迟滞。
不是突然坏掉,是每小时都在变差 —— 所以客户描述永远是「越用越卡」而不是「今天坏了」。
为什么会「每次重启」:两套入口抢同一块屏
两个入口各起一份 GUI:
| 入口 | 用的是哪个 python |
|---|---|
systemd service(走 KS_XCLIENT) |
虚拟环境里的 python |
.xinitrc 第 4 行 |
系统 python |
两份 GUI 抢同一块屏与同一个触摸设备,谁也画不完整,于是互相触发重启,实例越堆越多。
修法:干掉 xinit,让 service 直连 X
三处改动,缺一处都会复发:
| 改法 | 细节 |
|---|---|
新增启动脚本 ks-direct.sh |
等 X 就绪 → 应用屏幕旋转 → 直接 exec 虚拟环境版 screen.py |
systemd drop-in 10-direct.conf |
覆盖 ExecStart,不再经过 xinit ⇒ 孤儿根本不会产生 |
.xinitrc |
也改成虚拟环境路径(改前备份原文件) |
drop-in 的关键是先清空再赋值,否则会变成两个 ExecStart 并存:
# /etc/systemd/system/klipperscreen.service.d/10-direct.conf
[Service]
ExecStart=
ExecStart=/usr/local/bin/ks-direct.sh
Restart=always
RestartSec=3
启动脚本必须等 X 就绪再 exec,不能靠 sleep 猜时间:
#!/bin/sh
# /usr/local/bin/ks-direct.sh
# 等 X socket 出现(最多 30 s),再应用旋转与触摸映射,最后 exec 正式进程
i=0
while [ ! -S /tmp/.X11-unix/X0 ] && [ $i -lt 60 ]; do i=$((i+1)); sleep 0.5; done
[ -S /tmp/.X11-unix/X0 ] || { echo "X not ready" >&2; exit 1; }
xrandr --output DSI-1 --rotate left || true
exec /opt/kalios/venv/bin/python /opt/kalios/KlipperScreen/screen.py
用 exec 而不是 &,是为了让 systemd 追踪到的 PID 就是真正的 UI 进程 —— 否则守护脚本和重启策略都会跟错对象。
验收判据(四条同时满足才算过):
- 窗口树里有
1280x800 "KlipperScreen"在渲染; - 旋转生效(
DSI-1 left); - 触摸设备在位(
goodix-ts); - 观察 60 秒,实例数不增长。
再重启一次复验:仍然只有 1 个实例,父进程是 init,xinit 计数为 0。
前后对比
| 指标 | 修复前 | 修复后 |
|---|---|---|
| 触屏 UI 实例 | 多份并存,持续新增 | 1 份(父进程 init) |
| Xorg CPU | 24.8% | 8.3% |
| 内存(used / available) | 402 MiB / 48 MiB | 310 MiB / 1.2 GiB |
Xorg 那 24.8% 的真相是「2–3 份 GUI 同时往一个 X 上画」,不是渲染本身重。
同一现象在另一台机上的量化:两份 screen.py 合计白吃 11.5% CPU + 221 MB 内存 ——
在 1.9 GB 的机器上,这两份里有一份是完全多余的。
关省电之后要验的副作用
工业设备上「关省电」是默认动作(governor=performance、关无线固件省电、日志降级、mask 掉睡眠/休眠),
但这些改动会改变温度与功耗曲线,必须验一遍没有引入新问题。最直接的判据是温度链路一致性:
| 读取位置 | 读数 |
|---|---|
| 传感器链路真值 | 28.29 ℃ |
| 内核内部值 | 28.29 ℃ |
| 屏显值 | 28.3 ℃ |
三者一致、无滞后,说明关省电没有把温度采样链路搞乱(有些平台切 governor 会影响采样周期,读数看起来「掉了几度」)。 反过来,如果三者出现系统性偏差,要先怀疑采样而不是真的降温了。
三、触摸中断风暴与「假高负载」
第二类问题更迷惑人:load 6.2–6.7,但 CPU 90% 空闲。
load average 6.19 / 6.34 / 5.22
CPU 空闲 90~92% iowait ≈ 0
判据:loadavg 统计的是「可运行 + 不可中断睡眠(D 状态)」的任务数,不是 CPU 利用率。 6 个 D 状态任务 ≈ load 6.4,几乎可以一一对上。所以第一步永远是数 D 状态任务,而不是加 CPU。
# 1) 数 D 状态任务(不可中断睡眠)
ps -eo state,comm | awk '$1=="D"{print $2}' | sort | uniq -c | sort -rn
# 2) 看真实运行队列与 CPU 空闲(r 列是 running 数,不是 loadavg)
vmstat 1 5
# 3) 按中断号取速率:两次采样相减
awk '/^ *108:/{s=0;for(i=2;i<=NF-2;i++)s+=$i;print s}' /proc/interrupts
sleep 10
awk '/^ *108:/{s=0;for(i=2;i<=NF-2;i++)s+=$i;print s}' /proc/interrupts # 两次差值 / 10 = 次/秒
# 4) 确认触摸驱动与 I2C 地址
dmesg | grep -i goodix
实测(10 秒采样 +6457 次 ⇒ 约 645 次/秒):
108: 862052 ... rockchip_gpio_irq gt9xx
进程: D irq/108-gt9xx ← 已耗 2 分 03 秒 CPU
屏是 Goodix 触摸(goodix-ts 2-0014,I2C 0x14,720×1080)。启动日志早就有线索:
goodix-ts 2-0014: Failed get int-trigger-type from dts, set default —— 驱动拿不到触发类型,用了默认值。
根因在设备树:irq-gpios 的第三格(flags)写成 8,给 INT 引脚加了偏置并配成电平触发 ⇒
中断线持续有效 ⇒ 没人碰屏也在跑,速率稳定在 645–724 次/秒,同源的 I2C 控制器中断(ffa10000.i2c)
以 1:1 的比例一起涨(~611–674 次/秒,6 天累计 6100 万次以上)。
(记载里对偏置方向有不同的描述,判据只依赖「偏置 + 电平触发」这两点,具体方向以主板方案商的原始 DTS 为准。)
一条最省时间的定案判据
停掉上层软件(触屏 UI / 输入守护)后中断频率不变 ⇒ 别再折腾驱动,去查 pinconf / DTB。
这条判据的价值在于它把「驱动参数问题」和「引脚配置问题」一刀切开。反过来,如果停掉上层后速率掉下来, 那就说明是应用在不停读设备,属于软件问题。
影响与优先级要摆正:触摸功能本身是正常的,CPU 也还空着,所以它不在紧急路径上。 但 D 状态会带来内核延迟尖峰,对 Klipper 这类低延迟主机是有害的 —— 它让通信超时更容易被判死, 等于给掉线类故障加一把火。修复收益:改 1 个字节 → 重启 → 中断从 724/秒降到接近 0,CPU0 释放 6~8%,loadavg 回落。
另一型:不是 flag 错,是触摸控制器根本没应答
同一批屏在另一批机队上的表现完全不同:
| 项 | 配置型(flag 错) | 硬件型 |
|---|---|---|
| probe 结果 | 能正常 probe,只是中断不停 | probe of 2-0014 failed with error -1,绑不回去 |
| 停上层软件后 | 速率不变 | 解绑后线程消失,重绑失败 |
| 结论 | 查 DTB / pinconf | 触摸控制器 I2C 无应答 ⇒ 查排线、供电、座子 |
实测(硬件型):解绑前 load 6.03、中断线程 8.5% CPU;解绑 40 秒后 load 5.53、线程消失; 重新绑定 ⇒ probe 失败。能解绑说明软件链路没问题;绑不回去说明 I2C 上没有设备应答。 修复路径是现场重插 FPC 排线、清洁金手指、查 3.3 V 与座子,其次才是换屏。
⚠️ 别把 USB 中断算进来:实测 USB 中断只有约 26 次/秒,不是负载主因,两件事必须分开判。
四、SDIO WiFi 驱动的 5 个 D 线程:基线永远是 +5
同一批板子上,Realtek 无线网卡的 out-of-tree 驱动在 insmod 时就创建 5 个内核线程:
RTW_XMIT_THREAD / RTW_RECV_THREAD / RTW_CMD_THREAD / RTWHALXT ×2
它们用 down_killable 这类不可中断等待「停车等活」,CPU 占用 0.0–0.2%,但内核把 D 状态算进 loadavg
⇒ 这台机的负载基线永远 +5,跟你做什么优化都无关。实测解绑触摸后 load 从 6.03 降到 5.52,正好少 1;
/proc/loadavg 只有 1 个 running、CPU 88% 空闲。
推论:拿 loadavg 跨机型比较毫无意义。别的系统显示 load ≈1 不是「性能更好」,只是它没用这颗驱动、 或者走了有线。判「忙不忙」要看 running 数 + CPU idle% + 有没有 D 线程在真吃 CPU。
最省事的一次收益(零代码):插网线 + 卸载这颗 SDIO 无线驱动 ⇒ load 从 7.7 降到约 2,5 个 D 状态直接消失。 现场如果有条件走有线,这一步比任何调参都值。
五、防劣化落地:两个包,十件事
修复不能停在手工改。落成两个可下发的包:
| 包 | 内容 |
|---|---|
| 稳定性修复 | ① USB 禁 autosuspend(udev 规则)② NetworkManager wifi.powersave=2 + 无线驱动参数 ③ mask 掉 sleep/suspend/hibernate ④ kalios-cpu-performance.service(governor=performance) |
| 防劣化守护 | ① 触屏 UI 单实例守护 ② journald 限 100 M ③ KlipperScreen.log > 5 M 轮转留 2 份 ④ zram swap(lz4,50% 内存)⑤ 冗余服务条件清理 ⑥ 写 status 供 verify 读取 |
顺手关掉的 6 个无意义服务(都要带条件判断,不能盲删):
| 服务 | 为什么该关 |
|---|---|
蜂窝模组管理(quectel-CM) |
机器上没有 4G 模组,进程 while true 空转,11 小时白烧 1 分 51 秒 CPU |
strongswan-starter / xl2tpd / openvpn |
配置全空,纯占启动时间 |
bluetooth |
工业机不用 |
bootapp |
二进制缺失,每次开机必 failed |
保留 input-event-daemon(MUTE 键、CTRL+ALT+ESC、空闲 90 分钟自动灭屏)与 acpid —— 这些是现场真的在用的。
守护脚本的两个坑
坑一:pgrep -f 会误杀。 按命令行模糊匹配,会命中任何命令行里提到该字符串的进程 ——
实测把自己的 SSH 会话都杀了(因为那条命令本身就带着这个字符串)。
改成按 argv[0] 是 python 且参数精确匹配。专项用例要覆盖三条:假重复实例被杀 ✓、bash 诱饵未误杀 ✓、真实主进程保持 active ✓。
坑二:/etc/modprobe.d 可能完全无效。 有一批系统没装 kmod(modinfo/modprobe 都不存在),
写进去的驱动参数重启后不生效,无线参数仍是 2/1。正解是在开机 oneshot 服务里直接写 sysfs:
# 无线驱动省电:直接写 sysfs,不依赖 modprobe
echo 0 > /sys/module/rtw_8723ds/parameters/rtw_power_mgnt
echo 0 > /sys/module/rtw_8723ds/parameters/rtw_ips_mode
守护间隔为什么必须是 30 秒:最初版本做「每 5 分钟杀一次孤儿」止血,但这类 bug 每 ~15 秒就冒一个新实例 ⇒ 5 分钟内可堆 20 个 × 127 MB = 2.5 GB,1.9 GB 的板子早 OOM 了,根本等不到守护动手。 所以守护只是兜底,根因修法(直连 X、不产生孤儿)才是主菜;兜底间隔压到 30 秒。
六、下发通道:这类修复应该随升级自动到位
主机层修复不该让运维一台台点。做成 agent 通道的一部分:
| 设计点 | 实现 | 为什么 |
|---|---|---|
| 版本解耦 | 主机修复独立版本号,与 agent 版本分开 | 只推主机修复不必发 agent 版本 |
| 幂等 | marker 文件记录版本,相同则 0 ms 返回 | 每次开机不重复执行 |
| 失败隔离 | 每份脚本独立子进程 + 独立 rc,整体 try/except 绝不抛 | 主机修复炸了不能拖死心跳 |
| 灰度 | 走 agent 自更新通道 ⇒ 天然受 canary 白名单控制 | 先 1 台 → 再全队 |
| 开关 / 重试 | 环境变量跳过;失败 3 次放弃 | 防每次重启反复跑 |
脚本在构建时逐字节嵌入 agent,运行时落盘执行后即删;心跳里加主机修复版本字段,后台设备页加一列直接看。
两条必须提前讲清的边界:
- 回滚 agent 不会撤销已应用的主机修复(刻意设计 —— 稳定性修复不该因为回滚而失效)。
要撤只能在设备上跑对应的
uninstall.sh。 - 插件包卫生要看
remove_paths。曾有两个包,新包是旧包的子集(前四项写同一批文件), 但两者remove_paths完全一样 ⇒ 卸载任何一个都会删掉共享的 udev 规则 / 网络配置 / 满频服务, 另一个静默失效,而状态文件还在、后台仍显示「已安装」。 处置原则:只装过旧包的机器推新包;两个都装过的机器只删旧包的版本标记,绝不要跑旧包的 uninstall。
顺带一条容易被忽略的启动开销:agent 服务的启动耗时曾达到 19.08 秒(systemd 自身总耗时之外的唯一异常项),
原因是它用 PAMName 起了会话,导致进程跑出了自己的 cgroup —— 后面按 cgroup 统计资源时就会漏掉它。
查启动耗时用 systemd-analyze blame,看单项是否远高于其它服务。
七、还有一处被忽略的磁盘占用
journalctl --disk-usage 只统计当前 machine-id 的日志目录。机器换过身份(重刷、重新生成 machine-id)之后,
/var/log/journal/ 下会遗留旧目录 —— 实测有 457 MB + 33 MB 两坨,--disk-usage 完全看不见。
查法:
du -sh /var/log/journal/* # 按目录看,而不是看 journalctl 的统计
journalctl --disk-usage
在 12 GB 的 rootfs 上,这半 G 是实打实的。
八、排查顺序总结
- 数实例:同一 UI 进程有几个(
ps按名字数,不看 loadavg)。 - 数 D 状态:
ps -eo state,comm | awk '$1=="D"',看有没有内核线程常驻。 - 算中断速率:
/proc/interrupts两次采样相减。 - 才看 loadavg,并与 running 数、CPU idle% 对照。
- 修复优先「改结构」(不产生孤儿、不加载有问题的驱动),其次才是调参。