Crash学习笔记

Crash是什么

Crash其实就是当有些操作不被系统或者软件允许时,通过一些信号或者异常让进程中止的现象。当然,在收到信号或者异常时,也可以选择不处理让进程继续运行,但是一般不会这样做,因为后可能导致一些不可预知的后果。在OSX&iOS中,Crash一般由以下两种信号产生。

Hardware-Generated Signals

硬件层面的信号(比如常见的野指针)最早是来自处理器的traps,然后会被Mach层捕获,从Mach异常转换为Unix信号,转换逻辑见代码(通用Mach和机器相关异常处理)

Mach Exception to Unix Signal

转换为Unix信号之后会统一调用threadsignal最终走到act_set_astbsd()。整个流程如下:

hardware_exception_process

这里有个有趣的地方,就是通过代码我们发现,如果发生stack overflow,那么signalSIGSEGV而不是SIGBUS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
kern_return_t handle_ux_exception(thread_t                    thread,
int exception,
mach_exception_code_t code,
mach_exception_subcode_t subcode)
{
//...
if (code == KERN_PROTECTION_FAILURE &&
ux_signal == SIGBUS) {
user_addr_t sp = subcode;

user_addr_t stack_max = p->user_stack;
user_addr_t stack_min = p->user_stack - MAXSSIZ;
if (sp >= stack_min && sp < stack_max) {
/*
* This is indeed a stack overflow. Deliver a
* SIGSEGV signal.
*/
ux_signal = SIGSEGV;
// ...
}
}
/* Send signal to thread */
if (ux_signal != 0) {
ut->uu_exception = exception;
//ut->uu_code = code; // filled in by threadsignal
ut->uu_subcode = subcode;
threadsignal(thread, ux_signal, code, TRUE);
}

proc_rele(p);
return KERN_SUCCESS;
}

Software-Generated Signals

除了硬件产生的信号,其他产生的信号(越界,竞态等的软件产生的信号)一般都来自kill(2)或者pthread_kill(2)两个API,最终也会走到act_set_astbsd()。

software_exception_process.png

为了保持统一的处理机制,系统和用户产生的软件异常,会首先被转换Mach异常然后再转化为Unix信号。

Crash的捕获

通过上面的简单分类,我们可能会觉得只要捕获Unix信号可以捕获所有Crash了?其实不是。

  1. 如果只捕获Mach异常,不捕获Unix信号,那么遇到EXC_CRASH这种异常就可能出问题。

    1
    2
    3
    4
    PLCrashReporter
    /* We still need to use signal handlers to catch SIGABRT in-process. The kernel sends an EXC_CRASH mach exception
    * in an uninterruptable wait. Thus, we fall back on BSD signal handlers for SIGABRT, and do not register for
    * EXC_CRASH. */
  2. 如果只捕获Unix信号,不捕获Mach异常,可能会漏掉一些错误,因为不是所有Mach异常都会转换为Unix信号。

    这点通过上面的映射表和下面的所有Mach异常定义可以看出来(比如,EXC_GUARD在上表中就没有找到对应的映射Unix Signal)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    #define EXC_BAD_ACCESS		1	/* Could not access memory */
    /* Code contains kern_return_t describing error. */
    /* Subcode contains bad memory address. */

    #define EXC_BAD_INSTRUCTION 2 /* Instruction failed */
    /* Illegal or undefined instruction or operand */

    #define EXC_ARITHMETIC 3 /* Arithmetic exception */
    /* Exact nature of exception is in code field */

    #define EXC_EMULATION 4 /* Emulation instruction */
    /* Emulation support instruction encountered */
    /* Details in code and subcode fields */

    #define EXC_SOFTWARE 5 /* Software generated exception */
    /* Exact exception is in code field. */
    /* Codes 0 - 0xFFFF reserved to hardware */
    /* Codes 0x10000 - 0x1FFFF reserved for OS emulation (Unix) */

    #define EXC_BREAKPOINT 6 /* Trace, breakpoint, etc. */
    /* Details in code field. */

    #define EXC_SYSCALL 7 /* System calls. */

    #define EXC_MACH_SYSCALL 8 /* Mach system calls. */

    #define EXC_RPC_ALERT 9 /* RPC alert */

    #define EXC_CRASH 10 /* Abnormal process exit */

    #define EXC_RESOURCE 11 /* Hit resource consumption limit */
    /* Exact resource is in code field. */

    #define EXC_GUARD 12 /* Violated guarded resource protections */

    #define EXC_CORPSE_NOTIFY 13 /* Abnormal process exited to corpse state */

