iOS Crash收集,符号化分析看我就够了

Crash是iOS平台的一个老生常谈的问题了,在应用开发阶段,我们可以通过设备连接电脑进行调试,当应用Crash的时候,大部分会断到问题代码那一行,我们可以很直观的看到问题的所在,但是当应用发布之后,出现Crash,我们就很难定位到问题所在了。

不过好在我们能够通过一些手段来拿到Crash的信息,Crash信息里面最重要的就是程序最后调用堆栈了,我们可以通过调用堆栈,来定位程序出问题的的代码。

本文将涵盖Crash方面的各种问题,知识点。

Crash信息

我们平常所说的Crash信息包含两种Crash信息, 一种是App崩溃时,系统收集到的Crash信息,另外一种是崩溃时,我们自己用代码捕获到的Crash信息。

iOS 系统收集的Crash信息

系统收集的Crash信息非常详细,但是缺点是我们只能通过将设备连接电脑才能拿到,所以对我们定位已发布版本的问题是没有什么帮助的。

我们可以通过xcode->window->devices找到自己的设备,然后点击View Device Logs来查看设备上保存的所有Crash信息

下面是一个标准的系统收集的Crash信息,只截取了一部分

Crash里面记录了崩溃的设备信息,App的版本信息等等
比较重要的信息是 Incident Identifier

1
Incident Identifier: 7DD7F9A3-B0F1-4F00-8B58-3A738A15F344

Incident Identifier 是二进制包的UUID,这个UUID与build生成的app.dSYM符号文件是一一对应的。

接着就是最重要的程序调用堆栈(backtrace)

1
2
3
4
5
6
7
8
9
10
0   libsystem_platform.dylib      	0x0000000181bd1908 0x181bcc000 + 22792
1 CrashTest 0x0000000100016234 0x100010000 + 25140
2 libdispatch.dylib 0x00000001819bd4bc 0x1819bc000 + 5308
3 libdispatch.dylib 0x00000001819bd47c 0x1819bc000 + 5244
4 libdispatch.dylib 0x00000001819c8d08 0x1819bc000 + 52488
5 libdispatch.dylib 0x00000001819bd47c 0x1819bc000 + 5244
6 libdispatch.dylib 0x00000001819d4090 0x1819bc000 + 98448
7 libdispatch.dylib 0x00000001819bf970 0x1819bc000 + 14704
8 libdispatch.dylib 0x00000001819c263c 0x1819bc000 + 26172
9 CoreFoundation 0x0000000181f28d50 0x181e48000 + 920912

调用堆栈分为四列
第一列:调用顺序,最上面的是最后调用的位置
第二列:对应函数所在的二进制文件名,即binary image
第三列:对应函数在栈上的地址 即stack address
第四列:地址或者符号+偏移,把地址转化成10进制然后再加上偏移,最后转化为16进制=第三列的地址

上面的调用堆栈中第三列的 0x00000001819bd47c 这是对应代码的stack address地址,如果系统没有 ASLR 的话,用这个 stack address 就能在dsym 中找到对应符号信息,但是iOS系统是有 ASLR 机制的,所以这个地址不能直接拿来定位代码位置,需要了解ASLR之后,进行相应的计算才能得到最终的地址

ASLR

ASLR(Address space layout randomization)是一种针对缓冲区溢出的安全保护技术,通过对堆、栈、共享库映射等线性区布局的随机化,通过增加攻击者预测目的地址的难度,防止攻击者直接定位攻击代码位置,达到阻止溢出攻击的目的。据研究表明ASLR可以有效的降低缓冲区溢出攻击的成功率,如今Linux、FreeBSD、Windows等主流操作系统都已采用了该技术。

Apple在iOS4.3内导入了ASLR。

Android 4.0提供地址空间配置随机加载(ASLR),以帮助保护系统和第三方应用程序免受由于内存管理问题的攻击,在Android 4.1中加入地址无关代码(position-independent code)的支持。

由于ios系统使用了ASLR,那么我们拿到的Crash堆栈信息里面的stack address是程序运行时的地址,与app.dSYM中的symbol address是无法对应的。程序运行时加载的地址都是随机的,每次会有一个随机的偏移量offset。所以我们拿到stack address需要做如下转换

1
symble address = stack address -offset;

获取ASLR 偏移量

每次程序运行,偏移量都不同,所以需要我们在运行时获取

运行时用代码获取

1
2
3
4
5
6
7
8
#import <mach-o/dyld.h>
long offset;
for (uint32_t i = 0; i < _dyld_image_count(); i++) {
if (_dyld_get_image_header(i)->filetype == MH_EXECUTE) {
offset = _dyld_get_image_vmaddr_slide(i);
break;
}
}

从crash文件计算得到

