首发于iOS Crash

iOS/OSX Crash:捕捉异常

iOS/OSX Crash:崩溃日志报告

iOS/OSX Crash:异常类型

iOS/OSX Crash:捕捉异常

iOS Crash:函数调用栈

OSX Crash:为什么抛出异常时应用不崩溃


捕捉异常代码来自KSCrash!

针对不同的异常(Objective-C异常、Mach异常、UNIX信号、C++异常)需要不同的捕捉方式。

1 Objective-C 异常

苹果提供了两个函数来设置顶层OC异常处理句柄,可以使你在程序终止之前记录最新的日志。

  • 获取当前已设置的顶层异常处理句柄
NSUncaughtExceptionHandler * NSGetUncaughtExceptionHandler(void);
  • 设置顶层异常处理句柄
void NSSetUncaughtExceptionHandler(NSUncaughtExceptionHandler *);

异常处理函数:

typedef void (NSException * _Nonnull) NSUncaughtExceptionHandler;

OC异常处理步骤:

1、调用NSGetUncaughtExceptionHandler获取当前顶层异常处理句柄,以供在异常发生时调用,防止异常处理链断裂。

2、调用NSSetUncaughtExceptionHandler设置自己的顶层异常处理句柄,以在发生异常时可以获取最新的异常日志。

3、在异常处理函数中,收集异常信息。特别地,异常发生时系统会提供NSException信息,我们可以从中得到OC异常的详细信息:

4、调用NSGetUncaughtExceptionHandler取得的上一次异常处理函数。

static void handleException(NSException* exception, BOOL currentSnapshotUserReported) {
    // 收集异常信息
    ...
    if (g_previousUncaughtExceptionHandler != NULL)
    {
        g_previousUncaughtExceptionHandler(exception);
    }
}
...
g_previousUncaughtExceptionHandler = NSGetUncaughtExceptionHandler();
NSSetUncaughtExceptionHandler(&handleUncaughtException);
...

2 Mach 异常

Mach异常是内核级异常,在系统的位置如下图所示:

OS X kernel architecture

2.1 Mach相关知识

Mach在系统中处于最接近底层的模块,是XNU内核的内核,被BSD包裹。Mach内核作为系统一个底层的基础,仅与驱动操作系统所需的最低需要有关。 其他所有内容都由操作系统的更高层来实现,然后再利用Mach并以其认为合适的任何方式对其进行操作。

Mach提供了一小部分内核抽象,这些内核抽象被设计为既简单又强大。与Mach异常相关的内核抽象有:

  • tasks

资源所有权单位; 每个任务由一个虚拟地址空间、一个端口权限名称空间和一个或多个线程组成。 (类似于进程)

  • threads

任务中CPU执行的单位。

  • ports

安全的单工通信通道,只能通过发送和接收功能(称为端口权限)进行访问。

这些内核对象,对于Mach来说都是一个个的Object,这些Objects基于Mach实现自己的功能,并通过Mach Message来进行通信,Mach提供了相关的应用层的API来操作。与Mach异常相关的几个API有:

  • task_get_exception_ports:获取task的异常端口
  • task_set_exception_ports:设置task的异常端口
  • mach_port_allocate:创建调用者指定的端口权限类型
  • mach_port_insert_right:将指定的端口插入目标task

2.2 如何捕捉Mach异常

由上图可知,主要的流程是:在监控线程中监听Mach异常并处理异常信息。主要的步骤如下:

1、备份当前异常端口

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

2、分配新的异常端口并设置给task作为新的接收异常的端口

// 分配异常端口并设置接收权限
KSLOG_DEBUG("Allocating new port with receive rights.");
kr = mach_port_allocate(thisTask,
                        MACH_PORT_RIGHT_RECEIVE,
                        &g_exceptionPort);
...
// 设置发送权限
KSLOG_DEBUG("Adding send rights to port.");
kr = mach_port_insert_right(thisTask,
                            g_exceptionPort,
                            g_exceptionPort,
                            MACH_MSG_TYPE_MAKE_SEND);
