Tenloy's Blog

iOS崩溃监控与分析

Word count: 8.5kReading time: 35 min
2021/08/01 Share

一、Crash概述

1.1 常见Crash的类型

常见的 crash 类型总结下来,分为三种:

  • Mach kernel exceptions:是指最底层的内核级异常。用户态的开发者可以直接通过Mach API设置thread,task,host的异常端口,来捕获Mach异常。
  • Unix Fatal signals:又称BSD 信号,如果开发者没有捕获Mach异常,则会被host层的方法ux_exception()将异常转换为对应的UNIX信号,并通过方法threadsignal()将信号投递到出错线程。可以通过方法signal(x, SignalHandler)来捕获single。
    • 很多内存错误、访问错误的地址造成的异常会以Mach异常、unix标准的signal机制的形式造成crash的现象。
  • 应用级异常:
    • Objective-C exceptions(NSException),它是未被捕获的Objective-C异常,导致程序向自身发送了SIGABRT信号而崩溃,对于未捕获的Objective-C异常,是可以通过try catch来捕获的,或者通过NSSetUncaughtExceptionHandler()机制来捕获。
    • C++ exceptions
    • Main thread deadlock (experimental) 主线程死锁
    • Custom crashes (e.g. from scripting languages) 自定义崩溃

此外,在iOS中还有一些信号捕获不到的崩溃。12 | iOS 崩溃千奇百怪,如何全面监控?

  • 后台任务超时导致的崩溃。
    • 在使用Background Task 方式进行后台保活时,任务最多执行 3 分钟,3 分钟内 yourTask 运行完成,你的 App 就会挂起。 如果 yourTask 在 3 分钟之内没有执行完的话,系统会强制杀掉进程,从而造成崩溃,这就是为什么 App 退后台容易出现崩溃的原因。
  • 内存打爆、主线程卡顿时间超过阈值被 watchdog 杀掉。

1.2 处理流程

参考图:

二、Darwin操作系统简单介绍

可以通过下面两张图,简单的看一下OS/iOS系统的分层结构

os-structure

Darwin是macOS和iOS操作环境的操作系统部分。苹果公司于2000年把Darwin发布给开放源代码社区。

  • 既然是OS,那肯定要包括系统内核XNU、驱动、Shell 等内容,这一层是开源的,其所有源码都可以在 opensource.apple.com 里找到。
  • XNU是一个混合内核,它采用了来自OSF的OSFMK 7.3(Open Software Foundation Mach Kernel)和FreeBSD的各种要素(包括过程模型,网络堆栈和虚拟文件系统),还有一个称为I/O Kit的面向对象的设备驱动程序API。
    • Mach是一个由卡内基梅隆大学开发的计算机操作系统微内核,为了用于操作系统之研究,特别是在分布式与并行运算上。是最早实现微核心操作系统的例子之一,是许多其它相似的项目的标准。
    • OSFMK 是 Unix 操作系统的一个变体,是很早的使用 Mach kernel 的操作系统之一。
    • BSD伯克利软件套件(Berkeley Software Distribution UNIX),是一个派生自Unix(类Unix)的操作系统。
    • FreeBSD是FreeBSD项目的发展成果,是开放源代码的类Unix操作系统,基于BSD Unix的源代码派生发展而来。
  • XNU将宏内核与微内核两者的特性兼收并蓄,以期同时拥有两种内核的优点。微内核的灵活性:比如在微内核中提高操作系统模块化程度以及让操作系统更多的部分接受内存保护的消息传递机制。宏内核的性能:宏内核在高负荷下表现的高性能。

我们在深入看一下 Darwin 这个核心的架构:

os-structure

其中,在硬件层上面的三个组成部分:Mach、BSD、IOKit (还包括一些上面没标注的内容),共同组成了 XNU 内核。

  • XNU 内核的内环被称作 Mach,其作为一个微内核,仅提供了诸如处理器调度、IPC (进程间通信)等非常少量的基础服务。
  • BSD 层可以看作围绕 Mach 层的一个外环,其提供了诸如进程管理、文件系统和网络等功能。(BSD是宏内核)
  • IOKit 层是为设备驱动提供了一个面向对象(C++)的一个框架。

Mach 本身提供的 API 非常有限,而且苹果也不鼓励使用 Mach 的 API,但是这些API非常基础,如果没有这些API的话,其他任何工作都无法实施。在 Mach 中,所有的东西都是通过自己的对象实现的,进程、线程和虚拟内存都被称为”对象”。和其他架构不同, Mach 的对象间不能直接调用,只能通过消息传递的方式实现对象间的通信。”消息”是 Mach 中最基础的概念,消息在两个端口 (port) 之间传递,这就是 Mach 的 IPC (进程间通信) 的核心。 也是RunLoop的底层实现支持技术。

iOS中的 POSIX API 就是通过 Mach 之上的 BSD 层实现的:

os-structure2

三、捕获 — Mach异常与Unix信号异常

iOS系统自带的 Apple’s Crash Reporter 记录在设备中的Crash日志,Exception Type项通常会包含两个元素: Mach异常 和 Unix信号。

1
2
Exception Type:         EXC_BAD_ACCESS (SIGSEGV)    
Exception Subtype: KERN_INVALID_ADDRESS at 0x041a6f3

Mach异常是什么?它又是如何与Unix信号建立联系的?

3.1 Mach kernel exceptions

3.1.1 Mach异常的产生

Mach异常是指最底层的内核级异常。

捕获方法:每个thread,task,host都有一个异常端口数组,Mach的部分API暴露给了用户态,用户态的开发者可以直接通过Mach API设置thread,task,host的异常端口,来捕获Mach异常,抓取Crash事件。

Mach-except-handle

3.1.2 Mach异常的捕获原理

3.2 Unix/BSD Signals

Unix信号,又称为BSD 信号。

3.2.1 信号的产生