所以,正确的捕获Crash方式应该是两种方式同时采用,互相补充。

KSCrash捕获原理

我们拿开源的KSCrash来分析,看下针对Crash的捕获需要做些什么。

KSCrash对Crash的捕获分成了几种类型

  • Mach kernel exceptions
  • Fatal signals
  • C++ exceptions
  • Objective-C exceptions
  • Main thread deadlock (experimental)

主线程死锁暂时不看,因为机制完全不一样(通过监听主线程完成类似watchdog的工作)。同时我们注意到除了Mach异常和Unix信号,还单独拆分了C++异常和OC异常(NSException),那是因为C++异常和OC属于上层语言提供的异常,可以被catch和处理,当然也有可能转化为底层信号,但是他们附带的信息会更多,比如NSException抛出时会把原因,用户信息,堆栈等都直接抛出,而捕获底层信号时获取堆栈反而比较麻烦。

Mach kernel exceptions

Mach的异常捕获,依赖Mach层提供的API,从代码流量可看出大致需要做哪些:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
static bool installExceptionHandler() {
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;
if(g_exceptionPort == MACH_PORT_NULL) {
// 新建一个端口接收异常消息,可接收消息
kr = mach_port_allocate(thisTask,
MACH_PORT_RIGHT_RECEIVE,
&g_exceptionPort);

// 使端口可发送消息
kr = mach_port_insert_right(thisTask,
g_exceptionPort,
g_exceptionPort,
MACH_MSG_TYPE_MAKE_SEND);
}
// 指定g_exceptionPort端口接收异常消息
kr = task_set_exception_ports(thisTask,
mask,
g_exceptionPort,
(int)(EXCEPTION_DEFAULT | MACH_EXCEPTION_CODES),
THREAD_STATE_NONE);
// 创建线程处理异常
// 实际代码中创建了两个线程同时处理,为了代码执行中的死循环
KSLOG_DEBUG("Creating primary exception thread.");
error = pthread_create(&g_primaryPThread,
&attr,
&handleExceptions,
kThreadPrimary);
return true;
}
static void* handleExceptions(void* const userData) {
// 循环等待g_exceptionPort端口发来的异常消息
for(;;) {
// Wait for a message.
kern_return_t kr = mach_msg(&exceptionMessage.header,
MACH_RCV_MSG,
0,
sizeof(exceptionMessage),
g_exceptionPort,
MACH_MSG_TIMEOUT_NONE,
MACH_PORT_NULL);
if(kr == KERN_SUCCESS) {
break;// 收到异常后退出循环,处理异常
}
}
if(g_isEnabled) {
//挂起所有除了处理异常线程意外的线程
ksmc_suspendEnvironment(&threads, &numThreads);
g_isHandlingCrash = true;
kscm_notifyFatalExceptionCaptured(true);
// 记录上下文信息,包括所有堆栈和cpu等逆袭
KSMC_NEW_CONTEXT(machineContext);
KSCrash_MonitorContext* crashContext = &g_monitorContext;
crashContext->offendingMachineContext = machineContext;
kssc_initCursor(&g_stackCursor, NULL, NULL);
// ....
kscm_handleException(crashContext);
g_isHandlingCrash = false;
// 恢复现场
ksmc_resumeEnvironment(threads, numThreads);
}
return NULL;
}

其中处理现场和获取机器堆栈等信息部分太长,有兴趣可以自己查看。总体看下来大致两个步骤:1. 注册一个端口接收异常 2. 新建一个线程监控和处理异常

