如何找到程序崩溃的 “凶手” ?

1,293 阅读8分钟

一、引言

在 iOS 应用程序开发过程中,我们难免会碰到因各种异常而导致应用程序崩溃的情况。

对于开发过程中遇到的崩溃,我们可以根据本地崩溃信息快速定位问题。但对于线上版本发生的一些崩溃情况,我们只能通过收集崩溃信息来分析具体的原因。虽然 Apple 提供了崩溃信息上报的功能,但是并非所有的用户都开启了该功能。因此,对于数据采集 SDK 来说,采集崩溃信息并上报是一项必不可少的功能。

下面针对神策分析 iOS SDK 崩溃采集模块进行解析,希望能够给大家提供一些参考。

二、崩溃类型

采集应用程序的崩溃信息,主要分为以下两种场景:

  • NSException 异常;
  • Unix 信号异常。 设计崩溃采集方案之前,我们不妨先认识一下 NSException 和 Unix 信号。

2.1 NSException NSException[1] 是 Foundation 框架提供的一个类。用于封装一些异常信息,在需要的时候向外抛出。封装的异常信息包括异常名称、异常原因、调用堆栈。


@interface NSException : NSObject <NSCopying, NSSecureCoding>
 
@property (readonly, copy) NSExceptionName name;
@property (nullable, readonly, copy) NSString *reason;
@property (readonly, copy) NSArray<NSString *> *callStackSymbols;
 
@end

在 iOS 应用程序中,最常见的就是通过 @throw 抛出的异常,如图 2-1 所示:

image.png 图 2-1 异常处理流程(图片来源于 Apple 开发者文档[2] )

比如常见的数组越界访问异常:


@throw [NSException exceptionWithName:@"NSRangeException" reason:@"index 2 beyond boun

运行程序会出现如下异常信息:


Terminating app due to uncaught exception 'NSRangeException', reason: 'index 2 beyond bounds [0 .. 1]'
terminating with uncaught exception of type NSException

2.2 Unix 信号

在 iOS 系统自动采集的崩溃日志中,经常可以看到类似下面的日志:


Exception Type: EXC_BAD_ACCESS (SIGSEGV)
Exception Subtype: KERNINVALIDADDRESS at 0x0000000001000010
VM Region Info: 0x1000010is not in any region. Bytes before following region: 4283498480
REGION TYPE START - END [ VSIZE] PRT/MAX SHRMOD REGION DETAIL UNUSED SPACE AT START TEXT 0000000100510000-0000000100514000 [16K] r-x/r-x SM=COW
.app/Ekuaibao
Termination Signal: Segmentation fault: 11
Termination Reason: Namespace SIGNAl, Code 0xb Terminating Process: exc handler [21776]
Triggered by Thread: 9

其中,Exception Type 中的两个字段 EXC_BAD_ACCESS 和 SIGSEGV 分别指 Mach 异常和 Unix 信号。

那什么是 Mach 异常和 Unix 信号呢?

Mach 是 macOS 和 iOS 操作系统的微内核,Mach 异常是最底层的内核级异常[3]。Mach 异常会被转换成相应的 Unix 信号,并传递给出错的线程。上述 Exception Type 中的 EXC_BAD_ACCESS 是 Mach 层的异常,被转换成了 Unix 信号 SIGSEGV,然后传递给出错的线程。之所以会将 Mach 异常转换成 Unix 信号,是为了兼容 POSIX 标准(SUS 规范)[4],这样一来,开发者即使不了解 Mach 内核也可以通过 Unix 信号的方式进行兼容开发。

Unix 信号的种类有很多,在 iOS 应用程序中,常见的 Unix 信号[5]有如下几种:

  • SIGILL:程序非法指令信号,通常是因为可执行文件本身出现错误,或者试图执行数据段。堆栈溢出时也有可能产生该信号;
  • SIGABRT:程序中止命令中止信号,调用 abort 函数时产生该信号;
  • SIGBUS:程序内存字节地址未对齐中止信号,比如访问一个 4 字节长的整数,但其地址不是 4 的倍数;
  • SIGFPE:程序浮点异常信号,通常在浮点运算错误、溢出及除数为 0 等算术错误时都会产生该信号;
  • SIGKILL:程序结束接收中止信号,用来立即结束程序运行,不能被处理、阻塞和忽略;
  • SIGSEGV:程序无效内存中止信号,即试图访问未分配的内存,或向没有写权限的内存地址写数据;
  • SIGPIPE:程序管道破裂信号,通常是在进程间通信时产生该信号;
  • SIGSTOP:程序进程中止信号,与 SIGKILL 一样不能被处理、阻塞和忽略。 神策分析 iOS SDK 针对 NSException 异常和 Unix 信号异常设计并实现了一套适用于数据分析的崩溃采集方案。

三、NSException异常采集

3.1 方案简介

NSException 类中定义的 NSSetUncaughtExceptionHandler 可以设置全局异常处理函数。因此,我们可以先通过 NSSetUncaughtExceptionHandler 设置的函数来处理异常,然后收集异常堆栈信息并触发相应的事件($AppCrashed),来实现 NSException 异常的埋点。

NSSetUncaughtExceptionHandler 函数接收一个 C 语言函数的指针,函数定义如下:


typedef void NSUncaughtExceptionHandler(NSException *exception);
 
FOUNDATION_EXPORT void NSSetUncaughtExceptionHandler(NSUncaughtExceptionHandl

3.2 具体实现

  1. 设计采集 $AppCrashed 事件的方法,将堆栈信息记录到事件属性 app_crashed_reason 中:

- (void)sa_handleUncaughtException:(NSException *)exception {
    // 采集 $AppCrashed 事件
    SensorsAnalyticsSDK *sdk = [SensorsAnalyticsSDK sharedInstance];
    if (sdk.configOptions.enableTrackAppCrash) {
        NSMutableDictionary *properties = [[NSMutableDictionary alloc] init];
        if ([exception callStackSymbols]) {
            // 若有异常堆栈信息即获取异常堆栈信息
            NSString *exceptionStack = [[exception callStackSymbols] componentsJoinedByString:@"\n"];
            // 采集应用程序崩溃原因
            [properties setValue:[NSString stringWithFormat:@"Exception Reason:%@\nException Stack:%@", [exception reason], exceptionStack] forKey:@"app_crashed_reason"];
        } else {
            // 若无异常堆栈信息即获取线程堆栈信息
            NSString *exceptionStack = [[NSThread callStackSymbols] componentsJoinedByString:@"\n"];
            // 采集应用程序崩溃原因
            [properties setValue:[NSString stringWithFormat:@"%@ %@", [exception reason], exceptionStack] forKey:@"app_crashed_reason"];
        }
        // 触发 $AppCrashed 事件
        [sdk trackPresetEvent:SA_EVENT_NAME_APP_CRASHED properties:properties];
    }
    NSSetUncaughtExceptionHandler(NULL);
}
  1. 创建 SensorsAnalyticsExceptionHandler 类并新增 + sharedHandler 方法:

+ (instancetype)sharedHandler {
    static SensorsAnalyticsExceptionHandler *gSharedHandler = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        gSharedHandler = [[SensorsAnalyticsExceptionHandler alloc] init];
    });
    return gSharedHandler;
}
  1. 实现 SensorsAnalyticsExceptionHandler 类的初始化方法 - init,设置全局异常处理函数并触发 $AppCrashed 事件:

- (instancetype)init {
    self = [super init];
    if (self) {
        [self setupHandlers];
    }
    return self;
}
 
- (void)setupHandlers {
    // 设置全局异常处理函数
    NSSetUncaughtExceptionHandler(&SAHandleException);
}
 
static void SAHandleException(NSException *exception) {
    SensorsAnalyticsExceptionHandler *handler = [SensorsAnalyticsExceptionHandler sharedHandler];
    // 处理捕获的 NSException 异常,触发 $AppCrashed 事件
    [handler sa_handleUncaughtException:exception];
}
  1. 在 SensorsAnalyticsSDK 类的 - initWithConfigOptions:debugMode: 方法中初始化 SensorsAnalyticsExceptionHandler 类的单例对象:

- (instancetype)initWithConfigOptions:(nonnull SAConfigOptions *)configOptions debugMode:(SensorsAnalyticsDebugMode)debugMode {
    self = [super init];
    if (self) {
        // 开启崩溃采集功能
        if (_configOptions.enableTrackAppCrash) {
            [[SensorsAnalyticsExceptionHandler sharedHandler];
        }
    }
    return self;
}

3.3 方案优化

在实际开发过程中,可能会集成多个 SDK,如果这些 SDK 都按照上面介绍的方法采集异常信息,总会有一些 SDK 采集不到异常信息。这是因为通过 NSSetUncaughtExceptionHandler 函数设置的是一个全局异常处理函数,后面设置的异常处理函数会自动覆盖前面设置的异常处理函数。 那么如何解决这个问题呢? 常见的做法是:在调用 NSSetUncaughtExceptionHandler 函数设置全局异常处理函数之前,先通过 NSGetUncaughtExceptionHandler 函数获取之前已设置的异常处理函数并保存,在处理完异常信息后,再主动调用已保存的处理函数,即可解决上面提到的覆盖问题。

  1. 新增一个 NSUncaughtExceptionHandler 类型的属性 defaultExceptionHandler ,用来保存之前已经设置的异常处理函数:

@property (nonatomic) NSUncaughtExceptionHandler *defaultExceptionHandler;
 
- (void)setupHandlers {
    // 备份之前设置的异常处理函数
    _defaultExceptionHandler = NSGetUncaughtExceptionHandler();
    // 设置全局异常处理函数
    NSSetUncaughtExceptionHandler(&SAHandleException);
}
  1. 触发 $AppCrashed 事件后调用之前已设置的异常处理函数,传递 UncaughtExceptionHandler:

static void SAHandleException(NSException *exception) {
    // 处理捕获的 NSException 异常,触发 $AppCrashed 事件
     
    // 传递 UncaughtExceptionHandler
    if (handler.defaultExceptionHandler) {
        handler.defaultExceptionHandler(exception);
    }
}

通过上面的处理,即可把所有的异常处理函数形成链条,确保之前设置的异常处理函数也能采集到异常信息。

四、Unix 信号异常采集

4.1方案简介

在 iOS 应用程序中,一般情况下会采集 SIGILL、SIGABRT、SIGBUS、SIGFPE 和 SIGSEGV 这几个常见的信号,即能满足日常采集应用程序异常信息的需求。我们可以先新增信号处理函数,然后注册信号处理函数,使用 Unix 信号信息构造一个 NSException 对象,复用上节采集 $AppCrashed 事件的方法。

4.2 具体实现

  1. 新增捕获 Unix 信号的处理函数:

static NSString * const UncaughtExceptionHandlerSignalExceptionName = @"UncaughtExceptionHandlerSignalExceptionName";
static NSString * const UncaughtExceptionHandlerSignalKey = @"UncaughtExceptionHandlerSignalKey";
 
static void SASignalHandler(int crashSignal, struct __siginfo *info, void *context) {
    SensorsAnalyticsExceptionHandler *handler = [SensorsAnalyticsExceptionHandler sharedHandler];
    // 将 Unix 信号异常构造成 NSException 异常
    NSDictionary *userInfo = @{UncaughtExceptionHandlerSignalKey: @(crashSignal)};
    NSString *reason = [NSString stringWithFormat:@"Signal %d was raised.", crashSignal];
    NSException *exception = [NSException exceptionWithName:UncaughtExceptionHandlerSignalExceptionName reason:reason userInfo:userInfo];
    // 处理捕获的 Unix 信号异常,触发 $AppCrashed 事件
    [handler sa_handleUncaughtException:exception];
}
  1. 注册信号处理函数

- (void)setupHandlers {
    // 备份和设置 NSException 全局异常处理函数
     
    // 定义信号集结构体
    struct sigaction action;
    // 将信号集初始化为空
    sigemptyset(&action.sa_mask);
    // 在处理函数中传入 __siginfo 参数
    action.sa_flags = SA_SIGINFO;
    // 设置信号处理函数
    action.sa_sigaction = &SASignalHandler;
    // 定义需要采集的信号类型
    int signals[] = {SIGABRT, SIGILL, SIGSEGV, SIGFPE, SIGBUS};
    for (int i = 0; i < sizeof(signals) / sizeof(int); i++) {
        struct sigaction prev_action;
        int err = sigaction(signals[i], &action, &prev_action);
        if (err) {
            SALogError(@"Errored while trying to set up sigaction for signal %d", signals[i]);
        }
    }
}

注意:由于 Unix 信号异常对象是我们自己构建的,因此没有堆栈信息,这里默认获取当前线程的堆栈信息。上节 - sa_handleUncaughtException: 方法中已经处理该逻辑。

4.3方案优化

同样,为了避免影响其他 SDK 捕获 Unix 信号,我们应当在处理 Unix 信号之前保存已经设置的 Unix 信号异常处理函数。然后,在处理完异常信息后再主动调用保存的 Unix 信号异常处理函数。传递 Unix 信号的逻辑与上节传递 UncaughtExceptionHandler 类似。

  1. 新增一个属性 prev_signal_handlers ,用来保存之前已经设置的 Unix 信号异常处理函数:

@property (nonatomic, unsafe_unretained) struct sigaction *prev_signal_handlers;
 
- (void)setupHandlers {
    // 备份和设置 NSException 全局异常处理函数
     
    // 注册信号集
    struct sigaction action;
    sigemptyset(&action.sa_mask);
    action.sa_flags = SA_SIGINFO;
    action.sa_sigaction = &SASignalHandler;
    int signals[] = {SIGABRT, SIGILL, SIGSEGV, SIGFPE, SIGBUS};
    for (int i = 0; i < sizeof(signals) / sizeof(int); i++) {
        struct sigaction prev_action;
        int err = sigaction(signals[i], &action, &prev_action);
        if (err == 0) {
            char *address_action = (char *)&prev_action;
            // 保存 Unix 信号异常处理函数
            char *address_signal = (char *)(_prev_signal_handlers + signals[i]);
            strlcpy(address_signal, address_action, sizeof(prev_action));
        } else {
            SALogError(@"Errored while trying to set up sigaction for signal %d", signals[i]);
        }
    }
}
  1. 触发 $AppCrashed 事件后向之前保存的异常处理函数传递 Unix 信号并调用:

static void SASignalHandler(int crashSignal, struct __siginfo *info, void *context) {
    // 处理捕获的 Unix 信号异常,触发 $AppCrashed 事件
     
    // 获取异常处理函数并其传递 Unix 信号
    struct sigaction prev_action = handler.prev_signal_handlers[crashSignal];
    if (prev_action.sa_flags & SA_SIGINFO) {
        if (prev_action.sa_sigaction) {
            prev_action.sa_sigaction(crashSignal, info, context);
        }
    } else if (prev_action.sa_handler && prev_action.sa_handler != SIG_IGN) {
        // SIG_IGN 表示忽略信号
        prev_action.sa_handler(crashSignal);
    }
}

注意:如果其他 SDK 在处理 Unix 信号时忽略了某个信号,那么在触发 $AppCrashed 事件后应当避免向其传递忽略的 Unix 信号,我们在调用 sa_handler 函数时做了判断以处理该逻辑。

五、补发退出事件

一旦程序发生异常,我们就采集不到 App 退出事件(AppEnd)。这样会造成在用户的行为序列中,出现App启动事件(AppEnd)。这样会造成在用户的行为序列中,出现 App 启动事件(AppStart)和 App 退出事件(AppEnd)不成对的情况。因此,在应用程序发生崩溃时,我们需要补发AppEnd)不成对的情况。因此,在应用程序发生崩溃时,我们需要补发 AppEnd 事件:


- (void)sa_handleUncaughtException:(NSException *)exception {
    // 采集 $AppCrashed 事件
     
    // 补发 $AppEnd 事件
    if (![sdk isAutoTrackEventTypeIgnored:SensorsAnalyticsEventTypeAppEnd]) {
        [SACommonUtility performBlockOnMainThread:^{
            if (UIApplication.sharedApplication.applicationState == UIApplicationStateActive) {
                [sdk trackAutoEvent:SA_EVENT_NAME_APP_END properties:nil];
            }
        }];
    }
    // 阻塞当前线程,完成 serialQueue 中数据相关的任务
    sensorsdata_dispatch_safe_sync(sdk.serialQueue, ^{});
}

在进行这样的处理之后,当应用程序发生异常时,我们不仅可以采集 AppCrashed事件,还能正常采集AppCrashed 事件,还能正常采集 AppEnd 事件。

六、总结

本文主要介绍了神策分析 iOS SDK 崩溃采集模块的具体实现。SDK 崩溃采集涵盖了 NSException 异常和 Unix 信号异常,详细的实现可以参考 iOS SDK 源码[6]。

最后,希望通过这篇文章,大家能够对神策分析 iOS SDK 的崩溃模块有一个系统的了解。

参考文献:

[1]developer.apple.com/documentati…

[2]developer.apple.com/library/arc…

[3]mp.weixin.qq.com/s/hOOzVzJ-n…

[4]zh.wikipedia.org/wiki/%E5%96…

[5]blog.51cto.com/arthurchen/…

[6]github.com/sensorsdata…