Mach已经通过异常机制提供了底层的异常处理,但为了兼容更为流行的POSIX标准(SUS规范),BSD在Mach异常机制之上构建的UNIX信号处理机制。这样不必了解Mach内核也可以通过Unix信号的方式来兼容开发。

  • Mach异常如果没在Mach级别处理,那么都会在host层被ux_exception转换为相应的Unix信号,并通过threadsignal将信号投递到出错的线程。
  • 另外,不是所有的 “Mach异常” 类型都映射到了 “UNIX信号”。 如 EXC_GUARD 。在苹果开源的 xnu 源码中可以看到这点。
  • 因为硬件产生的信号(通过CPU陷阱)被Mach层捕获,然后才转换为对应的Unix信号;苹果为了统一机制,于是操作系统和用户产生的信号(通过调用kill和pthread_kill)也首先沉下来被转换为Mach异常,再转换为Unix信号。

因此,EXC_BAD_ACCESS (SIGSEGV)表示的意思是:Mach层的EXC_BAD_ACCESS异常,在host层被转换成SIGSEGV信号投递到出错的线程。

BSD-signals

3.2.2 信号的捕获原理

1. signal函数

既然最终以信号的方式投递到出错的线程,那么就可以通过注册signalHandler来捕获信号:

1
signal(SIGSEGV,signalHandler);

2. sigaction函数

如果需要用相同的方式处理信号多次出现,且信号容易多次出现,则建议使用sigaction函数;若可以保证信号长时间内只出现并只需要处理一次,则可以使用signal函数。

3.2.3 系统中都有哪些信号

默认情况下,大多数信号都是致命的(Fatal signals)。 任何具有“terminate终止”或“dump core核心转储”的默认操作的信号都是致命的,除非它被忽略或明确处理。查看方式:

  • usr/include/sys/signal.h 中,定义了31种
  • 也可以使用命令 kill -l 查看,显示了25种
  • 针对特定的信号,应用程序可以写对应的信号处理函数。如果不指定,则采取默认的处理方式
  • core dump:是操作系统在进程收到某些信号而终止运行时,将此时进程地址空间的内容以及有关进程状态的其他信息写出的一个磁盘文件(有的系统中将文件命名为 core.进程号,也有的命名为 core-命令名-pid-时间戳)。这种信息往往用于调试。

3.3 常见的Mach异常与Unix信号

信号可以看做是对硬件异常跟软件异常的封装,常见的几种signals:

1
2
3
4
5
6
7
8
SIGSEGV, // SEGV segmentation violation(段 违反) 非法访问地址,比如试图访问未分配给自己的内存, 或试图往没有写权限的内存地址写数据。比如:给已经release的对象发送消息
SIGBUS, // 操作非法地址。比如修改只读数据区。
SIGILL, // 执行非法指令, 通常是因为可执行文件本身出现错误, 或者试图执行数据段. 堆栈溢出时也有可能产生这个信号。
SIGFPE, // 算术运算错误
SIGSYS, // 非法的系统调用
SIGPIPE, // 进程间通信产生,通信管道破裂。这个信号通常在进程间通信产生,比如采用FIFO(管道)通信的两个进程,读管道没打开或者意外终止就往管道写,写进程会收到SIGPIPE信号。此外用Socket通信的两个进程,写进程在写Socket的时候,读进程已经终止。
SIGABRT, // 调用abort生成的信号,有可能是NSException也有可能是Mach
SIGTRAP, // 由断点指令或其它trap指令产生,一般出现在debug调试时
  • EXC_BAD_ACCESS:is a Mach exception sent by the kernel to your application when you try to access memory that is not mapped for your application(访问没有映射到你APP的内存时). If not handled at the Mach level, it will be translated into a SIGBUS or SIGSEGV BSD signal.(如果没有在mach级别处理,就会被转换成这两种信号) —— Matt大神的回答
  • SIGABRT is a BSD signal sent by an application to itself when an NSException or obj_exception_throw is not caught.

Mach exception和Signal转换:

mach-bsd

3.4 Crash的收集【实现】

如上述所说,通过捕获Mach异常或者Unix信号都可以抓到crash事件,于是总结起来实现方案就一共有3种。

Q: 哪种方式更好呢?

优选Mach异常,因为Mach异常处理会先于Unix信号处理发生,如果Mach异常的handler让程序exit了,那么Unix信号就永远不会到达这个进程了。

另外,不是所有的 “Mach异常” 类型都映射到了 “UNIX信号”。 如 EXC_GUARD 。在苹果开源的 xnu 源码中可以看到这点。

3.4.1 Mach异常方式

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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
static mach_port_t server_port;
static void *exc_handler(void *ignored);

//判断是否 Xcode 联调
bool ksdebug_isBeingTraced(void)
{
struct kinfo_proc procInfo;
size_t structSize = sizeof(procInfo);
int mib[] = {CTL_KERN, KERN_PROC, KERN_PROC_PID, getpid()};

if(sysctl(mib, sizeof(mib)/sizeof(*mib), &procInfo, &structSize, NULL, 0) != 0)
{
return false;
}

return (procInfo.kp_proc.p_flag & P_TRACED) != 0;
}

#define EXC_UNIX_BAD_SYSCALL 0x10000 /* SIGSYS */
#define EXC_UNIX_BAD_PIPE 0x10001 /* SIGPIPE */
#define EXC_UNIX_ABORT 0x10002 /* SIGABRT */
static int signalForMachException(exception_type_t exception, mach_exception_code_t code)
{
switch(exception)
{
case EXC_ARITHMETIC:
return SIGFPE;
case EXC_BAD_ACCESS:
return code == KERN_INVALID_ADDRESS ? SIGSEGV : SIGBUS;
case EXC_BAD_INSTRUCTION:
return SIGILL;
case EXC_BREAKPOINT:
return SIGTRAP;
case EXC_EMULATION:
return SIGEMT;
case EXC_SOFTWARE:
{
switch (code)
{
case EXC_UNIX_BAD_SYSCALL:
return SIGSYS;
case EXC_UNIX_BAD_PIPE:
return SIGPIPE;
case EXC_UNIX_ABORT:
return SIGABRT;
case EXC_SOFT_SIGNAL:
return SIGKILL;
}
break;
}
}
return 0;
}

