海神平台 iOS 端崩溃日志解析踩坑之旅

发布于:2020 年 8 月 6 日 14:06

前阿里 P9 技术专家 李运华,正在传授十几年架构实战心法,超过 46000 人跟随学习,点击查看 >>
海神平台iOS端崩溃日志解析踩坑之旅

开始这篇文章的时候,我内心是拒绝的,毕竟 Google 搜索“iOS Crash 解析”,内容不要太多。

自己其实也是阅文无数,但是每每到了动手解析一个 Crash 日志的时候,往往还要再去翻书签。“纸上得来终觉浅,绝知此事要躬行”。这次我们换个思路,以前都是讲完原理讲应用,这次我们讲海神平台在 Crash 解析功能开发过程中遇到的问题以及解决方案,争取大家看完以后不用存书签。

1. 如何获取 Crash 日志

元数据获取的话题永不过时。

常见的日志获取方式有以下几种:

  1. 将崩溃设备连接到 Xcode 导出 Crash 日志,如果有符号表,则 Crash 日志直接被解析
  2. 依赖三方应用,类似于 iTools、iMazing 等,可以导出 Crash 日志
  3. 通过 iTunes Connection 获取,此项需要用户在客户端授权
  4. 使用 imobiledevice 套件进行导出,这种方式在自动化测试中广泛应用
  5. 使用 bugly、Fabric 等商业平台收集,这种方式在发布环境使用较多
  6. 使用开源 Crash 框架收集上报,这种方式在发布环境使用较多

前 4 种方案在开发测试阶段非常有效,但是应用发布之后却比较无力,因为开发同学既无法获取崩溃设备,也无法保证用户授权。

方案 5,当前商业平台都没有数据导出接口,所以无法获取崩溃日志。

海神平台的定位是已发布应用的崩溃日志收集平台,所以最终依赖 KSCrash 在发布阶段获取客户端 Crash 日志。

1.1 KSCrash 与上下文

KSCrash 是著名且有效的崩溃日志收集框架,提供抓取多种类型崩溃上下文,包括:mach、signal、C++ Exception、OC Exception 等的能力,并支持多种渠道数据上报。详细内容可以参考 Github。

KSCrash 抓取的上下文默认组织为 JSON 格式,同时框架提供了类和方法用于将 JSON 转换为 Apple Format。下面是一个 JSON 格式的例子:

复制代码
{
"report": {
"id": "2BF8D28A-A2A5-4076-952C-F8EFB4A42456",
"process_name": "LJBaseCrashReporter_Example",
"timestamp": 1569731896698234,
...
},
"binary_images": [
{
"image_addr": 4377903104,
"image_vmaddr": 4294967296,
"image_size": 2818048,
"name": "/var/containers/Bundle/Application/92937E4A-DFA9-4BAF-9780-2F8796A1A6C7/LJBaseCrashReporter_Example.app/LJBaseCrashReporter_Example",
"uuid": "FE056305-553D-3ED3-AB93-7AAB60BDE692",
"cpu_type": 16777228,
"cpu_subtype": 0,
"major_version": 0,
"minor_version": 0,
"revision_version": 0
},
...
],
"system": {
"system_name": "iOS",
"system_version": "12.4",
"machine": "iPhone8,2",
"model": "N66mAP",
"CFBundleIdentifier": "com.lianjia.LJBaseCrashReporter",
...
},
"crash": {
"error": {
"mach": {
"exception": 1,
"exception_name": "EXC_BAD_ACCESS",
"code": 1,
"code_name": "KERN_INVALID_ADDRESS",
"subcode": 8
},
"signal": {
"signal": 11,
"name": "SIGSEGV",
"code": 0,
"code_name": "SEGV_NOOP"
},
"address": 1,
"type": "mach"
},
"threads": [
{
"backtrace": {
"contents": [
{
"object_name": "LJBaseCrashReporter_Example",
"object_addr": 4377903104,
"symbol_name": "-[LJCrashDebugMachsController tableView:didSelectRowAtIndexPath:]",
"symbol_addr": 4378464516,
"instruction_addr": 4378464680
},
...
],
"skipped": 0
},
}
]
}
}

