崩溃捕获系统的原理(一)异常信号

之前在头条搞了端监控,比较有意思的部分是Crash捕获,持续做了1年。开源的Crash捕获框架KSCrash,PLCrashReporter 都值得一看。当然也存在各自的问题。这边博客主要简单讲一下Mach系统的异常处理模型。

Crash捕获

下面是Crash捕获处理系统几个核心的关键领域知识。感兴趣的可以自行了解一下,每一部分都值得研究,弄明白了 系统的异常处理机制,Dynamic Image, Thread Context,可以加深对iOS底层工作原理的理解。

  • UNIX signals 信号
  • Mach Exceptions Mach异常
  • Basic Thread details 线程现场信息
  • Binary image information 动态库信息
  • Frame pointer-based stack traces 基于fp的callstack回溯
  • Compact Unwind support 辅助callstack回溯的 Compact Unwind信息

本片主要介绍的是异常和信号的处理。
具体代码怎么抓 Mach / Signal Exception 可以看看开源的 KSCrash 和 PLCrashReporter.

异常处理流程

  • Mach异常模型
  • Mach异常和Signal的转换
  • 软件异常
  • 硬件异常触发流程
  • 系统是怎么抓的crash? 为什么都是 EXC_CRASH 类型?

首先要明确几个概念及其之间的联系:硬件异常, 软件异常,mach异常, Signal异常。这四种异常概念,自底向上构建了iOS系统的异常处理模型。

其之间的关系如下

Mach异常模型

基于 mach message 机制, 可以

  • 通过注册端口监听异常消息
    • 可以为 host, task, thread 注册异常处理端口。(例如:小程序注册thread 级别的处理,然后自己处理异常。)
  • 发送异常消息

mach异常信号的来源,可以看到通过两种途径:

  • 硬件级别的触发异常
  • proc退出时会触发异常(EXC_CRASH)。

Mach异常和Signal的转换

Mach异常如果不处理,默认会转化为Signal异常。
内核注册了host-level的exception handler,负责将mach异常转换为对应的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
/* Called with kernel funnel held */
void
bsdinit_task(void)
{
proc_t p = current_proc();
struct uthread *ut;
thread_t thread;

process_name("init", p);

ux_handler_init();

thread = current_thread();
(void) host_set_exception_ports(host_priv_self(),
EXC_MASK_ALL & ~(EXC_MASK_RPC_ALERT),//pilotfish (shark) needs this port
(mach_port_t) ux_exception_port,
EXCEPTION_DEFAULT| MACH_EXCEPTION_CODES,
0);

ut = (uthread_t)get_bsdthread_info(thread);

bsd_init_task = get_threadtask(thread);
init_task_failure_data[0] = 0;

#if CONFIG_MACF
mac_cred_label_associate_user(p->p_ucred);
mac_task_label_update_cred (p->p_ucred, (struct task *) p->task);
#endif
load_init_program(p);
lock_trace = 1;
}

硬件异常触发流程

硬件异常会转化为Mach异常

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void
i386_exception(
int exc,
mach_exception_code_t code,
mach_exception_subcode_t subcode)
{
mach_exception_data_type_t codes[EXCEPTION_CODE_MAX];

DEBUG_KPRINT_SYSCALL_MACH("i386_exception: exc=%d code=0x%llx subcode=0x%llx\n",
exc, code, subcode);
codes[0] = code; /* new exception interface */
codes[1] = subcode;
exception_triage(exc, codes, 2);
/*NOTREACHED*/
}
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
void
exception_triage(
exception_type_t exception,
mach_exception_data_t code,
mach_msg_type_number_t codeCnt)
{
thread_t thread;
task_t task;
host_priv_t host_priv;
lck_mtx_t *mutex;
kern_return_t kr;

assert(exception != EXC_RPC_ALERT);

thread = current_thread();

/*
* Try to raise the exception at the activation level.
*/
mutex = &thread->mutex;
kr = exception_deliver(thread, exception, code, codeCnt, thread->exc_actions, mutex);
if (kr == KERN_SUCCESS || kr == MACH_RCV_PORT_DIED)
goto out;

/*
* Maybe the task level will handle it.
*/
task = current_task();
mutex = &task->lock;
kr = exception_deliver(thread, exception, code, codeCnt, task->exc_actions, mutex);
if (kr == KERN_SUCCESS || kr == MACH_RCV_PORT_DIED)
goto out;

/*
* How about at the host level?
*/
host_priv = host_priv_self();
mutex = &host_priv->lock;
kr = exception_deliver(thread, exception, code, codeCnt, host_priv->exc_actions, mutex);
if (kr == KERN_SUCCESS || kr == MACH_RCV_PORT_DIED)
goto out;

/*
* Nobody handled it, terminate the task.
*/

(void) task_terminate(task);

out:
if ((exception != EXC_CRASH) && (exception != EXC_RESOURCE) &&
(exception != EXC_GUARD))
thread_exception_return();
return;
}

