本文由 DeepSeek V4.1 Flash 调研、解决并书写。

现象

机器是 Arch Linux + KDE Plasma 6(Wayland)。有一阵子,微信、Krita 5(AppImage)、LMMS 1.2.2 只要一点“打开 / 保存文件”,窗口立刻无响应,单核 CPU 跑满;而 Dolphin、Kate 这些 KDE 自带程序完全正常。

一开始的怀疑方向和大多数人一样:xdg-desktop-portal?字体?KDE 平台主题?Qt 自己?本文记录的是一次“翻案”:真正的根因,是一行被写坏成 245 MB 的 Qt 配置文件。

环境

组件 版本
发行版 Arch Linux(rolling)
桌面 KDE Plasma 6.7.5 / Wayland
Qt5 qt5-base 5.15.19+kde+r96
Qt6 qt6-base 6.11.2
fontconfig 2:2.18.3
微信 4.1(自解压目录,静态链接 Qt 5.15.14)

走过的弯路:把“症状”当成了根因

早期的排查非常“正统”:

  1. 先排除 portal —— 直接用 DBus 调 org.freedesktop.portal.FileChooser.OpenFile,立即返回请求句柄,portal 没问题。
  2. 用 PyQt5 写最小复现:一个空的 QFileDialog 就 100% 卡死;而朴素的 QDialog + QListView + QFileSystemModel 却完全正常——说明问题只出在 QFileDialog 内部。
  3. strace 看到卡死时进程在反复 mmap/munmap ~54MB、纯 CPU 空转、无任何 I/O。
  4. gdb 抓主线程栈,顶部永远是同一处:
1
2
3
4
5
6
7
8
9
10
#0  FcCharSetHasChar () from /usr/lib/libfontconfig.so.1
#1 ?? (Qt fontconfig 字体引擎)
#2 QFontEngineMulti::stringToCMap(...)
#3 QTextEngine::shapeText(int)
#4 QTextLine::layout_helper(int)
#5 QCommonStylePrivate::viewItemSize(QStyleOptionViewItem const*, int) const
...
#12 QListView::doItemsLayout()
#25 QDialog::setVisible(bool)
#27 QDialog::exec()

再加上一个“致命”的巧合:微信和 Qt 都卡在同一个 FcCharSetHasChar。于是很自然地得出了(错误的)结论——这是 fontconfig 层面的系统级共同根因。

接下来就是漫长的排除法:换最小字体集、换 fontconfig 2.17/2.18、重建缓存、清空 HOME、换 harfbuzz 版本、改 locale、换平台/样式……全部照卡。

现在回头看,方向从一开始就错了:

FcCharSetHasChar 只是“把一个超长字符串排版出来”这条路上的必经一点。真正该追问的不是“为什么 fontconfig 慢”,而是——这个超长字符串到底是从哪来的?

换个思路:直接看文件对话框里装了什么

不再用 gdb 去猜寄存器,而是直接在进程内把文件对话框的侧边栏模型打印出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <QApplication>
#include <QFileDialog>
#include <QListView>
#include <cstdio>

int main(int argc, char **argv) {
QApplication app(argc, argv);
QFileDialog dlg(nullptr, "test", "/home/lanshuye");
dlg.setOption(QFileDialog::DontUseNativeDialog);
auto *sb = dlg.findChild<QListView *>("sidebar");
for (int r = 0; r < sb->model()->rowCount(); ++r)
printf("row %2d size=%d\n", r,
sb->model()->index(r, 0).data(Qt::DisplayRole).toString().size());
return 0;
}

结果一目了然:

1
2
3
4
5
6
7
8
row  0 size=8
row 1 size=8
row 2 size=12582912 ← 1258 万字符
row 3 size=9
row 4 size=12582912
row 5 size=12582916
row 6 size=6291456
...

这些不是“看起来有点怪”的字符串,而是真实分配的巨型 QString(QString::Data 里 alloc = size + 1)。内容是一串重复的乱码 U+00C3 U+0083 U+00C2 …。

再查 Qt::UserRole + 1(QUrlModel::UrlRole),发现连 URL 本身都是乱的:

1
url = file:///home/lanshuye/ÃÂÃÂÃÂ…(1258 万个字符)

而用 QFileSystemModel 直接查询这些路径(包括中文目录)时,返回的字符串完全正常。也就是说:坏数据既不是文件系统的问题,也不是字体的问题,而是从配置里读出来的时候就已经坏了。

顺藤摸瓜:这些书签存在哪

翻一下 Qt5 的源码(src/widgets/dialogs/qfiledialog.cpp):

  • 保存(:2942):settings.setValue("shortcuts", QUrl::toStringList(sidebar->urls()))
  • 读取(:2975):sidebarUrls = QUrl::fromStringList(settings.value("shortcuts").toStringList())
  • 应用(:3012):sidebar->setUrls(sidebarUrls)