Fatal signals

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
static bool installSignalHandler() {
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++) {
// SIGABRT,SIGBUS,SIGFPE,SIGILL,SIGPIPE,SIGSEGV,SIGSYS,SIGTRAP,
if(sigaction(fatalSignals[i], &action, &g_previousSignalHandlers[i]) != 0) {
// error handle
}
}
return true;
}
static void handleSignal(int sigNum, siginfo_t* signalInfo, void* userContext) {
if(g_isEnabled) {
thread_act_array_t threads = NULL;
mach_msg_type_number_t numThreads = 0;
ksmc_suspendEnvironment(&threads, &numThreads);
kscm_notifyFatalExceptionCaptured(false);

KSLOG_DEBUG("Filling out context.");
KSMC_NEW_CONTEXT(machineContext);
ksmc_getContextForSignal(userContext, machineContext);
kssc_initWithMachineContext(&g_stackCursor, KSSC_MAX_STACK_DEPTH, machineContext);
// 记录信息
kscm_handleException(crashContext);
ksmc_resumeEnvironment(threads, numThreads);
}
// This is technically not allowed, but it works in OSX and iOS.
raise(sigNum);
}

Unix的信号捕获依赖各个类型信号的注册 sigaction ,而其处理过程和之前类似。有意思的是处理完之后,会直接raiseraise的作用是给当前线程发一个信号,不知道为什么注释里写着技术上不允许。

C++ exceptions

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// std::set_terminate指定处理
g_originalTerminateHandler = std::set_terminate(CPPExceptionTerminate);
static void CPPExceptionTerminate(void) {
// 保存现场,挂起线程
ksmc_suspendEnvironment(&threads, &numThreads);
if(name == NULL || strcmp(name, "NSException") != 0){
// 不处理NSException,避免重复
kscm_notifyFatalExceptionCaptured(false);
KSCrash_MonitorContext* crashContext = &g_monitorContext;
memset(crashContext, 0, sizeof(*crashContext));
try
{
throw;
}
catch(std::exception& exc)
{
strncpy(descriptionBuff, exc.what(), sizeof(descriptionBuff));
}

// 记录各种信息
KSMC_NEW_CONTEXT(machineContext);
ksmc_getContextForThread(ksthread_self(), machineContext, true);

KSLOG_DEBUG("Filling out context.");
crashContext->crashType = KSCrashMonitorTypeCPPException;
crashContext->eventID = g_eventID;
crashContext->registersAreValid = false;
crashContext->stackCursor = &g_stackCursor;
crashContext->CPPException.name = name;
crashContext->exceptionName = name;
crashContext->crashReason = description;
crashContext->offendingMachineContext = machineContext;
kscm_handleException(crashContext);
}
// 恢复现场
ksmc_resumeEnvironment(threads, numThreads);
// 退出
g_originalTerminateHandler();
}

C++的异常主要依靠std::set_terminate ,中间处理的时候用try{ throw; } catch {} 来获取异常的信息。

Objective-C exceptions

OC的异常比较容易,也都很熟悉,使用NSSetUncaughtExceptionHandler 即可,就不贴代码了。

Crash的符号化

Crash的符号化前提是,先得有一个符号表dSYM 和crash日志。

生成dSYM

Xcode的Build Setting中,需要设置:

  1. Generate Debug Symbols(GCC_GENERATE_DEBUGGING_SYMBOLS) = YES
  2. Debug Infomation Format(DEBUG_INFORMATION_FORMAT) = DWARF with dSYM File

当开启了上述配置,生成的二进制文件中将不再包含符号信息,取而代之的是一个单独的dSYM文件。当发生crash时,crash report里面展示的将是各种对象和方法的内存地址,必须配合dSYM文件才能将真实调用信息解析出来。

获取Crash Report

  • TestFlight用户或者允许了发送诊断信息的Appstore用户,可以在Xcode→Window→Organizer→Crash中看到崩溃信息。
  • 本地测试机器,可以在手机的Setting→Privacy→Analytics & Improvements →Analytics Data→_找到。
  • 自己捕获Crash然后上报(Bugly, KSCrash, PLCrashReporter…)