软件异常

通过kill() 直接抛出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
37
38
39
40
41
42
43
__private_extern__ void
__abort()
{
struct sigaction act;

if (!CRGetCrashLogMessage())
CRSetCrashLogMessage("__abort() called");
act.sa_handler = SIG_DFL;
act.sa_flags = 0;
sigfillset(&act.sa_mask);
(void)_sigaction(SIGABRT, &act, NULL);
sigdelset(&act.sa_mask, SIGABRT);

/* <rdar://problem/7397932> abort() should call pthread_kill to deliver a signal to the aborting thread
* This helps gdb focus on the thread calling abort()
*/
if (__is_threaded) {
/* Block all signals on all other threads */
sigset_t fullmask;
sigfillset(&fullmask);
(void)_sigprocmask(SIG_SETMASK, &fullmask, NULL);

/* <rdar://problem/8400096> Set the workqueue killable */
__pthread_workqueue_setkill(1);

(void)pthread_sigmask(SIG_SETMASK, &act.sa_mask, NULL);
(void)pthread_kill(pthread_self(), SIGABRT);
} else {
(void)_sigprocmask(SIG_SETMASK, &act.sa_mask, NULL);
(void)kill(getpid(), SIGABRT);
}
usleep(TIMEOUT); /* give time for signal to happen */

/* If for some reason SIGABRT was not delivered, we exit using __builtin_trap
* which generates an illegal instruction on i386: <rdar://problem/8400958>
* and SIGTRAP on arm.
*/
sigfillset(&act.sa_mask);
sigdelset(&act.sa_mask, SIGILL);
sigdelset(&act.sa_mask, SIGTRAP);
(void)_sigprocmask(SIG_SETMASK, &act.sa_mask, NULL);
__builtin_trap();
}

系统是怎么抓的crash? 为什么都是 EXC_CRASH 类型?

系统通过launchd监听了EXC_CRASH。
EXC_CRASH 是一种特殊类型,囊括硬件和软件异常,什么都能抓。但是需要out-of-process 处理。(信号发出的时候,进程已经跪了)
看进程退出逻辑,EXC_CRASH 基本上会囊括所有的崩溃类型。软硬件异常都会转化为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
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
void
proc_prepareexit(proc_t p, int rv, boolean_t perf_notify)
{
mach_exception_data_type_t code, subcode;
struct uthread *ut;
thread_t self = current_thread();
ut = get_bsdthread_info(self);

/* If a core should be generated, notify crash reporter */
if (hassigprop(WTERMSIG(rv), SA_CORE) || ((p->p_csflags & CS_KILLED) != 0)) {
/*
* Workaround for processes checking up on PT_DENY_ATTACH:
* should be backed out post-Leopard (details in 5431025).
*/
if ((SIGSEGV == WTERMSIG(rv)) &&
(p->p_pptr->p_lflag & P_LNOATTACH)) {
goto skipcheck;
}

/*
* Crash Reporter looks for the signal value, original exception
* type, and low 20 bits of the original code in code[0]
* (8, 4, and 20 bits respectively). code[1] is unmodified.
*/
code = ((WTERMSIG(rv) & 0xff) << 24) |
((ut->uu_exception & 0x0f) << 20) |
((int)ut->uu_code & 0xfffff);
subcode = ut->uu_subcode;
(void) task_exception_notify(EXC_CRASH, code, subcode);
}

skipcheck:
/* Notify the perf server? */
if (perf_notify) {
(void)sys_perf_notify(self, p->p_pid);
}

/*
* Remove proc from allproc queue and from pidhash chain.
* Need to do this before we do anything that can block.
* Not doing causes things like mount() find this on allproc
* in partially cleaned state.
*/

proc_list_lock();

LIST_REMOVE(p, p_list);
LIST_INSERT_HEAD(&zombproc, p, p_list); /* Place onto zombproc. */
/* will not be visible via proc_find */
p->p_listflag |= P_LIST_EXITED;

proc_list_unlock();


#ifdef PGINPROF
vmsizmon();
#endif
/*
* If parent is waiting for us to exit or exec,
* P_LPPWAIT is set; we will wakeup the parent below.
*/
proc_lock(p);
p->p_lflag &= ~(P_LTRACED | P_LPPWAIT);
p->p_sigignore = ~(sigcantmask);
ut->uu_siglist = 0;
proc_unlock(p);
}

DWARF和符号化

DWARF 格式简介

DWARF: 它是可执行程序与源代码关系的一个紧凑的表示.