而这个 QSettings 对应的用户级文件就是:

1
~/.config/QtProject.conf

看一眼它的体积:

1
2
$ stat -c '%s %n' ~/.config/QtProject.conf
245367547 ~/.config/QtProject.conf # 245 MB!

其中 99.99% 都堆在 [FileDialog] 的 shortcuts 这一行上(从第 95 字节一直排到第 245367182 字节)。

把乱码反推回原形

文件里是 QSettings 的转义形式,开头长这样:

1
shortcuts=file:, file:///home/lanshuye, file:///home/lanshuye/\xc3\x83\xc2\x83\xc3\x82...

\xc3\x83 是 QSettings 对字符 U+00C3 的转义。而 U+00C3, U+0083, U+00C2, U+0083 … 这段序列有个很明显的特征,它满足:

1
s' = QString::fromLatin1( s.toUtf8() )

即:把字符串按 UTF-8 编码成字节,再把每个字节当成一个 Latin-1 字符读回来。对小字符串,这个变换会让长度大约翻倍;反复套用就会指数增长。

于是写一个反向程序,套用 s = QString::fromUtf8( s.toLatin1() ):

1
2
3
4
5
6
7
8
QString cur = /* 从 shortcuts 读到的最后一个条目 */;
for (int i = 0; i < 40 && cur.size() > 1; ++i) {
QString nx = QString::fromUtf8(cur.toLatin1());
if (nx == cur) break; // 收敛
cur = nx;
printf("rev %2d: chars=%d\n", i, cur.size());
}
printf("recovered: %s\n", cur.toUtf8().constData());

输出:

1
2
3
4
5
6
rev  0: chars=786459
rev 1: chars=393243
...
rev 17: chars=33
rev 18: chars=29
recovered: file:///home/lanshuye/文档/lmms

真相大白:原本只是一个指向 ~/文档/lmms 的普通书签(大概率是之前排查 LMMS 时被顺手写进去的),经过大约 18 轮双重编码,从几个字符一步步长成了 150 万字符、最终膨胀到 245 MB。

为什么微信也“同病相怜”

既然根因是 Qt,那没有链接 Qt 的微信为什么也卡在同一处?答案是:微信其实用了 Qt。

  • 运行时 /proc/<pid>/maps 里没有动态 libQt,所以乍看不像 Qt 程序;
  • 但 strings /opt/wechat/wechat | grep -i QFile 能看到一整套 Qt Widgets 符号:
1
2
3
4
5
QFileDialogPrivate
QFileDialogListView
QFileDialogComboBox
QSettings
QSettingsPrivate

也就是说,微信主进程静态链接了 Qt 5.15.14,它的文件选择器用的就是 Qt 内置 QFileDialog,读的自然也是同一个 ~/.config/QtProject.conf。之前把它归为“Chromium/Skia”是对无符号地址的错误归因——那些 RadiumWMPF / WeChatAppEx 进程才是小程序运行时。

根因链

  1. Qt 内置 QFileDialog 把侧边栏书签持久化到 ~/.config/QtProject.conf 的 [FileDialog] shortcuts。
  2. 某个环节的编解码器不匹配,把书签里带中文的路径反复双重编码,指数膨胀到 245 MB。
  3. 打开文件对话框时,Qt 把这段巨串当文本交给 QTextLayout 排版,QFontEngineMulti::stringToCMap 对每个字符调用 FcCharSetHasChar,GUI 线程就此空转 → “一点就卡死”。
  4. 微信静态链接 Qt,读同一个文件 → 一起卡死。

fontconfig 从头到尾都只是受害者,不是元凶。

修复

1
2
3
4
5
# 1) 备份(留证据),然后重置
mv ~/.config/QtProject.conf ~/.cache/opencode-tests/QtProject.conf.corrupt.bak

# 2) 打开一次 Qt5 内置文件对话框,让它自动重建干净配置(也可直接删除该文件)
QT_QPA_PLATFORM=offscreen timeout 8 ./t_exact ; echo rc=$?

重建后,文件只有几百字节:

1
2
3
4
$ stat -c %s ~/.config/QtProject.conf
500
$ grep -a '^shortcuts=' ~/.config/QtProject.conf
shortcuts=file:, file:///home/lanshuye

微信重启后也会把它重写为干净值(qtVersion=5.15.14)。

验证

测试 修复前 修复后
t_exact(Qt5 内置 QFileDialog,offscreen) rc=124(超时卡死) rc=0
PyQt5 最小复现 fd.py 卡死 正常返回
干净 XDG_CONFIG_HOME 交叉验证 仍卡 rc=0(证明是 QtProject.conf 的问题)
微信文件选择器 99% CPU 卡死 正常