...
// 设置为当前异常端口
KSLOG_DEBUG("Installing port as exception handler.");
kr = task_set_exception_ports(thisTask,
                                mask,
                                g_exceptionPort,
                                (int)(EXCEPTION_DEFAULT | MACH_EXCEPTION_CODES),
                                THREAD_STATE_NONE);

3、创建两个线程用于监听异常端口

两个线程轮流监听异常端口,做到没有真空期。

pthread_attr_t attr;
...
// 创建第二线程
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);
...
// 创建第二线程
KSLOG_DEBUG("Creating primary exception thread.");
error = pthread_create(&g_primaryPThread,
                        &attr,
                        &handleExceptions,
                        kThreadPrimary);
...
pthread_attr_destroy(&attr);

4、线程中监听并处理异常

// 监听异常
KSLOG_DEBUG("Waiting for mach exception");

// Wait for a message.
MachExceptionMessage exceptionMessage = {{0}};
kern_return_t kr = mach_msg(&exceptionMessage.header,
                            MACH_RCV_MSG,
                            0,
                            sizeof(exceptionMessage),
                            g_exceptionPort,
                            MACH_MSG_TIMEOUT_NONE,
                            MACH_PORT_NULL);
...
// 获取异常信息
...
// 重新发送异常信息
KSLOG_DEBUG("Replying to mach exception message.");
// Send a reply saying "I didn't handle this exception".
MachReplyMessage replyMessage = {{0}};
replyMessage.header = exceptionMessage.header;
replyMessage.NDR = exceptionMessage.NDR;
replyMessage.returnCode = KERN_FAILURE;

mach_msg(&replyMessage.header,
            MACH_SEND_MSG,
            sizeof(replyMessage),
            0,
            MACH_PORT_NULL,
            MACH_MSG_TIMEOUT_NONE,
            MACH_PORT_NULL);

mach_msg用于从目标端口发送和接收消息:

mach_msg_return_t   mach_msg
                    (mach_msg_header_t                msg,
                     mach_msg_option_t             option,
                     mach_msg_size_t            send_size,
                     mach_msg_size_t        receive_limit,
                     mach_port_t             receive_name,
                     mach_msg_timeout_t           timeout,
                     mach_port_t                   notify);

mach_msg实际上调用了mach_msg_overwritetrap,进入内核后通过调用ipc_kmsg_*系列函数来实现Mach Message的发送和接收。

关于mach_msg具体可参考:

3 UNIX信号

Mach已经通过异常机制提供了底层的异常处理,但为了兼容更为流行的POSIX标准,BSD在Mach异常机制之上构建的UNIX信号处理机制。异常信号首先被转换为Mach异常,如果没有被外界捕捉,则会被默认的异常处理ux_exception()转换为UNIX信号。

3.1 如何捕捉UNIX信号

在KSCrash中,捕捉了以下信号:

static const int g_fatalSignals[] =
{
    SIGABRT,
    SIGBUS,
    SIGFPE,
    SIGILL,
    SIGPIPE,
    SIGSEGV,
    SIGSYS,
    SIGTRAP,
};

1、创建备用信号栈

if(g_signalStack.ss_size == 0)
{
    KSLOG_DEBUG("Allocating signal stack area.");
    g_signalStack.ss_size = SIGSTKSZ;
    g_signalStack.ss_sp = malloc(g_signalStack.ss_size);
}

KSLOG_DEBUG("Setting signal stack area.");
if(sigaltstack(&g_signalStack, NULL) != 0)
{
    KSLOG_ERROR("signalstack: %s", strerror(errno));
    goto failed;
}

这里使用到的函数是sigalstack(),允许进程定义新的备用信号堆栈或获取现有的备用信号栈的状态。

为什么要使用备用信号栈?

一般情况下,信号处理函数被调用时,内核会在进程的栈上为其创建一个栈帧。但这里就会有一个问题,如果之前栈的增长达到了栈的最大长度,或是栈没有达到最大长度但也比较接近,那么就会导致信号处理函数不能得到足够栈帧分配。

备用信号栈最常见的用法是处理SIGSEGV信号,如果进程的栈可用空间已耗尽,SIGSEGV处理函数不能在进程栈上调用,进程就会因此而结束。如果我们想处理它,我们必须使用备用信号栈。

