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 | 0 libsystem_platform.dylib 0x0000000181bd1908 0x181bcc000 + 22792 |
调用堆栈分为四列
第一列:调用顺序,最上面的是最后调用的位置
第二列:对应函数所在的二进制文件名,即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 | #import <mach-o/dyld.h> |
从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 | atos -arch arm64 -o ALiuLian_Indiana.app/ALiuLian_Indiana 0x100097190 |
dwarfdump 需要计算symble address
1 | dwarfdump --arch arm64 --lookup 0x100097190 CrashTest.app.dSYM/ |
atos 不需要计算symble address的方式
另外我们也可以不去计算symble address
,我们可以把第三列以及第四列+号前的地址输入,让其自己自己算。当然前提是第四列的地址不是符号化的,比如下面这样
1 | 3 CrashTest 0x100099eac 0x100094000 + 24236 |
1 | atos -o CrashTest.app.dSYM/Contents/Resources/DWARF/CrashTest -arch arm64 -l 0x100094000 0x100099eae |
符号表
概念
符号表就是指在Xcode项目编译后,在编译生成的二进制文件.app的同级目录下生成的同名的.dSYM文件。
.dSYM文件其实是一个目录,在子目录中包含了一个16进制的保存函数地址映射信息的中转文件,所有Debug的symbols都在这个文件中(包括文件名、函数名、行号等),所以也称之为调试符号信息文件。
一般地,Xcode项目每次编译后,都会生成一个新的.dSYM文件。因此,App的每一个发布版本,都需要备份一个对应的.dSYM文件,以便后续调试定位问题。
使用Xcode的Organizer查看崩溃日志时,也自动根据本地存储的.dSYM文件进行了符号化的操作。
并且,崩溃日志也有UUID信息,这个UUID和对应的.dSYM文件是一致的,即只有当三者的UUID一致时,才可以正确的把函数地址符号化。
一般地,Xcode项目默认的配置是会在编译后生成.dSYM,开发者无需额外修改配置。
项目的Build Settings的相关配置如下:
1 | Generate Debug Symbols = Yes |
获取符号文件的UUID
dwarfdump
1 | dwarfdump --uuid CrashTest.app.dSYM |
mdls
1 | mdls -name com_apple_xcode_dsym_uuids -raw CrashTest.app.dSYM |
用符号表解析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 | ....... |
当然也可以通过命令行直接查看Mach-o文件的uuid
1 | dwarfdump --uuid CrashTest.app/CrashTest |
这里的到的UUID用来验证是否与dysm符号文件中的UUID对应。
为什么上面的偏移量计算要减去0x100000000
Mach-o文件的load commond
区列出了可执行文件包括的所有segment,一个可执行文件包含多个段。可执行文件不同的部分将会加载进不同的段。
我们这里主要看Load command 0 (__PAGEZERO段)
与Load command 1(__TEXT段)
1 | Load command 0 |
第一个段是__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 | void UncaughtExceptionHandler(NSException *exception) { |
这样就能打印出类似下面的信息
1 | exception type : NSRangeException |
我们可以看到崩溃的原因
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 | #import <mach-o/dyld.h> |
获取二进制文件的UUID
如果你看过友盟后台的崩溃日志 你会发现,友盟额外的收集了如下信息
uuid 帮助我们匹配dsym文件,
cpu type 帮助我们在使用命令行的时候制定指令集
获取UUID
1 | #import <mach-o/ldsyms.h> |
这样拿到这个Crash信息之后,通过uuid匹配到dsym文件,然后计算symbol address,最后调用命令即可得到崩溃信息