大多数现代编程语言是块结构的:每个实体(例如,一个类定义或一个函数)被包含在另一个实体中。在一个C程序里,每个文件可能包含多个数据定义、多个变量定义,及多个函数。DWARF遵循这个模型,它也是块结构的。在DWARF里基本的描述项是调试信息项(DebuggingInformation Entry——DIE)。一个DIE有一个标签,它指明了这个DIE描述什么及一个填入了细节并进一步描述该项的属性列表。一个DIE(除了最顶层的)被一个父DIE包含(或者说拥有),并可能有兄弟DIE或子DIE。属性可能包含各种值:常量(比如一个函数名),变量(比如一个函数的起始地址),或对另一个DIE的引用(比如一个函数的返回值类型)。

利用DWARF符号化

符号化地址,我们期望的结果是,获取地址对应的函数名称和调用行数等信息。DWARF调试格式中携带了大量信息,当然包括函数名称和地址。因此如果我们提供的调用地址能在函数中找到信息,优先使用DWARF中的符号信息进行符号化。

在 DWARF 中,这些信息主要由 DW_AT_subprogram 和 line table 承载。
我们只需要根据地址在 DWARF 文件中找到对应的 信息。

函数名称

DW_AT_Subprogram 中可以获取到的信息

很容易看到,Dwarf调试信息中精心设计了 函数信息的具体表示,我们可以获取名称和类型等重要的信息。

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

1$: DW_TAG_unspecified_type
DW_AT_name("void")
...
2$ DW_TAG_base_type
DW_AT_name("int")
...
3$: DW_TAG_class_type
DW_AT_name("A")
...
4$: DW_TAG_pointer_type
DW_AT_type(reference to 3$)
...
5$: DW_TAG_const_type
DW_AT_type(reference to 3$)
...
6$: DW_TAG_pointer_type
DW_AT_type(reference to 5$)
...
7$: DW_TAG_subprogram
DW_AT_name("func1")
DW_AT_type(reference to 1$)
DW_AT_object_pointer(reference to 8$)
! References a formal parameter in this member function
...
8$: DW_TAG_formal_parameter
DW_AT_artificial(true)
DW_AT_name("this")
DW_AT_type(reference to 4$)
! Makes type of 'this' as 'A*' =>
! func1 has not been marked const or volatile
DW_AT_location ...
...
9$: DW_TAG_formal_parameter
DW_AT_name(x1)
DW_AT_type(reference to 2$)
...
10$: DW_TAG_subprogram
DW_AT_name("func2")
DW_AT_type(reference to 1$)
DW_AT_object_pointer(reference to 11$)
! References a formal parameter in this member function
...
11$: DW_TAG_formal_parameter
DW_AT_artificial(true)
DW_AT_name("this")
DW_AT_type(reference to 6$)
! Makes type of 'this' as 'A const*' =>
! func2 marked as const
DW_AT_location ...
...
12$: DW_TAG_subprogram
DW_AT_name("func3")
DW_AT_type(reference to 1$)
...
! No 'this' formal parameter => func3 is static
13$: DW_TAG_formal_parameter
DW_AT_name(x3)
DW_AT_type(reference to 2$)
...

内联函数处理

对于内联函数,编译器会将其在多处展开。可以通过 DW_AT_inline 标志判断其展开的状态和判断其是否是”abstract instance entry”.
基于存储空间的考虑,把 某个内联函数构造成 “abstract instance entry” 提供名称等信息,可以避免多个具体内联函数展开的重复空间消耗。

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
       ! Abstract instance for OUTER
!
OUTER.AI.1.1:
DW_TAG_subprogram
DW_AT_name("OUTER")
DW_AT_inline(DW_INL_declared_inlined)
! No low/high PCs
OUTER.AI.1.2:
DW_TAG_formal_parameter
DW_AT_name("OUTER_FORMAL")
DW_AT_type(reference to integer)
! No location
OUTER.AI.1.3:
DW_TAG_variable
DW_AT_name("OUTER_LOCAL")
DW_AT_type(reference to integer)
! No location
!
! Abstract instance for INNER
!
INNER.AI.1.1:
DW_TAG_subprogram
DW_AT_name("INNER")
DW_AT_inline(DW_INL_declared_inlined)
! No low/high PCs
INNER.AI.1.2: DW_TAG_formal_parameter
DW_AT_name("INNER_FORMAL")
DW_AT_type(reference to integer)
! No location
INNER.AI.1.3: DW_TAG_variable
DW_AT_name("INNER_LOCAL")
DW_AT_type(reference to integer)
! No location
...
0

! No DW_TAG_inlined_subroutine (concrete instance)
! for INNER corresponding to calls of INNER

...
0

Figure 66. Inlining example #1: abstract instance

concrete instance 表示编译时,某处内联函数的展开。它最主要的信息就是 DW_AT_low_pc,DW_AT_high_pc 通过pc地址信息,我们可以将函数调用地址定位到具体的DWARF debug entry。
由于存在 “abstract instance”,”concrete instance” 的某些属性会被省略,因此我们需要通过 DW_AT_abstract_origin 属性去找到对应的 abstract instance entry,获取被省略掉的信息。

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
        ! Concrete instance for call "OUTER(7)"