static NSString *stringForMachException(exception_type_t exception) {
switch(exception)
{
case EXC_ARITHMETIC:
return @"EXC_ARITHMETIC";
case EXC_BAD_ACCESS:
return @"EXC_BAD_ACCESS";
case EXC_BAD_INSTRUCTION:
return @"EXC_BAD_INSTRUCTION";
case EXC_BREAKPOINT:
return @"EXC_BREAKPOINT";
case EXC_EMULATION:
return @"EXC_EMULATION";
case EXC_SOFTWARE:
{
return @"EXC_SOFTWARE";
break;
}
}
return 0;
}

void installExceptionHandler() {
if (ksdebug_isBeingTraced()) {
// 当前正在调试状态, 不启动 mach 监听
return ;
}
kern_return_t kr = mach_port_allocate(mach_task_self(), MACH_PORT_RIGHT_RECEIVE, &server_port);
assert(kr == KERN_SUCCESS);

kern_return_t rc = 0;
exception_mask_t excMask = EXC_MASK_BAD_ACCESS |
EXC_MASK_BAD_INSTRUCTION |
EXC_MASK_ARITHMETIC |
EXC_MASK_SOFTWARE |
EXC_MASK_BREAKPOINT;

rc = mach_port_allocate(mach_task_self(), MACH_PORT_RIGHT_RECEIVE, &server_port);
if (rc != KERN_SUCCESS) {
fprintf(stderr, "------->Fail to allocate exception port\\\\\\\\n");
return;
}

rc = mach_port_insert_right(mach_task_self(), server_port, server_port, MACH_MSG_TYPE_MAKE_SEND);
if (rc != KERN_SUCCESS) {
fprintf(stderr, "-------->Fail to insert right");
return;
}

rc = thread_set_exception_ports(mach_thread_self(), excMask, server_port, EXCEPTION_DEFAULT, MACHINE_THREAD_STATE);
if (rc != KERN_SUCCESS) {
fprintf(stderr, "-------->Fail to set exception\\\\\\\\n");
return;
}

//建立监听线程
pthread_t thread;
pthread_create(&thread, NULL, exc_handler, NULL);
}

static void *exc_handler(void *ignored) {
// Exception handler – runs a message loop. Refactored into a standalone function
// so as to allow easy insertion into a thread (can be in same program or different)
mach_msg_return_t rc;
fprintf(stderr, "Exc handler listening\\\\\\\\n");
// The exception message, straight from mach/exc.defs (following MIG processing) // copied here for ease of reference.
typedef struct {
mach_msg_header_t Head;
/* start of the kernel processed data */
mach_msg_body_t msgh_body;
mach_msg_port_descriptor_t thread;
mach_msg_port_descriptor_t task;
/* end of the kernel processed data */
NDR_record_t NDR;
exception_type_t exception;
mach_msg_type_number_t codeCnt;
integer_t code[2];
int flavor;
mach_msg_type_number_t old_stateCnt;
natural_t old_state[144];
} Request;

Request exc;

struct rep_msg {
mach_msg_header_t Head;
NDR_record_t NDR;
kern_return_t RetCode;
} rep_msg;

for(;;) {
// Message Loop: Block indefinitely until we get a message, which has to be
// 这里会阻塞,直到接收到exception message,或者线程被中断。
// an exception message (nothing else arrives on an exception port)
rc = mach_msg( &exc.Head,
MACH_RCV_MSG|MACH_RCV_LARGE,
0,
sizeof(Request),
server_port, // Remember this was global – that's why.
MACH_MSG_TIMEOUT_NONE,
MACH_PORT_NULL);

if(rc != MACH_MSG_SUCCESS) {
/*... */
break ;
};

//Mach Exception 类型
NSMutableString *crashInfo = [NSMutableString stringWithFormat:@"mach exception:%@ %@\n\n",stringForMachException(exc.exception), stringForSignal(signalForMachException(exc.exception, exc.code[0]))];

rep_msg.Head = exc.Head;
rep_msg.NDR = exc.NDR;
rep_msg.RetCode = KERN_FAILURE;

kern_return_t result;
if (rc == MACH_MSG_SUCCESS) {
result = mach_msg(&rep_msg.Head,
MACH_SEND_MSG,
sizeof (rep_msg),
0,
MACH_PORT_NULL,
MACH_MSG_TIMEOUT_NONE,
MACH_PORT_NULL);
}
//移除其他 Crash 监听, 防止死锁
NSSetUncaughtExceptionHandler(NULL);
signal(SIGHUP, SIG_DFL);
signal(SIGINT, SIG_DFL);
signal(SIGQUIT, SIG_DFL);
signal(SIGABRT, SIG_DFL);
signal(SIGILL, SIG_DFL);
signal(SIGSEGV, SIG_DFL);
signal(SIGFPE, SIG_DFL);
signal(SIGBUS, SIG_DFL);
signal(SIGPIPE, SIG_DFL);
}

return NULL;
}

监听 Mach 异常需要注意:

  • 避免在 Xcode 联调时监听。原因是我们监听了EXC_BREAKPOINT这类型的Exception,一旦启动 app 联调后, 会立即触发EXC_BREAKPOINT。而这段代码处理完后,会进入下一个循环等待,可主线程还等着消息处理结果,这就造成等待死锁。

3.4.2 Unix信号方式

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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
#import "NWCrashSignalExceptionHandler.h"
#import <execinfo.h>
#import "NWCrashTool.h"

