iOS 中异常产生与捕获小结

2,896 阅读9分钟

APP Crash相关内容是APP开发者都需要了解的, 这篇文章就针对iOS APP Crash做一个简单小结.

Mach异常与Unix信号

DarwinMac OSiOS的操作系统, 而XNU是Darwin操作系统的内核部分. XNU是混合内核, 兼具宏内核和微内核的特性, 而Mach即为其微内核, Mach微内核中有几个基础概念:

  • Tasks, 拥有一组系统资源的对象, 允许thread在其中执行。
  • Threads, 执行的基本单位, 拥有task的上下文, 并共享其资源。
  • Ports, Task之间通讯的一组受保护的消息队列; task可对任何port发送/接收数据。
  • Message, 有类型的数据对象集合,只可以发送到port。

如果APP没有进行Crash捕获, 一般来说APP 的Crash(通常的情况是闪退), 是因为the result of an unhandled signal sent to your application. 这里苹果为了让iOS/Mac OS系统能支持POSIX标准, 在Mach内核上层封装了BSD层, 因此Mach 异常会在BSD层封装成Signal, 然后发送给APP!!!

img

这些导致APP Crash的unhandled signal一般来自3个地方:

  1. kernel内核
  2. 其他的进程process
  3. Application自己

其中最常见的两种Crash关联的signal是:

  • EXC_BAD_ACCESS : APP访问了不属于本进程或者已经释放的内存地址。如果没有在Mach层捕获, 它将在BSD层被转化成SIGSEGV SIGBUS BSD信号。
  • SIGABRT : 是一个标准的BSD signal. 它是因为 NSException 或者 obj_exception_throw 没有在应用层 catch.

如果是Objective-C中的异常, 最常见的Crash原因是:

  • sending an unimplemented selector to an object -- 给OC对象发送消息没有IMP实现! (OC是动态语言, OC对象调用方法实际是消息发送)
  • sending to an already released -- 给已经release的OC对象继续发送消息!!! (野指针, 不一定100%Crash, 可能运气好没有Crash)

我们常常收到的如下Crash: EXC_BAD_ACCESS (SIGSEGV), 它表示是Mach层EXC_BAD_ACCESS异常, 在BSD层封装成SIGSEGV signal

捕获异常的方式

为了监控Crash, 我们一般需要捕获异常, 通常一般需要两条线:

  1. 使用 NSUncaughtExceptionHandler去捕获Objective-C Exceptions, 例如OC层中的NSException的子类异常
  2. 使用 Linux 的API sighandler_t signal(int signum, sighandler_t handler);来捕获BSD signal信号

在代码中可以用如下方式:

void InstallUncaughtExceptionHandler() {
		// 1. OC 层的NSException 异常的捕获
    NSSetUncaughtExceptionHandler(&HandleException);
    // 2. BSD 层的signal信号捕获
    //  SIGKILL and SIGSTOP 两个信号是无法捕获的
    signal(SIGABRT, SignalHandler);
    signal(SIGILL, SignalHandler);
    signal(SIGSEGV, SignalHandler);
    signal(SIGFPE, SignalHandler);
    signal(SIGBUS, SignalHandler);
    signal(SIGPIPE, SignalHandler);
}

另外, 第三方框架例如 PLCrashReporter ,KSCrash等会优先在Mach层捕获一些Crash, 而不用等到异常到BSD层被封装成signal以后再捕获, :

We still need to use signal handlers to catch SIGABRT in-process. The kernel sends an EXC_CRASH mach exception to denote SIGABRT termination. In that case, catching the Mach exception in-process leads to process deadlock in an uninterruptable wait. Thus, we fall back on BSD signal handlers for SIGABRT, and do not register for EXC_CRASH.

这里有一个更加完整优秀的Crash生成和捕获的流程图:

img

KSCrash中也捕获了Mach kenel Exception, signal, C++ Exception, Objective-C NSException等异常类型:

/** Various aspects of the system that can be monitored:
 * - Mach kernel exception
 * - Fatal signal
 * - Uncaught C++ exception
 * - Uncaught Objective-C NSException
 * - Deadlock on the main thread
 * - User reported custom exception
 */