固化:别让它再犯

根因虽然清楚了,但“某个程序在某天又把配置文件写坏”这件事本身很难 100% 预测。于是做了两层防护。

第一层:写入即自愈(用户级 systemd,无需 root)

~/.local/bin/qtproject-guard.sh:检查 QtProject.conf,一旦超过 64 KiB(健康文件通常 < 2 KiB)就把它移为 *.corrupt.<时间>.bak 并复位,只保留最新一份备份,同时写日志。

1
2
3
4
5
6
7
8
9
10
11
#!/bin/sh
set -eu
f="${XDG_CONFIG_HOME:-$HOME/.config}/QtProject.conf"
threshold=65536
[ -f "$f" ] || exit 0
sz=$(stat -c %s "$f" 2>/dev/null || echo 0)
[ "$sz" -gt "$threshold" ] || exit 0
bak="$f.corrupt.$(date +%Y%m%d-%H%M%S).bak"
mv -f "$f" "$bak" 2>/dev/null || rm -f "$f"
ls -1t "$f".corrupt.*.bak 2>/dev/null | tail -n +2 | while read -r old; do rm -f "$old"; done
logger -t qtproject-guard "reset oversized QtProject.conf (${sz}B)" 2>/dev/null || true

配合两个 systemd user 单元:

  • qtproject-guard.service:跑上面的脚本;
  • qtproject-guard.path:PathModified=~/.config/QtProject.conf,文件一被写入就立刻触发;
  • qtproject-guard.timer:登录后 1 分钟首跑,之后每 5 分钟兜底一次。
1
systemctl --user enable --now qtproject-guard.path qtproject-guard.timer

实测:用一个 QSettings 风格的“临时文件 + rename 原子替换”写入 100 KB,约 1~2 秒内被复位回 500 B。

第二层(可选):把配置文件真正冻结

如果只求“永远别再动它”,可以直接上不可变位(需要 sudo):

1
2
3
sudo chattr +i ~/.config/QtProject.conf
lsattr ~/.config/QtProject.conf # 看到 ----i--------
# 需要修改时:sudo chattr -i ~/.config/QtProject.conf

chattr +i 会让该 inode 不可写、不可删、不可被 rename 覆盖;而 Qt 的 QSettings 正是“写临时文件再 rename 覆盖”,于是根本写不进去,读取却不受影响,对话框照常工作。
注意 chmod 444 不够——同目录可写,rename 仍能覆盖,必须用 chattr +i。代价是文件对话框不再记忆“上次目录 / 侧边栏宽度 / 书签排序”。

第三层(可选):抓出是哪个程序写坏的

如果想弄清“到底是谁在破坏它”(需要 root,且与“冻结”互斥):

1
2
3
4
sudo systemctl enable --now auditd
sudo auditctl -w /home/lanshuye/.config/QtProject.conf -p wa -k qtproject
# 复现后:
sudo ausearch -k qtproject -i # 看 comm / exe / pid,即可锁定程序

持久化规则写入 /etc/audit/rules.d/50-qtproject.rules 后 sudo augenrules --load 即可。也可以装 fatrace,用 sudo fatrace -f W -t | grep QtProject.conf 实时看写入进程。

复盘

  • 千万不要把崩溃点当成根因。 FcCharSetHasChar 只是症状;真正的问题在应用自己的配置里。
  • 当多个互不相关的程序卡在同一个底层函数时,优先怀疑它们共同读取的持久化数据/配置,而不是底层库本身。
  • Qt 内置文件对话框的状态文件(QtProject.conf)对用户是隐藏的,但单个超长值就足以让 GUI 永久卡死。以后遇到“文件对话框卡死”,第一件事就该去看它。

附录:本次用到的命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 看 Qt 状态文件是否异常(正常只有几百字节)
stat -c '%s %n' ~/.config/QtProject.conf
grep -a '^shortcuts=' ~/.config/QtProject.conf | head -c 200

# 检查侧边栏模型里有没有巨串(见正文代码,编译:pkg-config --cflags --libs Qt5Widgets)

# 复现检测:rc=124 表示卡死,rc=0 表示正常
QT_QPA_PLATFORM=offscreen timeout 8 ./t_exact ; echo rc=$?

# 确认微信是否用了 Qt(静态链接也看得到符号)
strings /opt/wechat/wechat | grep -aE 'QFileDialog|QSettings' | sort -u

# 查看守卫状态与日志
systemctl --user list-timers qtproject-guard.timer
journalctl --user -t qtproject-guard -n 20
cat ~/.cache/qtproject-guard.log

参考资料