如果我们拿到的是Crash文件,我们可以从文件中找到偏移量,入下图

序号为3的一行是我们App的调用,红圈中的地址是我们的二进制文件中,App代码所在的起始地址(二进制文件由我们的App可执行代码,以及其它静态库,等等组成),红圈中去掉最高位1,0x94000就是offset。

如果你拿到的堆栈信息是半符号化的,即第四列0x100094000 这里变成了CrashTest这种符号,你可以接着去Crash文件的下面 找到offset

也就是说我们从crash文件拿到binary image(这里是CrashTest)的地址区域0x100094000 - 0x10009bfff,然后用起始地址0x100094000 - 0x100000000就得到了偏移量,至于为什么减去0x100000000,在后面分析mach-o文件内容的时候会解释

定位崩溃位置

拿到了stack address,offset,即可计算最终的符号地址

1
symble address = stack address -offset;

上面的例子中我们计算得到symble address =0x100097190

我们可以通过atos或者dwarfdump命令来解析地址

atos 需要计算symble address

1
2
3
atos -arch arm64  -o ALiuLian_Indiana.app/ALiuLian_Indiana  0x100097190
结果
-[XXXXX btnPressed:] (in CrashTest) (ViewController.m:33)

dwarfdump 需要计算symble address

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
dwarfdump --arch arm64 --lookup 0x100097190 CrashTest.app.dSYM/

结果
Looking up address: 0x0000000100005eac in .debug_info... found!

0x0004f370: Compile Unit: length = 0x00000224 version = 0x0002 abbr_offset = 0x00000000 addr_size = 0x08 (next CU at 0x0004f598)

0x0004f37b: TAG_compile_unit [99] *
AT_producer( "Apple LLVM version 7.0.2 (clang-700.1.81)" )
AT_language( DW_LANG_ObjC )
AT_name( "/Users/zhangxu/Desktop/IOS_Project/CrashTest/CrashTest/ViewController.m" )
AT_stmt_list( 0x000082b2 )
AT_comp_dir( "/Users/zhangxu/Desktop/IOS_Project/CrashTest" )
AT_APPLE_major_runtime_vers( 0x02 )
AT_low_pc( 0x0000000100005de4 )
AT_high_pc( 0x0000000100005f98 )

0x0004f430: TAG_subprogram [105] *
AT_low_pc( 0x0000000100005e34 )
AT_high_pc( 0x0000000100005ee8 )
AT_frame_base( reg29 )
AT_object_pointer( {0x0004f44e} )
AT_name( "-[ViewController btnPressed:]" )
AT_decl_file( "/Users/zhangxu/Desktop/IOS_Project/CrashTest/CrashTest/ViewController.m" )
AT_decl_line( 21 )
AT_prototyped( 0x01 )
Line table dir : '/Users/zhangxu/Desktop/IOS_Project/CrashTest/CrashTest'
Line table file: 'ViewController.m' line 33, column 5 with start address 0x0000000100005ea0

Looking up address: 0x0000000100005eac in .debug_frame... not found.

atos 不需要计算symble address的方式
另外我们也可以不去计算symble address,我们可以把第三列以及第四列+号前的地址输入,让其自己自己算。当然前提是第四列的地址不是符号化的,比如下面这样

1
3   CrashTest                     	0x100099eac 0x100094000 + 24236
1
2
3
4
atos -o CrashTest.app.dSYM/Contents/Resources/DWARF/CrashTest -arch arm64 -l 0x100094000 0x100099eae

结果
-[ViewController btnPressed:] (in CrashTest) (ViewController.m:33)

符号表

概念

符号表就是指在Xcode项目编译后,在编译生成的二进制文件.app的同级目录下生成的同名的.dSYM文件。
.dSYM文件其实是一个目录,在子目录中包含了一个16进制的保存函数地址映射信息的中转文件,所有Debug的symbols都在这个文件中(包括文件名、函数名、行号等),所以也称之为调试符号信息文件。
一般地,Xcode项目每次编译后,都会生成一个新的.dSYM文件。因此,App的每一个发布版本,都需要备份一个对应的.dSYM文件,以便后续调试定位问题。

使用Xcode的Organizer查看崩溃日志时,也自动根据本地存储的.dSYM文件进行了符号化的操作。
并且,崩溃日志也有UUID信息,这个UUID和对应的.dSYM文件是一致的,即只有当三者的UUID一致时,才可以正确的把函数地址符号化。

一般地,Xcode项目默认的配置是会在编译后生成.dSYM,开发者无需额外修改配置。
项目的Build Settings的相关配置如下:

1
2
Generate Debug Symbols = Yes
Debug Information Format = DWARF with dSYM File

获取符号文件的UUID

dwarfdump