上面的例子只保留了 JSON Format 数据的框架结构,原始数据大约在 200k 左右,包含全部线程信息以及全部二进制文件信息。

显而易见,JSON Format 对于 Server 端处理非常友好,而 Apple Format 对于开发人员阅读非常友好。所以海神平台选择了两全其美的方案:

  • 海神客户端上报 JSON Format 数据,海神平台后端数据流转也保持对象结构
  • 交叉编译 Apple Format 工具用于支持下载文件格式化

1.2 同步上传遇到的问题

时效性是监控系统最靓的🏷

海神希望能够最快知晓用户设备上发生了什么,所以客户端目标始终是同步上传 Crash 日志。

iOS 的 thread 和 runloop 是个好话题。当工程师熟练的创建 NSURLConnection 的时候,一定要感谢是 runloop 在背后默默的支持网络请求。

但是当应用崩溃时,包括 main thread 在内的所有线程 runloop 都会退出,所以无论是 async 还是 sync 的方式,NSURLConnection 都无法发送网络请求。

当然,NSURLConnection 已经是废弃的网络框架,现在提到 OC 网络框架时主要指 NSURLSession( https://developer.apple.com/documentation/foundation/nsurlsession )。

NSURLSession 是 OC 新一代网络框架,目标是取代 NSURLConnection。NSURLSession 由系统网络进程管理网络请求,实现统一的安全性、连接、带宽和能源等管理。不过糟糕的是 NSURLSession 文档非常少,导致很多具体的技术细节无从知晓。

不过,通过 控制台 连接 iOS 设备,可以看到 NSURLSession 的守护进程,并且能够看到该进程简单的状态信息。

复制代码
nsurlsessiond nsurlsessiond Application <private> entered foreground 17:30:18.758723 +0800

NSURLSessionConfiguration 是 NSURLSession 的配置类,通过方法:

-backgroundSessionConfigurationWithIdentifier: 可以创建后台会话 (Background Session)。

详见: https://developer.apple.com/documentation/foundation/nsurlsessionconfiguration?language=objc

Use this method to initialize a configuration object suitable for transferring data files while the app runs in the background. A session configured with this object hands control of the transfers over to the system, which handles the transfers in a separate process. In iOS, this configuration makes it possible for transfers to continue even when the app itself is suspended or terminated.

Background Session 能够保证应用终止后完成数据传输,似乎解决了崩溃后无法发送网络请求的问题。

Unlike data tasks, you can use upload tasks to upload content in the background.

NSURLSessionUploadTask 是 NSURLSession 的上传任务 (uploadTask) 类,有两种方式创建 uploadTask,在崩溃发生时同步上传。

详见: https://developer.apple.com/documentation/foundation/nsurlsessionuploadtask?language=objc

  • -uploadTaskWithStreamedRequest:
  • -uploadTaskWithRequest:fromData:

A URL request object that provides the URL, cache policy, request type, and so on. The body stream and body data in this request object are ignored.

但是实际测试发现,文档和代码有差别…上面的说明似乎没有生效。

于是我们很容易就有了下面的代码:

复制代码
NSURL *URL = [NSURL URLWithString:url];
NSMutableURLRequest *requestM = [NSMutableURLRequest requestWithURL:URL];
requestM.HTTPMethod = @"POST";
[requestM setHTTPBody:body];
NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration backgroundSessionConfigurationWithIdentifier:NSUUID.UUID.UUIDString];
NSURLSession *sharedInstance = [NSURLSession sessionWithConfiguration:configuration];
NSURLSessionUploadTask *task = [sharedInstance uploadTaskWithStreamedRequest:requestM];
[task resume];

上面的代码在 iOS 12.4 上运行的非常良好。从控制台可以看到完整的进程间通信过程:

复制代码
LJBaseCrashReporter_Example CFNetwork background session setup will wait for reply: session <private> with identifier <private> 17:18:50.724156 +0800
nsurlsessiond nsurlsessiond Creating session with identifier: <private> for bundle id: <private> 17:18:50.726110 +0800
nsurlsessiond nsurlsessiond Client <private> is a SpringBoard application 17:18:50.726390 +0800
nsurlsessiond nsurlsessiond Session <<private>>.<<private>> using resource timeout: 604800.000000, request timeout: 60.000000 allowsCellularAccess: 1, allowsExpensiveAccess: 1 _sourceApplicationBundleIdentifier: (null), _sourceApplicationSecondaryIdentifier: (null) 17:18:50.740688 +0800
LJBaseCrashReporter_Example CFNetwork background session setup reply received: session <private> with identifier <private> 17:18:50.742934 +0800
nsurlsessiond nsurlsessiond Task <8A4DAC69-A8AF-484C-9D3B-1F77D0DB2E1F>.<1> uploadTaskWithRequest: <private> fromFile: (null) 17:18:50.746283 +0800
nsurlsessiond nsurlsessiond Current discretionary status for <private> is non-discretionary 17:18:50.748662 +0800
nsurlsessiond CFNetwork Task <8A4DAC69-A8AF-484C-9D3B-1F77D0DB2E1F>.<1> is for <<private>>.<<private>>.<1> 17:18:50.748973 +0800
nsurlsessiond nsurlsessiond Task <8A4DAC69-A8AF-484C-9D3B-1F77D0DB2E1F>.<1> enqueueing 17:18:50.749067 +0800
LJBaseCrashReporter_Example CFNetwork Task <8A4DAC69-A8AF-484C-9D3B-1F77D0DB2E1F>.<1> resuming, QOS(0x15) 17:18:50.749642 +0800
LJBaseCrashReporter_Example libnetwork.dylib Create activity <nw_activity 12:3> 17:18:50.749979 +0800
LJBaseCrashReporter_Example libnetwork.dylib Activated <nw_activity 12:3 [1CF15E8F-791C-4987-8DA4-AEE007DDEDF1] (reporting strategy default)> 17:18:50.752752 +0800
LJBaseCrashReporter_Example CFNetwork [Telemetry]: Activity <nw_activity 12:3 [1CF15E8F-791C-4987-8DA4-AEE007DDEDF1] (reporting strategy default)> on Task <8A4DAC69-A8AF-484C-9D3B-1F77D0DB2E1F>.<1> was not selected for reporting 17:18:50.753120 +0800

Anything Perfect! But 上面的例子在 系统兼容性 的测试上无法通过。

在较低版本的 iOS 系统上 (实在是找不齐系统版本😂),已知会出现三种类型的异常:

类型一: iOS 9.x、iOS 10.x 等系统在应用崩溃后创建 Background Session 导致进程卡死无法退出

这种情况发生时,如果手动退出应用的话,仍然可以通过 Xcode 获取 Crash 日志。忽略掉无关代码之后,导致进程卡死的调用栈如下:

复制代码
0 libsystem_kernel.dylib semaphore_wait_trap + 8
1 libdispatch.dylib _dispatch_semaphore_wait_slow + 244
2 CFNetwork -[__NSURLBackgroundSession setupBackgroundSession] + 540
3 CFNetwork -[__NSURLBackgroundSession initWithConfiguration:delegate:delegateQueue:] + 412
4 CFNetwork +[NSURLSession sessionWithConfiguration:delegate:delegateQueue:] + 560
...
11 LJBaseCrashReporter_Example handleExceptions + 1769296 (KSCrashMonitor_MachException.c:363)
12 libsystem_pthread.dylib _pthread_body + 156
13 libsystem_pthread.dylib _pthread_body + 0
14 libsystem_pthread.dylib thread_start + 4

如果熟悉 GCD 的话,那么对于 semaphore_wait_trap 一定很熟悉,因为无论是 dispatch_once 还是 dispatch_semphore 内部都使用了陷阱模式来实现线程 wait。

从堆栈可以确定,-[__NSURLBackgroundSession setupBackgroundSession] 在等待一个“响应”,并根据这个响应退出陷阱模式,而这个“响应”永远都没有到来。