!
OUTER.CI.1.1:
DW_TAG_inlined_subroutine
! No name
DW_AT_abstract_origin(reference to OUTER.AI.1.1)
DW_AT_low_pc(...)
DW_AT_high_pc(...)
OUTER.CI.1.2:
DW_TAG_formal_parameter
! No name
DW_AT_abstract_origin(reference to OUTER.AI.1.2)
DW_AT_const_value(7)
OUTER.CI.1.3:
DW_TAG_variable
! No name
DW_AT_abstract_origin(reference to OUTER.AI.1.3)
DW_AT_location(...)
!
! No DW_TAG_subprogram (abstract instance) for INNER
!
! Concrete instance for call INNER(OUTER_LOCAL)
!
INNER.CI.1.1:
DW_TAG_inlined_subroutine
! No name
DW_AT_abstract_origin(reference to INNER.AI.1.1)
DW_AT_low_pc(...)
DW_AT_high_pc(...)
DW_AT_static_link(...)
INNER.CI.1.2: DW_TAG_formal_parameter
! No name
DW_AT_abstract_origin(reference to INNER.AI.1.2)
DW_AT_location(...)
INNER.CI.1.3: DW_TAG_variable
! No name
DW_AT_abstract_origin(reference to INNER.AI.1.3)
DW_AT_location(...)
...
0

! Another concrete instance of INNER within OUTER
! for the call "INNER(31)"

...
0

非定义调试信息项处理

当函数定义并不在声明区域时,subprogram DIE 就会有 DW_AT_specification 属性,指向相关的函数定义DIE.
对于带有 DW_AT_specification 的调试信息项,我们需要进行特殊处理。

定位地址对应Dwarf debug info

backtrace() 我们可以拿到 image 的 loadaddress 和 address调用地址。

进而得到文件虚拟地址
fileVmAddress = address - (loadaddress - imageFileVmTextAddress)

通过文件虚拟地址后通过 .debug_aranges 查找到指定的 DWARF compile unit,进而查找到具体的DIE. 从属性中获取我们需要的信息。

.debug_aranges 是DWARF 中包含 debug info entry offset的 Section。
DWARF compile unit 是编译单元,在这里我们可以理解为文件或文件中的片段。也是DIE

行号信息

对于一个文件,我们将其编译后,其产出汇编代码类似于这种形式,在原始文件中的行对应着多条汇编指令。
对于行号的查找我们只需要 将符号的运行时地址转换为文件的虚拟地址,在该文件的行号信息中进行范围查找即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
1: int
2: main()
0x239: push pb
0x23a: mov bp,sp
3: {
4: printf(“Omit needless words\n”);
0x23c: mov ax,0xaa
0x23f: push ax
0x240: call _printf
0x243: pop cx
5: exit(0);
0x244: xor ax,ax
0x246: push ax
0x247: call _exit
0x24a: pop cx
6: }
0x24b: pop bp
0x24c: ret
7:
0x24d:

对应的编码类似于: SPECIAL(n,m) 制定了行号增加和地址增加

1
2
3
4
5
6
7
8
9
Opcode  Operand Byte Stream
--------------------------------------------------------------------------------
DW_LNS_advance_pc LEB128(0x239) 0x2, 0xb9, 0x04
SPECIAL(2, 0) 0xb
SPECIAL(2, 3) 0x38
SPECIAL(1, 8) 0x82
SPECIAL(1, 7) 0x73
DW_LNS_advance_pc LEB128(2) 0x2, 0x2
DW_LNE_end_sequence 0x0, 0x1, 0x1

利用symtab符号化

当我们代码编译的时候,可以选择产生调试信息。也可以不产生调试信息,作为降级方案,我们仍可采用symbtab所包含的信息进行符号化。
symtab段我们可以提取到具体符号的地址,对应字符串表中的偏移地址,很容易就取到对应的符号名称。