1
2
3
dwarfdump --uuid CrashTest.app.dSYM
结果
UUID: 71BEA9E4-BA71-3436-AD49-234FCEEBBE8E (arm64) CrashTest.app.dSYM/Contents/Resources/DWARF/CrashTest

mdls

1
2
3
4
5
mdls -name com_apple_xcode_dsym_uuids -raw CrashTest.app.dSYM
结果
(
"71BEA9E4-BA71-3436-AD49-234FCEEBBE8E"
)

用符号表解析Crash文件

当我们拿到的Crash文件与dsym符号文件的uuid相同,就可以对Crash文件进行整体的符号化了。
使用到命令行工具symbolicatecrash

不同的xcode版本,这个工具的位置不同,本人测试环境为xcode7.2,该命令的目录如下

1
/Applications/Xcode.app/Contents/SharedFrameworks/DVTFoundation.framework/Versions/A/Resources/symbolicatecrash

如果我们找不到symbolicatecrash,可以使用如下命令来查找其路径

1
find /Applications/Xcode.app -name symbolicatecrash -type f

在使用该命令之前需要添加环境变量DEVELOPER_DIR

1
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer

最后通过命令符号化Crash文件

1
/Applications/Xcode.app/xxx/symbolicatecrash   test.crash  CrashTest.app.dSYM > test.log

最后打开log文件,已经能够看到具体问题代码了

Mach-o文件

在OSX和iOS中可执行文件是Mach-o格式。Mach-o包含三个基本区域

  • 头部(header structure)。
  • 加载命令(load command)即二进制文件加载的所有段会在这里列出来。
  • 段(segment)。可以拥有多个段(segment),每个段可以拥有零个或多个区域(section)。每一个段(segment)都拥有一段虚拟地址映射到进程的地址空间。

这里并不打算详细介绍Mach-o,只会提及与本文Crash相关的信息

查看Mach-o文件的UUID

Mach-o文件的uuid保存在 load commond区域,叫做LC_UUID,我们可以通过如下命令查看Mach-o文件的load commond

1
otool -l CrashTest.app/CrashTest >load_commond.txt

打开文件搜索LC_UUID,即能查到uuid的值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
.......
Load command 7
cmd LC_LOAD_DYLINKER
cmdsize 32
name /usr/lib/dyld (offset 12)
Load command 8
cmd LC_UUID
cmdsize 24
uuid 71BEA9E4-BA71-3436-AD49-234FCEEBBE8E
Load command 9
cmd LC_VERSION_MIN_IPHONEOS
cmdsize 16
version 9.2
sdk 9.2
.......

当然也可以通过命令行直接查看Mach-o文件的uuid

1
2
3
dwarfdump --uuid CrashTest.app/CrashTest
结果
UUID: 71BEA9E4-BA71-3436-AD49-234FCEEBBE8E (arm64) CrashTest.app/CrashTest

这里的到的UUID用来验证是否与dysm符号文件中的UUID对应。

为什么上面的偏移量计算要减去0x100000000

Mach-o文件的load commond区列出了可执行文件包括的所有segment,一个可执行文件包含多个段。可执行文件不同的部分将会加载进不同的段。

我们这里主要看Load command 0 (__PAGEZERO段)Load command 1(__TEXT段)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Load command 0
cmd LC_SEGMENT_64
cmdsize 72
segname __PAGEZERO
vmaddr 0x0000000000000000
vmsize 0x0000000100000000
fileoff 0
filesize 0
maxprot 0x00000000
initprot 0x00000000
nsects 0
flags 0x0
Load command 1
cmd LC_SEGMENT_64
cmdsize 712
segname __TEXT
vmaddr 0x0000000100000000
vmsize 0x0000000000008000
fileoff 0
filesize 32768
maxprot 0x00000005
initprot 0x00000005
nsects 8
flags 0x0

第一个段是__PAGEZERO。这个有4GB(0x100000000)大小。这4GB并不是文件的真实大小,但是说明了进程的前4GB地址空间将会被映射为,不能执行,不能读,不能写。这就是为什么在去写NULL指针或者一些低位的指针的时候,你会得到一个EXC_BAD_ACCESS错误。这是操作系统在尝试防止你引起系统崩溃

__TEXT段包含了可执行的代码,即我们自己app的代码。它们被以只读和可执行的方式映射。进程被允许执行这些代码,但是不能修改。这些代码也不能改变它们自己,并且这些页从来不会被污染。

TEXT段的加载命令可以看出,TEXT段映射对应的虚拟地址空间是从0x0000000100000000开始的。因为__PAGEZERO段是从0开始size为0x0000000100000000

没有 ASLR机制时:

加载时 装载器会将此 ELF 文件的 前 32768个字节(因为 offset 0 ,filesize 32768)映射到 进程空间以 0x0000000100000000 开始的一块虚拟内存空间里.