Google 上并没有多少关于 NSURLSession 跨进程通信的说明,简书上有一篇 Blog( https://www.jianshu.com/p/4b51c85c82b3)描述了一个 socket 通讯管道破裂导致崩溃的场景,允许我们大胆猜测 NSURLSession 大概也因为类似的原因导致永远无法从陷阱模式退出。

类型二: iOS 11.x 等系统在 Swift 应用崩溃后创建 Background Session 导致进程卡死无法退出

虽然现象是相同的,但是背后的原因却并不相同。此类场景下导致进程卡死的调用栈如下:

复制代码
0 libsystem_kernel.dylib 0x000000020bb6cf2c __psynch_mutexwait + 8
1 libsystem_pthread.dylib 0x000000020bbe8a84 _pthread_mutex_firstfit_lock_wait + 92
2 libsystem_pthread.dylib 0x000000020bbe89f4 _pthread_mutex_firstfit_lock_slow$VARIANT$mp + 272
3 libdyld.dylib 0x000000020ba23760 dyldGlobalLockAcquire+ 14176 () + 20
4 dyld 0x0000000107850a40 dlopen_internal + 296
5 libdyld.dylib 0x000000020ba24908 dlopen + 176
6 CFNetwork 0x000000020c64b9fc initMKBDeviceUnlockedSinceBoot+ 883196 () + 44
7 CFNetwork 0x000000020c589eb4 -[__NSURLBackgroundSession setupBackgroundSession] + 76
8 CFNetwork 0x000000020c5965c4 -[__NSURLBackgroundSession initWithConfiguration:delegate:delegateQueue:] + 492
9 CFNetwork 0x000000020c57e314 +[NSURLSession sessionWithConfiguration:delegate:delegateQueue:] + 644
...
18 CoreFoundation 0x000000020bfd05b8 __handleUncaughtException + 692
19 libobjc.A.dylib 0x000000020b1aadf4 _objc_terminate+ 24052 () + 112
20 open_dev 0x0000000104d92404 0x1048b0000 + 5121028
21 libc++abi.dylib 0x000000020b19f838 std::__terminate(void (*)+ 55352 ()) + 16
22 libc++abi.dylib 0x000000020b19f434 __cxa_rethrow + 144
23 libobjc.A.dylib 0x000000020b1aabc8 objc_exception_rethrow + 44
24 CoreFoundation 0x000000020bf5c11c CFRunLoopRunSpecific + 544
25 GraphicsServices 0x000000020e15c79c GSEventRunModal + 104
26 UIKitCore 0x0000000238506978 UIApplicationMain + 212
27 open_dev 0x0000000104935258 main + 545368
28 libdyld.dylib 0x000000020ba218e0 start + 4

Swift 依赖的网络框架和 OC 似乎并不相同,导致在崩溃时还需要调用 libdyld.dylib 来启动额外的库以支持 Background Session。与上一节不同的是,这类卡死是因为 pthread_mutex 无法释放导致的。

类型三: iOS8.x Background Session 无效

贝壳现在最低支持 iOS 9,所以这里就不写了😝😝😝

1.3 海神的同步上传方案

由于存在以上各种问题,海神客户端放弃了基于 OC Runtime 的网络框架,使用 Standard C 实现同步网络请求。

站在巨人的肩膀上总是更轻松,海神对 curl 进行裁剪和移植,作为客户端同步上传的网络基础库。实现的代码如下:

复制代码
curl_global_init(CURL_GLOBAL_ALL);
CURL *curl = curl_easy_init();
curl_easy_setopt(curl, CURLOPT_VERBOSE, 1);
curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_POST, 1);
// 设置请求头
struct curl_slist *headers = curl_slist_append(NULL, "Content-Encoding:gzip");
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
// 设置请求体
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body);
curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, body_size);
curl_easy_perform(curl);

2. 如何获取系统符号表

系统符号表和项目符号表类似,都是记录可执行文件的符号信息。通常我们将设备连接到 Xcode 后,Xcode 会自动从设备中导出系统符号表,也就是显示“Preparing debugger support for Device”的过程。在 Mac 上 iOS 系统符号表默认的存储路径:

复制代码
~/Library/Developer/Xcode/iOS DeviceSupport

海神平台创建之初,目标是支持 iOS 8.0 开始所有发布版本的日志解析。我们都知道:

Crash 原始日志 + 项目符号表 + 系统符号表 = Crash 解析日志

项目符号表可以通过持续集成平台获取到,Crash 原始日志在上一节中也已实现,剩下的工作就是找到系统符号表。

2.1 iOS System Symbols 项目

系统符号表和 iOS 系统是一一对应的。标准的系统版本号包括发布版本和 Build 版本,同一个版本号又包含多个架构类型。以 iOS 12.1 为例:

Build Version Arch
16B92 arm64、arm64e
16B93 arm64、arm64e

基本上所有公司都没有收集系统符号表的传统,贝壳也是😞。由于测试机数量有限,而 iOS 系统又持续升级,再加上很多“清理 Mac 存储空间”的坑爹文章,导致海神平台缺失不少版本和架构的符号表。

iOS-System-Symbols 是 Zuikyo 发起的系统符号表收集项目(具体见 GitHub),基本集齐了 7.0-12.x 的所有版本。项目还在持续更新,感谢 Zuikyo 的工作,海神平台当前就是使用这套系统符号表作为基础。

2.2 系统符号表的及时性

iOS 新版本通常会有新功能和优化体验,而且随着更新习惯养成和网速的不断提升,更新成本也越来越低。Apple 官方数据表明,iOS 用户越来越愿意升级新版本。

iOS-System-Symbols 项目更新总是在新版本发布一段时间之后,完全依赖此项目会导致新版本系统产生 Crash 时,海神平台还没有该系统符号表。于是解析流程被中断,Crash 数量和率都出现异常。

是时候开始自动监控 iOS 新版本并自动生成和部署系统符号表了!

为什么连接设备能够导出系统符号表?其实系统符号表和 iOS 固件是密不可分的,Apple 没有提供下载系统符号表的方式,但是我们完全可以通过固件提取指定版本的符号表。