2、备份并更改信号处理函数

struct sigaction action = {{0}};
action.sa_flags = SA_SIGINFO | SA_ONSTACK;
#if KSCRASH_HOST_APPLE && defined(__LP64__)
action.sa_flags |= SA_64REGSET;
#endif
sigemptyset(&action.sa_mask);
action.sa_sigaction = &handleSignal;

for(int i = 0; i < fatalSignalsCount; i++)
{
    KSLOG_DEBUG("Assigning handler for signal %d", fatalSignals[i]);
    if(sigaction(fatalSignals[i], &action, &g_previousSignalHandlers[i]) != 0)
    {
        char sigNameBuff[30];
        const char* sigName = kssignal_signalName(fatalSignals[i]);
        if(sigName == NULL)
        {
            snprintf(sigNameBuff, sizeof(sigNameBuff), "%d", fatalSignals[i]);
            sigName = sigNameBuff;
        }
        KSLOG_ERROR("sigaction (%s): %s", sigName, strerror(errno));
        // Try to reverse the damage
        for(i--;i >= 0; i--)
        {
            sigaction(fatalSignals[i], &g_previousSignalHandlers[i], NULL);
        }
        goto failed;
    }
}

针对每一种信号,调用sigaction()来更换自己的信号处理函数,并把对应的已有的信号处理函数备份到g_previousSignalHandlers中。备份的信号处理函数将在出错时或者退出自己的信号处理时再次设置。

3.2 为什么捕捉Mach异常还需要捕捉UNIX信号

首先要明确的一点是,Mach异常和UNIX信号都可以被捕获,他们也几乎一一对应,但我们需要优先处理Mach异常,因为Mach异常更接近底层,而UNIX信号存在被Mach默认异常处理函数直接退出进程而无法生成的情况。

有一些异常如EXC_CRASH,在Mach异常的阶段没有捕捉,而是放到UNIX信号中捕捉,其中的解释在PLCrashReporter的注释中有详细的解释:

/* 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. */

4 C++异常

iOS和OSX都会在CFRunLoop中捕捉所有没有被捕捉的异常,包括C++异常。在OSX中,会通过对话框展示异常给用户,但在iOS中,只是重新抛出异常。

4.1 为什么要捕捉C++异常

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

当系统在RunLoop捕捉到的C++异常时,此时的调用堆栈是异常发生时的堆栈,但当系统在不能转换为OC异常时调用__cxa_throw时,上层捕捉此再抛出的异常获取到的调用堆栈是RunLoop异常处理函数的堆栈,导致原始异常调用堆栈丢失。

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

4.2 如何捕捉C++异常

为了获得C++异常的调用堆栈,我们需要模拟抛出NSException的过程并在此过程中保存调用堆栈。

1、设置异常处理函数

g_originalTerminateHandler = std::set_terminate(CPPExceptionTerminate);

调用std::set_terminate设置新的全局终止处理函数并保存旧的函数。

2、重写__cxa_throw

void __cxa_throw(void* thrown_exception, std::type_info* tinfo, void (*dest)(void*))

在异常发生时,会先进入此重写函数,应该先获取调用堆栈并存储;再调用原始的__cxa_throw函数。

3、异常处理函数

__cxa_throw往后执行,进入set_terminate设置的异常处理函数。判断如果检测是OC异常,则什么也不做,让OC异常机制处理;否则获取异常信息。

5 不能被捕获的异常

不能被捕获的异常包括:部分Mach信号(如EXC_RESOURCE),卡死、爆内存等。

5.1 爆内存(Jetsam)

当App在前台时,系统在App占用的内存达到了系统对单个App占用的内存上限后,就会杀掉App进程。

当App在后台时,系统如果发现内存压力过大,也会按照一定的优先级规则来杀掉后台进程。

杀掉进程的表现是:通过在BSD层产生SIGKILL信号来杀掉进程,同时产生Jetsam log。

关于爆内存,参考iOS 内存 Jetsam 机制探究

参考

KSCrash

iOS Mach 异常、Unix 信号 和NSException 异常

Mach IPC Interface

iOS: How to get stack trace of an unhandled std::exception?

编辑于 2022-01-28 17:34