Warning
该项目仅供以测试和娱乐为目的的使用。
在实际测试中已发现问题:对于开启 drkonqi 的 KDE 用户,在使用 libschrodinger 触发 16 次错误后会导致 drkonqi 不可用。请参见后文 对于该问题的描述和解决方案。
libschrodinger 是 Linux 上的一个 LD_PRELOAD 整活项目。
libschrodinger 在程序触发部分致命信号(如 SIGSEGV、SIGBUS、SIGILL、SIGFPE、SIGABRT 等)时拦截,弹出一个 Microsoft Windows 风格的错误对话框,以还原该 现代商业操作系统 自 Windows XP 流传下来的神秘提示。普通 errno 报错、正常退出、SIGINT、SIGTERM 等不受影响。
弹出的对话框上复刻了 Windows 的两个按钮。其中「确定」将恢复之前的信号处置流程并重新投递原信号,程序按照正常路径终止;「取消」将关闭对话框,随后在终端(优先 kitty、回退 konsole)中启动 pwndbg -p <pid>(回退 gdb)附加到仍然冻结在信号处理上下文中的进程。关闭调试器后,程序按照原信号终止。
当前版本的 libschrodinger 支持下面的信号:
| 信号 | 提示文本 |
|---|---|
SIGSEGV(读访问) |
"<ip>" 指令引用的 "<addr>" 内存。该内存不能为 read。 |
SIGSEGV(写访问) |
同上,但为 written |
SIGBUS(BUS_ADRALN) |
同上,但为 aligned |
SIGBUS(其他) |
同上,但为 read |
SIGILL |
"<ip>" 指令引用的 "<addr>" 内存。该指令不能为 execute。 |
SIGFPE(整数除零等) |
"<ip>" 处发生整数除法。该除数不能为 zero。 等,按 si_code 细分 |
SIGABRT |
MSVCRT 风格:Runtime Error! 一段,仅一个 确定 按钮 |
SIGFPE 与 SIGABRT 不会显示内存故障地址,仅显示发生的运算类型。
read / written 不由 si_code 判断,而由 x86-64 页故障错误码的写位判定:读 PROT_NONE 页时是 SEGV_ACCERR,写未映射页时是 SEGV_MAPERR。
构建时需要 Qt6 Widgets 开发环境、GCC 和 G++、Make。对于 Nix 用户,可以 git clone 该 repo 后使用 nix develop 准备开发环境:
gh repo clone LyCecilion/libschrodinger
cd libschrodinger
nix develop
make对于其他用户,参阅对应发行版的包管理器以安装全部依赖。make 后会获得 2 个编译产物:
./build/libschrodinger.so # ELF 共享对象(LD_PRELOAD 库)
./build/schrodinger-dialog # Qt6 崩溃对话框辅助程序
可用 file 确认类型:
file ./build/libschrodinger.so ./build/schrodinger-dialogmake clean 仅删除 build/。Makefile 默认通过 pkg-config 消费 Nix 提供的 Qt6 元数据;等价的主机 Qt6(如 Fedora 的 qt6-qtbase-devel)也可作为 Nix 之外的回退,不改变运行时行为。
对一次命令注入(不会修改当前 shell 的后续命令):
LD_PRELOAD="$PWD/build/libschrodinger.so" <program>下面给出一些可复现的崩溃示例:
# SIGABRT
LD_PRELOAD="$PWD/build/libschrodinger.so" python3 -c 'import os; os.abort()'
# SIGSEGV
LD_PRELOAD="$PWD/build/libschrodinger.so" python3 -c 'import ctypes; ctypes.string_at(0)'在崩溃发生后会弹出对话框。点击「确定」将会使程序继续以错误终止;点击「取消」则会先关闭对话框,再经由终端模拟器打开调试器,你可以在这里检查仍存活的进程与栈。退出调试器后程序终止。
另一种方式是,在 tests/ 下用独立的 C 崩溃程序逐个触发(cd tests && make)。例如测试 SIGSEGV 写:
LD_PRELOAD="$PWD/../build/libschrodinger.so" ./crash_segv_write值得注意的是,在 Nix dev shell 里编译 tests/ 时,Nix 的 gcc wrapper 会默认注入 -D_FORTIFY_SOURCE,而测试程序刻意用 -O0(保证崩溃点不被优化掉),于是会触发一条无害的 #warning _FORTIFY_SOURCE requires -O。该 warning 可忽略,不影响测试程序的行为。
类似于前作 libobscure,该项目同样仅支持 Linux、不支持静态链接程序、不注入 setuid/setcap 程序。
libschrodinger 默认你具有 X11/Wayland 图形环境、Kitty 和 Konsole 中至少其一、pwndbg 和 gdb 中至少其一。libschrodinger 的触发是尽力而为的,在子进程失败、调试器无法打开、终端模拟器不可用等任何情况下,libschrodinger 都会回退以恢复默认信号处置并重新投递原信号,不吞掉原程序的崩溃。
libschrodinger 的错误处理逻辑可能会被目标程序内自带的错误处理逻辑替换。
Note
本节初稿由 DeepSeek V4 Pro 0813 编写,最终版本由 DeepSeek V4.1 Flash 1017 校对和措辞优化。
本库在程序启动时接管五个致命信号。崩溃发生时,处理器只 fork 一个子进程去 exec Qt 对话框,自己停在原地等待用户选择:点「确定」按原信号终止,点「取消」先打开调试器,再按原信号终止。整个过程分成四步:
flowchart TD
A["进程启动<br/>动态链接器加载 libschrodinger.so"] --> B["构造函数 schrodinger_init()<br/>预解析路径、保存环境、注册信号处理器"]
B --> C["进入 main(),程序正常运行"]
C -->|"SIGSEGV / SIGBUS / SIGILL / SIGFPE / SIGABRT"| D["crash_handler()<br/>只进行异步信号安全的操作"]
D --> E["fork + exec<br/>schrodinger-dialog"]
E --> F["Qt 对话框显示崩溃信息"]
F -->|"确定(退出码 0)"| G["恢复原信号处置<br/>重新投递原信号"]
F -->|"取消(退出码 1)"| H["fork + exec<br/>在终端中运行 pwndbg / gdb -p PID"]
H --> I["调试器退出"]
I --> G
G --> J["程序按原信号终止<br/>可正常产生 core dump"]
Linux 启动动态链接的 ELF 程序时,动态链接器会先加载 LD_PRELOAD 指定的共享对象,再进入程序的 main()。libschrodinger.so 一被加载,它的 __attribute__((constructor)) 构造函数 schrodinger_init() 就立刻执行。
这个时刻通常还是单线程,分配内存、读环境变量、扫描 PATH 都是安全的。构造函数趁机把崩溃时来不及做、也不适合做的事全部提前完成:
- 定位辅助程序:用
dladdr()拿到库自身的绝对路径,推出同目录下的schrodinger-dialog,运行时不依赖当前工作目录。 - 取程序名:从
/proc/self/exe读取可执行文件路径,取出 basename,用作对话框标题。 - 解析外部工具:在
PATH中查找kitty、konsole、pwndbg、gdb的绝对路径。 - 准备干净环境:复制一份去掉
LD_PRELOAD的环境变量。 - 准备备用栈:用
sigaltstack()注册一块 16 KiB 的备用信号栈。 - 接管信号:用
sigaction(..., SA_SIGINFO | SA_ONSTACK)给SIGSEGV、SIGBUS、SIGILL、SIGFPE、SIGABRT装上同一个处理器,并缓存它们原来的处置。
「提前做完」是关键。崩溃那一刻,处理器能调用的函数非常有限(见下一节),getenv()、unsetenv()、dladdr() 这些准备工作都不在其中,所以只能在启动时完成。
程序触发 SIGSEGV 这类致命信号时,内核默认会立刻终止进程。现在这个信号的处置被换成了 crash_handler(),内核于是把控制权交给它,并传入现场信息:
siginfo_t:si_addr是故障地址,si_code是故障子类型(如SEGV_MAPERR、SEGV_ACCERR)。ucontext_t:寄存器现场,可以取出RIP(出错指令的地址)和 x86-64 的页故障错误码REG_ERR。
但处理器运行在一个极受限的环境里。信号可能落在程序执行任意一条指令的时候,例如某个线程正握着 malloc 的内部锁。此时处理器如果调用 malloc、printf 或 setenv,就可能去抢同一把锁;锁的持有者已经被打断,没有机会释放,整个进程就会卡死。
这就是「异步信号安全」(async-signal-safe)的含义:POSIX 只保证一小部分函数可以在信号处理器中调用,例如 fork、execve、waitpid、write、kill、sigaction。因此 crash_handler() 里没有 printf;地址格式化是手写的十六进制/十进制转换,缓冲区全部是固定大小的静态数组。
记住这条限制,后面几个看起来奇怪的设计就都能解释了。
Qt 对话框要初始化 QApplication、分配内存、连接显示服务器、运行事件循环。这些操作没有一个是异步信号安全的,不能放在处理器里。
所以实际流程是:处理器 fork() 出一个子进程,子进程立刻 execve() 成 schrodinger-dialog。父进程留在处理器里 waitpid(),等对话框退出;子进程则换上一套全新的地址空间去运行 Qt。
为什么不在 fork 出来的子进程里直接初始化 Qt?因为 fork 只复制内存,不复制锁的状态。如果崩溃时另一个线程正握着某个内部锁,子进程里的这把锁同样是「已锁定」;子进程再去 malloc,就会等一个永远不会到来的解锁。exec 会丢弃整个旧地址空间,把这些坏掉的锁一起丢掉。
把对话框做成独立程序还有一个好处:Qt 不会被链接进 libschrodinger.so,也就不会随着 LD_PRELOAD 进入每一个目标程序。
这里有两个实现细节:
- 不用 glibc 的
fork()。 glibc 的fork()会执行pthread_atfork回调。崩溃可能正好打断一个持锁线程,这些回调会在子进程里再次抢锁。因此这里直接调用syscall(SYS_fork),绕过用户态逻辑;aarch64 没有独立的fork系统调用,改用SYS_clone。 - 不能在崩溃时
unsetenv("LD_PRELOAD")。 子进程如果带着LD_PRELOAD去exec,辅助程序会再次加载本库并重新安装处理器,甚至可能套娃弹窗。但unsetenv()会修改并锁定environ,不是异步信号安全的。于是构造函数提前做好一份不含LD_PRELOAD的环境快照,子进程直接用execve(path, argv, snapshot)。
辅助程序把用户的选择通过退出码回报给父进程:
| 用户操作 | 退出码 | 父进程接下来做什么 |
|---|---|---|
| 确定,或直接关闭窗口 | 0 |
恢复原信号处置、解除屏蔽,重新投递原信号;程序走正常崩溃路径,可能产生 core dump |
| 取消 | 1 |
再 fork 一个子进程,在 kitty(回退 konsole)里运行 pwndbg(回退 gdb)-p <pid>;调试器退出后重新投递原信号 |
| 参数错误、Qt 起不来、辅助程序异常退出 | 125 或其他 |
兜底:不做额外处理,直接重新投递原信号 |
关键点是:对话框不会吞掉崩溃。无论用户怎么选,进程最终都会死于原来的信号,区别只是中途多了一次查看现场的机会。
「取消」为什么能调试到现场?因为崩溃线程一直停在 crash_handler() 里没有返回,信号帧和寄存器现场都还在栈上,进程也仍然存活。调试器附加后可以检查调用栈、内存和寄存器;等调试器退出,父进程才恢复旧处置并重新投递信号。
父进程恢复的是本库安装处理器之前的处置,通常就是 SIG_DFL。重新投递后,内核会走正常的终止流程,包括按 core_pattern 生成 core dump。
read/written不是由si_code决定的。si_code只说明页面「不存在」(SEGV_MAPERR)还是「权限不足」(SEGV_ACCERR),不说明访问是读还是写。例如读一个PROT_NONE页面得到SEGV_ACCERR,写一个未映射页面却得到SEGV_MAPERR。真正可靠的依据是 x86-64 页故障错误码REG_ERR的写位(bit 1)。- 备用信号栈。 栈耗尽引发的
SIGSEGV没有可用的原栈。sigaltstack()+SA_ONSTACK让处理器在一块独立的 16 KiB 栈上运行,这类崩溃也能弹窗。 - 重入守卫。 如果处理器执行期间又来了第二个致命信号,再弹一个对话框只会让局面更糟。此时直接恢复默认处置并重新投递,让进程立刻结束。判断用
volatile sig_atomic_t,保证原子。 - 架构差异。 x86-64 使用
REG_RIP和REG_ERR;aarch64 使用pc,但没有对应的页故障错误码,读/写区分退化为固定的read。 - 不是所有信号都有故障地址。
SIGFPE和SIGABRT的si_addr没有意义,对话框只显示运算类型;SIGABRT使用固定的 MSVCRT 文案,并且只有一个「确定」按钮。
这是 libschrodinger 在 KDE 上引发的副作用,与上面的弹窗流程没有直接关系。
进程崩溃后,内核会按照 core_pattern 的配置,把崩溃进程的信息通过管道交给 systemd-coredump。后者保存 core 文件,并向 journal 写入一条包含栈回溯和模块列表的记录,然后触发 drkonqi-coredump-processor@。
processor 以 root 身份运行,不能直接向用户的桌面会话弹窗,只能通过用户会话中的一个本地 socket 通知用户态 launcher。这个 socket 的 MaxConnections 是 16。
launcher 收到通知后会启动 drkonqi-coredump-gui。GUI 会到 ~/.cache/kcrash-metadata 下查找对应程序的 .ini 元数据;我们的裸 C 测试程序没有这些元数据,于是 launcher 卡在 poll() 里不退出。
每发生一次这样的崩溃,就有一个 launcher 卡住并占住一个连接。累计 16 次后,socket 达到连接上限,后续崩溃再也无法通知到用户会话,drkonqi 便不再弹窗。
清理掉这些卡住的 launcher 就能恢复:
pkill -f '^/usr/libexec/drkonqi-coredump-launcher'之后新的崩溃可以正常走到 drkonqi。
MIT LICENSE. By Limity'roChen & LyCecilion, 2026.
洛汐 (Limity'roChen) 和零音 (LyCecilion) 完成了该项目的文档和视频剪辑。DeepSeek V4 Pro 完成了该项目的全部代码和原理解释部分,GPT 5.6 sol 完成了代码审计。
另外,感谢 Project Hazelita 社群成员的帮助。
零音谨以此项目,祭奠一段已不值得祭奠的关系,并令众人行使其一切审判权。
🍀 | 🌌 | 🪼 | ❄️


