众里寻他千百度,蓦然回首,那人却在灯火阑珊处。--《青玉案·元夕》
要学会看crash崩溃和报告
一个应用程序并不总会一直运行的很好,它总会有出现crash崩溃的情况。如果在应用程序中接入了一些第三方的crash收集工具或者自建crash收集报告平台的话将会很好的帮助开发者去分析和解决应用程序在线上运行的问题,当出现的崩溃问题能得到及时的解决和快速的修复时必将会大大的提升应用程序的用户体验。
当前比较流行的crash收集分析工具很多都是基于开源的KSCrash代码来进行封装和改进的。苹果自身也构建了一套crash采集和分析的机制,你可以从真机的联机日志或者从开发者账号中去查看对应的crash信息。网络上也有很多关于crash分析的文章,以及crash堆栈符号化处理的文章。这里假定你已经了解了一些查看crash报告的方法和技巧以及一些简单的crash分析技巧,因为这些是作为开发者需要具备的技能之一。
一个objc_msgSend+16崩溃栈
应用程序出现的crash崩溃异常有一些能够简单的被分析和解决,往往这些crash崩溃异常都会带有明确的上下文信息和函数调用层级堆栈。但并不是所有的crash崩溃异常都能被简单的解决,尤其是那些没有明确上下文信息的函数调用堆栈或者那些调用堆栈中没有一个函数或者方法能够被直接定位到源代码的场景,就如下面这个崩溃的函数调用栈(部分信息):
Incident Identifier: 85BE3461-D7FD-4043-A4B9-1C0D9A33F63D
CrashReporter Key: 9ec5a1d3b8d5190024476c7068faa58d8db0371f
Hardware Model: iPhone7,2
Code Type: ARM-64
Parent Process: ? [1]
Date/Time: 2018-08-06 16:36:58.000 +0800
OS Version: iOS 10.3.3 (14G60)
Report Version: 104
Exception Type: EXC_BAD_ACCESS (SIGBUS)
Exception Codes: 0x00000000 at 0x00000005710bbeb8
Crashed Thread: 2
Thread 2 name: WebThread
Thread 2 Crashed:
0 libobjc.A.dylib objc_msgSend + 16
1 UIKit -[UIWebDocumentView _updateSubviewCaches] + 40
2 UIKit -[UIWebDocumentView subviews] + 92
3 UIKit -[UIView(CALayerDelegate) _wantsReapplicationOfAutoLayoutWithLayoutDirtyOnEntry:] + 72
4 UIKit -[UIView(CALayerDelegate) layoutSublayersOfLayer:] + 1256
5 QuartzCore -[CALayer layoutSublayers] + 148
6 QuartzCore CA::Layer::layout_if_needed(CA::Transaction*) + 292
7 QuartzCore CA::Layer::layout_and_display_if_needed(CA::Transaction*) + 32
8 QuartzCore CA::Context::commit_transaction(CA::Transaction*) + 252
9 QuartzCore CA::Transaction::commit() + 504
10 QuartzCore CA::Transaction::observer_callback(__CFRunLoopObserver*, unsigned long, void*) + 120
11 CoreFoundation __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__ + 32
12 CoreFoundation __CFRunLoopDoObservers + 372
13 CoreFoundation CFRunLoopRunSpecific + 456
14 WebCore RunWebThread(void*) + 456
15 libsystem_pthread.dylib _pthread_body + 240
16 libsystem_pthread.dylib _pthread_body + 0
Thread 2 crashed with ARM-64 Thread State:
cpsr: 0x0000000020000000 fp: 0x000000016e18d7c0 lr: 0x000000018e2765fc pc: 0x0000000186990150
sp: 0x000000016e18d7b0 x0: 0x0000000174859740 x1: 0x000000018eb89b7b x10: 0x0000000102ffc000
x11: 0x00000198000003ff x12: 0x0000000102ffc290 x13: 0xbadd8a65710bbead x14: 0x0000000000000000
x15: 0x000000018caeb48c x16: 0x00000005710bbea8 x17: 0x000000018e2765d4 x18: 0x0000000000000000
x19: 0x0000000103a52800 x2: 0x0000000000000000 x20: 0x00000000000002a0 x21: 0x0000000000000000
x22: 0x0000000000000000 x23: 0x0000000000000000 x24: 0x0000000000000098 x25: 0x0000000000000000
x26: 0x000000018ebade52 x27: 0x00000001ad018624 x28: 0x0000000000000000 x29: 0x000000016e18d7c0
x3: 0x000000017463db60 x4: 0x0000000000000000 x5: 0x0000000000000000 x6: 0x0000000000000000
x7: 0x0000000000000000 x8: 0x00000001acfb9000 x9: 0x000000018ebf8829
Binary Images:
0x100030000 - 0x1022cbfff +xxxx arm64 <6b98f446542b3de5818256a8f2dc9ebf> /var/containers/Bundle/Application/441619EF-BD56-4738-B6CF-854492CDFAC9/xxxx.app/xxxx
0x1063f8000 - 0x106507fff MacinTalk arm64 <0890ce05452130bb9af06c0a04633cbb> /System/Library/TTSPlugins/MacinTalk.speechbundle/MacinTalk
0x107000000 - 0x1072e3fff TTSSpeechBundle arm64 <d583808dd4b9361b99a911b40688ffd0> /System/Library/TTSPlugins/TTSSpeechBundle.speechbundle/TTSSpeechBundle
...
0x18e03d000 - 0x18ede3fff UIKit arm64 <314063bdf85f321d88d6e24a0de464a2> /System/Library/Frameworks/UIKit.framework/UIKit
0x18ede4000 - 0x18ee0cfff CoreBluetooth arm64 <ced176702d7c37e6a9027eeb3fbf7f66> /System/Library/Frameworks/CoreBluetooth.framework/CoreBluetooth
这是一个在iOS10.3.3版本的64位设备上的一条crash异常报告的片段信息,要记住这些信息,它对定位crash崩溃异常有很大的帮助。从崩溃的函数调用栈中可以看出异常是出现在最顶层的函数调用objc_msgSend+16处,也就是在objc_msgSend函数的第5条指令处(通常情况下arm体系结构中每条指令占用4个字节,上述的信息表明是崩溃在函数的第16个字节的偏移地址处,也就是函数的第5条指令处)。崩溃异常类型显示为EXC_BAD_ACCESS表明是产生了无效的地址的读写访问,整个崩溃函数调用栈中没应用程序中的任何上下文信息。objc_msgSend函数是runtime方法执行的核心引擎而且调用如此的频繁,函数内部是不可能有BUG的。 那么为什么会崩溃在这呢?
当异常出现在没有源代码的函数内部时,唯一的方法就是去看它内部的“源代码”实现
既然出现问题是在objc_msgSend函数的第5条指令处,可以来看看这个函数实现的汇编代码指令开头片段:
;iOS10以后的objc_msgSend的部分实现代码。
_objc_msgSend:
00000001800bc140<+0> cmp x0, #0x0 ;判断对象receiver和0进行比较
00000001800bc144<+4> b.le 0x1800bc1ac ;如果对象指针为0或者高位为1则执行特殊处理跳转。
00000001800bc148<+8> ldr x13, [x0] ;取出对象的isa指针赋值给x13
00000001800bc14c<+12> and x16, x13, #0xffffffff8 ;得到对象的Class对象指针赋值给x16
00000001800bc150<+16> ldp x10, x11, [x16, #0x10] ;取出Class对象的cache成员分别保存到x10,x11寄存器中
-----------------------------------------------------------上面的指令就是代码崩溃处。
00000001800bc154<+20> and w12, w1, w11
无论是真机还是模拟器,XCODE都支持在运行时来查看任何调用的函数的汇编代码实现,你可以通过设置符号断点或者进入汇编调试模式以及单指令跳转的方式来查看函数的汇编代码实现。
从代码中可以看出是在读取对象的Class对象指针的数据成员cache时出现了无效的地址访问异常。但是对象的Class对象这部分定义数据是存储在进程内存的数据区段中,并且伴随着整个应用的生命周期而存在,是不可能被释放和销毁的,因此正常情况下是不可能存在非法内存地址访问异常的。会出现这种问题的原因就是调用方法的OC对象被销毁了,再说具体一点就是对一个已经被释放掉的OC对象继续调用了实例方法而导致的。因此当出现这种类型的崩溃时,不管是否有明确上下文,其原因都是一致的。下面这张图就能很清楚的说明其中的原因了:
实际上在arm64位系统中isa中保存的并不是对象的Class对象地址,上面的图目的是为了更加直观的显示问题原因。
一个OC对象obj在被销毁前,其中的isa指针会指向正确的Class对象所在的内存地址。因此调用objc_msgSend方法将会正常的运行,而一旦obj对象被销毁后,为其分配的堆内存将被回收用作其他用途,因此有可能这部分内存区域的数据会被覆写。当对一个已经释放了的OC对象继续调用实例方法时,在objc_msgSend函数内部读取到obj的isa指针得到的将是一个未知或者有可能无效的指针值。所以当对这个未知地址指向的内存进行访问时就出现了上面的EXC_BAD_ACCESS的异常崩溃了。
CPU指令中操作寄存器和常数的指令一般不会产生崩溃异常,比如上面的第1,2,4,6条指令;而一般产生访问异常的指令是发生在那些访问内存地址的指令当中,比如第3条和5条。
也许你会好奇既然obj对象已经被释放了,为什么崩溃会出现在objc_msgSend函数的第5条指令,其中的第3条指令是访问对象的isa数据的,为什么不崩溃在这呢? 其实答案很简单,因为几乎所有的OC对象都是从堆内存区域中分配内存的,所以当某个OC对象被销毁后,其所占用的内存仍然会放回堆内存区域中进行管理,而堆内存区域的地址是可以进行任意的读写访问的,所以即使对象被销毁释放,仍然是可以访问对象所指向的内存区域的数据的。
应用程序出现崩溃异常时除了函数调用栈可提供分析参考外,还可以从寄存器中的值来进行一步分析。根据上述的函数指令实现中可以看出:
x0 寄存器中的保存的就是那个被销毁了的对象指针。
x1 寄存器中保存的就是产生崩溃的对象的方法名称的地址。
x13 寄存器中保存的就是对象的isa指针值。
x16 寄存器中保存的就是对象的Class指针对象。
函数崩溃处指令为:
ldp x10, x11, [x16, #0x10]
这时候因为x16中其实保存的是一个非法的Class对象指针地址了,所以当执行ldp指令来从x16所指向地址的偏移0x10处读取内存数据时就产生了崩溃,而崩溃的异常代码:
Exception Codes: 0x00000000 at 0x00000005710bbeb8
中的地址值也刚好和x16寄存器中的值是一致的。也就是表明x16中所保存的Class对象指针就是一个非法和无效的内存地址。
在所有的OC方法中如果你设置了符号断点那么在方法开始执行时x0中保存的总是执行方法的对象,也是第一个方法的参数;x1中总是保存的执行的方法的名称字符串,也是第二个方法的参数;然后x2到x15有可能依次是方法的其他参数。因此通常情况下你可以在调试控制台中输入:
po $x0
来显示对象信息,p (char*)$x1
来显示方法名称。 具体的详细介绍可以参考我的另外一篇文章:寄存器介绍
上面的崩溃调用栈中,所有的函数和方法都是系统函数并没有程序自身的源代码,因此很难跟踪或者发现问题产生的原因,因为此时是无法知道是哪个类的对象执行方法调用而产生的crash了,唯一的线索就是x1寄存器中的值了。这个寄存器中的值保存的是调用的方法名, 它是一个SEL类型的数据,因此可以根据x1中保存的方法名来进行反推,也就是从方法名来反推出产生崩溃的对象的类名。
x1寄存器中保存的方法的内存地址是存在于某个加载的库Image的代码段中,因此可以在崩溃日志的Binary Images列表中找到定义方法名的库Image信息,Binary Images列表中的每个库Image都有这个库加载的开始和结束地址以及路径名称,可以很容易就从这些区间列表中找到x1寄存器所指的方法名到底属于哪个库。就上面的例子来说可以很明确的看到方法地址0x18eb89b7b是属于:
0x18e03d000 - 0x18ede3fff UIKit arm64 <314063bdf85f321d88d6e24a0de464a2> /System/Library/Frameworks/UIKit.framework/UIKit
也就是UIKit库中定义的某个对象在执行x1所指的方法而产生了崩溃。有了这个更进一步的信息后就可以在源代码中进行检查看看哪部分代码调用到了产生崩溃的库中所定义的对象了(当然UIKit这里不具备代表性,实际中崩溃时方法名也许会在其他的库中)。这样就从一定程度上能够缩小排查问题的范围。
常见的崩溃异常分析定位方法
当出现了没有上下文的崩溃异常调用栈时,并不是对它束手无策。除了可以根据异常类型(signal的类型)分析外,还可以借助搜索引擎以及一些常见的问题解答站点来寻找答案,当然还可以借助下面列出几种定位和分析的方法:
1.开源代码法
这个方法其实很简单,苹果其实开源了非常多的基础库的源代码,因此当程序崩溃在这些开源的基础库上时就可以去下载对应的基础库的源代码进行阅读。然后从源代码上进行问题的分析,从而找到产生异常崩溃的原因。你可以从https://opensource.apple.com处去下载开源的最新的源代码。这种方法的缺点是并不是所有的代码都是开源的,而且开源的代码并不一定是你真机设备上运行的iOS版本。因此这种方法只能是一种辅助方法。
2.方法符号断点法
采用这种方法时,确保你手头上要有一台和产生崩溃异常问题的操作系统版本相同的真机设备,以方便联机调试和运行。你可以在崩溃异常报告的:
OS Version: iOS 10.3.3 (14G60)
部分看到产生异常的操作系统版本号,就如本文的例子里面产生异常的操作系统版本号为iOS 10.3.3。因为相同的操作系统版本号中所有库中代码实现的都是一样的。如果实在没有对应的版本号的设备则可以试图找一台版本号最相近的设备。明确了操作系统版本和真机设备后再从代码仓库中检出和你线上相同版本的应用程序的源代码(假如崩溃调用栈中没有任何我们编写的函数代码则这个条件要求不必那么严格)。并打开项目工程,然后为产生崩溃的函数调用栈的栈顶函数或者方法名添加一个符号断点。如果你不知道如何添加符号断点请参考文章:https://blog.csdn.net/xuhen/article/details/77747456, 或者查找关键字:“XCODE 符号断点"。
设置符号断点的方法或者函数名时可以有如下的选择:
- 如果产生崩溃的栈顶是一个OC对象的方法则可以直接用这个类名和方法名来设置符号断点。
- 如果产生崩溃的栈顶是一个通用的C函数比如objc_msgSend、free、objc_release则考虑用函数调用栈的第二层函数和方法名来设置符号断点。比如文本例子中的-[UIWebDocumentView _updateSubviewCaches]方法。
- 如果产生崩溃的函数调用栈顶是一个没有对外暴露的C函数,因为这种函数设置符号断点的难度比交大,所以往往考虑采用函数调用栈的第二层函数或者方法名来做为符号断点。
设置符号断点的目的是为了在崩溃函数调用堆栈重现时,能在运行时的断点处进行动态分析。当你设置了符号断点后,如果程序逻辑运行到这个函数或者方法时,系统就会在设置的方法或者函数的第一条指令处停止下来。这时候就可以查看此时的函数调用栈是否和产生崩溃时的调用栈相符,如果相符合那么表明能够重现可能发生问题的逻辑了,如果断点处的调用栈和产生崩溃的调用栈不相同,则可能需要让程序继续运行,以便下次在同样断点处时进行调用栈的比较,因为设置断点的方法名并不一定只在一处被调用。
当程序停在了设置符号断点的函数或者方法的开始地址后,接下来就需要在这个方法内进行第二个断点的设置,设置的地方就是崩溃函数调用栈中函数调用上层函数的偏移处,这个可以在崩溃的报告中看到:
0 libobjc.A.dylib objc_msgSend + 16
1 UIKit -[UIWebDocumentView _updateSubviewCaches] + 40
也就是需要在_updateSubviewCaches函数的第11条指令或者函数的第40个偏移字节附近处添加一个断点。这样当程序运动到断点处时就可以在函数调用上层函数前查看各寄存器的值从而进行问题的定位和分析。
一般情况下崩溃函数栈报告中除栈顶函数外的每一层函数名后 + 的数字表明是在当前函数的对应的地址偏移处附近进行了上层函数的调用,也就是对应的地址偏移附近一般都会存在一条bl指令或者blr这两条指令,这两条指令的作用就是执行函数的调用。
通过二次断点的设置,程序运行到断点时的指令是:
0x18c0248fc <+36>: bl 0x1893042dc ;0x1893042dc 这个地址就是objc_msgSend的函数地址
本例子的异常崩溃的原因是对一个已经释放的对象继续调用方法而产生的崩溃。所以当断点停在指令处时,我们可以在右下角的lldb控制台中打印指令:
(lldb)po $x0
<__NSArrayM 0x1c044c2a0>(
<UIWebOverflowScrollView: 0x1281d7e00; frame = (0 0; 375 603); clipsToBounds = YES; gestureRecognizers = <NSArray: 0x1c0851190>; layer = <WebLayer: 0x1c4426ba0>; contentOffset: {0, 0}; contentSize: {375, 12810}; adjustedContentInset: {0, 0, 0, 0}>
)
(lldb) p (char*)$x1
(char *) $6 = 0x000000018cb9dd70 "release"
(lldb)
可以看出x0是一个数组对象,而x1中则是release方法。这样就进一步明确了是对一个已经释放了的数组对象调用了release方法而导致异常崩溃了。至于x0是一个什么数组以及保存在哪里,则可以通过汇编指令中的x0寄存器的使用进行回溯往上查找指令来进一步分析了。其实这个问题如果进一步观察就可以看出:崩溃的线程并不是出现在主线程,而是在一个工作线程中。而视图的操作基本都应该放在主线程进行,因此当主线程的某些子视图数组对象被释放后,这里又在辅助线程中进行读取访问,就出现了上面的异常崩溃问题了。
在函数调用bl或者blr指令处设置断点后,因为根据ABI规则所有非浮点数的参数分别依次保存在x0,x1,....这些寄存器中。所以可以在断点处分别打印出这些寄存器的值就可以知道函数调用前所传递的参数值了。这个方法非常有助于进行问题的定位和分析。
3.手动重现法
有时候即使你设置了符号断点,场景依然无法重现,这时候就需要采用一些特殊的手段,那就是手动的执行方法调用。实现方式很简单就是在某个演示代码中人为的进行崩溃栈顶函数的调用。就比如上面的例子当[UIWebDocumentView _updateSubviewCaches]
方法一直不被执行时,就可以自己手动的去创建一个UIWebDocumentView对象,并手动的调用对应的方法_updateSubviewCaches即可。这里存在的两个问题是有可能这个类并没有对外进行声明,或者我们并不知道方法的参数类型或者需要传递的值。对于第一个问题解决的方法可以采用NSClassFromString来得到类信息并进行对象创建。而第二个问题则可以借助一些工具比如class-dump或者一些其他的手段来确认方法的参数个数和参数类型。总之,目的就是为了能够进入函数的断点,甚至都可以在不知道如何传递参数时将所有的参数都传值为0或者nil来临时解决问题。下面就是模拟崩溃函数的调用实现代码:
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
// Override point for customization after application launch.
//因为类名和方法名都未对外公开,我们可以借助一些技术手段来让某个特定的方法执行,目的是为了能够进入到方法的内部实现。
Class cls = NSClassFromString(@"UIWebDocumentView");
id obj = [[cls alloc] init];
SEL sel = sel_registerName("_updateSubviewCaches");
[obj performSelector:sel];
//...
}
测试代码可以写在任何一个地方,这里为了方便就在程序启动处加上测试代码。等代码编写完毕后,就可以为方法设置符号断点。这样当程序一运行时就一定能够进入到这个函数的内部去。一旦函数被执行后出现了断点,就可以按照第2种方法中的介绍进行崩溃分析了。
其实第3种方法的原则就是只要能让产生崩溃异常的方法被调用,这其中可以尝试着采用各种手段将对象和方法run起来。
4.第三方工具静态分析法
前面两种介绍的都是动态分析法, 有时候还可以借助一些反编译的工具来对程序代码进行静态分析。比如像Hopper或者IDA之类的工具。缺点就是这些工具是收费的,而且效果没有动态分析那么的好。在使用上个人觉得IDA分析工具更加友好和强大一些。
采用第三方工具时需要找到产生崩溃的函数所在的库,函数所在的库在崩溃的函数调用栈列表中就能找到了。如果崩溃函数是在应用程序本身中被定义,那么需要将上传到appstore的ipa文件解压缩并提取出其中的可执行程序用工具打开即可。如果崩溃函数是在某个系统库中被定义,那么可从如下的路径:
~/Library/Developer/Xcode/iOS DeviceSupport/
iOS DeviceSupport这个文件夹下的内容将展示你所有曾经联机调试过的各种操作系统版本的库的一份拷贝,如果你没有真机调试过出现崩溃的操作系统版本,请找一个安装了这个操作系统版本的真机设备,并联机,这样你的文件夹中就会有对应的操作系统版本下的系统库的拷贝信息了。
中找到对应的产生崩溃的手机操作系统版本号的库文件:10.3.3(14G60)/Symbols/System/Library/Frameworks/UIKit.framework/UIKit
当用IDA工具打开对应的库文件或者可执行文件时你看到的将是这个库文件的所有汇编形式的代码和数据。因此你可以通过搜索菜单来查找产生崩溃的函数或者方法名。这时候你就可以进一步对产生问题的函数的汇编代码进行分析了。采用IDA工具进行汇编代码分析的缺点是静态分析无法看到运行时的各个寄存器的真实的值,因此采用这种方法可能更需要考虑你对汇编代码的理解能力。下面就是本文例子中的[UIWebDocumentView _updateSubviewCaches]方法的实现汇编代码:
采用IDA工具进行分析时,需要了解一些比如库基地址和代码数据偏移地址以及地址重定向相关的知识。苹果系统为安全对每个库的加载都采用了ASLR的方式,也就是库所加载的基地址每次运行时都是随机的,这样当某次崩溃发生时需要将产生崩溃时的地址转化为我们通过IDA工具打开的地址。 转换公式为:
转换后的地址 = 崩溃时寄存器中保存的原始地址值 - 崩溃时地址所在的库的基地址值 + 工具打开库时所设定的基地址。
就以上面崩溃异常为例,当我们用IDA工具看看x1寄存器中的值到底是一个什么方法名,那么只需要把x1的值(0x018eb89b7b),减去其所在的库UIKit的基地址值(0x18e03d000),在加上IDA工具打开库时的基地址(要想看基地址则滚动到IDA视图的最开始部分,本次打开的基地址为:0x187769000)。所以x1寄存器中的地址值被转化后应该为:
0x018eb89b7b - 0x18e03d000 + 0x187769000 = 0x1882B5B7B
在IDA工具中将地址跳转到0x1882B5B7B就可以看到本例子中产生崩溃的方法名是叫release:
当然IDA工具是可以手动进行基地址的自定义设置的,这样就不需要进行计算以便和线上崩溃的基地址对齐。
如果你手头上没有第三方工具,其实系统内置的otools工具也可以帮我们进行问题的定位以及汇编代码的查看和分析了,具体的方法大家就去查找相关的对otools使用的教程即可,这里就不展开了。
总结
上面列出的所有分析方法中有静态分析的也有动态分析。当出现了崩溃时除了从崩溃函数调用栈去分析问题,还可以从寄存器,以及加载的镜像列表,以及崩溃栈顶部的函数的汇编代码等等进行综合的分析和判断。当然即使这样也不能保证所有问题就一定能够得到解决,本文中列举的例子只是在实际中的一种非常常见的崩溃异常,希望通过这个示例来起到一个抛砖引玉的效果,毕竟不同的崩溃异常的差异是比较大的。遇到问题需要具体分析,走进函数的内部实现就一定能够找到产生问题的根源。
👉【返回目录】