typedef enum {
    /* Captures and reports Mach exceptions. */
    KSCrashMonitorTypeMachException = 0x01,

    /* Captures and reports POSIX signals. */
    KSCrashMonitorTypeSignal = 0x02,

    /* Captures and reports C++ exceptions.
     * Note: This will slightly slow down exception processing.
     */
    KSCrashMonitorTypeCPPException = 0x04,

    /* Captures and reports NSExceptions. */
    KSCrashMonitorTypeNSException = 0x08,

    /* Detects and reports a deadlock in the main thread. */
    KSCrashMonitorTypeMainThreadDeadlock = 0x10,

    /* Accepts and reports user-generated exceptions. */
    KSCrashMonitorTypeUserReported = 0x20,

    /* Keeps track of and injects system information. */
    KSCrashMonitorTypeSystem = 0x40,

    /* Keeps track of and injects application state. */
    KSCrashMonitorTypeApplicationState = 0x80,

    /* Keeps track of zombies, and injects the last zombie NSException. */
    KSCrashMonitorTypeZombie = 0x100,
} KSCrashMonitorType;

Mach Exception 的异常捕获

对于 Mach 异常, KSCrash中注册mach exception handler的下逻辑:

// 注册 mach exception handler
static bool installExceptionHandler() {
    KSLOG_DEBUG("Installing mach exception handler.");

    bool attributes_created = false;
    pthread_attr_t attr;

    kern_return_t kr;
    int error;

    const task_t thisTask = mach_task_self();
    exception_mask_t mask = EXC_MASK_BAD_ACCESS | EXC_MASK_BAD_INSTRUCTION | EXC_MASK_ARITHMETIC | EXC_MASK_SOFTWARE | EXC_MASK_BREAKPOINT;

    KSLOG_DEBUG("Backing up original exception ports.");
    kr = task_get_exception_ports(thisTask,
                                  mask,
                                  g_previousExceptionPorts.masks,
                                  &g_previousExceptionPorts.count,
                                  g_previousExceptionPorts.ports,
                                  g_previousExceptionPorts.behaviors,
                                  g_previousExceptionPorts.flavors);
    if (kr != KERN_SUCCESS) {
        KSLOG_ERROR("task_get_exception_ports: %s", mach_error_string(kr));
        goto failed;
    }

    if (g_exceptionPort == MACH_PORT_NULL) {
        KSLOG_DEBUG("Allocating new port with receive rights.");
        kr = mach_port_allocate(thisTask, MACH_PORT_RIGHT_RECEIVE, &g_exceptionPort);
        if (kr != KERN_SUCCESS) {
            KSLOG_ERROR("mach_port_allocate: %s", mach_error_string(kr));
            goto failed;
        }

        KSLOG_DEBUG("Adding send rights to port.");
        kr = mach_port_insert_right(thisTask, g_exceptionPort, g_exceptionPort, MACH_MSG_TYPE_MAKE_SEND);
        if (kr != KERN_SUCCESS) {
            KSLOG_ERROR("mach_port_insert_right: %s", mach_error_string(kr));
            goto failed;
        }
    }

    KSLOG_DEBUG("Installing port as exception handler.");
    kr = task_set_exception_ports(thisTask, mask, g_exceptionPort, EXCEPTION_DEFAULT, THREAD_STATE_NONE);
    if (kr != KERN_SUCCESS) {
        KSLOG_ERROR("task_set_exception_ports: %s", mach_error_string(kr));
        goto failed;
    }

    KSLOG_DEBUG("Creating secondary exception thread (suspended).");
    pthread_attr_init(&attr);
    attributes_created = true;
    pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
    error = pthread_create(&g_secondaryPThread, &attr, &handleExceptions, kThreadSecondary);
    if (error != 0) {
        KSLOG_ERROR("pthread_create_suspended_np: %s", strerror(error));
        goto failed;
    }
    g_secondaryMachThread = pthread_mach_thread_np(g_secondaryPThread);
    ksmc_addReservedThread(g_secondaryMachThread);

    KSLOG_DEBUG("Creating primary exception thread.");
    error = pthread_create(&g_primaryPThread, &attr, &handleExceptions, kThreadPrimary);
    if (error != 0) {
        KSLOG_ERROR("pthread_create: %s", strerror(error));
        goto failed;
    }
    pthread_attr_destroy(&attr);
    g_primaryMachThread = pthread_mach_thread_np(g_primaryPThread);
    ksmc_addReservedThread(g_primaryMachThread);

    KSLOG_DEBUG("Mach exception handler installed.");
    return true;

failed:
    KSLOG_DEBUG("Failed to install mach exception handler.");
    if (attributes_created) {
        pthread_attr_destroy(&attr);
    }
    uninstallExceptionHandler();
    return false;
}