解析Crash Report

  • 如果是在Xcode→Window→Organizer→Crash中看到的Crash,只要把dSYM文件下载到本地机器,可以直接在Organizer中symbolicate
  • 如果是从手机中Analytics Data导出的报告或者自己上报的crash,可以将报告下载下来,按照下面步骤解析。

    1
    2
    3
    4
    5
    //找到命令行工具symbolicatecrash
    find /Applications/Xcode.app -name symbolicatecrash -type f
    //将symbolicatecrash+dSYM+Crash Report放到一起,假设tmp目录下。
    //解析生成crash log
    ./symbolicatecrash ./xxx.ips ./AppName.app.dSYM > crash.log
  • 单行Crash解析

    Crash Report中除了堆栈调用外,最底下还有镜像的各种信息,拿下面这个举例子,找到想要解析的行,取出想要解析的地址,再找到对应的镜像加载地址和架构。

    single_line_crash_parse.png

    拿到这些信息后,可以按照固定格式调用atos

    1
    atos -arch <BinaryArchitecture> -o <PathToDSYMFile>/Contents/Resources/DWARF/<BinaryName>  -l <LoadAddress> <AddressesToSymbolicate>

    用一个最近的例子说明,我们的crash采集系统有的时候会堆栈解析不出来,大概长这样

    1
    8   IBUWireless                     0x0000000103813788 void* std::__1::__thread_proxy<std::__1::tuple<std::__1::unique_ptr<std::__1::__thread_struct, std::__1::default_delete<std::__1::__thread_struct> >, void (MyThread::*)(), MyThread*> >(void*) + 12452532

    我尝试用atos解析,还是能正常解析的(XXXXX是打码部分),所以我们的crash采集系统还有待改进。

    1
    2
    % atos -arch arm64 -o IBUWireless.app.dSYM/Contents/Resources/DWARF/IBUWireless -l 0x101130000 0x0000000103813788
    % __44-[XXXXX updateCell:]_block_invoke (in IBUWireless) (XXXXXX.m:0)

Crash的分析

要分析Crash,先要了解Crash Report的内容组成

Crash Report的组成

crash_report_component.png

头部信息

头部信息一般包含以下字段:

  • Incident Identifier: 唯一标识,不同report不一样。
  • CrashReporter Key: 唯一设备标识,同一台设备的report这个值是一样的,抹除设备会重制这个值。
  • Beta Identifier: 只有Testflight有,结合设备和运营商的唯一标识。
  • Hardware Model: 型号
  • Process: 进程
  • Path: 可执行文件的路径
  • Identifier: BundleID
  • Version: build号和版本号的结合
  • AppStoreTools: Xcode版本
  • AppVariant: app thinning产生的字段
  • Code Type: CPU结构, ARM-64, ARM, X86-64, or X86.
  • Parent Process: 父进程ID
  • Date/Time: 时间信息
  • Launch Time: 启动时间
  • OS Version: 系统版本

异常信息

  • Exception Type: Mach的异常(Unix信号)

    常见Crash

  • Exception Codes: 处理器特有标识,一般没啥用

  • Exception Subtype: 异常的描述,有些
  • Exception Message: 额外的描述信息
  • Termination Reason: 操作系统中止进程时带上的具体原因,常见的case如下
  • Triggered by Thread or Crashed Thread: 崩溃的线程,大部分情况只看崩溃线程的堆栈就就能定位问题

诊断信息

  • Application Specific Information: 在 OC和C++ 异常情况下,这里一般有软件层抛出的具体错误信息,能很快定位问题,比如

    1
    Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[XXXX method]: unrecognized selector sent to instance 0x2834ac5d0'
  • Termination Description: 如果是watchdog杀死进程,则可能会有这个字段描述

    1
    2
    3
    Termination Description: SPRINGBOARD, 
    scene-create watchdog transgression: application<com.example.MyCoolApp>:667
    exhausted real (wall clock) time allowance of 19.97 seconds
  • VM Region Info: 内存原因的Crash会带上

    1
    2
    3
    4
    5
    VM Region Info: 0 is not in any region.  Bytes before following region: 4307009536
    REGION TYPE START - END [ VSIZE] PRT/MAX SHRMOD REGION DETAIL
    UNUSED SPACE AT START
    --->
    __TEXT 0000000100b7c000-0000000100b84000 [ 32K] r-x/r-x SM=COW ...pp/MyGreatApp