具体可以参考各种实现,这里贴一段fishhook里的代码段

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
static void rebind_symbols_for_image(struct rebindings_entry *rebindings,
const struct mach_header *header,
intptr_t slide) {
Dl_info info;
if (dladdr(header, &info) == 0) {
return;
}
printf("dli_fname:%s \ndli_sname:%s", info.dli_fname, info.dli_sname);

segment_command_t *cur_seg_cmd;
segment_command_t *linkedit_segment = NULL;
struct symtab_command* symtab_cmd = NULL;
struct dysymtab_command* dysymtab_cmd = NULL;

uintptr_t cur = (uintptr_t)header + sizeof(mach_header_t);
for (uint i = 0; i < header->ncmds; i++, cur += cur_seg_cmd->cmdsize) {
cur_seg_cmd = (segment_command_t *)cur;
if (cur_seg_cmd->cmd == LC_SEGMENT_ARCH_DEPENDENT) {
if (strcmp(cur_seg_cmd->segname, SEG_LINKEDIT) == 0) {
linkedit_segment = cur_seg_cmd;
}
} else if (cur_seg_cmd->cmd == LC_SYMTAB) {
symtab_cmd = (struct symtab_command*)cur_seg_cmd;
} else if (cur_seg_cmd->cmd == LC_DYSYMTAB) {
dysymtab_cmd = (struct dysymtab_command*)cur_seg_cmd;
}
}

if (!symtab_cmd || !dysymtab_cmd || !linkedit_segment ||
!dysymtab_cmd->nindirectsyms) {
return;
}

// Find base symbol/string table addresses
uintptr_t linkedit_base = (uintptr_t)slide + linkedit_segment->vmaddr - linkedit_segment->fileoff;
nlist_t *symtab = (nlist_t *)(linkedit_base + symtab_cmd->symoff);
char *strtab = (char *)(linkedit_base + symtab_cmd->stroff);

// Get indirect symbol table (array of uint32_t indices into symbol table)
uint32_t *indirect_symtab = (uint32_t *)(linkedit_base + dysymtab_cmd->indirectsymoff);

cur = (uintptr_t)header + sizeof(mach_header_t);
for (uint i = 0; i < header->ncmds; i++, cur += cur_seg_cmd->cmdsize) {
cur_seg_cmd = (segment_command_t *)cur;
if (cur_seg_cmd->cmd == LC_SEGMENT_ARCH_DEPENDENT) {
if (strcmp(cur_seg_cmd->segname, SEG_DATA) != 0 &&
strcmp(cur_seg_cmd->segname, SEG_DATA_CONST) != 0) {
continue;
}
for (uint j = 0; j < cur_seg_cmd->nsects; j++) {
section_t *sect =
(section_t *)(cur + sizeof(segment_command_t)) + j;
if ((sect->flags & SECTION_TYPE) == S_LAZY_SYMBOL_POINTERS) {
perform_rebinding_with_section(rebindings, sect, slide, symtab, strtab, indirect_symtab);
}
if ((sect->flags & SECTION_TYPE) == S_NON_LAZY_SYMBOL_POINTERS) {
perform_rebinding_with_section(rebindings, sect, slide, symtab, strtab, indirect_symtab);
}
}
}
}
}

优化

符号化工具提供了 将函数地址 转化为 具体名称等信息的能力。但是提供大规模的符号化能力,需要在 网络I/O,缓存,Server端进一步的优化,这些优化我会写在内网Wiki上 hhhhh…

写在最后

符号化仅是DWARF提供能力的冰山一角,作为提供给 lldb, gdb等调试器的文件格式,蕴含着大量的信息,也就是说,上传了DWARF文件,也相当于应用裸奔了…

APM Crash系统

整体架构

design

客户端

  1. Crash/OOM/ANR 抓取
  2. 上报日志格式

Crash/OOM/卡顿 抓取

这块技术有很多的开源方案,

Crash抓取有 KSCrash PLCrash…
OOM 可以参考腾讯的OOMDetector自己优化一下
ANR 可以参考很多方案,这里就不细说了

日志上报格式

上报的日志我认为最好兼容苹果的 symbolicatecrash 工具, 这就需要我们去理解 symbolicate 工作的原理,随后我们可以加上自己的内容,方便自己进行更多的功能扩展。

symbolicatecrash 的原理

  1. 解析头部信息是否符合规范
  2. 解析堆栈信息,符号表 信息
  3. 在文件中查找符号表的路径
  4. 根据堆栈信息去匹配相应符号表
  5. 使用atosl工具进行符号化
  6. 文本替换成符号化后的日志

服务端

  1. 符号化和解析服务
  2. 堆栈聚合服务
  3. 符号文件管理系统

符号化和解析服务

我们可以把这块看成两个部分,1符号化,2解析服务

符号化

符号化就是把地址翻译成对应的符号信息,比如说 把堆栈调用的地址 翻译成 调用方法的名称。
苹果给我们在 MacOS 下提供了 atosl 工具,可以帮助我们完成这部分工作。但是这个工具仅仅可以在MacOS 下工作,对于大规模的用户日志来说,依赖于MacOS系统不容易部署维护,费用也十分昂贵,因此我们需要在Linux下实现一个相等功能的工具。

解析服务

由于我们的日志是文本格式,我们需要提取其中的信息才可以将其用作符号化工具的输入,可以通过正则匹配的方式去处理这些信息。

工具实现

  1. Linux 平台下可用的符号化工具

Linux 平台下可用的符号化工具

总体来说,iOS符号化我们需要了解两部分知识, 1 Dwarf调试格式 2 Macho文件格式