typedef void(*SignalHandler)(int signal, siginfo_t *info, void *context);

static SignalHandler previousABRTSignalHandler = NULL;
static SignalHandler previousBUSSignalHandler = NULL;
static SignalHandler previousFPESignalHandler = NULL;
static SignalHandler previousILLSignalHandler = NULL;
static SignalHandler previousPIPESignalHandler = NULL;
static SignalHandler previousSEGVSignalHandler = NULL;
static SignalHandler previousSYSSignalHandler = NULL;
static SignalHandler previousTRAPSignalHandler = NULL;

@implementation NWCrashSignalExceptionHandler

+ (void)registerHandler {
// 将先前别人注册的handler取出并备份
[self backupOriginalHandler];

[self signalRegister];
}

+ (void)backupOriginalHandler {
struct sigaction old_action_abrt;
sigaction(SIGABRT, NULL, &old_action_abrt);
if (old_action_abrt.sa_sigaction) {
previousABRTSignalHandler = old_action_abrt.sa_sigaction;
}

struct sigaction old_action_bus;
sigaction(SIGBUS, NULL, &old_action_bus);
if (old_action_bus.sa_sigaction) {
previousBUSSignalHandler = old_action_bus.sa_sigaction;
}

struct sigaction old_action_fpe;
sigaction(SIGFPE, NULL, &old_action_fpe);
if (old_action_fpe.sa_sigaction) {
previousFPESignalHandler = old_action_fpe.sa_sigaction;
}

struct sigaction old_action_ill;
sigaction(SIGILL, NULL, &old_action_ill);
if (old_action_ill.sa_sigaction) {
previousILLSignalHandler = old_action_ill.sa_sigaction;
}

struct sigaction old_action_pipe;
sigaction(SIGPIPE, NULL, &old_action_pipe);
if (old_action_pipe.sa_sigaction) {
previousPIPESignalHandler = old_action_pipe.sa_sigaction;
}

struct sigaction old_action_segv;
sigaction(SIGSEGV, NULL, &old_action_segv);
if (old_action_segv.sa_sigaction) {
previousSEGVSignalHandler = old_action_segv.sa_sigaction;
}

struct sigaction old_action_sys;
sigaction(SIGSYS, NULL, &old_action_sys);
if (old_action_sys.sa_sigaction) {
previousSYSSignalHandler = old_action_sys.sa_sigaction;
}

struct sigaction old_action_trap;
sigaction(SIGTRAP, NULL, &old_action_trap);
if (old_action_trap.sa_sigaction) {
previousTRAPSignalHandler = old_action_trap.sa_sigaction;
}
}

+ (void)signalRegister {
NWSignalRegister(SIGABRT);
NWSignalRegister(SIGBUS);
NWSignalRegister(SIGFPE);
NWSignalRegister(SIGILL);
NWSignalRegister(SIGPIPE);
NWSignalRegister(SIGSEGV);
NWSignalRegister(SIGSYS);
NWSignalRegister(SIGTRAP);
}

#pragma mark - Private

#pragma mark Register Signal

static void NWSignalRegister(int signal) {
struct sigaction action;
action.sa_sigaction = NWSignalHandler;
action.sa_flags = SA_NODEFER | SA_SIGINFO;
sigemptyset(&action.sa_mask);
sigaction(signal, &action, 0);
}

#pragma mark SignalCrash Handler

static void NWSignalHandler(int signal, siginfo_t* info, void* context) {
NSMutableString *mstr = [[NSMutableString alloc] init];
[mstr appendString:@"Signal Exception:\n"];
[mstr appendString:[NSString stringWithFormat:@"Signal %@ was raised.\n", signalName(signal)]];
[mstr appendString:@"Call Stack:\n"];

// 这里过滤掉第一行日志
// 因为注册了信号崩溃回调方法,系统会来调用,将记录在调用堆栈上,因此此行日志需要过滤掉
for (NSUInteger index = 1; index < NSThread.callStackSymbols.count; index++) {
NSString *str = [NSThread.callStackSymbols objectAtIndex:index];
[mstr appendString:[str stringByAppendingString:@"\n"]];
}

[mstr appendString:@"threadInfo:\n"];
[mstr appendString:[[NSThread currentThread] description]];

// 保存崩溃日志到沙盒cache目录
[NWCrashTool saveCrashLog:[NSString stringWithString:mstr] fileName:@"Crash(Signal)"];

NWClearSignalRegister();

// 调用之前崩溃的回调函数
// 在自己handler处理完后自觉把别人的handler注册回去,规规矩矩的传递
previousSignalHandler(signal, info, context);

kill(getpid(), SIGKILL);
}

#pragma mark Signal To Name

static NSString *signalName(int signal) {
NSString *signalName;
switch (signal) {
case SIGABRT:
signalName = @"SIGABRT";
break;
case SIGBUS:
signalName = @"SIGBUS";
break;
case SIGFPE:
signalName = @"SIGFPE";
break;
case SIGILL:
signalName = @"SIGILL";
break;
case SIGPIPE:
signalName = @"SIGPIPE";
break;
case SIGSEGV:
signalName = @"SIGSEGV";
break;
case SIGSYS:
signalName = @"SIGSYS";
break;
case SIGTRAP:
signalName = @"SIGTRAP";
break;
default:
break;
}
return signalName;
}

#pragma mark Previous Signal

static void previousSignalHandler(int signal, siginfo_t *info, void *context) {
SignalHandler previousSignalHandler = NULL;
switch (signal) {
case SIGABRT:
previousSignalHandler = previousABRTSignalHandler;
break;
case SIGBUS:
previousSignalHandler = previousBUSSignalHandler;
break;
case SIGFPE:
previousSignalHandler = previousFPESignalHandler;
break;
case SIGILL:
previousSignalHandler = previousILLSignalHandler;
break;
case SIGPIPE:
previousSignalHandler = previousPIPESignalHandler;
break;
case SIGSEGV:
previousSignalHandler = previousSEGVSignalHandler;
break;
case SIGSYS:
previousSignalHandler = previousSYSSignalHandler;
break;
case SIGTRAP:
previousSignalHandler = previousTRAPSignalHandler;
break;
default:
break;
}

if (previousSignalHandler) {
previousSignalHandler(signal, info, context);
}
}

