背景
在 C++ 中,当一个异常被抛出(throw)但未被任何 catch 块捕获时,程序会调用 std::terminate() 函数。为了在程序异常终止前执行自定义的异常监听,C++ 标准库提供了 std::set_terminate() 函数。它允许我们注册一个自定义的终止处理程序(Termination Handler),这个处理程序将在 std::terminate() 被调用时执行。
KSCrash 正是利用了这一机制来捕获未处理的 C++ 异常:
ini
static void install()
{
KSCM_InstalledState expectedState = KSCM_NotInstalled;
if (!atomic_compare_exchange_strong(&g_state.installedState, &expectedState, KSCM_Installed)) {
return;
}
kssc_initCursor(&g_stackCursor, NULL, NULL);
g_state.originalTerminateHandler = std::set_terminate(CPPExceptionTerminate);
}
在 iOS 平台上,捕获 C++ 未处理异常并获取完整堆栈信息面临着独特的挑战。这并非 C++ 语言本身的问题,而是源于两大系统框架的底层机制:GCD 和 RunLoop。
iOS 的主线程运行在 RunLoop 中,而后台任务和异步操作则大量依赖 GCD 进行调度。为了保证框架自身的稳定性和健壮性,这些框架在调用我们的业务代码(可能是 C++)时,通常会用 try catch 捕获异常。
示例 _dispatch_client_callout 的实现:
scss
_dispatch_client_callout(void *ctxt, dispatch_function_t f)
{
@try {
return f(ctxt);
}
@catch (...) {
objc_terminate();
}
}
当我们的 C++ 代码抛出异常时,它不会直接传播到顶层触发我们设置的terminate_handler。相反,它会先被 libdispatch 的 _dispatch_client_callout 或 RunLoop 的内部机制捕获。框架捕获到这个它无法处理的 C++ 异常后,会认为这是一个无法恢复的致命错误。它会选择直接调用 std::terminate() 或进行 rethrow。此时的调用堆栈已经位于系统库内部,原始的 C++ 异常上下文(即异常发生的位置和堆栈)已经丢失。
如果不做额外处理,当 C++ 异常被 GCD 或 RunLoop 捕获后,我们最终得到的崩溃堆栈如下所示。这份堆栈对于定位问题根源几乎没有任何帮助。
bash
#0 0x00000001ecb3f42c in __pthread_kill ()
#1 0x00000002008dec0c in pthread_kill ()
#2 0x00000001ab9e2ba0 in abort ()
#3 0x00000002007fcca4 in abort_message ()
#4 0x00000002007ece40 in demangling_terminate_handler ()
#5 0x000000019b925e3c in _objc_terminate ()
#6 0x00000002007fc068 in std::__terminate ()
#7 0x00000002007fc00c in std::terminate ()
#8 0x000000019b930afc in objc_terminate ()
#9 0x0000000107aae7d0 in _dispatch_client_callout ()
#10 0x0000000107ab130c in _dispatch_queue_override_invoke ()
#11 0x0000000107ac2ae4 in _dispatch_root_queue_drain ()
#12 0x0000000107ac34d8 in _dispatch_worker_thread2 ()
#13 0x00000002008db8f8 in _pthread_wqthread ()
传统方案
使用 fishhook hook __cxa_throw 方法,保留堆栈并建立和抛出异常的映射关系,在 terminate 回调里面取之前的保留的堆栈信息。
ini
struct rebinding item = { 0 }
item.name = "__cxa_throw";
item.replacement = (void *)fishhook_new_cxa_throw;
item.replaced = (void **)&origin_cxa_throw;
ks_rebind_symbols(&item, 1);
fishhook_new_cxa_throw 会在每次 C++ 异常抛出时会执行如下操作:
- 捕获堆栈:在当前上下文中捕获完整的调用堆栈,这是"第一现场"信息。
- 建立映射:将捕获到的堆栈与正在被抛出的异常对象 (thrown_exception) 关联起来,并存储在一个全局的数据结构中。
- 调用原始函数:完成信息保存后,调用原始的 origin_cxa_throw,让异常流程继续进行,不影响程序原有逻辑。
javascript
static void fishhook_new_cxa_throw(void *thrown_exception, void *tinfo, void (*dest)(void *)) {
/*** 捕获堆栈 建立映射 ***/
origin_cxa_throw(thrown_exception, tinfo, dest);
}
我们设置的 terminate_handler 可以根据当前的异常对象,从全局存储中取出之前保存好的完整堆栈信息。
这个依赖于动态符号替换的方案在 iOS 15 及更高版本中再次遭遇了挑战。为了提升安全性和启动性能,苹果引入了新的动态链接机制------chained fixups。这个机制通过一种指针链的方式预先计算和链接了系统库的符号地址,绕过了传统的 dyld 绑定流程。导致 fishhook 这类依赖于修改 __DATA 段符号指针的工具,在尝试 Hook 系统库(如 libc++abi.dylib)导出的符号时会失效。系统不再通过可修改的指针来查找 __cxa_throw,而是直接跳转到硬编码的地址,我们的钩子函数因此完全不会被触发。
因此业界需要寻找新的、不依赖传统符号 Hook 的方法来应对 iOS 上的 C++ 异常捕获问题。
替代方案
__cxa_throw 被 chained fixups 封堵后,我们需要寻找一个新的、不受其影响的拦截点。答案隐藏在 C++ 异常处理的底层机制中。chained fixups 加固了系统库之间的符号链接,但它并不影响主二进制文件(我们的 App)对系统符号的调用,也不影响我们 Hook 自己二进制文件内的符号。这为我们保留了一些操作空间。
当一个异常被抛出,底层的 libunwind 库会启动一个两阶段的栈回溯过程(详细过程可参考 juejin.cn/post/733192...%EF%BC%9A "https://juejin.cn/post/7331924057925304370)%EF%BC%9A")
Phase 1: Search (搜索阶段):libunwind 会从异常抛出点开始,逆向遍历调用栈的每一帧 (stack frame)。对于每一帧,它会调用一个名为 "Personality Routine" (个性化例程) 的函数。这个函数就像一个"本地向导",负责告知 libunwind 当前栈帧是否有能力处理这个异常(即是否存在匹配的 catch 块)。
Phase 2: Cleanup (清理阶段):一旦在搜索阶段找到了能处理异常的 catch 块,libunwind 就会进入清理阶段,再次回溯到该栈帧,并沿途析构所有局部对象。
在 Seach 阶段(Cleanup 阶段线程上下文已经发生改变)当栈回溯到属于我们主二进制文件的栈帧时,它调用的 Personality Routine 也在我们的主二进制文件内。
在 ARM 架构下,编译器通常只会生成少数几个固定的 Personality Routine(如 __gxx_personality_v0)。我们只需要在 App 启动时,用 fishhook 将这几个函数替换成我们自己的版本。
通过 Hook Personality Routine,我们将拦截点从异常的"抛出"瞬间,后移到了"寻找 catch 块"的途中。这种方法巧妙地绕过了 chained fixups 的限制,使得在绝大部分场景下(只要调用栈中包含我们 App 的代码),我们都能在 iOS 15+ 系统上重新获得第一现场的 C++ 异常堆栈。
针对主二进制文件中的 __gxx_personality_v0 符号进行重绑定(Rebinding)。
ini
struct rebinding r;
r.name = "__gxx_personality_v0";
r.replacement = (void *)new_gxx_personality_v0;
r.replaced = (void **)&original_gxx_personality_v0;
// 仅对主可执行文件进行重绑定(hard code 是为了简单写这个 demo)
const struct mach_header_64 *header = (const struct mach_header_64 *)_dyld_get_image_header(4);
intptr_t slide = _dyld_get_image_vmaddr_slide(4);
ks_rebind_symbols_image((void *)header, slide, &r, 1);
自定义 Personality Routine:new_gxx_personality_v0 函数是我们实现的核心。
arduino
static _Unwind_Reason_Code (*original_gxx_personality_v0)(int version,
_Unwind_Action actions,
uint64_t exceptionClass,
struct _Unwind_Exception *exceptionObject,
struct _Unwind_Context *context);
static _Unwind_Reason_Code new_gxx_personality_v0(int version,
_Unwind_Action actions,
uint64_t exceptionClass,
struct _Unwind_Exception *exceptionObject,
struct _Unwind_Context *context) {
if ((actions & _UA_SEARCH_PHASE) != 0) {
/*** 捕获堆栈 建立映射 去重***/
}
if (original_gxx_personality_v0) {
return original_gxx_personality_v0(version, actions, exceptionClass, exceptionObject, context);
}
return _URC_CONTINUE_UNWIND;
}
成功捕获到了一份信息详尽的 C++ 异常堆栈。