有ASLR 机制时:

加载时 装载器会将此 ELF 文件的 前 32768 个字节(因为 offset 0 ,filesize 32768)映射到 进程空间以 0x0000000100000000 (+offset)开始的一块虚拟内存空间里.

所以 : 如果没有 ASLR 机制,那么运行时的内存布局 就和 Load command 中指定的布局一致,也就意味着stack address和 symbol address 一致

有 ASLR 的情况也不复杂,只是 加了一个 随意的偏移量 offset

所以当我们从Crash文件中拿到 我们app的起始地址,用 起始地址-0x100000000,就能得到ASLR生成的offset

代码捕获Crash信息

iOS系统收集的Crash信息,必须要用户主动上传(去itunes connect查看),或者连接用户设备主动肚读取才能拿到,但是一般,我们根本拿不到用户的设备,所以系统收集的Crash信息对我们帮助不大,我们必须想办法自己收集Crash信息,并且在后台上传到我们的服务器。

现在市面上的一些第三方Crash收集SDK都是通过代码捕获异常崩溃,然后收集Crash信息,并且提供符号化服务,典型的有友盟,Bugly。

我们只需简单的几步就可以补货app抛出的NSException异常

注册异常补货函数

1
NSSetUncaughtExceptionHandler(&UncaughtExceptionHandler);

异常处理函数

1
2
3
4
5
6
7
8
void UncaughtExceptionHandler(NSException *exception) {
NSArray *arr = [exception callStackSymbols];//得到当前调用栈信息
NSString *reason = [exception reason];//非常重要,就是崩溃的原因

NSString *str=[NSString stringWithFormat:@"exception type : %@ \n crash reason : %@ \n call stack info : %@",name, reason, arr];

NSLog(str);
}

这样就能打印出类似下面的信息

1
2
3
4
5
6
7
8
9
10
exception type : NSRangeException 
crash reason : *** -[__NSArrayM objectAtIndex:]: index 10 beyond bounds for empty array
call stack info : (
0 CoreFoundation 0x0000000181f72dc8 <redacted> + 148
1 libobjc.A.dylib 0x00000001815d7f80 objc_exception_throw + 56
2 CoreFoundation 0x0000000181e52dfc <redacted> + 0
3 CrashTest 0x00000001000a5eac -[ViewController btnPressed:] + 120
4 UIKit 0x0000000187108be8 <redacted> + 100
5 UIKit 0x0000000187108b64 <redacted> + 80
6 UIKit 0x00000001870f0870 <redacted> + 436

我们可以看到崩溃的原因

1
*** -[__NSArrayM objectAtIndex:]: index 10 beyond bounds for empty array

可以看到崩溃的位置,注意这里的第四列已经不是纯粹的地址了,是半符号化内容,已经可以看到崩溃的函数,但是无法定位到具体的行数。

1
3   CrashTest                           0x00000001000a5eac -[ViewController btnPressed:] + 120

通过上面的分析可知,光有这些信息是不够的,我们至少需要得到ASLR 偏移量,才能得到symbol address

1
symbol address=0x00000001000a5eac-Offset

获取offset

1
2
3
4
5
6
7
8
#import <mach-o/dyld.h>
long offset;
for (uint32_t i = 0; i < _dyld_image_count(); i++) {
if (_dyld_get_image_header(i)->filetype == MH_EXECUTE) {
offset = _dyld_get_image_vmaddr_slide(i);
break;
}
}

获取二进制文件的UUID

如果你看过友盟后台的崩溃日志 你会发现,友盟额外的收集了如下信息

uuid 帮助我们匹配dsym文件,
cpu type 帮助我们在使用命令行的时候制定指令集

获取UUID

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#import <mach-o/ldsyms.h>
NSString *executableUUID()
{
const uint8_t *command = (const uint8_t *)(&_mh_execute_header + 1);
NSString *magic=[NSString stringWithFormat:@"%x",_mh_execute_header.magic];
for (uint32_t idx = 0; idx < _mh_execute_header.ncmds; ++idx) {
if (((const struct load_command *)command)->cmd == LC_UUID) {
command += sizeof(struct load_command);
return [NSString stringWithFormat:@"%02X%02X%02X%02X-%02X%02X-%02X%02X-%02X%02X-%02X%02X%02X%02X%02X%02X",
command[0], command[1], command[2], command[3],
command[4], command[5],
command[6], command[7],
command[8], command[9],
command[10], command[11], command[12], command[13], command[14], command[15]];
} else {
command += ((const struct load_command *)command)->cmdsize;
}
}
return nil;
}

这样拿到这个Crash信息之后,通过uuid匹配到dsym文件,然后计算symbol address,最后调用命令即可得到崩溃信息