#pragma mark Clear

static void NWClearSignalRegister() {
signal(SIGSEGV,SIG_DFL);
signal(SIGFPE,SIG_DFL);
signal(SIGBUS,SIG_DFL);
signal(SIGTRAP,SIG_DFL);
signal(SIGABRT,SIG_DFL);
signal(SIGILL,SIG_DFL);
signal(SIGPIPE,SIG_DFL);
signal(SIGSYS,SIG_DFL);
}

@end

3.4.3 Mach异常+Unix信号方式

Github上多数开源项目都采用的这种方式。

为什么不能只监听 Mach Exception?网上所说的原因都是因为 EXC_CRASH 不能通过 Mach 监控来抓捕。即使在优选捕获Mach异常的情况下,也放弃捕获EXC_CRASH异常,而选择捕获与之对应的SIGABRT信号。

著名开源项目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.

我们仍然需要使用信号处理程序来捕获进程内的SIGABRT。内核发送一个EXC_CRASH mach异常来表示SIGABRT终止。在这种情况下,在进程中捕获Mach异常会导致不可中断等待中的进程死锁。因此,我们对SIGABRT使用BSD信号处理程序,而不注册EXC_CRASH。

四、捕获 — 应用级异常

对于应用级异常,还需要单独的特殊处理。

4.1 NSException

4.1.1 为何要实现 NSException 监听

按照我们前面所说,通过 Mach/Signal 的方式我们已经可以监听绝大部分崩溃场景了,那为何我们还要实现NSException 监听呢?

原因是:对于Objective-C异常,一般可通过try catch来捕获。未被try catch 的 NSException,会发出 killpthread_kill 信号-> Mach异常-> Unix信号(SIGABRT),但是如果通过捕获SIGABRT信号的方式,来抓取异常,那么在处理收集信息时,获取当前堆栈时获取不到,所以需要采用NSSetUncaughtExceptionHandler 单独处理。

4.1.2 常见的 NSException 及其场景

NSException.h 常见的几种异常名称:

  • NSInvalidArgumentException
    • 非法参数异常(NSInvalidArgumentException)是 Objective – C 代码最常出现的错误,所以平时在写代码的时候,需要多加注意,加强对参数的检查,避免传入非法参数导致异常,其中尤以nil参数为甚。
    • unrecognized selector send to instance.
  • NSRangeException
    • 越界异常(NSRangeException)也是比较常出现的异常。
  • NSGenericException
    • NSGenericException这个异常最容易出现在foreach操作中,在for in循环中如果修改所遍历的数组,无论你是add或remove,都会出错。“for in” 内部遍历使用了类似 Iterator进行迭代遍历,一旦元素变动,之前的元素全部被失效,所以在foreach的循环当中,最好不要去进行元素的修改动作,若需要修改,循环改为for遍历,由于内部机制不同,不会产生修改后结果失效的问题。
  • NSInternalInconsistencyException
    • 不一致导致出现的异常。比如NSDictionary当做NSMutableDictionary来使用,从他们内部的机理来说,就会产生一些错误。
  • NSFileHandleOperationException
    • 处理文件时的一些异常,最常见的还是存储空间不足的问题,比如应用频繁的保存文档,缓存资料或者处理比较大的数据。所以在文件处理里,需要考虑到手机存储空间的问题。
  • NSMallocException
    • 内存分配异常。这也是内存不足的问题,无法分配足够的内存空间。
  • 非主线程刷新UI
  • KVO引起的崩溃:没有正确的添加、移除观察者。

4.1.3 NSException类示例

以iOS开发常见的 NSException 为例,你是否见过崩溃在main函数的crash日志,但是函数栈里面没有你的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Thread 0 Crashed:
0 libsystem_kernel.dylib 0x3a61757c __semwait_signal_nocancel + 0x18
1 libsystem_c.dylib 0x3a592a7c nanosleep$NOCANCEL + 0xa0
2 libsystem_c.dylib 0x3a5adede usleep$NOCANCEL + 0x2e
3 libsystem_c.dylib 0x3a5c7fe0 abort + 0x50
4 libc++abi.dylib 0x398f6cd2 abort_message + 0x46
5 libc++abi.dylib 0x3990f6e0 default_terminate_handler() + 0xf8
6 libobjc.A.dylib 0x3a054f62 _objc_terminate() + 0xbe
7 libc++abi.dylib 0x3990d1c4 std::__terminate(void (*)()) + 0x4c
8 libc++abi.dylib 0x3990cd28 __cxa_rethrow + 0x60
9 libobjc.A.dylib 0x3a054e12 objc_exception_rethrow + 0x26
10 CoreFoundation 0x2f7d7f30 CFRunLoopRunSpecific + 0x27c
11 CoreFoundation 0x2f7d7c9e CFRunLoopRunInMode + 0x66
12 GraphicsServices 0x346dd65e GSEventRunModal + 0x86
13 UIKit 0x32124148 UIApplicationMain + 0x46c
14 XXXXXX 0x0003b1f2 main + 0x1f2
15 libdyld.dylib 0x3a561ab4 start + 0x0

可以看出是因为某个NSException导致程序Crash的,只有拿到这个NSException,获取它的reason,name,callStackSymbols信息才能确定出问题的程序位置。

1
2
3
4
5
/* NSException Class Reference */
@property(readonly, copy) NSString *name;
@property(readonly, copy) NSString *reason;
@property(readonly, copy) NSArray *callStackSymbols;
@property(readonly, copy) NSArray *callStackReturnAddresses;

