『极致』的 iOS Crash 防护

2,866 阅读4分钟

Crash 是最影响用户体验的指标之一,Crash 防护一定程度上能够提高用户体验。

苹果有提供 try catch 机制 (Exception Programming Topics )对 NSException 类型的 Crash 进行防护,但这种机制会带来性能、包体、可能的内存泄漏等问题,并且仅能防护 NSException 类型,所以在 iOS 平台上并没有得到广泛使用。

业界针对 Crash 防护基本是参考 网易大白健康系统, 大都是有损的。典型的如容器类的判空、越界处理,需要 hook 系统相关的 API 对其添加额外的判断逻辑,这就导致即使不会触发 Crash 的场景同样需要执行额外的判断逻辑,带来一定的性能损耗。

另外一种思路是由 sunnyxx 分享基于 Runloop 实现:

CFRunLoopRef runLoop = CFRunLoopGetCurrent(); 
CFArrayRef allModes = CFRunLoopCopyAllModes(runLoop); 

while (YES) { 
    for (NSString *mode in (__bridge NSArray *)allModes) { 
        CFRunLoopRunInMode((CFStringRef)mode, 0.001, false); 
    } 
} 

这种思路的问题:

  • 强依赖 Runloop;
  • 原环境丢失;
  • 额外 CPU 消耗;

这里分享下一种与上述不同的思路:如何将 side effect 最小化。

NSException

以 NSException 为例,构建一个 NSMutableArray 的 Crash:

[@[].mutableCopy addObject:nil];

崩溃堆栈如下:

image.png

当程序触发异常时,首先会执行 objc_exception_throw 函数:

image.png

这个函数内部做了以下处理:

  • 执行 exception_preprocessor 函数,这个函数对外提供了开发者预处理的接口;

image.png

  • 填充 exception 信息;
  • 打印 exception 信息(可选);
  • dtrace 记录(dtrace 是动态追踪工具,追踪性能、异常问题,instruments 工具借助 dtrace 获取信息);
  • 调用 cxa_thorw 函数,进一步抛出异常,使程序崩溃;
  • 调用 builtin_trap() 函数,执行非法指令终止程序;

如果可以 hook objc_exception_throw 函数替换其实现,那么就可以避免执行导致程序崩溃的函数,从而起到防护的作用。

尝试使用 fishhook,发现并不能成功 hook objc_exception_throw 函数,查看 CoreFoundation 的 Dynamic Symbol Table 发现有 objc_exception_throw 符号,但并不在 got 或 la_symbol_ptr 的 section 中。

另外可以看到 CoreFoundation 中有很多 auth 相关的 section,CoreFoundation 调用外部函数并不是 got 或 la_symbol_ptr 传统流程,所以这里 fishhook 并不适用(fishhook 相关可以另起一篇展开)。

image.png

回到前面,苹果有提供 objc_setExceptionPreprocessor 函数回调给到我们提前做一些逻辑处理。

那么其实可以在这个预处理函数中,获取 crash 的信息,然后阻止程序继续向下执行。子程序返回后执行的下一条指令是依赖 LR 寄存器中存储的值,所以如果更改 LR 的内容,那么就可以跨过某些指令的执行(另一种 hook 思路)。

- (void)viewDidLoad 
{
    [super viewDidLoad];

    objc_setExceptionPreprocessor(my_objc_exception_preprocessor);

    [@[].mutableCopy addObject:nil];
    NSLog(@"----end-----");
    // Do any additional setup after loading the view.
}

image.png

从堆栈情况来看,如果在 my_objc_exception_preprocessor 返回后,将 LR 的内容修改为 0x104eb00f8 这条指令,那么就可以跨过使程序崩溃的指令,并且可以让程序继续执行。当然根据实际情况还需要将其它寄存器以及栈空间进行合理恢复。(demo 中的寄存器恢复是根据编译后的指令直接计算出结果的,理论上运行时可以获取汇编指令内容对每条指令进行反汇编,得出每条指令具体含义)

以下是 demo(release、iOS 17.5):

id my_objc_exception_preprocessor(NSException *exception)
{
    NSLog(@"exception: %@, exception.callStackSymbols: %@, NSThread.callStackSymbols: %@", exception, exception.callStackSymbols, NSThread.callStackSymbols);
    // 用来调用 exception_protect,理论上可以直接将 exception_protect 合并到此函数中
    __asm__ volatile (
        "adr x30, .\n"
        "add x30, x30, #0x24\n"
        "str x30, [sp, #0x48]\n"
    );
    return nil;
}

struct frame_t
{
    struct frame_t *previous_fp;
    uintptr_t lr;
};

__attribute__ ((used))
void exception_protect(void)
{
    struct frame_t *frame;
    __asm__ volatile (
        "mov %0, x29" 
        : "=r" (frame)
    );

    frame = frame->previous_fp;
    uintptr_t offset = frame->lr + 4;

#define PAC_MASK 0x0000000fffffffffULL

    offset = offset & PAC_MASK;
    __asm__ volatile (
        "mov x30, %[offset]\n"
        "add sp, sp, #4095\n"
        "add sp, sp, #129\n"
        :
        : [offset] "r" (offset)
        : "memory"
    );
}

程序运行后,可以正确获取到 Crash 信息,并且可以继续正确执行:

image.png

总结

以上方案可以完全避免非 Crash 用户的影响,但如果运用到生产环境,还需要考虑非常多的细节问题,更多的意义是提供不一样的思路。