崩溃分析
崩溃日志(crash log)
根据符号表来监测崩溃位置
-
什么是符号表
符号表就是指在Xcode项目编译后,在编译生成的二进制文件.app的同级目录下生成的同名的.dSYM文件。
.dSYM文件其实是一个目录,在子目录中包含了一个16进制的保存函数地址映射信息的中转文件,所有Debug的symbols都在这个文件中(包括文件名、函数名、行号等),所以也称之为调试符号信息文件。
-
符号表有什么用
符号表就是用来符号化 crash log(崩溃日志)。crash log中有一些方法16进制的内存地址等,通过符号表就能找到对应的能够直观看到的方法名之类。
-
如何得到.dsYM文件
我们在Archive的时候会生成.xcarchive文件,然后显示包内容就能够在里面找到.dsYM文件和.app文件。
Xcode中查看崩溃信息
Xcode->Window->Organizer->Crashes
崩溃日志分析
参考iOS应用崩溃日志分析里面有很详细的分析介绍。
以上是一个完整的崩溃日志。
如何得到崩溃日志
-
把设备连上电脑,得到自己设备的崩溃日志
崩溃日志可以从xcode里打开Devices看到对应手机的一些崩溃信息。点击下图的View Device Logs就能看到崩溃日志。
-
使用第三方崩溃管理工具
我暂时只使用过友盟,友盟里面有错误分析,就是截取的崩溃日志。
-
自己截取崩溃日志
自己写入代码,然后截取到崩溃日志,把崩溃日志发送到开发者邮箱里。 iOS Crash(崩溃)调试技巧这篇文章中有介绍如何截取崩溃日志并发送到邮箱。
分析崩溃日志
-
崩溃日志中的(3)异常
Exception Type: 异常类型 通常包含1.7中的Signal信号和EXC_BAD_ACCESS。
Exception Codes: 异常编码 0x8badf00d: 读做 “ate bad food”! (把数字换成字母,是不是很像 :p)该编码表示应用是因为发生watchdog超时而被iOS终止的。 通常是应用花费太多时间而无法启动、终止或响应用系统事件。
0xbad22222: 该编码表示 VoIP 应用因为过于频繁重启而被终止。
0xdead10cc: 读做 “dead lock”!该代码表明应用因为在后台运行时占用系统资源,如通讯录数据库不释放而被终止 。
0xdeadfa11: 读做 “dead fall”! 该代码表示应用是被用户强制退出的。根据苹果文档, 强制退出发生在用户长按开关按钮直到出现 “滑动来关机”, 然后长按 Home按钮。强制退出将产生 包含0xdeadfa11 异常编码的崩溃日志, 因为大多数是强制退出是因为应用阻塞了界面。
-
崩溃日志中的(4)线程回溯
这部分提供应用中所有线程的回溯日志。 回溯是闪退发生时所有活动帧清单。它包含闪退发生时调用函数的清单。看下面这行日志:
它包括四列: 帧编号—— 此处是2。(数子从大到小为发生的顺序) 二进制库的名称 ——此处是 XYZLib. 调用方法的地址 ——此处是 0x34648e88. 第四列分为两个子列,一个基本地址和一个偏移量。此处是0×83000 + 8740, 第一个数字指向文件,第二个数字指向文件中的代码行。
野指针分析方法 ( Enable Malloc Scribble )
因为野指针的原因发生崩溃是常常出现的事,而且比较随机。关于一些原因及概念后面我们会讲到。所以我们要提高野指针的崩溃率好来帮我们快速找到有问题的代码。
对象释放后只有出现被随机填入的数据是不可访问的时候才会必现Crash。
这个地方我们可以做一下手脚,把这一随机的过程变成不随机的过程。对象释放后在内存上填上不可访问的数据,其实这种技术其实一直都有,xcode的Enable Scribble就是这个作用。
更加详细的介绍可以参考:如何定位Obj-C野指针随机Crash。
僵尸模式 ( NSZombieEnabled )
启用了NSZombieEnabled的话,它会用一个僵尸来替换默认的dealloc实现,也就是在引用计数降到0时,该僵尸实现会将该对象转换成僵尸对象。僵尸对象的作用是在你向它发送消息时,它会显示一段日志并自动跳入调试器。
所以当启用NSZombieEnabled时,一个错误的内存访问就会变成一条无法识别的消息发送给僵尸对象。僵尸对象会显示接受到得信息,然后跳入调试器,这样你就可以查看到底是哪里出了问题。
所以这时一般崩溃的原因是:调用了已经释放的内存空间,或者说重复释放了某个地址空间。
如何找出问题
-
NSZombieEnabled
打开NSZombieEnabled之后,如果遇到对应的崩溃类型既调用了已经释放的内存空间,或者说重复释放了某个地址空间。那么就能在GDB中看到对应的输出信息。
比如会出现如下这样的问题:
[__NSArrayM addObject:]: message sent to deallocated instance 0x7179910
-
MallocStackLoggingNoCompact
如果崩溃是发生在当前调用栈,通过上面的做法,系统就会把崩溃原因定位到具体代码中。但是,如果崩溃不在当前调用栈,系统就仅仅只能把崩溃地址告诉我们,而没办法定位到具体代码,这样我们也没法去修改错误。这时就可以修改scheme,让xcode记录每个地址alloc的历史,这样我们就可以用命令把这个地址还原出来。 如图:(跟设置
NSZombieEnabled
一样,添加MallocStackLoggingNoCompact
,并且设置为YES)这样,当出现崩溃原因是message sent to deallocated instance 0x7179910,我们可以使用以下命令,把内存地址还原:
(gdb) nfo malloc-history 0x7179910
也可以使用下面的命令(gdb) shell malloc_history {pid/partial-process-name} {address}
这篇文章中有介绍MallocStackLoggingNoCompact的使用。
-
总结
还有官方文档 Enabling the Malloc Debugging Features 介绍了类似
NSZombieEnabled
和MallocStackLoggingNoCompact
这类的环境变量的作用。TODO:翻译Enabling the Malloc Debugging Features这篇文章,写对应的demo测试这类变量设置后如何找出内存出错问题。
Enable Address Sanitizer(地址消毒剂)
设置这个参数后就能看到一些更详细的错误信息提示,甚至会有内存使用情况的展示。
C语言是一门危险的语言,内存安全是一个主要的问题。C语言中根本没有内存安全可言。像下面的代码,会被正常的编译,而且可能正常运行: char *ptr = malloc(5); ptr[12] = 0
; 对于内存安全的验证已经有一些解决方案了。如Clang的静态代码分析,可以从代码中查找特定类型的内存安全问题。如Valgrind之类的程序可以在运行时检测到不安全的内存访问。
Address Sanitizer是另外一种解决方案。它使用了一种新的方法,有利有弊。但仍不失为一个查找代码问题的有力工具。
这类工具的理论依据是:访问内存时,通过比较访问的内存和程序实际分配的内存,验证内存访问的有效性,从而在bug发生时就检测到它们,而不会等到副作用产生时才有所察觉。
malloc函数总是最少分配16个字节。为了储存针对标准malloc的内存的保护,需要分配内存到16字节的范围内,因此,若分配的内存大小不是16字节的整数倍,余出的几个字节将不受保护。
Address Sanitizer会追踪受限内存,使用了一种简单但是很巧妙的方法:它在进程的内存空间上保存了一个固定的区域,叫做“影子内存区”。用内存消毒剂的术语来说,一个被标记为受限的内存被称作“中毒”内存。“影子内存区”会记录哪些内存字节是中毒的。通过一个简单的公式,可以将进程中的内存空间映射到“影子内存区”中,即:每8字节的正常内存块映射到一个字节的影子内存上。在影子内存上,会跟踪这8字节的“中毒状态”。
Address Sanitizer 这篇文章详细介绍了Enable Address Sanitizer,对应的中文翻译在Xcode 7上直接使用Clang Address Sanitizer
Static Analyzer(静态分析)
Static Analyzer是一个非常好的工具去发现编译器警告不会提示的问题和一些个人的内错泄露和死存储(不会用到的赋了值的变量)错误。这个方法可能大大的提高内存使用和性能,以及提升应用的整体稳定性和代码质量。
打开方式:Xcode->Product-Analyze 然后我们就能看到如下蓝色箭头所示的一些有问题的代码。
unrecognized selector send to instancd 快速定位
在debug navigator的断点栏里添加Create Symbolic Breakpoint。
在Symbolic中填写如下方法签名: -[NSObject(NSObject) doesNotRecognizeSelector:]
设置完成后再遇到类似的错误就会定位到具体的代码。
Signal和EXC_BAD_ACCESS错误分析
Signal
在计算机科学中,信号(英语:Signals)是Unix、类Unix以及其他POSIX兼容的操作系统中进程间通讯的一种有限制的方式。它是一种异步的通知机制,用来提醒进程一个事件已经发生。当一个信号发送给一个进程,操作系统中断了进程正常的控制流程,此时,任何非原子操作都将被中断。如果进程定义了信号的处理函数,那么它将被执行,否则就执行默认的处理函数。
在iOS中就是未被捕获的Objective-C异常(NSException),导致程序向自身发送了SIGABRT信号而崩溃。
Signal信号的类型:
- SIGABRT–程序中止命令中止信号
- SIGALRM–程序超时信号
- SIGFPE–程序浮点异常信号
- SIGILL–程序非法指令信号
- SIGHUP–程序终端中止信号
- SIGINT–程序键盘中断信号
- SIGKILL–程序结束接收中止信号
- SIGTERM–程序kill中止信号
- SIGSTOP–程序键盘中止信号
- SIGSEGV–程序无效内存中止信号
- SIGBUS–程序内存字节未对齐中止信号
- SIGPIPE–程序Socket发送失败中止信号
iOS异常捕获 这篇文章中有对各种信号的解释。
-
SIGSEGV
SIGSEGV
是当SEGV
发生的时候,让代码终止的标识。这是在iOS中最为常见导致崩溃的原因。当App视图去访问没有被开辟的内存或者已经被释放的内存时,这样异常就会产生。调试这样的Bug,需要找到已经被释放的变量,如果是在开发环境下发生这样的问题,可以使用Zombies来检测这样的问题。
在低内存的情况下,也可能发生类似的问题。这样非常难重现,在类和方法中跟踪堆栈可能会找到原因。
举个常见的例子,在使用代理的时候:
// 代理应该是用weak修饰的 self.delegate = myView; // myView从UINavigationController中Pop之后就会被销毁,而self.delegate仍然起作用,成了野指针 // 将会抛出异常 [self.delegate doSomething];
避免这种异常可以在调用之前检查一下代理是否为空,是否能够响应所给的
Selector
if(self.delegate != nil) { if([self.delegate respondsToSelector:@selector(doSomething)]) { [self.delegate doSomething]; } }
-
NSInvalidArgumentException
通常根据堆栈信息能够知道异常所发生的地方,最常见的引起
NSInvalidArgumentException
的原因就是an unrecognized selector
,当调用一个对象不存在方法是就会抛出。比如:
// 因为对象不能响应所调用的方法, 这就会抛出NSInvalidArgumentException异常 [@”myString” objectForKey:@”someKey”];
像上面这种明显的错误是编译不过的,所以需要注意动态参数,比如序列化的
JSON
。// data => [‘item0’, ‘item1’, ‘item2’] NSDictionary *json = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; // JSON Data是个数组而不是一个字典 // 错误在数组中调用了字典的方法 [json objectForKey:@”myKey”];
解决这样的问题,可以在获取对象前,判断一下是否响应这个放否,数组是否包含了某个元素。
-
SIGABRT
这是一个让程序终止的标识,当存在一个未处理的异常的时候
debugger
显示。在一个已经发布的app中,SIGABRT
会在断言或者在app内部、操作系统滴啊用终止方法的时候抛出。如果堆栈的信息是比较晦涩,看不懂。很有可能是在苹果
SDK
内部终止了。这样的话,操作系统会杀死app并且抛出堆栈异常,这样非常难调试。注意:这并不一定意味着是系统的代码存在Bug,代码仅仅是成为了无效的状态,或者异常状态。
SIGABRT
通常发生在异步执行系统方法的时候,所以一定小心,比如CoreData
,Accessing files
,NSUserDefaults
,还有一些其他的系统多线程操作。 -
SIGBUS
这个标识意味着
BUS Error
,它和SIGABRT
很容易混淆。两者都有相同点,都是在访问不可用内存的时候发生,SIGBUS
是在访问的地址不存在,或者无效的队列。换句话说,SIGBUS就是物理地址无效(不同于SIGSEGV
,SIGSEGV
是逻辑地址无效)SIGBUS
可能从大多数的同步方法中发出,当试图访问一个锁的时候可能会抛出这样的异常 -
NSRangeException
当代码中试图去访问对象的范围内不存在的索引的时候,根据堆栈信息可以追溯到这个异常,比如:
[__NSArrayM objectAtIndex:]: index 11 beyond bounds [0 .. 10]
大多数情况下引起这个问题的原因是数组和字符串,比如:
NSArray *arr = @[@1, @2, @3, @4]; NSNumber *num = [arr objectAtIndex:9]; // 抛出异常 NSString *mainString = @”myString”; NSString *subString = [mainString substringToIndex:24]; // 抛出异常
避免这种异常很简单,及时确保索引在对象的范围内,比如:
NSNumber *obj = nil; NSArray *arr = @[@1, @2, @3, @4]; if([arr count] > 9) { obj = [arr objectAtIndex:9]; } NSString *subString = nil; NSString *mainString = @”myString”; if([mainString length] > 24) { subString = [mainString substringToIndex:24]; }
截取Signal和Exception从容的崩溃
EXC_BAD_ACCESS
EXC_BAD_ACCESS是一个比较难处理的crash了,当一个app进入一种毁坏的状态,通常是由于内存管理问题而引起的时,就会出现出现这样的crash。通常1.7.1中的Signal信号错误都会提醒EXC_BAD_ACCESS。
文中1.3就介绍了用一些变量设置来找出这类错误。
崩溃类型收集
新老操作系统兼容
-
原因
开发人员在进行开发的时候,常常使用的是某个操作系统版本,所以在开发人员进行开发测试的那个系统版本上基本不会出现问题。但在其他版本上开发人员无法进行完全的测试,这就导致了在新系统上运行正常,但在旧系统上却崩溃的情况。
在新 iOS 上正常的应用,到了老版本 iOS 上秒退最常见原因是系统动态链接库或Framework无法找到。这种情况通常是由于 App 引用了一个新版操作系统里的动态库(或者某动态库的新版本)或只有新 iOS 支持的 Framework,而又没有对老系统进行测试,于是当 App 运行在老系统上时便由于找不到而秒退。
还有就是有些方法是新版操作系统才支持的,而又没有对该方法是否存在于老系统中做出判断。这种情况其实还是比较难出现的,除非开发人员太low了,因为这类方法在xcode编码时编辑器都会有提醒的。
-
解决
这种问题一般就是用户升级操作系统或者开发人员修改问题以兼容老系统。
本地存储的数据结构改变
-
原因
程序在升级时,修改了本地存储的数据结构,但是对用户既存的旧数据没有做好升级,结果导致初始化时因为无法正确读取用户数据而秒退。
-
解决
第一种: 是把服务端传过来的一些信息保存在本地,使用的时候从本地数据库取。
刚开始的时候我是第一次从服务端得到数据的时候直接解析成对应的model然后存入plist文件里面。这时就有一个问题,比如服务端新传了字段newId,但是我旧版model里面没有定义过,存入本地的数据还是没有这个字段。
然后等我升级了程序,新程序里model,定义了这个newId字段,但是旧版里面数据已经保存过一遍了没有这个字段。这时再去取就取不到了。
所以后来我就把存储时解析数据改成了读取时解析数据。就是不管服务端传什么数据都把它存下来,然后在使用的时候再把它解析成对应的model,这样就不会丢失字段了。
第二种: 自己的一些数据存储在本地SQLlite,新版的时候表结构改了。
SQLlite只支持更改一个表的名字,或者向表中增加一个字段(列),但是我们不能删除一个已经存在的字段,或者更改一个已经存在的字段的名称、数据类型、限定符等等。
这种就是有时候新版又添加字段了,或者改变了字段的名称了。一般来说原有的字段名称不应该改变,但是添加新字段是常有的事。
一般做法是在第一次创建表的时候加一些冗余字段,以防后面不时之需。但是如果真没办法需要在旧表上增加新字段了,那就要做数据迁移了。
这里有一个库在FMDB基础上管理SQLlite数据库了,可以用来做数据迁移用。FMDBMigrationManager
访问的数据为空或访问数据类型不对
-
原因
这类情况是比较常见的,后端传回了空数据,客户端没有做对应的判断继续执行下去了,这样就产生了crash。或者自己本地的某个数据为空数据而去使用了。还有就是访问的数据类型不是期望的数据类型而产生崩溃。
-
解决
第一种: 服务端都加入默认值,不返回空内容或无key,但是服务端往往会不太愿意改,还有就是有些确实应该无值的话key也不用传,减少数据量的传输。
第二种: 这种就是客户端自己做判断,如果每次都是自己去if判断是否为空或格式是否正确那肯定是比较麻烦的。所以这里用到了NSArray和NSDictionary的Category。一般我们访问的数据都是NSArray或NSDictionary,所以在取值方法里面做一下判断,返回正确的数据类型或默认值即可。
操作了不该操作的对象,野指针之类
野指针介绍
iOS中有空指针和野指针两种概念。
空指针是没有存储任何内存地址的指针。如Student *s1 = NULL;
和Student *s2 = nil;
而野指针是指指向一个已删除的对象(”垃圾”内存既不可用内存)或未申请访问受限内存区域的指针。野指针是比较危险的。因为野指针指向的对象已经被释放了,不能用了,你再给被释放的对象发送消息就是违法的,所以会崩溃。
空指针和野指针这篇文章介绍了空指针和野指针的概念。
野指针崩溃情况
野指针访问已经释放的对象crash其实不是必现的,因为dealloc执行后只是告诉系统,这片内存我不用了,而系统并没有就让这片内存不能访问。
所以野指针的崩溃是比较随机的,你在测试的时候可能没发生crash,但是用户在使用的时候就可能发生crash了。
现实出现问题大概是下面几种可能的情况:
- 对象释放后内存没被改动过,原来的内存保存完好,可能不Crash或者出现逻辑错误(随机Crash)。
- 对象释放后内存没被改动过,但是它自己析构的时候已经删掉某些必要的东西,可能不Crash、Crash在访问依赖的对象比如类成员上、出现逻辑错误(随机Crash)。
- 对象释放后内存被改动过,写上了不可访问的数据,直接就出错了很可能Crash在objc_msgSend上面(必现Crash,常见)。
- 对象释放后内存被改动过,写上了可以访问的数据,可能不Crash、出现逻辑错误、间接访问到不可访问的数据(随机Crash)。
- 对象释放后内存被改动过,写上了可以访问的数据,但是再次访问的时候执行的代码把别的数据写坏了,遇到这种Crash只能哭了(随机Crash,难度大,概率低)!!
- 对象释放后再次release(几乎是必现Crash,但也有例外,很常见)。
参考下面这张图:
内存处理不当
说到因为内存处理不当崩溃就要涉及到内存管理问题了。内存管理是软件开发中一个重要的课题。iOS自从引入ARC机制后,对于内存的管理开发者好像轻松了很多,但是还会发生一些内存泄露之类的问题。
那我们如何快速的来找出内存泄露呢?以前我们可能会使用Instruments来监测,但是我们会发现使用Instruments特别繁琐,而且不一定能定位到内存泄露。
所以这里伟大的Facebook工程师们开源了一些自动化工具来解决监测内存泄露问题:FBRetainCycleDetector
、FBAllocationTracker
、FBMemoryProfiler
。
原文介绍:Automatic memory leak detection on iOS
中文翻译:在iOS上自动检测内存泄露
主线程UI长时间卡死,被系统杀掉
主线程被卡住是非常常见的场景,具体表现就是程序不响应任何的UI交互。这时按下调试的暂停按钮,查看堆栈,就可以看到是到底是死锁、死循环等,导致UI线程被卡住。
这部分需要研究多线程,还有如何看调试栏里的线程的信息。
iOS调试里的进程跟线程 这篇文章中有介绍多线程及死锁的原因。
多线程之间切换访问引起的crash
多线程引起的崩溃大部分是因为使用数据库的时候多线程同时读写数据库而造成了crash。 多线程导致的iOS闪退分析这篇文章就是关于多线程crash的调试。
多个 Crash 日志收集服务共存的坑
在自己的程序里集成多个 Crash 日志收集服务实在不是明智之举。通常情况下,第三方功能性 SDK 都会集成一个 Crash 收集服务,以及时发现自己 SDK 的问题。当各家的服务都以保证自己的 Crash 统计正确完整为目的时,难免出现时序手脚,强行覆盖等等的恶意竞争,总会有人默默被坑。
-
如果同时有多方通过 NSSetUncaughtExceptionHandler 注册异常处理程序,和平的作法是:
后注册者通过 NSGetUncaughtExceptionHandler 将先前别人注册的 handler 取出并备份,在自己 handler 处理完后自觉把别人的 handler 注册回去,规规矩矩的传递。不传递强行覆盖的后果是,在其之前注册过的日志收集服务写出的 Crash 日志就会因为取不到 NSException 而丢失Last Exception Backtrace等信息。(P.S. iOS 系统自带的 Crash Reporter 不受影响)
在开发测试阶段,可以利用 fishhook 框架去 hookNSSetUncaughtExceptionHandler方法,这样就可以清晰的看到 handler 的传递流程断在哪里,快速定位污染环境者。不推荐利用调试器添加符号断点来检查,原因是一些 Crash 收集框架在调试状态下是不工作的。
-
检测事例代码:
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); }
文章写得比较用心转至: iOS 崩溃Crash解析