4.1.4 NSException的捕获

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
// 记录之前的崩溃回调函数
static NSUncaughtExceptionHandler *previousUncaughtExceptionHandler = NULL;

static void my_uncaught_exception_handler (NSException *exception) {
// 异常的堆栈信息
NSArray * stackArray = [exception callStackSymbols];
// 出现异常的原因
NSString * reason = [exception reason];
// 异常名称
NSString * name = [exception name];

NSString * exceptionInfo = [NSString stringWithFormat:@"========uncaughtException异常错误报告========\nname:%@\nreason:\n%@\ncallStackSymbols:\n%@", name, reason, [stackArray componentsJoinedByString:@"\n"]];

// 保存崩溃日志到沙盒cache目录
[NWCrashTool saveCrashLog:exceptionInfo fileName:@"Crash(Uncaught)"];

//在自己handler处理完后自觉把别人的handler注册回去,规规矩矩的传递
if (previousUncaughtExceptionHandler) {
previousUncaughtExceptionHandler(exception);
}

// 杀掉程序,这样可以防止同时抛出的SIGABRT被SignalException捕获
kill(getpid(), SIGKILL);
}

+ (void)registerHandler {
//将先前别人注册的handler取出并备份
previousUncaughtExceptionHandler = NSGetUncaughtExceptionHandler();

NSSetUncaughtExceptionHandler(&my_uncaught_exception_handler);
}

将拿到的NSException细节写入Crash日志,精准的定位出错程序位置:

1
2
3
4
5
6
7
8
9
10
Application Specific Information:
*** Terminating app due to uncaught exception 'NSUnknownKeyException', reason: '[ setValue:forUndefinedKey:]: this class is not key value coding-compliant for the key key.'
Last Exception Backtrace:
0 CoreFoundation 0x2f8a3f7e __exceptionPreprocess + 0x7e
1 libobjc.A.dylib 0x3a054cc objc_exception_throw + 0x22
2 CoreFoundation 0x2f8a3c94 -[NSException raise] + 0x4
3 Foundation 0x301e8f1e -[NSObject(NSKeyValueCoding) setValue:forKey:] + 0xc6
4 DemoCrash 0x00085306 -[ViewController crashMethod] + 0x6e
5 DemoCrash 0x00084ecc main + 0x1cc
6 DemoCrash 0x00084cf8 start + 0x24

4.1.5 注意点

Q: 是不是收到了大量crash在main函数却没有NSException信息的日志,就代表自己集成的Crash日志收集服务没有注册NSUncaughtExceptionHandler呢?

不一定,还有另外一种可能,就是被同时存在的其他Crash日志收集服务给坑了。

Q: 未设置NSSetUncaughtExceptionHandler的NSException最后会转成Unix信号吗?

无论设置NSSetUncaughtExceptionHandler与否,只要未被try catch,最终都会被转成Unix信号,只不过设置了无法在其ExceptionHandler中无法获得最终发送的Unix信号类型

3.2 ObjC野指针类的Crash

收集Crash日志这个步骤没有问题的情况下,还是有很多全系统栈的日志的情况,没有自己一行代码,分析起来十分棘手,ObjC野指针类的Crash正是如此,这里推荐几篇好文章:

除此之外,在Crash日志中补充记录一些额外信息可以辅助定位,如切面标记线程出处、队列出处,记录用户操作轨迹等等……

4.3 C++ exceptions

实质上C++异常也可以通过 Mach 异常的方式处理。只是在细节处理上仍多有区别。

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

在OSX中,会通过对话框展示异常给用户,但在iOS中,只是重新抛出异常。系统在捕捉到C++异常后,如果能够将此C++异常转换为OC异常,则抛出OC异常处理机制;如果不能转换,则会立刻调用__cxa_throw重新抛出异常。

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

1
2
3
4
5
6
7
8
9
10
11
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.3.2 如何捕捉C++异常

C++ exceptions使用系统封装好的函数std::set_terminate(CPPExceptionTerminate)来设置回调。逻辑类似NSException的处理。

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

    1
    g_originalTerminateHandler = std::set_terminate(CPPExceptionTerminate);
  2. 重写 __cxa_throw。在异常发生时,会先进入此重写函数,应该先获取调用堆栈并存储;再调用原始的__cxa_throw 函数。

    1
    void __cxa_throw(void* thrown_exception, std::type_info* tinfo, void (*dest)(void*))
  3. 异常处理函数。__cxa_throw往后执行,进入set_terminate设置的异常处理函数。判断如果检测是OC异常,则什么也不做,让OC异常机制处理;否则获取异常信息。

4.4 Swift exception