用一个图简单表示核心逻辑:

img

简单Demo实现可以参考: iOS Mach异常和signal信号

C++ 异常的处理

以下内容部分参考: zhuanlan.zhihu.com/p/271282052

在OSX中,会通过对话框展示异常给用户,但在iOS中,只是重新抛出异常。

系统在捕捉到C++异常后,如果能够将此C++异常转换为OC异常,则抛出OC异常处理机制;如果不能转换,则会立刻调用__cxa_throw重新抛出异常。

当系统在RunLoop捕捉到的C++异常时, 此时的调用堆栈是异常发生时的堆栈(我们需要的调用栈), 但如果这个异常无法被转换为Objective-C Exception(NSException)时, 会调用__cxa_throw, 上层捕捉以后再抛出的异常, 此时获取到的调用堆栈是RunLoop异常处理函数的堆栈,导致原始异常调用堆栈丢失(C++的真实异常函数调用栈丢失!!!)。

这里有一个标准的C++ 异常(无法转化成Objective-C 异常), 能看到实际的c++调用堆栈已经丢失:

Thread 0 Crashed:: Dispatch queue: com.apple.main-thread
0   libsystem_kernel.dylib          0x00007fff93ef8d46 __kill + 10
1   libsystem_c.dylib               0x00007fff89968df0 abort + 177
2   libc++abi.dylib                 0x00007fff8beb5a17 abort_message + 257
3   libc++abi.dylib                 0x00007fff8beb33c6 default_terminate() + 28
4   libobjc.A.dylib                 0x00007fff8a196887 _objc_terminate() + 111
5   libc++abi.dylib                 0x00007fff8beb33f5 safe_handler_caller(void (*)()) + 8
6   libc++abi.dylib                 0x00007fff8beb3450 std::terminate() + 16
7   libc++abi.dylib                 0x00007fff8beb45b7 __cxa_throw + 111
8   test                            0x0000000102999f3b main + 75
9   libdyld.dylib                   0x00007fff8e4ab7e1 start + 1

上图中的Runloop上层对C++异常发生到处理的过程:

  1. 先调用__cxa_throw(...)
  2. std::terminate()
  3. set_terminate(...)设置的异常处理函数safe_handler_caller()
  4. 调用abort()方法, 发送SIGABRT

其中最关键的就是__cxa_throw(...)函数的调用, 在理解这个内容之前, 建议先理解 C++ 异常是如何实现的, 最终比较完美的捕获C++的异常步骤为如下过程:

  1. 重写__cxa_throw(...)函数!!! 注意使用weak 符号声明, 防止符号冲突!!! 在异常发生时, 会先进入重写函数
    1. 此时, 使用int backtrace(void**,int) 获取调用堆栈并存储!!!
    2. 使用dlsym获取系统原来的__cxa_throw(...)称为orig_cxa_throw
    3. 调用orig_cxa_throw, 进入std::terminate()
  2. 设置异常处理函数: std::set_terminate(...), 异常处理回调函数称为CPPExceptionTerminate
  3. CPPExceptionTerminate回调函数中实现我们自己的处理:
    1. ksmc_suspendEnvironment(), 挂起所有的线程!!! 保护现场!!!
    2. 判断是否是OC Exception, 如果是OC Exception, 啥也不处理(因为会被上层OC Exception Handler 捕获)
    3. 如果不是OC Exception, 那就是 C++ Exception, 把之前保存的调用栈信息保存起来

KSCrash中的相关代码如下, 参考我的注释:

// 针对C++的异常捕获 使用 std::set_terminate() 回调
static void setEnabled(bool isEnabled) {
    if (isEnabled != g_isEnabled) {
        g_isEnabled = isEnabled;
        if (isEnabled) {
            initialize();
            ksid_generate(g_eventID);
            // 注册 c++ exception 回调函数
            g_originalTerminateHandler = std::set_terminate(CPPExceptionTerminate);
        } else {
            std::set_terminate(g_originalTerminateHandler);
        }
        g_captureNextStackTrace = isEnabled;
    }
}

