WWDC 2018 session 414: Understanding Crashes and Crash Logs
每个人在写代码的时候,或多或少都会犯错。有的错误就会导致程序崩溃,这非常影响用户体验。本session主要介绍崩溃的原理,他们为什么会发生,以及如何查看、分析崩溃日志,找到并解决问题。
基础知识
崩溃是什么?崩溃指的是应用程序在尝试执行不允许的操作时,突然中止的现象。
为什么会发生崩溃
主要是以下几方面的原因:
CPU无法执行的代码
例如,CPU无法执行除以0的操作
被操作系统“强杀”
系统为了用户体验,会强制终止掉那些启动时间过长或者内存消耗过高的应用
编程语言为了防止错误发生而触发
例如,数组访问越界
开发者为了防止错误发生而触发的崩溃
例如,一些判断非空的断言
崩溃的样子
在调试器里
当我们连接着Xcode进行调试时,遇到崩溃,程序会在终止之前暂停。
暂停时,我们可以拿到崩溃现场的调用栈,如果没有连接调试器,那么系统会将崩溃日志记录到磁盘中。
崩溃日志
通常情况下,发布的应用崩溃后,崩溃的堆栈信息不会展示调用的代码,而是展示二进制名称以及地址列表。这是未解析的日志片段。
通过解析符号表,可以将这些地址解析为您熟悉的函数名、文件名以及行号。
崩溃日志的获取
通过Xcode Organizer来获取
获取崩溃日志的方式很多,我们先来了解一下如何通过Xcode Organizer来获取从TestFlight或App Store下载的应用的崩溃日志。
- 提供了您所有平台中所有app以及app扩展的崩溃日志信息
- 最近数据分析,包含系统和机型两个维度
- 崩溃所在调用栈及崩溃位置的高亮
- 在对应工程中打开崩溃所在的文件,并跳转到指定位置,方便追踪问题
PS:除了上面提到的这几点,还有搜索、标记修复完毕等等功能,请自行体验。
那么如何才能在Organizer中获取对应的崩溃日志呢?
- 在 Xcode 中登录已付费的开发者帐号
- 上传应用到App Store或TestFlight时,一并上传符号文件。
- 打开Xcode Organizer窗口,选中Crashes tab(快捷键:
Command+Shift+6
)。
通过Devices Window获取
如果您没有通过TestFlight或者App Store进行分发,那么还有其他途径,例如Devices Window。连接设备到Mac后,打开Devices And Simulator窗口(快捷键:Command+Shift+2
),点击View Device Logs即可。
之后就可以看到设备上的崩溃日志了,如果此时您的电脑上恰好有符号表的话,那么会直接展示解析后的崩溃日志。如果无法解析的话,可以导出崩溃日志,手动进行解析。
自动化测试
当进行Xcode自动化测试时,测试结果包含来自您的应用的崩溃日志。
控制台
使用Mac控制台也可以查看来自Mac或者模拟器的崩溃日志。
iOS设备分析数据
iOS设备可通过这种操作获取,打开【设置】->【隐私】->【分析】->【分析数据】拿到对应的未符号化的崩溃日志,然后通过系统自带的分享即可传输到对应的设备上进行分析。
符号化最佳实践
- 如果您想使用Xcode Organizer来获取崩溃日志,那么请上传您应用的符号表文件
- 请务必保存您的应用归档文件,里面包含了符号表副本。Xcode会在必要时,自动查找、匹配符号表文件,来解析崩溃日志
- 如果您上传包含bitcode的应用,您应该从Xcode Organizer中或者App Store Connect中下载符号表文件
分析崩溃日志
我们得到了崩溃日志的源文件之后,就可以通过控制台程序或者其他文本编辑器来打开它。
崩溃日志包含的信息
崩溃摘要信息
包含了您的app名称,版本号,运行的操作系统版本,崩溃的日期和时间等等
崩溃原因
操作系统给进程发出的特定信号
崩溃信息
某些情况下包含一些日志信息。如果您有未捕获的异常,则这里可能包含异常的调用栈。在真机上,出于个人隐私这部分通常是隐藏的,但在模拟器和MacOS上可见。
崩溃线程的调用栈
崩溃时所有线程的调用栈,在崩溃线程会标记为crashed,但不一定崩溃都是该线程引起的。
寄存器状态
已加载到进程中的二进制映像
应用的可执行文件和所有其他的库,Xcode使用它进行解析符号表
如何分析崩溃日志
通过崩溃的原因开始分析,崩溃原因,也就是异常类型
图中异常类型是EXE_BAD_INSTRUCTION
,SIGILL
信号是非法指令信号。这意味着CPU正在尝试执行不存在的指令或者由于某些原因而变得无效的指令,这就是该进程被“杀死”的原因。
然后我们查看崩的线程的调用栈,结合崩溃信息(如果有的话)进行进一步的分析。找到第一行程序内的代码,进入代码中进行分析,然后还可以跟着调用栈逐步分析。上图中的崩溃可以很明显看出其原因是对 nil 进行了强制解包。
断言或者先决条件导致的崩溃
断言和先决条件的意义在于当错误发生时,强制终止当前进程。具体含下面几种情况(swift):
- 对nil进行强制解包
- 数组越界访问
- 算术溢出
- 未捕获的异常
- 代码的自定义的断言
操作系统“杀死”应用导致的崩溃
某些情况下,系统处于保护目的,会将一些异常的应用“杀死”。以下几种场景可能触发系统将应用“杀死”:
看门狗事件
主线程长时间无响应
设备发烫
操作系统会终止使用过多CPU的进程
设备内存不足
操作系统会终止使用大量内存的进程
非法签名的应用
如果应用签名无效或者未签名,会被系统终止进程
以上几种场景导致的崩溃,其崩溃日志可以在上面提到的Device Window中查看,Organizer Window并不一定能够收集到这些日志。更多细节可以参考苹果的这个技术讲座Understanding and Analyzing Application Crash Reports。
关于看门狗的例子
上面的崩溃类型为EXC_CRASH (SIGKILL)
,SIGKILL
一般代表的是系统从外部终止了进程的运行,这种信号无法被应用捕获,进而也就无法处理。终止原因为Namespace SPRINGBOARD
, Code 0x8badf00d
,从终止描述中来看,是由于启动时长超过了19.97秒。
Code 0x8badf00d
,的发音同 ate bad food。
如何避免启动超时
我们启动超时,可能是因为程序陷入死循环、可能等待网络请求等等。应用审核被拒的比较常见的原因就包含启动超时这一项,那如何避免启动超时?
在真机上测试
因为看门狗在模拟器以及调试阶段是被禁用的,所以要在真机上测试,而不是在真机上调试
在性能差的机器上测试
根据您的app的最低版本要求,来选择性能差的机器进行测试。例如,您app的最低版本要求是iOS 9,那么就请在iPhone 4s上进行测试。
如何避免内存问题
内存问题包括:过度释放、野指针、访问数组越界。
让我们接着来看一个例子。
这里的异常类型是EXC_BAC_ACCESS(SIGSEGV)
,SIGSEGV
代表the SEG violation signal(分段违规信号)
,这种类型的崩溃可能是两种情况导致的:
- 对只读内存进行了写入操作
- 访问不存在的内存地址
通过函数调用栈,我们发现了objc_release
和objc_dispose
等信息,我们知道,我们是在运行时释放Objective-C对象或者Swift对象。我们的LoginViewController
类调用了一个名为__ivar_destoryer
的函数,而这个函数是swift的一部分,用于释放对象是清理对象的ivar存储。所以我们应该是在释放LoginViewController
的时候,它的deinit
函数中视图清理它的属性时,发生了崩溃。
我们返回去查看异常码,KERN_INVALID_ADDRESS at 0x000007fdd5e70700
。可以根据经验以及日志中的相关信息得出结论,对应的BAD_ADDRESS
为0x7fdd5e70700
。原因是0x7fdd5e70700
刚好在日志中的这一段MALLOC_TINY 00007fdd5e400000-00007fdd5e800000
地址范围内。
一些关于内存及释放的基础
Objective-C对象和一些swift对象的内存布局如图所示,我们的对象有效时,它是以isa
指针开头的,isa
指针指向它所属的类。而objc_release
函数会读取该对象的isa
指针,然后解除isa
指针对Class
的引用。
正常情况下,一切都能照常工作。当我们的对象已经被释放了,会发生什么呢?当调用free
函数删除一个对象时,该对象会添加到一个包含其他已释放的对象的链表中,同时将之前isa
区域指向链表中下一个已释放对象。
这样做可以确保在那不是一个有效的内存地址,如果再错误的使用该对象就会发生崩溃。所以,此时当再次调用objc_release
函数,去读取isa
指针字段时,实际上获取的是rotated free list
指针,当解除rotated free list
指针的引用的时候,就崩溃了。
现在问题就清楚了,我们在释放该对象时,该对象已经被释放了。接下来,我们就去找具体是那个对象就可以了。
目前从崩溃的那一行来看,__ivar_destroyer
是编译器帮我们自动生成的函数。这里也没有关联文件或者行号,所以我们无从知晓具体是哪一行导致的问题。我们从代码上看,有三个属性:
// LoginViewController.swift
class LoginViewController: UIViewController {
var userName: String
var database: DatabaseProxy
var views: [UIView]
//...
}
是从@objc LoginViewController.__ivar_destroyer + 42
可以获取到一些信息,+42
代表着汇编里面的该函数的偏移量。我们可以对__ivar_destroyer
函数进行反汇编,然后看偏移量为42对应获取的是哪个属性,在Xcode中可以使用lldb
调试,也可以在终端运行lldb
。
lldb
调试器有一些命令来导入崩溃日志,就像它在调试器中内部崩溃一样。然后我们通过上图中第一个命令来加载崩溃日志解析命令,然后通过第二个命令将崩溃导入到lldb
调试器。所以我们需要三步来完成这项工作:
- 拷贝崩溃日志到mac上
- 拷贝app以及符号表文件
- 确保app、符号表、崩溃日志为相同的版本
Mac上拥有了这些文件,我们运行崩溃日志命令,lldb会查找匹配可执行文件、符号表,并将其加载到调试器中。我们看到了崩溃线程调用栈,看到了可用的文件和行号,就可以开始工作了。
通过disassemble -a 0x1000022ea
命令来得到汇编代码。即便不会汇编代码,也没有关系,对于崩溃日志,您实际上并不需要完全熟悉阅读汇编代码。如果我们阅读了这个函数,并且知道函数调用的方式是call
指令和mov
指令,我们就可以将这块代码分为三块。
三块分别是userName
、database
、views
的释放。我们找到+42
偏移量。我们找到第3块的第一行,有一点需要注意的是大部分情况下汇编的偏移地址是返回地址,所以调用objc_release
是在上一行。
这里我们看到确实是调用了objc_release
,所以可以判断出是在释放database
时出现了问题。虽然我们目前还不知道具体问题所在,但是可以通过这些信息缩小查找问题的范围,可以查找使用到database
的地方,来找到真正的问题所在。
// LoginViewController.swift
class LoginViewController: UIViewController {
var userName: String
var database: DatabaseProxy // TODO: This property is being over-released.
var views: [UIView]
...
}
内存日志分析总结
理解崩溃产生的原因
读取异常类型,了解异常类型的含义
检查崩溃调用栈信息
了解崩溃时程序正在做什么,以及实际发生的错误是什么
必要的时候使用反汇编
帮我们找到更多线索来分析
bad address
问题
常见的内存错误
- objc_msgSend或者retain/release崩溃
- 无法识别的方法导致的异常
- malloc和free内部的中止(例如,连续两次free一个对象)
日志分析的建议
- 不要只关注崩溃发生的那一行代码,多查看一下和崩溃相关的代码,比如上面那个崩溃代码并不是真正导致bug出现的原因
- 查看所有调用栈,不要只关注崩溃所在线程的调用栈,非崩溃线程调用栈可以帮助我们查看崩溃时应用所处状态
- 多查看一些崩溃日志,有些时候很多崩溃日志都是崩溃在同一个地方,但是某些崩溃日志会包含更多的信息
- 使用Xcode提供的工具来复现内存问题,比如
Address Sanitizer
或者Zombies
多线程问题
多线程问题通常是最难诊断和重现的错误类型之一。他们偶尔会导致崩溃,99%的情况下,您的代码似乎都可以正常工作。一些内存损坏可能是由多线程问题引起的。如果一个特定的类或方法出现在崩溃日志的多个线程中,这表明可能存在多线程的错误。
多线程导致的内存损坏问题通常是非常随机的。所以您可能看到崩溃发生在稍微不同的代码行或者稍微不同的地址上,但本质上他们是相同的问题。此外,崩溃线程实际上可能不是导致崩溃的罪魁祸首,因此要在崩溃日志中查看其它线程的调用栈是非常重要的。
接下来我们看一个多线程问题的例子,然后展示一下如何使用Thread Sanitizer
工具来诊断多线程问题。
通过崩溃日志崩溃线程调用栈中的free
、abort
,我们可以知道这代表堆损坏,是一种内存错误。通过查看线程5,我们发现里面执行了LazyImageView
类的代码。接着我们看下这组崩溃日志的另外一个。
很类似,崩溃线程调用free
、abort
方法,而线程4中也执行了LazyImageView
类的代码。这很可能不是巧合,高度怀疑这是一个多线程问题。那么就来看下LazyImageView
类。
LazyImageView
继承于UIImageView
,但它有额外的功能,因为他是懒加载,而且是异步的。在初始化时,将创建图片放到后台队列,创建完成后回到主队列展示到屏幕上。崩溃日志指向的地方,表示我们正在使用图像的缓存,以确保我们不糊多次不必要的创建相同的图像。因此,缓存的实现方式可能存在一些错误。
那么,我们来复现一下这个问题,但是就像之前说的多线程问题总是很难复现,我们只能一次一次的测试,祈祷能早点复现这个问题。但即便是复现了该问题,也很难在调试其中进行调试,很难去分析、解决这个问题。
幸运的是Xcode提供了Thread Sanitizer
工具,来帮助解决这个问题。在Edit Scheme
->Diagnostics
->勾选Thread Sanitizer
。然后再次运行,我们发现只试了一次,应用程序就中止运行了,因为Thread Sanitizer
发现该错误。
在左侧线程调用栈中,我们看到两个不同的线程执行了两次访问,这里是线程2和线程4,他们都视图同时访问同一个内存位置—storage
变量,这是不允许的。
我们可以看到storage
是一个简单的字典。默认情况下,swift字典不是线程安全的。然后我们进行一些修改。
修改后,现在storage
是线程安全的了,因为无论是getter还是setter,都在一个同步的队列中进行,所以可以保证在同一时间内,最多只有一个线程来访问它。这样,问题就解决了。
所以Thread Sanitizer
的优点:
- 可稳定复现多线程 bug
- 在模拟器下也可进行
- 只查找当前正在执行的代码的问题
tips
在创建GCD Queue、(NS)OperationQueue、(NS)Thread 时,使用自定义名称,方便后续调试以及崩溃日志内查看。
let queue = DispatchQueue(label: "com.example.myapp.networking")
let operationQueue = OperationQueue()
operationQueue.name = "Networking OperationQueue"
let thread = Thread(...)
thread.name = "Networking Thread"
额外的建议
- 尽可能使用真机调试
- 尝试复现,从用户处拿到崩溃日志后根据调用栈尝试去复现问题
- 使用工具来查找难以复现的bug
- 使用 Address Sanitizer 来查看内存问题
- 使用 Thread Sanitizer 来查看多线程问题
关于两个工具的使用,可查看 WWDC 2016 Session 412 Thread Sanitizer and Static Analysis。