普通视图

发现新文章,点击刷新页面。
今天 — 2025年10月29日首页

iOS 26 你的 property 崩了吗?

作者 yuec
2025年10月28日 14:57

本文首次发表在快手大前端公众号

背景

iOS 26 Runtime 新增特性,对 nonatomic (非原子) 属性的并发修改更加容易产生崩溃。系统合成的 setter 方法会短暂地存入一个哨兵值 0x400000000000bad0 ,而该值可能会被另一个并发访问此属性的线程所读取。如果程序因访问这个哨兵值而崩溃,则表明正在访问的属性存在线程安全问题。

崩溃示例:

核心改动

对于 nonatomic strong 属性的赋值操作,编译时会自动生成对 objc_storeStrong 函数的调用。

示例:

@property (nonatomic, strong) NSObject *obj1;

系统生成的 setter 方法:

Example`-[ViewController setObj1:]:
    0x1046298c4 <+0>:  sub    sp, sp, #0x30
    0x1046298c8 <+4>:  stp    x29, x30, [sp, #0x20]
    0x1046298cc <+8>:  add    x29, sp, #0x20
    0x1046298d0 <+12>: stur   x0, [x29, #-0x8]
    0x1046298d4 <+16>: str    x1, [sp, #0x10]
    0x1046298d8 <+20>: str    x2, [sp, #0x8]
    0x1046298dc <+24>: ldr    x1, [sp, #0x8]
    0x1046298e0 <+28>: ldur   x8, [x29, #-0x8]
    0x1046298e4 <+32>: adrp   x9, 5159
    0x1046298e8 <+36>: ldrsw  x9, [x9, #0xba4]
    0x1046298ec <+40>: add    x0, x8, x9
--> 0x1046298f0 <+44>: bl     0x105600a10               ; symbol stub for: objc_storeStrong
    0x1046298f4 <+48>: ldp    x29, x30, [sp, #0x20]
    0x1046298f8 <+52>: add    sp, sp, #0x30
    0x1046298fc <+56>: ret    

objc_storeStrong 在旧版本的的实现:

void objc_storeStrong(id *location, id obj) {
    // 1. 先用一个临时变量 prev 持有旧值
    id prev = *location;
    
    // 2. 如果新旧值相同,直接返回,避免不必要的内存操作
    if (obj == prev) {
        return;
    }
    
    // 3. 对新值执行 retain,使其引用计数+1
    objc_retain(obj);
    
    // 4. 将指针指向新值
    *location = obj;
    
    // 5. 对旧值执行 release,使其引用计数-1
    objc_release(prev);
}

反汇编 objc_storeStrong 在 iOS 26 新版本的实现:

void objc_storeStrong_iOS_26(id *location, id obj) {
    // 1. 读取旧值
    // ldr x20, [x0]
    id prev = *location;

    // 2. 检查新旧值是否相同,相同则直接返回
    // cmp x20, x1
    // b.eq ... (跳转到函数末尾)
    if (prev == obj) {
        return;
    }

    // 为了后续操作,保存新值 obj 和地址 location
    // mov x19, x1  (x19 = obj)
    // mov x21, x0  (x21 = location)
    id new_obj_saved = obj;
    id* location_saved = location;

    // 3. 【核心改动】向属性地址写入哨兵值
    // mov  x8, #0xbad0
    // movk x8, #0x4000, lsl #48  --> x8 = 0x400000000000bad0
    // str  x8, [x0]
    *location = (id)0x400000000000bad0; // 调试陷阱

    // 4. 对新值执行 retain
    // mov x0, x1
    // bl objc_retain
    objc_retain(obj);

    // 5. 将真正的新值写入属性地址,覆盖哨兵值
    // str x19, [x21]
    *location_saved = new_obj_saved;

    // 6. 释放旧值(通过尾调用优化)
    // mov x0, x20
    // b objc_release
    // 这相当于 return objc_release(prev);
    objc_release(prev);
}

为了更主动地暴露 nonatomic 属性的线程安全问题,objc_storeStrong 函数在 iOS 26 中增加了一个关键步骤。

旧实现 (时序:Retain -> Assign -> Release)

  1. objc_retain(newValue);
  2. *location = newValue;
  3. objc_release(oldValue);

新实现 (时序:写入哨兵值 -> Retain -> Assign -> Release)

  1. *location = 0x4...bad0; // <-- 新增:写入哨兵值

  2. objc_retain(newValue);

  3. *location = newValue;

  4. objc_release(oldValue);

旧实现中数据竞争触发崩溃需要满足的条件:

  1. 对象状态:prev 对象的引用计数 == 1,执行完 objc_release 之后 prev 对象被释放。

  2. 线程时序:读线程获取到了 prev 对象,并未对 prev 对象的引用计数+1,写线程执行完 objc_release(prve),读线程仍在继续使用 prev。

  3. 行为前提:读线程必须对这个已成为悬垂指针的 prev 地址执行解引用操作,但是这是一个必要不充分条件,因为该内存可能已被重用,不一定会触发崩溃。

新实现通过引入哨兵值,将不确定的崩溃条件转变为一个确定的、主动触发的机制:

  1. 定义"危险窗口": 写线程在 objc_storeStrong 内部创建了一个明确的"危险窗口"——从写入哨兵值 (*location = 0x4...bad0) 开始,到写入新值 (*location = obj) 结束。访问哨值触发崩溃与旧值 prev 对象的引用计数无关
  2. 简化触发条件: 只要读线程的读取操作落入这个时间窗口内,它必然会获取到哨兵值。对这个非法的哨兵地址进行任何解引用操作,都将必然、立即触发一个带有明确特征 (0x4...bad0) 的 EXC_BAD_ACCESS 崩溃。

另外新的崩溃机制并非替换了旧的崩溃逻辑,而是与之叠加,因此极大地放大了崩溃的概率。

崩溃场景

当一个线程(线程 A)正在为属性赋值,并已写入哨兵值但尚未写入新值时,*location 处于 "危险窗口"。此时,另一个线程(线程 B)的并发读写操作会导致崩溃。

崩溃场景一:写写并发 → objc_release 崩溃

  1. 线程 A:执行 setter,向属性地址写入哨兵值 0x4...bad0。

  2. 线程 B:并发执行 setter,调用 objc_storeStrong 函数。

  3. 关键点:线程 B 此时读到了 "旧值" 是线程 A 写入的哨兵值 0x4...bad0。

  4. 崩溃:objc_storeStrong 在赋值完成后,尝试调用 objc_release(旧值),实际上执行了 objc_release(0x4...bad0)。由于这是一个无效的对象地址,程序立即崩溃,堆栈栈顶指向 objc_release。

复现代码:

崩溃栈顶:

崩溃场景二:读写并发 → objc_retain 崩溃

  1. 线程 A:执行 setter,写入哨兵值 0x4...bad0。

  2. 线程 B:此时执行 getter 来读取该属性。

  3. 关键点:getter 直接从内存中返回了当前的哨兵值 0x4...bad0。

  4. 崩溃:ARC 为了保证对象生命周期,会对这个值执行 retain 操作。这导致系统调用 objc_retain(0x4...bad0)。同样,由于这是一个无效地址,程序崩溃,堆栈栈顶指向 objc_retain。

复现代码:

崩溃栈顶:

根因修复

iOS 26 Runtime 针对 property 新增的哨兵机制,其目的是主动暴露潜藏的多线程数据竞争问题。因此,修复的根本目标是解决底层的线程冲突。

最直接快速的修复方案是把 nonatomic 修改为 atomic。它能有效地规避 iOS 26 此次更新导致的,访问哨兵 0x400000000000bad0 触发的崩溃问题。

需要注意的是 atomic 也有一些局限性,只保证 setter 或 getter 本身是原子操作。如果有一系列依赖该属性的操作,atomic 无法保护整个操作序列是线程安全的。典型场景比如数组、字典的更新,atomic 可以保证线程安全的获取数组或字典对象,但是无法保证对数组和字典的增删是线程安全的,此时需要用锁或者队列覆盖系列复合操作来保证线程安全。

影响范围

这是一次由操作系统 Runtime 变更引发的、波及全量线上版本的崩溃问题。当用户升级操作系统后,代码库中所有潜藏的 nonatomic 数据竞争问题都将被新的"哨兵"机制主动暴露,导致崩溃呈现高度分散的特点,增加了问题处置的复杂性。如下所示,不仅分布为多个崩溃堆栈,并且每个堆栈的 App 版本跨度非常大。

对于线上 App 版本,如果不做任何止损操作,iOS 26 系统的用户崩溃率将比存量系统激增近两个数量级。

Ekko(安全气垫)

崩溃波及全量的线上 App 版本,对于线上历史版本, 从用户体验的角度出发,我们不能够任由崩溃发生,也不能简单粗暴地强制用户升级 App。那么如何在允许的的规则范围内进行崩溃止损呢?快手的答案是使用 Ekko(安全气垫)。

Ekko 是什么?

Ekko 是快手自研的全新的安全气垫框架,命名源自英雄联盟的艾克,他的 R 技能可以回到数秒前位置并恢复生命值,非常契合快手安全气垫的技术实现。Ekko 核心机制是:在异常发生之后,App 闪退之前,通过修改程序执行流,在代码逻辑上等价于绕过执行发生异常的函数,从而让 App 免于崩溃。Ekko 兜底偶现崩溃的场景下,当目标函数未发生崩溃时,执行逻辑不会受任何影响。

以典型的数组越界为例:

Ekko 兜底 objectAtIndex: 后,上述代码在异常发生后,执行逻辑上等价于:

Ekko 简介:

  • 平台覆盖:iOS & Android

  • 兜底能力:在 iOS 端能处理包括 Mach 异常在内的所有崩溃类型。在 Android 端能处理 Java Exception 和 Native Exception。

  • 稳定可靠:兜底的核心逻辑在异常发生后执行,对正常运行的 App 不发生作用。Ekko 系统上线至今,已在线上稳定运行超过一年,多次在异常退出类型的故障处置中发挥关键作用,为快手 App 的稳定性提供了坚实的保障。

Ekko 兜底实践

iOS 传统的安全气垫会通过 hook Objective-C 可能会抛异常的系统方法,在替换的方法内,添加 try catch 或者校验异常参数,防御已知的、可枚举的风险点,从而避免崩溃发生。

因为访问 0x400000000000bad0 触发了 bad access 类型的 Mach 异常能不能被 try catch 住呢?答案是不可以的。但是 Mach 同 Exception 一样,也是两段式的处理,当异常发生时,内核会挂起出错线程,并向用户态发起“问询”,并等待用户态的响应,然后根据用户态的回复决定是否终止进程还。这个问询等待回复后决策的机制是 Ekko 兜底 Mach 异常的关键所在。

针对此次 nonatomic 哨兵值崩溃,快手稳定性组通过 Ekko,对访问哨兵地址 0x400000000000bad0 触发的崩溃类型进行了统一兜底,拦截了用户百万次量级的崩溃。兜底主要处理以下两种系统堆栈触发的崩溃场景:

  • 场景一:objc_release
    • 兜底策略: 检测到参数为哨兵地址时,直接返回,不执行任何操作。
    • 业务影响: 无额外影响。此操作仅跳过了一次无效的 release,避免了崩溃。
  • 场景二:objc_retain
    • 兜底策略: 检测到参数为哨兵地址时,中断原始 retain 流程,并向上层返回 nil。

    • 业务影响:可控降级。上层业务代码在获取该属性值后会得到 nil。需要和相关业务方沟通并确认,业务逻辑能够正确处理 nil 返回值,兜底后的效果可接受。

本文仅是 Ekko 系列的开篇,后续我们将通过公众号,为大家详细介绍 Ekko 的技术实现细节,对相关内容感兴趣的可以关注公众号,敬请期待后续的更新~~

昨天以前首页

iOS 系统获取 C++ 崩溃堆栈 - 撒花完结篇

作者 yuec
2025年9月24日 19:13

背景

在 C++ 中,当一个异常被抛出(throw)但未被任何 catch 块捕获时,程序会调用 std::terminate() 函数。为了在程序异常终止前执行自定义的异常监听,C++ 标准库提供了 std::set_terminate() 函数。它允许我们注册一个自定义的终止处理程序(Termination Handler),这个处理程序将在 std::terminate() 被调用时执行。

KSCrash 正是利用了这一机制来捕获未处理的 C++ 异常:

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 的实现:

_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 捕获后,我们最终得到的崩溃堆栈如下所示。这份堆栈对于定位问题根源几乎没有任何帮助。

#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 回调里面取之前的保留的堆栈信息。

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,让异常流程继续进行,不影响程序原有逻辑。
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…

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)。

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 函数是我们实现的核心。

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。

// 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
}

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 方法时,对应的 personality routine 方法会返回 _URC_FATAL_PHASE1_ERROR,收到这个值 Seach 阶段会停止遍历,执行 terminate handler,此时保留了抛异常的第一现场。线下测试 iOS 26 系统,runloop 内抛出的 C++ 异常目前也可以通过 terminate handler 获取崩溃的第一现场。

总结

随着 libdispatch 的官方更新,苹果为这个困扰开发者多年的问题画上了句号。这是否意味着我们之前探索的替代方案 —— 巧妙 Hook Personality Routine 的设计失去了价值?当然答案并非如此。

技术方案总有其生命周期,会被更优的设计、甚至平台的原生支持所替代。但我们面对问题时,那种对底层原理的渴求、对未知领域的探索、以及在逆境中寻求突破的整个过程,其价值超越了任何单一解决方案。这次探索的真正产出,不是一个临时的 Hook 方案,应用价值也远不止于获取 C++ 崩溃堆栈——它为我们揭示了更多系统底层的可能性(尽管具体应用暂不便详述)。

当未来出现新的、未知的问题时,真正能让我们披荆斩棘的,正是这些沉淀下来的系统性知识、第一性原理的思考方式和坚韧的探索精神。这,才是技术演进中永不“过时”的核心资产。

❌
❌