崩溃堆栈

大部分情况,我们能通过崩溃堆栈找到错误原因。如果崩溃堆栈是系统线程,我们就需要借助别的手段。

主线程+其余所有堆栈

这里记录所有线程的堆栈,当崩溃堆栈不能直接指出原因时,我们可以查看崩溃那一刻,其他所有线程在做什么,寻找每个崩溃时多个线程的共同点,也许会有意外收获。

崩溃线程状态

这里记录了崩溃时,线程的状态,包括各个寄存器的值,一般用不到这里。但是一些难以定位的crash可能会借助这个来排查。(我自己也涉及不多-_-)

crash_thread_state.png

镜像列表

这里列举了所有镜像的信息

1
2
//格式 起始地址-结束地址 名称 架构 uuid 路径
0x11eca8000 - 0x11ecabfff iAdFramework arm64 <68b5e3f77743340e80ebed9ceaef8837> /System/Library/AccessibilityBundles/iAdFramework.axbundle/iAdFramework

一般镜像用于解析堆栈,当然也可以用来判断是否越狱,比如如果出现一些典型的比如hookkeyboard.dylib 基本可以判断这台机器是越狱的。

常见Crash分析

一般的NSException或者C++异常很好定位,比如空指针,数组越界等,就不赘述。我们单独来看看内存类的Crash,因为这类Crash一般不容易复现和定位。

内存类错误一般表现为EXC_BAD_ACCESS (SIGSEGV) or EXC_BAD_ACCESS (SIGBUS),原因是访问了非法内存地址(对象内存已经释放但是指针还在),或者往只读的内存写等。

使用Xcode分析

Xcode提供了很多工具分析内存类的错误,Address Sanitize Undefined Behavior Sanitizer Thread Sanitizer 和 静态分析等,都有可能发现App运行中内存问题,如果发现,及时修复。

Crash Report分析

有的时候通过Xcode提供的工具不一定能完全复现问题,这时候我们要对Crash Report深入分析。

  1. 如果能从崩溃堆栈看出具体代码,能比较快定位。如果堆栈出现objc_msgSend, objc_retain, objc_release ,那这很可能是zombie对象。
  2. 内存类错误有的会在Crash Report中的Exception Subtype 指明原因,我们可以通过这个字段进一步定位发生错误的原因。

    Exception Subtype举例

  3. 尝试通过寄存器还原现场

    ARM中,lr存放方法调用的返回地址,通过还原lr中的代码调用,可以看到出错代码的调用入口。

    拿一个真实的例子来看,通过atos解析lr寄存器存的地址(XXX照样打码),这可能对分析问题会有帮助。

    crash_thread_state2.png

    1
    2
    % atos -arch arm64 -o IBUWireless.app.dSYM/Contents/Resources/DWARF/IBUWireless -l 0x101130000 0x000000010456c89c
    -[XXXControlView1 topCustomContentView] (in IBUWireless) (XXXControlView1.m:671)
  4. 通过崩溃用户的所有信息分析共性(操作系统,网络环境,语言,地区…),尝试复现

  5. 针对zombie代码,可以试试看实现一个线上的zombie机制,具体可以参考KSCrash中的KSCrashMonitor_Zombie.c的实现,大致原理就是hookNSObject和NSProxy的dealloc方法,当对象dealloc的时候,生成zombie代替原来对象,这样当下次再有该对象的dealloc消息过来,就可以判定为重复释放。

总结

总结一下,本次学习了Crash的生成,捕获,符号化和分析,但是真实的Crash还是有很多细节在里面,需要自己在实践中一步步感受。

参考文档

《Mac OS and iOS Internals》

Diagnosing Issues Using Crash Reports and Device Logs

Crash分析攻略