替代新方案于传统方案的总结对比:
特性 | Hook __cxa_throw (传统方案) | Hook Personality Routine (新方案) |
---|---|---|
Hook 范围 | 全部已加载镜像(上千个) | 仅主二进制文件 (1 个) |
Hook 目标 | __cxa_throw 符号 | __gxx_personality_v0 等少数符号 |
实现复杂度 | 高,需遍历所有镜像 | 低,目标明确 |
性能影响 | 存在启动时开销 | 可忽略不计 |
iOS 15+ 兼容性 | 失效 | 有效 |
来自 Gemini 的肯定:
这种从"广撒网"到"精准打击"的转变,不仅是应对系统限制的无奈之举,更是一次技术方案上的巨大飞跃,体现了对底层原理深入理解所带来的优雅与高效。
系统支持
在这场开发者与系统机制的长期博弈之后,苹果最终为这个问题画上了句号。从 libdispatch-1521.100.80 版本开始,_dispatch_client_callout 的实现被彻底重构,从根本上解决了 C++ 异常堆栈丢失的问题。新的实现:告别 try...catch。通过自定义 ___dispatch_noexcept_personality 方法,硬编码返回 _URC_FATAL_PHASE1_ERROR,
scss
// The .cfi_personality directive is used to control the personality routine
// (used for exception handling) encoded in the CFI (Call Frame Information).
// We use that directive to override the normal personality routine with one
// that always reports an error, leading the Phase 1 of unwinding to abort the
// program.
//
// The encoding we use here is 155, which is 'indirect pcrel sdata4'
// (DW_EH_PE_indirect | DW_EH_PE_pcrel | DW_EH_PE_sdata4). This is known to
// work for x86_64 and arm64.
#define OVERRIDE_PERSONALITY_ASSEMBLY() \
__asm__(".cfi_personality 155, ___dispatch_noexcept_personality")
#undef _dispatch_client_callout
extern "C" void
_dispatch_client_callout(void *ctxt, dispatch_function_t f)
{
OVERRIDE_PERSONALITY_ASSEMBLY();
f(ctxt);
__asm__ __volatile__(""); // prevent tailcall
}
arduino
extern "C" __attribute__((used)) _Unwind_Reason_Code
__dispatch_noexcept_personality(int version, _Unwind_Action action,
uint64_t exceptionClass, struct _Unwind_Exception *exceptionObject,
struct _Unwind_Context *context)
{
(void)version;
(void)action;
(void)exceptionClass;
(void)exceptionObject;
(void)context;
return _URC_FATAL_PHASE1_ERROR;
}
当 Seach 阶段遍历到 _dispatch_client_callout 方法时,Seach 阶段会停止遍历,执行 terminate handler,此时保留了抛异常的第一现场。线下测试 iOS 26 系统,runloop 也可以通过 terminate handler 获取崩溃的第一现场。
总结
随着 libdispatch 的官方更新,苹果为这个困扰开发者多年的问题画上了句号。这是否意味着我们之前探索的替代方案 ------ 巧妙 Hook Personality Routine 的设计失去了价值?当然答案并非如此。
技术方案总有其生命周期,会被更优的设计、甚至平台的原生支持所替代。但我们面对问题时,那种对底层原理的渴求、对未知领域的探索、以及在逆境中寻求突破的整个过程,其价值超越了任何单一解决方案。这次探索的真正产出,不是一个临时的 Hook 方案,应用价值也远不止于获取 C++ 崩溃堆栈------它为我们揭示了更多系统底层的可能性(尽管具体应用暂不便详述)。
当未来出现新的、未知的问题时,真正能让我们披荆斩棘的,正是这些沉淀下来的系统性知识、第一性原理的思考方式和坚韧的探索精神。这,才是技术演进中永不"过时"的核心资产。