Qt 文件选择器卡死真相:一行 245 MB 的配置乱码
本文由 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) |
走过的弯路:把“症状”当成了根因
早期的排查非常“正统”:
- 先排除 portal —— 直接用 DBus 调
org.freedesktop.portal.FileChooser.OpenFile,立即返回请求句柄,portal 没问题。 - 用 PyQt5 写最小复现:一个空的
QFileDialog就 100% 卡死;而朴素的QDialog + QListView + QFileSystemModel却完全正常——说明问题只出在QFileDialog内部。 strace看到卡死时进程在反复mmap/munmap ~54MB、纯 CPU 空转、无任何 I/O。- gdb 抓主线程栈,顶部永远是同一处:
1 | #0 FcCharSetHasChar () from /usr/lib/libfontconfig.so.1 |
再加上一个“致命”的巧合:微信和 Qt 都卡在同一个 FcCharSetHasChar。于是很自然地得出了(错误的)结论——这是 fontconfig 层面的系统级共同根因。
接下来就是漫长的排除法:换最小字体集、换 fontconfig 2.17/2.18、重建缓存、清空 HOME、换 harfbuzz 版本、改 locale、换平台/样式……全部照卡。
现在回头看,方向从一开始就错了:
FcCharSetHasChar只是“把一个超长字符串排版出来”这条路上的必经一点。真正该追问的不是“为什么 fontconfig 慢”,而是——这个超长字符串到底是从哪来的?
换个思路:直接看文件对话框里装了什么
不再用 gdb 去猜寄存器,而是直接在进程内把文件对话框的侧边栏模型打印出来:
1 |
|
结果一目了然:
1 | row 0 size=8 |
这些不是“看起来有点怪”的字符串,而是真实分配的巨型 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 | $ stat -c '%s %n' ~/.config/QtProject.conf |
其中 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 | QString cur = /* 从 shortcuts 读到的最后一个条目 */; |
输出:
1 | rev 0: chars=786459 |
真相大白:原本只是一个指向 ~/文档/lmms 的普通书签(大概率是之前排查 LMMS 时被顺手写进去的),经过大约 18 轮双重编码,从几个字符一步步长成了 150 万字符、最终膨胀到 245 MB。
为什么微信也“同病相怜”
既然根因是 Qt,那没有链接 Qt 的微信为什么也卡在同一处?答案是:微信其实用了 Qt。
- 运行时
/proc/<pid>/maps里没有动态 libQt,所以乍看不像 Qt 程序; - 但
strings /opt/wechat/wechat | grep -i QFile能看到一整套 Qt Widgets 符号:
1 | QFileDialogPrivate |
也就是说,微信主进程静态链接了 Qt 5.15.14,它的文件选择器用的就是 Qt 内置 QFileDialog,读的自然也是同一个 ~/.config/QtProject.conf。之前把它归为“Chromium/Skia”是对无符号地址的错误归因——那些 RadiumWMPF / WeChatAppEx 进程才是小程序运行时。
根因链
- Qt 内置
QFileDialog把侧边栏书签持久化到~/.config/QtProject.conf的[FileDialog] shortcuts。 - 某个环节的编解码器不匹配,把书签里带中文的路径反复双重编码,指数膨胀到 245 MB。
- 打开文件对话框时,Qt 把这段巨串当文本交给
QTextLayout排版,QFontEngineMulti::stringToCMap对每个字符调用FcCharSetHasChar,GUI 线程就此空转 → “一点就卡死”。 - 微信静态链接 Qt,读同一个文件 → 一起卡死。
fontconfig 从头到尾都只是受害者,不是元凶。
修复
1 | # 1) 备份(留证据),然后重置 |
重建后,文件只有几百字节:
1 | $ stat -c %s ~/.config/QtProject.conf |
微信重启后也会把它重写为干净值(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 |
|
配合两个 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 | sudo chattr +i ~/.config/QtProject.conf |
chattr +i 会让该 inode 不可写、不可删、不可被 rename 覆盖;而 Qt 的 QSettings 正是“写临时文件再 rename 覆盖”,于是根本写不进去,读取却不受影响,对话框照常工作。
注意 chmod 444 不够——同目录可写,rename 仍能覆盖,必须用 chattr +i。代价是文件对话框不再记忆“上次目录 / 侧边栏宽度 / 书签排序”。
第三层(可选):抓出是哪个程序写坏的
如果想弄清“到底是谁在破坏它”(需要 root,且与“冻结”互斥):
1 | sudo systemctl enable --now auditd |
持久化规则写入 /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 | # 看 Qt 状态文件是否异常(正常只有几百字节) |