Dwarf调试格式

我们经常可以接触到的dsym文件是一个目录,其中包含了一个格式为Dwarf的调试信息文件。
调试信息是在编译器生成机器码的时候一起产生的。它代表着可执行程序和源代码之间的关系。这个信息以预定义的格式进行编码,并同机器码一起存储。
在DWARF里基本的描述项是调试信息项(DebuggingInformation Entry——DIE)。一个DIE有一个标签,它指明了这个DIE描述什么及一个填入了细节并进一步描述该项的属性列表。一个DIE(除了最顶层的)被一个父DIE包含(或者说拥有),并可能有兄弟DIE或子DIE。
通过提取Dwarf文件中的调试信息和对应的堆栈进行匹配,就可以解出一些符号信息,包含调用行号文件名称等信息。

需要注意的一些知识点

DW_AT_abstract_origin, DW_AT_specification

A debugging information entry that represents a declaration that
completes another (earlier) non-defining declaration may have a
DW_AT_specification attribute whose value is a reference to the
debugging information entry representing the non-defining declaration.
A debugging information entry with a DW_AT_specification attribute
does not need to duplicate information provided by the debugging
information entry referenced by that specification attribute.
A DW_TAG_subprogram entry can contain a reference to a DW_AT_abstract_origin entry instead of > duplicating the information.

这意味着 我们需要针对 DW_AT_abstract_origin 进行特别处理

Macho 文件格式

上文中提到的符号文件,是Macho文件格式的。
Macho文件中包含 SymbolTable 可以提取出符号的名称,对于系统库和外部符号(例如你的静态库中的符号),我们可以从SymbolTable中提取符号名称。

堆栈聚合服务

因为导致用户Crash的问题大多是相同的,导致用户Crash的堆栈存在许多相似和相等的情况,我们需要把同一类堆栈聚合在一起,协助平台用户高效的解决问题。

系列文章

接下来我会逐渐介绍这整套系统的设计和相关技术实现。

libmalloc "malloc" 探究

之前腾讯开源了 OOMDetector 用于监控内存分配,在集成到我司项目时, 修复了一些bug崩溃,优化了性能,在内存不是频繁分配的App上是有用武之地的。

OOMDetector 监控内存分配的核心处理 是通过 libmalloc库 中的 malloc_logger 指针实现的。

我之前一直疑惑 OOMDetector 的监控方式是否能够完善的监控到应用层面的"malloc"内存分配。这就有必要探究下应用层的内存分配API

iOS “malloc” 内存分配

iOS上都通过kernel进行内存分配,将虚拟内存页映射到应用内存空间上。我们在应用层可以通过mmap实现这种内存分配。

不过大多数情况下我们都通过“malloc”进行内存分配, 我们可以使用malloc来获取内存,而不用每次都请求vmpage映射。而malloc分配的内存实质上都是从vmpage映射获取的。

你一定注意到了 “malloc”, 是的,它代表calloc, realloc, valloc, malloc_zone_malloc, malloc_zone_calloc, malloc_zone_valloc, malloc_zone_realloc, malloc_zone_batch_malloc 等方法,本质上他们的分配都应该被归于一类,都是利用 scalable_zone 进行分配的。

顺便提一下,C++ new的分配 其实现也是用 libc 中的 malloc 进行分配的
new_opnt.cc

libmalloc 分析

default_zone

本着质疑的精神,一般我是不会相信空口无凭的文章的,因此,可以从libmalloc中找到答案。

首先找到 malloc, calloc等函数.. 的实现,因为原理类似,就不一一举出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void *
malloc(size_t size)
{
void *retval;
retval = malloc_zone_malloc(default_zone, size);
if (retval == NULL) {
errno = ENOMEM;
}
return retval;
}

void *
calloc(size_t num_items, size_t size)
{
void *retval;
retval = malloc_zone_calloc(default_zone, num_items, size);
if (retval == NULL) {
errno = ENOMEM;
}
return retval;
}

首先来看看这个 default_zone 是什么东西, 代码如下

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
typedef struct {
malloc_zone_t malloc_zone;
uint8_t pad[PAGE_MAX_SIZE - sizeof(malloc_zone_t)];
} virtual_default_zone_t;

static virtual_default_zone_t virtual_default_zone
__attribute__((section("__DATA,__v_zone")))
__attribute__((aligned(PAGE_MAX_SIZE))) = {
NULL,
NULL,
default_zone_size,
default_zone_malloc,
default_zone_calloc,
default_zone_valloc,
default_zone_free,
default_zone_realloc,
default_zone_destroy,
DEFAULT_MALLOC_ZONE_STRING,
default_zone_batch_malloc,
default_zone_batch_free,
&default_zone_introspect,
9,
default_zone_memalign,
default_zone_free_definite_size,
default_zone_pressure_relief
};