The iPhone Wiki( https://www.theiphonewiki.com/)由著名的黑客 geohot 创建,用于收集有关 iOS 操作系统(及其变体,tvOS 和 watchOS)以及运行该软件的设备(iPhone,iPod touch,iPad,Apple TV 和 Apple Watch)的所有公共知识。此项目中 Firmware( https://www.theiphonewiki.com/wiki/Firmware)类别包括了几乎所有固件,并且具有非常好的时效性。

The iPhone Wiki 一定要多逛一逛🏷

仍然以 iOS 12.1 为例下载 iPhone 5s 和 iPhone XS Max 的固件。之所以下载两个固件文件,是因为 iOS 12.1 支持 arm64 和 arm64e 两种架构。当 iOS 系统支持多种架构类型时,需要下载全部架构的固件分别提取系统符号表,然后合并成生成多架构系统符号表。下载好的固件:

  • iPhone_4.0_64bit_12.1_16B92_Restore.ipsw
  • iPhone11,4,iPhone11,6_12.1_16B92_Restore.ipsw

虽然文件后缀为“.ipsw”,但其本质上就是一个压缩文件,可以通过修改后缀为“.zip”,然后直接解压缩得到文件目录:

海神平台iOS端崩溃日志解析踩坑之旅

多个.dmg 文件中,占用空间最大的包含需要的系统库。iOS 10 之前的版本此文件是经过加密的,所以诞生了很多 VFDecrypt 工具,但是之后的版本不再加密。加载映像以后得到文件目录:

海神平台iOS端崩溃日志解析踩坑之旅

打开 /System/Library/Caches/com.apple.dyld/ 目录:

海神平台iOS端崩溃日志解析踩坑之旅

dyld_shared_cache_xxx 文件是所有系统库的压缩包,其中 xxx 表示具体架构。想要打开 cache 文件需要 dyld 的解压支持。

dyld 是 MachO 文件的开源加载库,在 iOS 中主要作用于应用 main 函数之前的加载流程。dyld 在 Github 上的仓库比较老,我个人更喜欢在 Apple Open Source 下载 iOS 和 OSX 的开源代码。 强烈建议

使用 551.x 版本( https://opensource.apple.com/tarballs/dyld/dyld-551.4.tar.gz ),尽管最新版本是 655.x,但其需要的编译条件比较多,想要编译成功略费劲。解压得到文件目录:

海神平台iOS端崩溃日志解析踩坑之旅

在 launch-cache 目录下,能够找到 dsc_extractor.cpp、dsc_iterator.cpp,这两个文件就是解压 cache 的工具库。打开 dsc_extractor.cpp,将“#if 0”修改为“#if 1”,然后使用 clang 就可以将源码编译为二进制文件,命令如下:

复制代码
clang dsc_extractor.cpp dsc_iterator.cpp -lc++ -o dsc_extractor

得到解压工具后,就可以开始解压 dyld_shared_cache_xxx 文件了。dsc_extractor 接收两个参数,第一个是待解压文件地址,第二个是解压目标目录。命令如下:

复制代码
./dsc_extractor dyld_shared_cache_arm64 arm64
./dsc_extractor dyld_shared_cache_arm64e arm64e
输出:
...
dyld_shared_cache_extract_dylibs_progress() => 0

得到两种结构的符号表之后,就要开始合并。合并是通过 lipo 实现的,lipo 是一个 OS X 中处理通用可执行文件的工具,支持查看架构,拆分、合并文件。命令如下:

复制代码
lipo -create arm64/xx/xxx arm64e/xx/xxx -output '12.1 (16B92)/xx/xxx'

xx/xxx 表示 xx 目录下的 xxx 文件。遍历文件目录,各种语言提供的工具比较多,这里就不再赘述。如果想在 OS X 上查看文件夹下完整目录,可以试试 tree。命令如下:

复制代码
brew install tree
tree xxx

合并好的文件可以通过 lipo 查看架构,命令如下:

复制代码
lipo -info '12.1 (16B92)/xx/xxx'
输出:
Architectures in the fat file: /12.1 (16B92)/xx/xxx are: arm64 arm64e

最后一步需要调整目录结构。可以查看 Xcode 导出的系统符号表的结构:

海神平台iOS端崩溃日志解析踩坑之旅

在 System 和 usr 的外层增加目录 Symbols,系统符号表就做好了。

海神平台当前是通过 Python 定时脚本抓取和分析固件信息。发现新版本系统后,自动下载、解压、合并和上传系统符号表。

3. symbolicatecrash 解析 Crash 日志的问题

3.1 symbolicatecrash 是什么

symbolicatecrash 是 Xcode 提供的傻瓜式解析脚本,使用 Perl 语言开发,用于将 Apple Format 原始日志中的地址解析成可读符号。

新版本的 Xcode,symbolicatecrash 文件位置为:

复制代码
/Applications/Xcode.app/Contents/SharedFrameworks/DVTFoundation.framework/Versions/A/Resources/symbolicatecrash

如果不能确定 symbolicatecrash 的位置,可以使用以下命令查找:

复制代码
find /Applications/Xcode.app -name symbolicatecrash -type f
输出:
/Applications/Xcode.app/Contents/Developer/Platforms/WatchSimulator.platform/Developer/Library/PrivateFrameworks/DVTFoundation.framework/symbolicatecrash
/Applications/Xcode.app/Contents/Developer/Platforms/AppleTVSimulator.platform/Developer/Library/PrivateFrameworks/DVTFoundation.framework/symbolicatecrash
/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/Library/PrivateFrameworks/DVTFoundation.framework/symbolicatecrash
/Applications/Xcode.app/Contents/SharedFrameworks/DVTFoundation.framework/Versions/A/Resources/symbolicatecrash

一般我们选择使用 SharedFrameworks 相关目录下的脚本。symbolicatecrash 的运行依赖 Xcode 工具链,所以需要在 Shell 环境提供 DEVELOPER_DIR:

复制代码
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer

友情提示一下:做饭需要米,解析 Crash 需要符号表。所以请先准备好系统符号表和项目符号表。

symbolicatecrash 要求系统符号表放在指定位置:

复制代码
/Users/LiXiangYu/Library/Developer/Xcode/iOS DeviceSupport

项目符号表放在什么位置就比较随意了,不过 建议 和 Crash 日志放在同一目录下。

现在可以开始解析 Crash 日志了。symbolicatecrash 只需要原始 Crash 日志的路径即可实现解析,不过解析内容会被打印在控制台,通过重定向可以很方便的输出到文件中。

复制代码
/Applications/Xcode.app/Contents/SharedFrameworks/DVTFoundation.framework/Versions/A/Resources/symbolicatecrash ~/Desktop/origin.crash > ~/Desktop/symboled.crash

如果你和你的 Shell 是 好朋友 的话,上面的命令会短很多:

复制代码
symbolicatecrash ~/Desktop/origin.crash > ~/Desktop/symboled.crash

3.2 symbolicatecrash 的错误

行至上一小节,简直完美对不对?但理想是丰满的,现实是骨感的。解析的时候,很可能会遇到这种错误:

复制代码
/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/size: /Users/LiXiangYu/Library/Developer/Xcode/iOS DeviceSupport/*/Symbols/System/Library/Frameworks/*.framework/* (for architecture *) truncated or malformed object (dataoff field of LC_SEGMENT_SPLIT_INFO command 12 extends past the end of the file)

还有这种错误:

复制代码
/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/objdump: /Users/LiXiangYu/Library/Developer/Xcode/iOS DeviceSupport/*/Symbols/System/Library/Frameworks/*.framework/*: truncated or malformed object (dataoff field of LC_SEGMENT_SPLIT_INFO command 14 extends past the end of the file)

And More…

系统符号表有问题?!祖传的系统符号表怎么说不行就不行了呢?明明测试机连上 Xcode 用的好好的呢?

虽然错误信息非常多,但是所有的错误信息都包含了同一个关键字:LC_SEGMENT_SPLIT_INFO。如果熟悉 MachO 文件格式,一定会想到这是可执行文件中的一个加载命令 (Load Command)。

通过 MachOView 分别打开有问题的符号表和相同版本没有问题的符号表,查看 MachO 的文件结构。

有问题时:

海神平台iOS端崩溃日志解析踩坑之旅

无问题时:

海神平台iOS端崩溃日志解析踩坑之旅

可以看到有问题的符号表中确实多一个 LC_SEGMENT_SPLIT_INFO 加载命令:

海神平台iOS端崩溃日志解析踩坑之旅

我们再查看 MachO 的头文件信息:

海神平台iOS端崩溃日志解析踩坑之旅

会发现 LC_SEGMENT_SPLIT_INFO 中指定的 Data Offset 远大于 Fat Header 中的 Offset + Size。如果在 MachoView 中尝试打开:

海神平台iOS端崩溃日志解析踩坑之旅

MachoView 会直接崩溃。

大概清楚错误的原因了。既然信息是 symbolicatecrash 打印出来的,那就再看下它的实现,验证下我们的猜测。Perl 是不可能学的,这辈子都不可能学的。幸好这并不影响接下来的分析。

symbolicatecrash 大概有 1.5k 行,虽然文件比较长,但是逻辑很清晰,可以很清楚的看到 symbolicatecrash 如何一步一步实现 Crash 日志解析。这里不再详细描述解析的过程,但有几个关键点需要列举:

  • 无需指定项目符号表的位置,因为内部使用 mdfind 进行全局文件查找,需要校验符号表
  • Apple Format 文件通过字符串操作,分解为各种元数据用于查询
  • 聚合相同堆栈,优化解析速度
  • 核心是使用 atos 进行地址到符号的解析

fetch_symbolled_binaries 函数中,我们发现了很有意思的东西:

复制代码
# <rdar://problem/21493669> 13T5280f: My crash logs aren't symbolicating
# System libraries were not being symbolicated because /usr/bin/size is always failing.
# That's <rdar://problem/21604022> /usr/bin/size doesn't like LC_SEGMENT_SPLIT_INFO command 12
#
# Until that's fixed, just hope for the best and assume no sliding. I've been informed that since
# this scripts always deals with post-mortem crash files instead of running processes, sliding shouldn't
# happen in practice. Nevertheless, we should probably add this sanity check back in once we 21604022
# gets resolved.
$real_base = $$lib{base}
# call to size failed. Don't use this image in symbolication; don't die
# delete $$images{$b};
#print STDERR "Error in symbol file for $symbol\n"; # and log it
# next;

rdar://problem/21604022 已经不可访问,所以历史原因亦无从追溯了。但是从备注中基本可以确定,这个是 Apple 留下的一个 bug…

后续 Google 也没有找到更多有价值的内容,问题似乎无解了,但是这没有阻止海神的脚步。通过前面分析 symbolicatecrash 的实现,对于 Crash 的解析我们还是得到了一些启发。

3.3 海神的解析方案

3.3.1 更快的查找速度

作为平台后端,每次通过 mdfind 来查找文件是不能接受的。海神平台后端直接存储了系统符号表文件路径,通过符号表 UUID 查找路径实现直接命中符号文件。

校验符号表操作,对于 symbolicatecrash 这种独立工具来讲是合理的,保证每次解析流程的准确性。但是对于海神平台就不必要了,因为符号表会稳定存储在目标机器上,不会出现“变质”情况,这样又节约一些时间。

3.3.2 聚合 JSON 格式数据

其实我并不理解 symbolicatecrash 为什么使用字符串操作这种 Stupid 的方式来解析 Crash 日志,也许又是因为什么“历史原因”吧。┓( ´∀` )┏

和 Perl 语言调用系统工具相同,Java 调用系统工具也需要进行跨进程通信。由于创建进程会消耗大量系统资源,所以减少解析次数对提高解析速度非常有效。

在上面 1.1 小节【KSCrash 与上下文】中已经讨论过,海神客户端与 Server 端通过 JSON 格式传输数据。使用 JSON 格式主要是便于 Java 直接将数据映射为对象,而对象方便进行相同库不同符号地址的聚合。

下面是一个栈帧的组成结构:

复制代码
{
"object_name": "LJBaseCrashReporter_Example",
"object_addr": 4377903104,
"symbol_addr": 4378464516,
"instruction_addr": 4378464680
}
  • object_name:

    可执行文件名称

  • object_addr:

    可执行文件加载地址

  • symbol_addr:

    函数地址

  • instruction_addr:

    调用地址

3.3.3 atos 解析

可以发现,symbolicatecrash 的大部分工作已经被优化掉或者由 Java 层承担,核心的解析操作就可以通过调用 atos 命令来实现了。

atos 是 OS X 系统的地址符号化工具,它接收可执行文件路径、可执行文件加载地址和符号地址,需要特别注意,地址值需要转换为十六进制。命令如下:

复制代码
atos -o 符号文件地址 -arch 架构 -l 可执行文件加载地址 符号地址 1 符号地址 2 ...
输出:
符号信息 1
符号信息 2
...

以 3.3.2 小节【聚合 JSON 格式数据】中的样例栈帧为例:

复制代码
atos -o LJBaseCrashReporter_Example.app.dSYM/Contents/Resources/DWARF/LJBaseCrashReporter_Example -arch arm64 -l 0x104F18000 0x104FA11A8
输出:
-[LJCrashDebugMachsController tableView:didSelectRowAtIndexPath:] (in LJBaseCrashReporter_Example) (LJCrashDebugMachsController.m:135)

获取的输出中包括符号、库、文件和行号。系统库和商业平台 SDK 经常会阉割符号表,导致 atos 输出信息不完整,拆分时需要兼容。

4. 总结

以上问题解决后,海神平台从获取到解析 Crash 日志的流程就基本完成了。

海神平台目前作为贝壳移动端基础设施之一,已经融进了贝壳的整个监控体系。随着公司业务的快速发展和新业务的出现,稳定性建设方向也出现了新的监控诉求。为此,海神也在通过不断的迭代来覆盖这些新场景,为业务的稳定发展提供有力保障。

本文转载自公众号贝壳产品技术(ID:beikeTC)。

原文链接

https://mp.weixin.qq.com/s/8rQE_bnSsswd-wTNcOevFg

阅读数:705 发布于:2020 年 8 月 6 日 14:06

更多 测试、开源、安全 相关课程,可下载【 极客时间 】App 免费领取 >

评论 (1 条评论)

发布
用户头像
点赞
2020 年 08 月 06 日 17:55
回复
没有更多评论了