// ============================================================================
#pragma mark - Callbacks -
// ============================================================================
typedef void (*cxa_throw_type)(void *, std::type_info *, void (*)(void *));
extern "C" {
// 声明成 weak 符号!!!!
void __cxa_throw(void *thrown_exception, std::type_info *tinfo, void (*dest)(void *)) __attribute__((weak));
void __cxa_throw(void *thrown_exception, std::type_info *tinfo, void (*dest)(void *)) {
		// 缓存 c++ 真实的 thread callstack
    if (g_captureNextStackTrace) {
        kssc_initSelfThread(&g_stackCursor, 1);
        g_capturedStackCursor = true;
    }
	
		// 调用原来的方法, 马上回进入  std::set_terminate(...) 设置的 exceptionHandler
    static cxa_throw_type orig_cxa_throw = NULL;
    unlikely_if(orig_cxa_throw == NULL) { orig_cxa_throw = (cxa_throw_type)dlsym(RTLD_NEXT, "__cxa_throw"); }
    orig_cxa_throw(thrown_exception, tinfo, dest);
    __builtin_unreachable();
}
}

// C++ exception 回调函数
static void CPPExceptionTerminate(void) {
    // 1. 挂起所有的 thread!!! 保护现场
    ksmc_suspendEnvironment();
    KSLOG_DEBUG("Trapped c++ exception");
    // 2. 获取当前 exception 关键信息 -- name
    const char *name = NULL;
    std::type_info *tinfo = __cxxabiv1::__cxa_current_exception_type();
    if (tinfo != NULL) {
        name = tinfo->name();
    }
    KSLOG_DEBUG("name:%s capured:%d", name, g_capturedStackCursor);
    // 3. 判断是否是 `Objective-C Exception, name 非NSException 的就是 c++ exception
    if (g_capturedStackCursor && (name == NULL || strcmp(name, "NSException") != 0)) {
        // 4.1 c++ exception -> 由于在 __cxa_throw()方法中已经缓存调用栈!!! 把调用栈搞出来
   			...
    } else {
        // 4.2 OC Exception -> 恢复, 啥也不处理
        KSLOG_DEBUG("Detected NSException. Letting the current NSException handler deal with it.");
        ksmc_resumeEnvironment();
    }
    // 5. 调用原来的 terminateHandler
    KSLOG_DEBUG("Calling original terminate handler.");
    g_originalTerminateHandler();
}

这里有一个扩展知识深入解构iOS系统下的全局对象和初始化函数 : 理解 C++ 全局static 对象的生命周期

异常处理函数handler的注意点

在APP Debug 环境下, 很多signal会被LLDB Debugger捕获, 需要使用LLDB调试命令,将指定signal处理抛到用户层处理.

KSCrash 定义了KSCrashMonitorTypeDebuggerUnsafe的参数用来APP在开发和Debug阶段使用, 这样很多Crash不会被捕获!

#define KSCrashMonitorTypeDebuggerUnsafe \
    (KSCrashMonitorTypeMachException | KSCrashMonitorTypeSignal | KSCrashMonitorTypeCPPException | KSCrashMonitorTypeNSException)

此外, 异常发生时, 进程可能执行到代码的任意位置. 因此要确保信号处理程序handler只执行可重入操作:

  • 写中断处理程序时, 假定中断进程可能处于不可重入函数中.
  • 慎重修改全局数据.

常见的 BSD signal

  1. SIGABRT是调用abort()生成的信号,有可能是NSException也有可能是Mach异常
  2. SIGBUS: 非法地址, 包括内存地址对齐(alignment)出错。比如访问一个四个字长的整数, 但其地址不是4的倍数。比如:
char *s = "hello world";
*s = 'H';
  1. SIGSEGV: 试图访问未分配给自己的内存, 或试图往没有写权限的内存地址写数据。比如:给已经release的对象发送消息
  2. SIGILL: 执行了非法指令. 通常是因为可执行文件本身出现错误, 或者试图执行数据段. 堆栈溢出时也有可能产生这个信号
  3. SIGPIPE: 管道破裂。这个信号通常在进程间通信产生,比如采用FIFO(管道)通信的两个进程,读管道没打开或者意外终止就往管道写,写进程会收到SIGPIPE信号。此外用Socket通信的两个进程,写进程在写Socket的时候,读进程已经终止
  4. SIGTRAP:由断点指令或其它trap指令产生. 由debugger使用

符号化

如果仅仅获取当前线程的调用栈信息, 可以直接使用[NSThread callStackSymbols], 但是如果想随时获取任意线程的调用栈, 可以参考Matrix中的实现.

参考

developer.aliyun.com/article/499…

www.cocoachina.com/articles/12…

www.jianshu.com/p/04f822f92…

zhuanlan.zhihu.com/p/271282052

www.apiref.com/cpp-zh/cpp/…

www.cocoawithlove.com/2010/05/han…