static malloc_zone_t *default_zone = &virtual_default_zone.malloc_zone;

static void *
default_zone_malloc(malloc_zone_t *zone, size_t size)
{
zone = runtime_default_zone();

return zone->malloc(zone, size);
}

MALLOC_ALWAYS_INLINE
static inline malloc_zone_t *
runtime_default_zone() {
return (lite_zone) ? lite_zone : inline_malloc_default_zone();
}


可以看到 default_zone 通过这种方式来初始化

1
2
3
4
5
6
7
static inline malloc_zone_t *
inline_malloc_default_zone(void)
{
_malloc_initialize_once();
// _malloc_printf(ASL_LEVEL_INFO, "In inline_malloc_default_zone with %d %d\n", malloc_num_zones, malloc_has_debug_zone);
return malloc_zones[0];
}

随后的调用如下
_malloc_initialize -> create_scalable_zone -> create_scalable_szone 最终我们创建了 szone_t 类型的对象,通过类型转换,得到了我们的 default_zone。

1
2
3
4
malloc_zone_t *
create_scalable_zone(size_t initial_size, unsigned debug_flags) {
return (malloc_zone_t *) create_scalable_szone(initial_size, debug_flags);
}

“malloc”

上文代码段中写道malloc 中调用了 malloc_zone_malloc, 看看malloc_zone_malloc 的实现是什么

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void *
malloc_zone_malloc(malloc_zone_t *zone, size_t size)
{
MALLOC_TRACE(TRACE_malloc | DBG_FUNC_START, (uintptr_t)zone, size, 0, 0);

void *ptr;
if (malloc_check_start && (malloc_check_counter++ >= malloc_check_start)) {
internal_check();
}
if (size > MALLOC_ABSOLUTE_MAX_SIZE) {
return NULL;
}

ptr = zone->malloc(zone, size); // if lite zone is passed in then we still call the lite methods


if (malloc_logger) {
malloc_logger(MALLOC_LOG_TYPE_ALLOCATE | MALLOC_LOG_TYPE_HAS_ZONE, (uintptr_t)zone, (uintptr_t)size, 0, (uintptr_t)ptr, 0);
}

MALLOC_TRACE(TRACE_malloc | DBG_FUNC_END, (uintptr_t)zone, size, (uintptr_t)ptr, 0);
return ptr;
}

其分配实现是 zone->malloc 根据之前的分析,就是szone_t结构体对象中对应的malloc实现。

在创建szone之后,做了一系列如下的初始化操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Initialize the security token.
szone->cookie = (uintptr_t)malloc_entropy[0];

szone->basic_zone.version = 9;
szone->basic_zone.size = (void *)szone_size;
szone->basic_zone.malloc = (void *)szone_malloc;
szone->basic_zone.calloc = (void *)szone_calloc;
szone->basic_zone.valloc = (void *)szone_valloc;
szone->basic_zone.free = (void *)szone_free;
szone->basic_zone.realloc = (void *)szone_realloc;
szone->basic_zone.destroy = (void *)szone_destroy;
szone->basic_zone.batch_malloc = (void *)szone_batch_malloc;
szone->basic_zone.batch_free = (void *)szone_batch_free;
szone->basic_zone.introspect = (struct malloc_introspection_t *)&szone_introspect;
szone->basic_zone.memalign = (void *)szone_memalign;
szone->basic_zone.free_definite_size = (void *)szone_free_definite_size;
szone->basic_zone.pressure_relief = (void *)szone_pressure_relief;

在magazine_malloc.c有着对应的实现。

malloc_logger

malloc_logger 在 libmalloc 中的以下方法内被调用 malloc_zone_malloc malloc_zone_calloc malloc_zone_valloc malloc_zone_realloc malloc_zone_free malloc_zone_free_definite_size malloc_zone_memalign 等函数中被调用,我们 “malloc” 系列的方法都会调用到这些函数

如果你使用 malloc_logger 回调,那么 “malloc” 分配你都可以监控到. OOMDetector 中也针对不同的分配做了不同的处理。因此使用malloc_logger回调是可以监控到 “malloc”分配的,验证了文章开头的猜测。

总结

以malloc_logger为线索,探究了一下libmalloc的源码,确定了OOMDetector的原理。当然最重要的是建立对 iOS 内存分配的整体理解。 本文只写了对”malloc”内存分配的理解,以后有时间,会写一下 XNU内存管理相关的文章。

iOS OOM

最近在做oom,简单的总结下相关知识

Out Of Memory 是 Jetsam同过响应压力通知杀掉优先级内消耗内存太多的进程, 导致应用闪退的一种现象,难于捕获和分析。

技术点:

1. iOS 内存分配系统的实现
2. 如何抓到回调与如何监控
3. 数据结构设计
4. iOS abort机制
5. OOM事件捕获

