借助 AI 在 30 分钟内定位并修复了三个偶发性崩溃问题。本文复盘整个排查过程,记录诊断思路和修复方案。

一、背景

项目是一个基于 ZLMediaKit 的嵌入式多媒体客户端,运行在 ARM 平台上。最近重构了 RTSP 服务管理代码后,程序同时出现三个症状:

  1. 偶尔 double-free 崩溃(ARM 和 x86 都有)
  2. ARM 启动即段错误,x86 却正常
  3. RTSP 服务无法正常退出(卡死)

前两个问题看起来像内存错误,第三个像死锁,直觉上互不相干。但折腾一圈后发现,三个 bug 共享同一个根——静态库与动态库的符号冲突。下面按排查顺序逐个说。

二、Bug 1:偶尔 double-free —— ELF 符号介入

症状

程序退出时偶尔报 double-free,不是每次都出现。valgrind 能抓到,但指向的调用栈涉及动态库的析构阶段,信息模糊。

排查过程

让 AI 遍历 src/ 下所有源文件做内存管理分析。AI 很快从 CMakeLists.txt 中拎出一段关键注释:

1
2
3
4
# libcore_api.so 提供 toolkit 基础库符号(SocketHelper, EventPoller 等)
# libzlmediakit.a 提供 C++ 高层接口(PlayerProxy, TcpServer 等)
# 两者都包含 ZLMediaKit 全局 std::string,ELF 符号介入导致同一块内存被析构两次 (double-free)
# 解决:用 -Wl,--no-as-needed 确保 libcore_api.so 被链接,并在退出前手动清理

注释已经把问题说清楚了。进一步检查 git diff,发现重构中新代码把 _exit(0) 改成了 return 0

1
2
3
4
5
6
7
// 旧代码(正确)
// 使用 _exit() 跳过 atexit/dl_fini 析构,避免 libcore_api.so 与 libzlmediakit.a
// 的全局 std::string 被 ELF 符号介入导致 double-free。
_exit(0);

// 新代码(有问题)
return 0;

根因

libcore_api.so(动态库)和 libzlmediakit.a(静态库)各自编译了一份 ZLMediaKit 的全局 std::string 对象。正常运行时相安无事,但程序退出的 atexit / dl_fini 阶段:

  1. 动态库 fini 析构一次 string,释放内存
  2. 可执行文件的静态析构再对同一块内存调用 free → double-free

这是 ELF 默认符号解析的经典陷阱:动态库的 std::string 符号介入了可执行文件中的同名符号,链接器把它们合并成了同一个对象,两块代码以为自己各管各的,实际上共享了一块内存。

修复

1
2
3
4
# CMakeLists.txt
set_target_properties(app PROPERTIES
LINK_FLAGS "-Wl,-Bsymbolic" # 可执行文件符号自绑定,阻止 .so 介入
)

-Bsymbolic 让可执行文件内的符号引用优先绑定到自身定义。libcore_api.so 的全局 string 和 libzlmediakit.a 的全局 string 从此各自独立,互不干预。

_exit(0) 恢复后 double-free 消失——但这只是掩盖,真正的修法还是 -Bsymbolic。前者跳过析构,后者让析构正确。

三、Bug 2:ARM 启动段错误 —— 静态初始化顺序 fiasco

症状

ARM 上程序启动即崩溃,GDB 的 backtrace 长这样:

1
2
#4  Factory::registerPlugin → 访问 s_plugins[...]
#7 __static_initialization_and_destruction_0 ← libcore_api.so 的静态构造

x86 上完全正常。

排查过程

backtrace 里 frame #4 在可执行文件地址空间,frame #7 在 libcore_api.so 地址空间,但都指向同一个源文件 Factory.cpp。同一个源文件怎么出现在两个不同的加载单元里?

AI 对比了 ARM 和 x86 预编译库的符号表。线索就藏在 mangled symbol 里:

1
2
3
4
5
6
7
# x86 libzlmediakit.a(旧版,正常)
_ZN8mediakitL10getPluginsEv ← getPlugins() 函数
_ZZN8mediakitL10getPluginsEvE9s_plugins ← s_plugins 在 getPlugins() 内部(Meyers' singleton)

# ARM libzlmediakit.a(新版,有问题)
_ZN8mediakitL9s_pluginsE ← s_plugins 是文件级 static(独立符号)
_ZN8mediakitL10getPluginsEv ← getPlugins() 也存在(但 registerPlugin 未使用它)

真相大白:

x86 预编译库 ARM 预编译库
ZLMediaKit 版本 旧版 新版
s_plugins 位置 函数内 static(Meyers' singleton) 文件级 static
初始化时机 首次调用 getPlugins() ELF 初始化阶段,顺序不可控

根因

Factory.cpp 的新版中,有人把 s_plugins 从函数内 static 改成了文件级 static:

1
2
3
4
5
6
7
8
9
// 旧版(正常)
static auto& getPlugins() {
static std::unordered_map<int, const CodecPlugin*> s_plugins;
return s_plugins; // 首次调用时才初始化
}

// 新版(崩溃)
static std::unordered_map<int, const CodecPlugin*> s_plugins;
// ↑ 文件级 static,构造函数何时执行取决于 ELF 初始化顺序——这就是 fiasco

崩溃链路非常清晰:

1
2
3
4
5
6
7
libcore_api.so 加载 → 静态构造函数运行
→ REGISTER_CODEC(vp8_plugin) 展开的 onceToken 构造
→ 调用 Factory::registerPlugin()
→ ELF 符号介入,解析到可执行文件的 registerPlugin 版本
→ 访问可执行文件中的 s_plugins
→ 可执行文件的静态构造还没跑到
→ s_plugins 尚未构造 → 访问未初始化的 unordered_map → 段错误

为什么 x86 正常?

因为 x86 预编译库是旧版 ZLMediaKit 编译的,s_plugins 是函数内 static(Meyers' singleton),首次调用时才初始化,天然免疫静态初始化顺序问题。

这跟 CPU 架构没有任何关系,纯粹是两套预编译库的 ZLMediaKit 版本不同。

修复

两件事:

  1. Factory.cpp 恢复成 Meyers' singleton 写法——这是治本
  2. ARM 预编译库从备份恢复旧版——这是兜底,万一别处还有类似改动

经验法则:永远不要在跨编译单元的全局对象初始化代码中依赖另一个编译单元的全局对象。 如果你必须这样做,把它包进函数内 static。

四、Bug 3:RTSP 无法正常退出 —— EventPoller 线程死锁

症状

程序退出时卡住不动,Ctrl+C 无效,只能 kill -9

排查过程

旧代码里其实已经有一段注释点明了问题:

PlayerProxy / TcpServer 的析构依赖 EventPoller 线程协同完成,推流重连时 EventPoller 被异步任务占满,同步析构必然死锁。

之前的"修复"手段很粗暴:让 stop() 方法什么也不做,靠 _exit(0) 直接退出,让操作系统回收资源。但 Bug 1 修了之后不能用 _exit(0) 了——你需要走正常的析构路径。

AI 查阅了 ZLMediaKit 的 EventPoller.h 头文件,找到了 async() API,提出了关键思路:把清理任务投递到 EventPoller 的线程内部去执行

根因

死锁的因果链如下:

1
2
3
4
5
6
主线程调用 proxy.reset()
→ PlayerProxy 析构函数需要取消 EventPoller 上的定时器
→ 向 EventPoller 线程投递"取消"任务,并同步等待完成
→ 但 EventPoller 线程正在处理推流重连的异步任务,忙得不可开交
→ "取消"任务永远排不到
→ 主线程永远在等 → 死锁

本质是一个经典模式:线程 A 析构一个对象,但这个对象的清理工作需要线程 B 配合——而线程 B 正忙,无法响应。 跨线程同步析构是这类 bug 的温床。

修复

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void stop() {
if (proxies.empty() && !server) return;

// 将析构投递到 EventPoller 线程执行,避免跨线程同步等待
auto poller = EventPollerPool::Instance().getFirstPoller();
if (poller) {
poller->async([proxies = std::move(this->proxies),
server = std::move(this->server)]() mutable {
// 在 EventPoller 自己的线程里析构 PlayerProxy
// 所有内部清理操作都是同线程同步,不会死锁
proxies.clear();
server.reset();
});
}
}

核心思想:让 PlayerProxy 的析构发生在 EventPoller 自己的线程里。析构函数内部的所有操作——取消定时器、关闭连接、清理 session——全部是同线程同步调用,没有任何跨线程等待。主线程只负责把 lambda 投递过去,投递完就返回了,不等待结果。

这是一个通用模式:谁创建的对象,就在谁的线程里析构它。 如果对象依赖某个事件循环,那把析构也投递到那个循环里。

五、AI 辅助调试的价值

这次排查过程让我对 AI 在系统级 bug 定位中的角色有了更具体的认识。几个关键时刻:

  1. 全量代码扫描:人工排查三个相互关联的 bug,需要逐个阅读 src/ 下几十个源文件并手工建立关联。AI 在几分钟内遍历完毕,直接给出可疑点列表。

  2. 精准定位关键注释CMakeLists.txt 里那段解释 ELF 符号介入的注释是整个 Bug 1 的关键线索。在几百行的构建配置里,AI 直接把它拎了出来——人工翻可能就扫过去了。

  3. 跨领域知识关联:ELF 符号介入、Meyers' singleton、静态初始化顺序 fiasco、EventPoller 线程模型——这四个知识点分属链接器、C++ 语言规范、Linux 动态加载和 ZLMediaKit 框架。单独排查时很容易卡在某一层的表象上,AI 能同时调动这几个领域的知识做交叉分析。

  4. 符号表对比分析:通过对比 x86 和 ARM 预编译库的 mangled symbol,AI 在几秒内确认了 s_plugins 在两个版本中的差异。人工做这一步,需要 strings | grep 然后手工 demangle,一条条比对,耗时且容易遗漏。

  5. API 发现:AI 主动查找并阅读了 EventPoller.h 的 API 定义,发现了 async() 方法——这个方法不在我日常使用的 API 子集里。没有它,死锁问题就只能继续用 _exit(0) 这种粗暴手段绕过去。

说到底,AI 的价值不在于替代你对底层机制的理解——你仍然需要能看懂 backtrace、理解符号表、判断根因的可信度。AI 的价值在于压缩从症状到根因的推理链条:帮你快速排除噪音、关联线索、找到那条关键的证据。你可以把节省下来的脑力用在更重要的决策上。

六、小结

三个 bug,各自触及不同的计算机科学基础概念:

Bug 涉及概念 一句话总结
double-free ELF 符号介入、动静态链接混用 -Wl,-Bsymbolic 让可执行文件的符号自绑定
启动段错误 静态初始化顺序 fiasco、Meyers' singleton 永远用函数内 static 替代文件级 static
RTSP 退出死锁 事件循环线程模型、异步析构 把析构投递到 EventPoller 自己的线程执行

三个 bug 都是"改一行修好"的类型——-Bsymbolic、Meyers' singleton、poller->async()——但定位每一行都需要对底层机制有准确理解。工程里的 hard bug 往往不是代码逻辑写错了,而是对运行时的假设出了问题:你以为符号是独立的,你以为初始化是有序的,你以为析构可以在任意线程等待——当你对底层机制的认知和实际行为之间有裂缝时,bug 就钻进去了。

调试的最高境界不是修好一个 bug,而是看懂一个假设——然后发现另外两个 bug 也基于同一个错误的假设。