Swift 下的 exception 的处理过程和 NSException 差别很大。

  • swift通常都是通过对应的signal来捕获crash。对于swift的崩溃捕获,Apple的文档中有描述说需要通过SIGTRAP信号捕获强转失败,及非可选的nil值导致的崩溃.具体描述如下:

    Trace Trap[EXC_BREAKPOINT // SIGTRAP]

    类似于异常退出,此异常旨在使附加的调试器有机会在其执行中的特定点中断进程。您可以使用该__builtin_trap()函数从您自己的代码触发此异常。如果没有附加调试器,则该过程将终止并生成崩溃报告。

    较低级的库(例如,libdispatch)会在遇到致命错误时捕获进程。有关错误的其他信息可以在崩溃报告的“ 附加诊断信息”部分或设备的控制台中找到。

    如果在运行时遇到意外情况,Swift代码将以此异常类型终止,例如:

    1. 具有nil值的非可选类型
    2. 一个失败的强制类型转换
  • 对于swift还有一种崩溃需要捕获(Intel处理器),为保险起见,也需要将信号SIGILL进行注册,Apple同样对其中做了描述

    Illegal Instruction[EXC_BAD_INSTRUCTION // SIGILL]

    该过程尝试执行非法或未定义的指令。该过程可能尝试通过错误配置的函数指针跳转到无效地址。

    在Intel处理器上,ud2操作码引起EXC_BAD_INSTRUCTION异常,但通常用于进程调试目的。如果在运行时遇到意外情况,Intel处理器上的Swift代码将以此异常类型终止。有关详细信息,请参阅Trace Trap。

五、Crash的采集

4.1 采集工具

崩溃日志收集服务,成熟的开源项目很多,如 KSCrashPLCrashReporterCrashKit 等。追求方便省心,对于保密性要求不高的程序来说,也可以选择各种一条龙Crash统计产品,如 CrashlyticsHockeyapp友盟Bugly 等等。

4.1.1 官方CrashReporter

iOS有自己的CrashReporter机制。在真机上产生的crash,在以下两个地方可以找到:

  • Xcode-Window-Devices - View Device Logs中可以看到crash文件。
  • 通过iTunes Connect(Manage Your Applications - View Details - Crash Reports)获取用户的crash日志。需要用户在设置-诊断与用量中允许将崩溃信息发送给开发者。然后在也可以在Xcode的Window - Organizer中可以看到对应的crash信息。(需要在Xcode中登录所属的开发者账号)

关于各个字段的含义,以下仅供参考:

字段 含义
Incident Identifier 当前crash的 id,可以区分不同的crash事件
CrashReporter Key 当前设备的id,可以判断crash在某一设备上出现的频率
Hardware Model 设备型号
Process 当前应用的名称,后面中括号中为当前的应用在系统中的进程id
Path 当前应用在设备中的路径
Identifier bundle id
Version 应用版本号
Code Type 还不清楚
Date/Time crash事件 时间(后面跟的应该是时区)
OS Version 当前系统版本
Exception Type 异常类型
Exception Codes 异常出错的代码(常见代码有以下几种)
0x8badf00d:Watchdog超时,意为“ate bad food”。
0xdeadfa11:用户强制退出,意为“dead fall”。
0xbaaaaaad:用户按住Home键和音量键,获取当前内存状态,不代表崩溃。
0xbad22222:VoIP应用(因为太频繁?)被iOS干掉。
0xc00010ff:因为太烫了被干掉,意为“cool off”。
0xdead10cc:因为在后台时仍然占据系统资源(比如通讯录)被干掉,意为“dead lock”。
Triggered by Thread 在某一个线程出了问题导致crash,Thread 0 为主线程、其它的都为子线程
Last Exception Backtrace 最后异常回溯,一般根据这个代码就能找到crash的具体问题

4.1.2 KSCrash

KSCrash 是 iOS 上一个知名的 crash 收集框架。包括腾讯刚开源的 APM 框架 Matrix,其中 crash 收集部分也是直接使用的 KSCrash。

4.1.3 PLCrashReporter

微软家的,Github

4.2 多个Crash日志收集服务共存的坑

是的,在自己的程序里集成多个Crash日志收集服务实在不是明智之举。通常情况下,第三方功能性SDK都会集成一个Crash收集服务,以及时发现自己SDK的问题。当各家的服务都以保证自己的Crash统计正确完整为目的时,难免出现时序手脚,强行覆盖等等的恶意竞争,总会有人默默被坑。

4.2.1 拒绝传递 UncaughtExceptionHandler

如果同时有多方通过NSSetUncaughtExceptionHandler注册异常处理程序,和平的作法是:后注册者通过NSGetUncaughtExceptionHandler将先前别人注册的handler取出并备份,在自己handler处理完后自觉把别人的handler注册回去,规规矩矩的传递。不传递强行覆盖的后果是,在其之前注册过的日志收集服务写出的Crash日志就会因为取不到NSException而丢失Last Exception Backtrace等信息。(P.S. iOS系统自带的Crash Reporter不受影响)

在开发测试阶段,可以利用 fishhook 框架去hookNSSetUncaughtExceptionHandler方法,这样就可以清晰的看到handler的传递流程断在哪里,快速定位污染环境者。不推荐利用调试器添加符号断点来检查,原因是一些Crash收集框架在调试状态下是不工作的。

检测代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static NSUncaughtExceptionHandler *g_vaildUncaughtExceptionHandler;
static void (*ori_NSSetUncaughtExceptionHandler)( NSUncaughtExceptionHandler * );

void my_NSSetUncaughtExceptionHandler( NSUncaughtExceptionHandler * handler)
{
g_vaildUncaughtExceptionHandler = NSGetUncaughtExceptionHandler();
if (g_vaildUncaughtExceptionHandler != NULL) {
NSLog(@"UncaughtExceptionHandler=%p",g_vaildUncaughtExceptionHandler);
}

ori_NSSetUncaughtExceptionHandler(handler);
NSLog(@"%@",[NSThread callStackSymbols]);

g_vaildUncaughtExceptionHandler = NSGetUncaughtExceptionHandler();
NSLog(@"UncaughtExceptionHandler=%p",g_vaildUncaughtExceptionHandler);
}

对于越狱插件注入应用进程内部,恶意覆盖NSSetUncaughtExceptionHandler的情况,应用程序本身处理起来比较弱势,因为越狱环境下操作时序的玩法比较多权利比较大。

4.2.2 Mach异常端口换出+信号处理Handler覆盖

和NSSetUncaughtExceptionHandler的情况类似,设置过的Mach异常端口和信号处理程序也有可能被干掉,导致无法捕获Crash事件。

4.2.3 影响系统崩溃日志准确性

应用层参与收集Crash日志的服务方越多,越有可能影响iOS系统自带的Crash Reporter。由于进程内线程数组的变动,可能会导致系统日志中线程的Crashed 标签标记错位,可以搜索abort()等关键字来复查系统日志的准确性。

若程序因NSException而Crash,系统日志中的Last Exception Backtrace信息是完整准确的,不会受应用层的胡来而影响,可作为排查问题的参考线索。

六、Crash的符号化

所谓的符号解析就是就是将崩溃日志中的地址映射成为可读的符号和源文件中的行号,方便开发者定位和修复问题。

6.1 异常信息的查看

异常信息有三种类型

  1. 已标记错误位置的,这种信息很明确了不用解析,如下:

    1
    0x0000000109708aeb -[ViewController buttonClick:] + 43
  2. 有模块地址的情况,如下:

    1
    2
    # 二进制库名(test),调用方法的地址(0x00000001018157dc),模块地址(0x100064000)+偏移地址(24844252)
    test 0x00000001018157dc 0x100064000 + 24844252
  3. 无模块地址的情况

    1
    2
    # 方法的地址-偏移地址,得到的就是模块地址  0x00000001018157dc - 24844252 = 0x100064000
    test 0x00000001018157dc test + 24844252

6.2 系统原生符号解析工具

一般Xcode项目每次 release 编译后, 都会产生一个新的.dSYM文件和.app文件,这两者有一个共同的UUID.

  • .dSYM文件是一个符号表文件, 这里面包含了一个16进制的保存函数地址映射信息的中转文件。
  • 获取:xcode -> window -> organizer->右键你的应用 show finder->右键.xcarchive 显示包内容->dSYMs->test.app.dYSM
  • 符号表是内存地址与函数名,文件名,行号的映射表。 符号表元素如下所示:
1
<起始地址> <结束地址> <函数> [<文件名:行号>]

6.2.1 symbolicatecrash

Xcode 提供的 symbolicatecrash。该命令位于:/Applications/Xcode.app/Contents/SharedFrameworks/DVTFoundation.framework/Versions/A/Resources/symbolicatecrash,是一个perl 脚本,里面整合了逐步解析的操作(也可以将命令拷贝出来,直接进行调用)。

用法:symbolicatecrash log.crash -d xxx.app.dSYM

优点:能非常方便的符号化整份 crash 日志。

缺点:

  1. 耗时比较久。
  2. 粒度比较粗,无法符号化特定的某一行。

6.2.2 atos

atos命令来符号化某个特定模块加载地址:

1
2
3
atos [-arch 架构名] [-o 符号表] [-l 模块地址] [方法地址]
# 比如
atos -o xxx.app.dSYM/Contents/Resources/DWARF/xxx -arch arm64/armv7 -l loadAddress runtimeAddress

优点:速度快,可以符号化特定的某一行,方便上层做缓存。

上面的这两个工具都有两个最大的缺陷就是:

  1. 都仅仅是单机的工具,无法作为在线服务提供。
  2. 必须依赖 macOS 系统,因 为字节服务端基建全部基于Linux,导致无法复用集团各种平台和框架,这就带来了非常高的机器成本,部署成本和运维成本。

七、Crash的分析

  • 当我们拿到crash日志时,应首先从crash Typecrash thread 快速定位到造成crash的代码段。之所以首先要看这两个,是因为type能大致知道crash的类型,如果是OC类型的异常,那基本上处理起来比较简单,如果是mach signals类型的,通过查看造成crash的线程堆栈,也能快速定位到方法,举个实际项目中的例子:

线上有个偶现的crash,crash Type为SIGSEGV,且thread不定,子线程,主线程都会存在,但是代码段相同,由于SIGSEGV是野指针异常类型,且由于在多线程中都会触发,说明问题基本上是多线程的对象读写安全问题

八、参考链接

Author:Tenloy

原文链接:https://tenloy.github.io/2021/08/01/Crash-Monitor.html

发表日期:2021.08.01 , 4:24 PM

更新日期:2024.04.26 , 2:21 PM

版权声明:本文采用Crative Commons 4.0 许可协议进行许可

CATALOG
  1. 一、Crash概述
    1. 1.1 常见Crash的类型
    2. 1.2 处理流程
  2. 二、Darwin操作系统简单介绍
  3. 三、捕获 — Mach异常与Unix信号异常
    1. 3.1 Mach kernel exceptions
      1. 3.1.1 Mach异常的产生
      2. 3.1.2 Mach异常的捕获原理
    2. 3.2 Unix/BSD Signals
      1. 3.2.1 信号的产生
      2. 3.2.2 信号的捕获原理
        1. 1. signal函数
        2. 2. sigaction函数
      3. 3.2.3 系统中都有哪些信号
    3. 3.3 常见的Mach异常与Unix信号
    4. 3.4 Crash的收集【实现】
      1. 3.4.1 Mach异常方式
      2. 3.4.2 Unix信号方式
      3. 3.4.3 Mach异常+Unix信号方式
  4. 四、捕获 — 应用级异常
    1. 4.1 NSException
      1. 4.1.1 为何要实现 NSException 监听
      2. 4.1.2 常见的 NSException 及其场景
      3. 4.1.3 NSException类示例
      4. 4.1.4 NSException的捕获
      5. 4.1.5 注意点
    2. 3.2 ObjC野指针类的Crash
    3. 4.3 C++ exceptions
      1. 4.3.1 为什么要捕捉 C++异常
      2. 4.3.2 如何捕捉C++异常
    4. 4.4 Swift exception
  5. 五、Crash的采集
    1. 4.1 采集工具
      1. 4.1.1 官方CrashReporter
      2. 4.1.2 KSCrash
      3. 4.1.3 PLCrashReporter
    2. 4.2 多个Crash日志收集服务共存的坑
      1. 4.2.1 拒绝传递 UncaughtExceptionHandler
      2. 4.2.2 Mach异常端口换出+信号处理Handler覆盖
      3. 4.2.3 影响系统崩溃日志准确性
  6. 六、Crash的符号化
    1. 6.1 异常信息的查看
    2. 6.2 系统原生符号解析工具
      1. 6.2.1 symbolicatecrash
      2. 6.2.2 atos
  7. 七、Crash的分析
  8. 八、参考链接