iOS内存分配系统的实现(用户层级)

iOS中,内存堆分配的基本控制者是 malloc_zone, 通常是default_zone. 实际上是一个 scallable zone. 我们的通过malloc 分配的内存都是 通过Zone中的空闲内存块链表中获取的. 实际上Zone 从VMPage获取4k对齐的内存(mvm_allocate_pages). 然后再分给我们小块的,效率高,我们通过malloc分配内存,就不必每次都需要申请vmpages. malloc 内存申请分为 tiny, small, large…

虚拟内存分配相关的知识:

vm_map, vm_map_entry, vm_object, vm_page, vm_object, vm_page
对虚拟内存页面的调用都在bsd/kern/kern_mman.c中实现。

物理内存: pmap…

iOS abort 机制

Pageout

管理页面交换的策略,判断哪些页面需要写回到其后备存储。
垃圾回收线程 (vm_pageout_grabage_collect()) 调用 consider_pressure_events -> vm_dispatch_memory_pressure() -> BSD -> NOTE_VM_PRESSURE-> 响应压力通知
如果进程并不是总能找到可以抛弃的内存,当这种协作方法失败时,Jetsam机制介入。

Jetsam

通过响应压力通知杀掉优先级内消耗内存太多的进程。
BSD层起了一个内核优先级最高的线程VM_memorystatus,这个线程会在维护两个列表,一个是我们之前提到的基于进程优先级的进程列表,还有一个是所谓的内存快照列表,即保存了每个进程消耗的内存页memorystatus_jetsam_snapshot。
这个常驻线程接受从内核对于内存的守护程序pageout通过内核调用给每个App进程发送的内存压力通知,来处理事件,这个事件转发成上层的UI事件就是平常我们会收到的全局内存警告或者每个ViewController里面的didReceiveMemoryWarning。

杀的类别

读了一下源码,发现 杀的机制有如下两种,他们大致的执行流程如下

highwater 的处理 -> 我们App占用的内存不要超过限制
1. 从优先级列表里循环寻找线程
2. 判断是否满足p_memstat_memlimit的限制条件
3. DiagnoseActive, FREEZE过滤
4. 杀进程,杀到了exit, 否则继续循环

memorystatus_act_aggressive处理 -> 内存占用高按优先级杀
1. 根据policy加载 jld_bucket_count, 用来判断是否开杀
2. 从JETSAM_PRIORITY_ELEVATED_INACTIVE 开始杀
3. jld_bucket_count 和 memorystatus_jld_eval_period_msecs 判断是否开杀
4. 根据优先级从低向高杀,直到memorystatus_avail_pages_below_pressure

触发入口

1
2
3
4
5
6
7
8
9
10
11
12
# static boolean_t
memorystatus_action_needed(void)
{
#if CONFIG_EMBEDDED
return (is_reason_thrashing(kill_under_pressure_cause) ||
is_reason_zone_map_exhaustion(kill_under_pressure_cause) ||
memorystatus_available_pages <= memorystatus_available_pages_pressure);
#else /* CONFIG_EMBEDDED */
return (is_reason_thrashing(kill_under_pressure_cause) ||
is_reason_zone_map_exhaustion(kill_under_pressure_cause));
#endif /* CONFIG_EMBEDDED */
}

针对这种情况我们可以

memorystatus_action_needed 判断 -> 规避Jetsam处理
1. 是否因为thrashing(如果是EMBEDED则不会触发这种情况)
2. 是否因为zone_map_exhaustion(判断Zone的消耗情况, vm_map 相关参数来做,感觉这个意义不大)
3. 是否因为memorystatus_available_pages <= memorystatus_available_pages_pressure(是否是EMBEDDED)(根据物理内存page占比计算得到) -> boot_arguments…能取到(外部拿不到,只能走内核调试拿.. )

watchdog

为了避免应用陷入错误状态导致界面无响应,Apple 设计了看门狗 (WatchDog) 机制。一旦超时,强制杀死进程。在不同的生命周期,触发看门狗机制的超时时间有所不同:

生命周期 超时时间
启动 Launch 20 s
恢复 Resume 10 s
悬挂 Suspend 10 s
退出 Quit 6 s
后台 Background 10 min

如何抓到回调与如何监控

抓到:libmalloc 中的 malloc_logger 函数指针。通过这个可以抓到所有malloc类分配。
vm 则可以根据hook或者私有变量,和 malloc_logger一致。

监控: 每次抓到都获取调用栈,存储进自己定义的数据结构中。 这里为什么不会循环调用需要值得注意一下。是通过不同的逻辑分支,保证不会走到相同的带分配的逻辑分支。

数据结构设计

1. 空间占用
2. 访问速度
3. 细节存储优化

OOM事件捕获

  1. 现有方案,排除法
  2. 存在的问题 applicationstate不准等
  3. 经验值+ANR 优化