一 背景
在日常mPaas客户端运维中,经常遇到一些iOS闪退,无法直接从闪退堆栈看到原因。主要是因为iOS客户端上传的崩溃日志里的调用栈信息都是通过内存地址记录的,无法直接看到闪退的调用栈信息。如果需要定位到调用栈,需要使用符号表对闪退日志进行符号化。本文从日志收集、日志符号化原理、符号化工具等方向介绍下iOS下crash日志符号化方案。
二 Crash⽇志符号化
1. Crash ⽇志监听与来源
1.1. 原⽣收集
- Xcode -> Window -> Organizer -> Crashes ⾥⾯可以查看数据化指标。 这⾥能查看到Xcode 和 App Store 数据化后设备上报的crash,但这⾥统计并不完全。
- 真机连接电脑,打开 Xcode -> Window -> Devices and Simulators -> 选中设备 -> View Device Logs,可以看到设备的所有⽇志,包括崩溃⽇志。
- 真机连接电脑,⽤户⼿机上设置 -> 隐私 -> 分析与改进 -> 分析数据,可以连接电脑 Xcode 导出,找到崩溃⽇志,未符号化。
- 通过“⾳乐”同步到本地 ~/Library/Logs/CrashReporter/MobileDevice/xxx的 iPhone,未符号化。
1.2. 应⽤内收集
- 接⼊ PLCrashReporter 、 KSCrash 等 SDK 进⾏收集,上报到⾃建平台统计。
- 接⼊ mPaaS、Bugly等产品。 mPaaS当前也是基于PLCrashReporter 进⾏崩溃信息收集,上报⽇志,后端进⾏符号化。
1.3. 监控原理
iOS端闪退监听分为两⼤类:
- OC异常,即捕获NSException异常,通过NSSetUncaughtExceptionHandler函数来全局设置异常处理函数。在 didFinishLaunchingWithOptions 中设置该⽅法 :
- (BOOL)application:(UIApplication *)application didFinishLaunchin gWithOptions:(NSDictionary *)launchOptions {
NSSetUncaughtExceptionHandler(&uncaughtExceptionHandler);
return YES;
}
void uncaughtExceptionHandler(NSException * exception){
//获取系统当前时间,(注:⽤[NSDate date]直接获取的是格林尼治时间,有时差)
NSDateFormatter *formatter =[[NSDateFormatter alloc] init];
[formatter setDateFormat:@"yyyy-MM-dd HH:mm:ss"];
NSString *crashTime = [formatter stringFromDate:[NSDate date]];
[formatter setDateFormat:@"yyyyMMdd"];
NSString *crashDate = [formatter stringFromDate:[NSDate date]];
//异常的堆栈信息
NSArray *stackArray = [exception callStackSymbols];
//出现异常的原因
NSString *reason = [exception reason];
//异常名称
NSString *name = [exception name];
//拼接错误信息
NSString *exceptionInfo = [NSString stringWithFormat:@"CrashTime: %@\nException reason: %@\nException name: %@\nException call stack:%@\n", crashTime, name, reason, stackArray];
//把错误信息保存到本地⽂件,设置errorLogPath路径下
//并且经试验,此⽅法写⼊本地⽂件有效。
NSString *errorLogPath = [NSString stringWithFormat:@"%d/Cras hLogs/%@/", NSDocumentsDirectory(), crashDate];
NSFileManager *manager = [NSFileManager defaultManager];
if (![manager fileExistsAtPath:errorLogPath]) {
[manager createDirectoryAtPath:errorLogPath withIntermedi ateDirectories:true attributes:nil error:nil];
}
errorLogPath = [errorLogPath stringByAppendingFormat:@"%@.lo g",crashTimeStr];
NSError *error = nil;
NSLog(@"%@", errorLogPath);
BOOL isSuccess = [exceptionInfo writeToFile:errorLogPath atom ically:YES encoding:NSUTF8StringEncoding error:&error];
if (!isSuccess) {
NSLog(@"将crash信息保存到本地失败: %@", error.userInfo);
}
}
- Unix信号异常,即signal 异常。常⻅的如:
SIGSEGV:(Segmentation Violation,段违例),⽆效内存地址,⽐如空指针,未初始化指针,栈溢出等;
SIGABRT:收到Abort信号,可能⾃身调⽤abort()或者收到外部发送过来的信号;
SIGBUS:总线错误。与SIGSEGV不同的是,SIGSEGV访问的是⽆效地址(⽐如虚存映射不到物理内存),⽽SIGBUS访问的是有效地址,但总线访问异常(⽐如地址对⻬问题);
SIGILL:尝试执⾏⾮法的指令,可能不被识别或者没有权限;
SIGFPE:Floating Point Error,数学计算相关问题(可能不限于浮点计算),⽐如除零操作;
SIGPIPE:管道另⼀端没有进程接⼿数据;
2. Crash ⽇志符号化
关于crash ⽇志的各个字段、⽇志块的分析这⾥不再赘述,有另外⽂档详细说明。 crash⽇志符号化通常是通过 atos 和 symbolicatecrash 这两个⼯具来完成。⾸先确认crash ⽇志和dsYM 符号表是否匹配⼀致,终端命令:
dwarfdump --uuid ⾃⼰App的.dsYM
dwarfdump --uuid ⾃⼰App的.app
包含armv7/arm64两组UUID。
2.1. 通过 atos 符号化
atos 是苹果提供的符号化⼯具,在Mac OS系统下默认安装,缺点是只能⼀个地址⼀个地址逐个翻译。
使用方法:
atos -arch -o /Contents/R esources/DWARF/ -l
需要传⼊这⼏个信息:arch 架构、dSYM路径、binary image 载⼊内存的初始地址、崩溃的地址。示例:
atos -arch arm64 -o eee.app.dSYM/Contents/Resources/DWARF/eApp -l 0x1009e0000 0x0000000101b00558
2.2. 通过 symbolicatecrash 符号化
symbolicatecrash 路径: /Applications/Xcode.app/Contents/SharedFrameworks/DVTFoundation.framework/Versions/A/ Resources/symbolicatecrash
使用方法:
export DEVELOPER_DIR="/Applications/Xcode.App/Contents/Developer" ./symbolicatecrash 原始crash⽇志.crash -d ⾃⼰App.app.dSYM > 符号化后的⽇志.log
示例:
export DEVELOPER_DIR="/Applications/Xcode.App/Contents/Developer" ./symbolicatecrash symbol.crash -d eee.app.dSYM > result.log
3. 符号化原理分析
symbolicatecrash 本质可以理解就是⼀个脚本,⾸先,⼀个基本原则是需要确保你的电脑上有每个 image 对应的 uuid 的符号表⽂件,这样crash⽂件才能被正确解析和符号化出来。
3.1. 解析所有的Binary Image
这是crash⽇志中的Binary Image格式
0x1cd997000 - 0x1cea7bfff UIKitCore arm64 <40a93e939f8635c1905c7 b947c7c2305> /System/Library/PrivateFrameworks/UIKitCore.framewor k/UIKitCore
转换为如下格式
'UIKitCore' =>
{
'extent' => '0x1cea7bfff',
'plus' => '',
'bundlename' => 'UIKitCore',
'uuid' => '40a93e939f8635c1905c7b947c7c2305',
'base' => '0x1cd997000',
'path' => '/System/Library/PrivateF rameworks/UIKitCore.framework/UIKitCore',
'arch' => 'arm64',
'nextID' => ''
}
把每⼀个Binary Image都存储为以上形式的对象。
Binary Image的作⽤是建⽴UIKitCore与uuid的关系,当需要符号化⼀个UIKitCore的地址时,会找到对应的uuid,并从⽂件系统中查找到这个符号表。
3.2. 解析所有线程
这是crash⽇志中堆栈的格式
8 TheElement 0x00000001044dcfc0 0x104058000 + 47 39008
转换为如下格式
'0x00000001044dcfc0 0x104058000 + 4739008' =>
{
'raw_address' => '0x00000001044dcfc0',
'bundle' => 'TheElement',
'address' => '0x00000001044dcfc0'
}
把所有堆栈存储为以上形式的对象。
3.3. 翻译Last Exception Backtrace
这是crash⽇志中的Last Exception Backtrace
Last Exception Backtrace:
(0x1a1a9127c 0x1a0c6b9f8 0x1a19adab8 0x1a1a96ac4 0x1a1a9875c 0x10 566d498 0x10423ab84 0x1ce255040 0x1cdcfe1c8 0x1cdcfe4e8 0x1cdcfd5 54 0x1ce28c304 0x1ce28d52c 0x1ce26d59c 0x10437fd20 0x1ce333714 0x 1ce335e40 0x1ce32f070 0x1a1a23018 0x1a1a22f98 0x1a1a22880 0x1a1a1 d7bc 0x1a1a1d0b0 0x1a3c1d79c 0x1ce253978 0x104283158 0x1a14e28e0)
翻译为:
0 libsystem_kernel.dylib 0x00000001a162e0dc 0x1a160b 000 + 143580
1 libsystem_pthread.dylib 0x00000001a16a7094 0x1a16a5 000 + 8340
2 libsystem_c.dylib 0x00000001a1587f4c 0x1a152d 000 + 372556
3 libsystem_c.dylib 0x00000001a1587eb4 0x1a152d 000 + 372404
4 libc++abi.dylib 0x00000001a0c54788 0x1a0c53 000 + 6024
5 libc++abi.dylib 0x00000001a0c54934 0x1a0c53 000 + 6452
6 libobjc.A.dylib 0x00000001a0c6be00 0x1a0c66 000 + 24064
7 TheElement 0x0000000104babb18 0x10405800 0 + 11877144
8 TheElement 0x00000001044dcfc0 0x10405800 0 + 4739008
9 libc++abi.dylib 0x00000001a0c60838 0x1a0c53 000 + 55352
10 libc++abi.dylib 0x00000001a0c60434 0x1a0c53 000 + 54324
11 libobjc.A.dylib 0x00000001a0c6bbc8 0x1a0c66 000 + 23496
12 CoreFoundation 0x00000001a1a1d11c 0x1a1979 000 + 672028
13 GraphicsServices 0x00000001a3c1d79c 0x1a3c13 000 + 42908
14 UIKitCore 0x00000001ce253978 0x1cd997 000 + 9161080
15 TheElement 0x0000000104283158 0x10405800 0 + 2273624
16 libdyld.dylib 0x00000001a14e28e0 0x1a14e1 000 + 6368
3.4. 删除不需要的image
因为crash⽇志把App⽤到的所有Binary Image都列举出来了,⽽崩溃堆栈中只⽤到了⼀⼩部分,所以这⾥把没有⽤到的Binary Image删除。后续要遍历所有images,去找到每个⼆进制对应的dSYM,这样做提⾼了效率。
3.5. 查找Binary Image的符号表
符号表的类型
- App编译出来的dSYM (⼀般输⼊命令时指定在哪⾥,如果没有会⾃动去查找)
- 系统库的符号表(⾃动查找)系统符号表和APP符号表是分开的。在~/Library/Developer/Xcode/iOS DeviceSupport/os/Symbols 这个路径再拼上image中的path,就是完整路径⽐如 ~/Library/Developer/Xcode/iOS DeviceSupport/os/Symbols/System/Library/PrivateFrameworks/UIKitCore.framework/UIKit Core
- 从search path中找(包括命令⾏输⼊的⼏个⽬录和系统符号表所在⽬录)
- mdfind搜索uuid相同的符号表,会使⽤uuid去查找,所以命令⾏中不传也没关系。
- 如果还没找到返回空并删除这个image,与这个image相关的都不能被符号化。
判断匹配的条件
- lipo -info 判断架构是否⼀致
- otool 命令打出来macho信息,找到uuid 并判断是否⼀致。只有uuid相同,才可以被符号化出来。相同代码重新打⼀个包出来也不能符号化,因为uuid不同。
3.6. 执⾏atos进⾏符号化
- 遍历所有线程。
- 取到每⼀条的bundle 还有地址在images中找到符号表路径。
- 执⾏命令并记录符号化后的内容。
3.7. 字符串替换⽣成最终的报告
逐⾏开始替换,⽐如将 '0x00000001044dcfc0 0x104058000 + 4739008' 替换为'CPPExceptionTerminate() (SentryCrashMonitor_CPPException.cpp:179)'
三 iOS 崩溃监控
1. 崩溃的⽇志都是如何捕获收集
App 上线后,是很脆弱的,导致其崩溃的问题,不仅包括编写代码时的各种⼩⻢⻁,还包括那些被系统强杀的疑难杂症。
下⾯,我们就先看看⼏个常⻅的编写代码时的⼩⻢⻁,是如何让应⽤崩溃的。
- 数组越界:在取数据索引时越界,App 会发⽣崩溃。还有⼀种情况,就是给数组添加了 nil 会崩溃。
- 多线程问题:在⼦线程中进⾏ UI 更新可能会发⽣崩溃。多个线程进⾏数据的读取操作,因为处理时机不⼀致,⽐如有⼀个线程在置空数据的同时另⼀个线程在读取这个数据,可能会出现崩溃情况。
- 主线程⽆响应:如果主线程超过系统规定的时间⽆响应,就会被 Watchdog 杀掉。这时,崩溃问题对应的异常编码是 0x8badf00d。关于这个异常编码,我还会在后⽂和你说明。野指针:指针指向⼀个已删除的对象访问内存区域时,会出现野指针崩溃。
- 野指针问题是需要我们重点关注的,因为它是导致 App 崩溃的最常⻅,也是最难定位的⼀种情况。
程序崩溃了,你的 App 就不可⽤了,对⽤户的伤害也是最⼤的。因此,每家公司都会⾮常重视⾃家产品的崩溃率,并且会将崩溃率(也就是⼀段时间内崩溃次数与启动次数之⽐)作为优 先级最⾼的技术指标,⽐如千分位是⽣死线,万分位是达标线等,去衡量⼀个 App 的⾼可⽤性。
⽽崩溃率等技术指标,⼀般都是由崩溃监控系统来搜集。同时,崩溃监控系统收集到的堆栈信息,也为解决崩溃问题提供了最重要的信息。
但是,崩溃信息的收集却并没有那么简单。因为,有些崩溃⽇志是可以通过信号捕获到的,⽽很多崩溃⽇志却是通过信号捕获不到的。
通过这张图⽚,我们可以看到, KVO 问题、NSNotification 线程问题、数组越界、野指针等崩溃信息,是可以通过信号捕获的。
但是,像后台任务超时、内存被打爆、主线程卡顿超阈值等信息,是⽆法通过信号捕捉到的。但是,只有捕获到所有崩溃的情况,我们才能实现崩溃的全⾯监控。也就是说,只有先发现了问题,然后才能够分析问题,最后解决问题。接下来,我就⼀起分析下如何捕获到这两类崩溃信息。
在提交时选上“Upload your app s symbols to receive symbolicated reports from Apple” ,以后你就可以直接在 Xcode 的 Archive ⾥看到符号化后的崩溃⽇志了。
但是这种查看⽇志的⽅式,每次都是纯⼿⼯的操作,⽽且时效性较差。所以,⽬前很多公司的崩溃⽇志监控系统,都是通过PLCrashReporter 这样的第三⽅开源库捕获崩溃⽇志,然后上传到⾃⼰服务器上进⾏整体监控的。⽽没有服务端开发能⼒,或者对数据不敏感的公司,则会直接使⽤ Fabric或者Bugly来监控崩溃。
它表示的是,EXC_BAD_ACCESS 这个异常会通过 SIGSEGV 信号发现有问题的线程。虽然信号的种类有很多,但是都可以通过注册 signalHandler 来捕获到。其实现代码,如下所示:
void registerSignalHandler(void) {
signal(SIGSEGV, handleSignalException);
signal(SIGFPE, handleSignalException);
signal(SIGBUS, handleSignalException);
signal(SIGPIPE, handleSignalException);
signal(SIGHUP, handleSignalException);
signal(SIGINT, handleSignalException);
signal(SIGQUIT, handleSignalException);
}
void handleSignalException(int signal) {
NSMutableString *crashString = [[NSMutableString alloc]init];
void* callstack[128];
int i, frames = backtrace(callstack, 128);
char** traceChar = backtrace_symbols(callstack, frames);
for (i = 0; i
[crashString appendFormat:@"%s\n", traceChar[i]];
}
NSLog(crashString);
}
上⾯这段代码对各种信号都进⾏了注册,捕获到异常信号后,在处理⽅法handleSignalException ⾥通过 backtrace_symbols ⽅法就能获取到当前的堆栈信息。堆栈信息可以先保存在本地,下次启动时再上传到崩溃监控服务器就可以了。
先将捕获到的堆栈信息保存在本地,是为了实现堆栈信息数据的持久化存储。那么,为什么要实现持久化存储呢?
这是因为,在保存完这些堆栈信息以后,App 就崩溃了,崩溃后内存⾥的数据也就都没有了。⽽将数据保存在本地磁盘中,就可以在 App 下次启动时能够很⽅便地读取到这些信息。
2. 信号捕获不到的崩溃信息怎么收集
2.1. 崩溃信息收集
你是不是经常会遇到这么⼀种情况,App 退到后台后,即使代码逻辑没有问题也很容易出现崩溃。⽽且,这些崩溃往往是因为系统强制杀掉了某些进程导致的,⽽系统强杀抛出的信号还由于系统限制⽆法被捕获到。
⼀般,在退后台时你都会把关键业务数据保存在内存中,如果保存过程中出现了崩溃就会丢失 或损坏关键数据,进⽽数据损坏⼜会导致应⽤不可⽤。这种关键数据的损坏会给⽤户带来巨⼤的损失。
那么,后台容易崩溃的原因是什么呢?如何避免后台崩溃?怎么去收集后台信号捕获不到的那些崩溃信息呢?还有哪些信号捕获不到的崩溃情况?怎样监控其他⽆法通过信号捕获的崩溃信息?
2.1.1 后台崩溃
⾸先,我们来看第⼀个问题,后台容易崩溃的原因是什么?这⾥,我先介绍下 iOS 后台保活的 5 种⽅式:Background Mode、Background Fetch、Silent Push、PushKit、Background Task。
- 使⽤ Background Mode ⽅式的话,App Store 在审核时会提⾼对 App 的要求。通常情况下,只有那些地图、⾳乐播放、VoIP 类的 App 才能通过审核。
- Background Fetch ⽅式的唤醒时间不稳定,⽽且⽤户可以在系统⾥设置关闭这种⽅式,导致它的使⽤场景很少。
- Silent Push 是推送的⼀种,会在后台唤起 App 30 秒。它的优先级很低,会调⽤application:didReceiveRemoteNotifiacation:fetchCompletionHandler: 这个delegate,和普通的 remote push notification 推送调⽤的 delegate 是⼀样的。
- PushKit 后台唤醒 App 后能够保活 30 秒。它主要⽤于提升 VoIP 应⽤的体验。
- Background Task ⽅式,是使⽤最多的。App 退后台后,默认都会使⽤这种⽅式。
我们就看⼀下,Background Task ⽅式为什么是使⽤最多的,它可以解决哪些问题?
在你的程序退到后台以后,只有⼏秒钟的时间可以执⾏代码,接下来就会被系统挂起。进程挂起后所有线程都会暂停,不管这个线程是⽂件读写还是内存读写都会被暂停。但是,数据读写过程⽆法暂停只能被中断,中断时数据读写异常⽽且容易损坏⽂件,所以系统会选择主动杀掉App 进程。
⽽ Background Task 这种⽅式,就是系统提供了beginBackgroundTaskWithExpirationHandler ⽅法来延⻓后台执⾏时间,可以解决你退后台后还需要⼀些时间去处理⼀些任务的诉求。
Background Task ⽅式的使⽤⽅法,如下⾯这段代码所示:
- (void)applicationDidEnterBackground:(UIApplication *)application {
self.backgroundTaskIdentifier = [application beginBackgroundTa skWithExpirationHandler:^(void){
[self yourTask];
}];
}
在这段代码中,yourTask 任务最多执⾏ 3 分钟,3 分钟内 yourTask 运⾏完成,你的 App 就会挂起。如果 yourTask 在 3 分钟之内没有执⾏完的话,系统会强制杀掉进程,从⽽造成崩溃,这就是为什么 App 退后台容易出现崩溃的原因。
后台崩溃造成的影响是未知的。持久化存储的数据出现了问题,就会造成你的 App ⽆法正常使⽤。
2.1.2. 如何避免后台崩溃
接下来,我们再看看第⼆个问题:如何避免后台崩溃呢?
你知道了, App 退后台后,如果执⾏时间过⻓就会导致被系统杀掉。那么,如果我们要想避免这种崩溃发⽣的话,就需要严格控制后台数据的读写操作。⽐如,你可以先判断需要处理的数据的⼤⼩,如果数据过⼤,也就是在后台限制时间内或延⻓后台执⾏时间后也处理不完的话,可以考虑在程序下次启动或后台唤醒时再进⾏处理。
同时,App 退后台后,这种由于在规定时间内没有处理完⽽被系统强制杀掉的崩溃,是⽆法通过信号被捕获到的。这也说明了,随着团队规模扩⼤,要想保证 App ⾼可⽤的话,后台崩溃的监控就尤为重要了。
那么,我们⼜应该怎么去收集退后台后超过保活阈值⽽导致信号捕获不到的那些崩溃信息呢?
采⽤ Background Task ⽅式时,我们可以根据 beginBackgroundTaskWithExpirationHandler 会让后台保活 3 分钟这个阈值,先设置⼀个计时器,在接近 3 分钟时判断后台程序是否还在执⾏。如果还在执⾏的话,我们就可以判断该程序即将后台崩溃,进⾏上报、记录,以达到监控的效果。
还有哪些信号捕获不到的崩溃情况?怎样监控其他⽆法通过信号捕获的崩溃信息?其他捕获不到的崩溃情况还有很多,主要就是内存打爆和主线程卡顿时间超过阈值被 watchdog 杀掉这两种情况。其实,监控这两类崩溃的思路和监控后台崩溃类似,我们都先要找到它们的阈值,然后在临近阈值时还在执⾏的后台程序,判断为将要崩溃,收集信息并上报。
对于内存打爆信息的收集,你可以采⽤内存映射(mmap)的⽅式来保存现场。主线程卡顿时间超过阈值这种情况,你只要收集当前线程的堆栈信息就可以了。
2.2. 崩溃信息分析
采集到崩溃信息后如何分析并解决崩溃问题呢?通过上⾯的内容,我们已经解决了崩溃信息采 集的问题。现在,我们需要对这些信息进⾏分析,进⽽解决 App 的崩溃问题。
我们采集到的崩溃⽇志,主要包含的信息为:进程信息、基本信息、异常信息、线程回溯。
- 进程信息:崩溃进程的相关信息,⽐如崩溃报告唯⼀标识符、唯⼀键值、设备标识;
- 基本信息:崩溃发⽣的⽇期、iOS 版本;
- 异常信息:异常类型、异常编码、异常的线程;线程回溯:崩溃时的⽅法调⽤栈。
- 线程回溯:崩溃时的⽅法调⽤栈。
通常情况下,我们分析崩溃⽇志时最先看的是异常信息,分析出问题的是哪个线程,在线程回溯⾥找到那个线程;然后,分析⽅法调⽤栈,符号化后的⽅法调⽤栈可以完整地看到⽅法调⽤ 的过程,从⽽知道问题发⽣在哪个⽅法的调⽤上。
有了崩溃的⽅法调⽤堆栈后,⼤部分问题都能够通过⽅法调⽤堆栈,来快速地定位到具体是哪 个⽅法调⽤出现了问题。有些问题仅仅通过这些堆栈还⽆法分析出来,这时就需要借助崩溃前⽤户相关⾏为和系统环境状况的⽇志来进⾏进⼀步分析。
四 总结
有了崩溃⽇志就能解决所有问题了吗?不能。基于汇编基础上的⼀种分析⽅法。可能发现定位到了具体的汇编⾏,但是有些问题还是较难分析,因为我们只拿到了代码信息,⽽运⾏时的各种状态都是丢失的。
进⼀步:根据代码信息猜测现场情况,缩⼩复现场景的范围; Crash 代码地⽅附近进⾏断点设置,观察正常情况下的寄存器⾏为。
现有的崩溃监控系统,不管是开源的崩溃⽇志收集库还是类似 Bugly 的崩溃监控系统,离最优解都还有⼀定的距离。
这个"⾮最优",我们需要分两个维度来看:⼀个维度是,怎样才能够让崩溃信息的收集效率更⾼,丢失率更低;另⼀个维度是,如何能够收集到更多的崩溃信息,特别是系统强杀带来的崩溃。
随着 iOS 系统的迭代更新,强杀阈值和强杀种类都在不断变化,因此崩溃监控系统也需要跟上系统迭代更新的节奏,同时还要做好向下兼容。