1. 什么是符号化?
在日常开发中,应用难免会发生崩溃。通常,我们直接从用户导出来的崩溃日志都是未符号化或者部分符号化的,都是一堆十六进制内存地址的集合,可读性较差。未符号化或者部分符号化的崩溃日志对闪退问题的解决几乎毫无帮助,如下所示:
Last Exception Backtrace:
只有符号化后的崩溃日志才能显示各个线程的函数调用,而不仅仅是毫无意义的虚拟内存地址。符号化后的崩溃日志如下所示, 此时,我们就能够直接从堆栈信息中知道应用 TestBacktrace 发生崩溃时的函数为 [AppDelegate Application:didFinishLaunchingWithOptions:],崩溃时函数所在文件为 AppDelegate.m,行号为23:
Last Exception Backtrace:
2. 符号化原理
丨2.1 什么是dSYM文件?
iOS 平台中, dSYM 文件是指具有调试信息的目标文件,文件名通常为:xxx.app.dSYM,其中 xxx 通常表示应用程序的二进制包名,如下图所示:
通常我们可以在 Xcode 打包出来的文件 xcarchive 里面看到 dSYM 文件以及目录架构:
dSYM 中存储着****文件名、方法名、行号等信息,是和可执行文件的16进制函数地址一一对应的,通过分析崩溃的崩溃文件可以准确知道具体的崩溃信息。DWARF(Debuging With Arbitrary Record Format) 是 ELF 和 Mach-O 等文件格式中用来存储和处理调试信息的标准格式。DWARF 中的数据是高度压缩的,可以通过 dwarfdump、otool 等命令提取其中的可读信息。比如提取关键的调试信息 debug_info 、debug_line,可使用命令
dwarfdump --debug-line /Users/xxxx/Desktop/resource/TestBacktrace.app.dSYM > debug_line.txt
导出debug_line 的信息到文件 debug_line.txt 中,debug_info 也可以使用类似命令导出。
ELF、Mach-O 分别是 Linux 和 Mac OS 平台用于存储二进制文件、可执行文件、目标代码和共享库的文件名称。
丨2.2 如何生成dSYM文件
在编译工程时, Debug 模式会默认选中不生成 dSYM 文件, 该配置可在 Build Setting|Build Option 中更改,Release 模式下 dSYM 是默认生成的。另外,如果开启了 bitcode 优化的话,苹果会做二次编译优化,所以最终的 dSYM 就需要在 Apple Connect 手动下载了。每次编译生成的 dSYM 都会有所差别,通常 dSYM 中会有一个唯一标识,称作 UUID ,用以区分不同的 dSYM 文件。
丨2.3 如何通过崩溃日志中应用的 UUID 找到匹配的 dSYM ?
还原崩溃堆栈时,需要 dSYM 的 UUID 与崩溃时的应用 UUID 一致。通常,每一个 dSYM 文件都有一个 UUID,和 App 文件中的 UUID 对应,代表着是一个应用。而每一条崩溃信息都会记录着应用的 UUID,用来和 dSYM 的 UUID 进行校对匹配。
1. 首先从崩溃日志的 Binary Images 后找到应用的 UUID,如下可得到 TestBacktrace 的 UUID 为 6be881754f573769926b838490e39857。
Binary Images:
2. 使用以下命令查看 dSYM 文件的 UUID,去掉 - 并且小写之后,与第一步中的 UUID 是完全一致的,证明两者是匹配的,否则是不匹配的。
xcrun dwarfdump --uuid < dSYM 文件>
3. 如果本地 dSYM 过多的话,一个个查看太麻烦,还可以使用mdfind命名根据 UUID 在本机查找 dSYM。以上面的 UUID 为例,直接在终端输入以下命令就可以了。
mdfind "com_apple_xcode_dsym_uuids == 6BE88175-4F57-3769-926B-838490E39857"
丨2.4 符号化流程
将崩溃日志中的 APP 二进制地址转化为函数流程如下所示:
获取到崩溃日志 App 关键行信息
从上图中可以看到 APP 的关建行为是:
3 TestBacktrace 0x102a47464 0x102a40000 + 29796
其中 TestBacktrace 为我们的二进制包名名称,其余行都是系统堆栈。
获取到偏移量、运行时堆栈地址、运行时APP起始地址
由关键行信息获取到 TestBacktrace 相对于起始地址的偏移量为 29796,运行时堆栈地址为 0x102a47464,运行时APP起始地址为 0x102a40000。
获取 dSYM 起始地址
dSYM 文件中保存中符号表 TEXT 段的起始地址,起始地址可通过以下命令获得:
otool -l /Users/xxxxx/Desktop/TestBacktrace.app.dSYM/Contents/Resources/DWARF/TestBacktrace | grep __TEXT -C 5
由上图中可得到 dSYM 中代码段起始地址为 0x10000000。
计算崩溃地址对应 dSYM 符号表中的地址
因为 iOS 加载 Mach-O 文件时为了安全使用了 ASLR(Address Space Layout Randomization) 机制,导致二进制 Mach-O 文件每次加载到内存的首地址都会不一样,但是偏移量,加载地址,起始地址的计算规则是一样的;从上面我们可以得到 0x102a47464 (运行时地址) = 0x102a40000 (起始地址) + 29796(偏移量)这个公式。因此通过 dSYM 的起始地址和偏移量就可以计算出 0x102a47464 对应在 dSYM 中的地址为 0x100007464 = 0x0000000100000000 + 29296。
获取到具体的函数/行数/文件
获取到运行堆栈地址在 dSYM 文件的对应地址 0x100007464 之后,在 dSYM 文件的 debug-info 中就可以查找到包含该地址的 DIE(Debug Information Entry) 单元,Mac OS 下可使用命令
dwarfdump TestBacktrace.app.dSYM --lookup 0x100007464
获取相应信息,如图所示:
DW_TAG_Subprogram 表示这个DIE单元表示的是函数方法。
DW_AT_low_pc 表示这个方法起始地址为 0x1000073b4 。
DW_AT_high_pc 表示这个方法结束地址为 0x1000074c4 。这就表示崩溃日志中 0x102a47464 转化后的偏移地址 0x100007464 正好位于这 DW_AT_low_p 和 DW_AT_high_pc 之间。
DW_AT_name 表示我们的函数名为 [AppDelegateApplication:didFinishLaunchingWithOptions:]。
DW_AT_decl_file表示函数所在文件路径为 AppDelegate.m。
DW_AT_decl_line 表示函数开始行数为 19。
组装并格式化
最终经过格式优化,崩溃日志中 0x102a47464 符号化出来对应的方法为:
3 TestBacktrace 0x102a47464 -[AppDelegate Application:didFinishLaunchingWithOptions:] + 29796 (AppDelegate.m:23
3. 本地符号化
丨3.1 符号化方法
Xcode 符号化
将崩溃日志、 dSYM 文件和可执行文件放在同一目录下,然后将 崩溃日志拖拽至 Devicelog中,右键 symbolicate Log 或者 Re-symbolicate Log 就能符号化。
使用 symbolicatecrash 命令行符号化
- 定位 symbolicatecrash 脚本
通常 symbolicatecrash 的路径为 /Applications/Xcode.App/Contents/SharedFrameworks/DVTFoundation.framework/Versions/A/Resources/symbolicatecrash
- 前置运行命令
运行 symbolicatecrash 前一般需要先运行:
export DEVELOPER_DIR="/Applications/XCode.App/Contents/Developer"
- 运行symbolicatecrash命令
首先将崩溃日志、 dSYM 以及 symbolicatecrash 复制出来放到同一个文件夹,然后 cd 到当前文件夹,运行如下命令符号化
./symbolicatecrash TestBacktrace-2021-07-30-135514.ips TestBacktrace.app.dSYM > symbol.log
丨3.2 系统日志符号化
值得注意的是,有些时候,崩溃日志里并不会有 App 的调用,而可能全都是系统库的调用,如下:
Thread 32 Crashed:
符号化后的日志为:
Thread 32 Crashed:
可以看出是 CFNetwork 网络请求时发生野指针导致的问题,那么我们就可以针对网络相关的请求做进一步排查。
至此可以得出结论:符号化系统库是很有必要的,特别是对一些 App 堆栈信息完全没有的崩溃日志。
如何符号化系统库符号
符号化自己 App 的方法名,需要编译生成的 dSYM 文件。而要将系统库的符号化为完整的方法名,也需要 iOS 各系统库的符号文件。
-
匹配对应的符号文件版本
用户的崩溃日志来自各种系统版本,需要对应版本的系统符号文件才能符号化。系统库符号文件不是通用的,而是对应崩溃所在设备的系统版本和 CPU 型号的。
崩溃日志中有这样几个信息:
Code Type: ARM-64
Code Type 表示此设备的 CPU 架构为 armv7、armv7s、arm64 还是 arm64e。
OS Version 表示此设备的系统版本号,括号中的字符串代表了此系统的 build 号。
Binary Images 中的<9c893b6aa3b13d9596326ef6952e7195> 里面的字符表示对应的系统库 dyld 的 UUID,只有 build + UUID 匹配的系统库符号文件才能符号化系统符号。
-
把符号文件放到指定位置
把获取到的对应版本的符号文件放到 Mac OS 的 ~/Library/Developer/Xcode/iOS DeviceSupport 目录下,就可以使用Xcode自带的符号化工具 symbolicatecrash 进行符号化了。这个工具会自动根据崩溃日志中系统库的 UUID 搜索本机系统库的符号文件。
丨3.3 获取系统符号文件的2个方法
从真机上获取
大部分系统库符号文件只能从真机上获取,苹果也没有提供直接的下载地址。但是当你用 Xcode 第一次连接某台设备进行真机调试时,会看到 Xcode 显示 Processing symbol files ,这时候就是在拷贝真机上的符号文件到 Mac OS 系统的 /Users/xxx/Library/Developer/Xcode/iOS DeviceSupport 目录下。
目录下的 14.7.1 (18G82) 这样的文件夹就是对应的符号文件,通常都有 1-5GB 的大小。
从固件中提取符号文件
从固件 (iPSW) 中可以通过一些方式提取到系统库符号文件。固件解密分为 下载并提取系统符号 和 系统库符号 提取两步。
1. 下载并提取系统符号
- iOS9 以及 iOS9 之前
a. 下载对应版本的 iPSW 固件,直接解压,解压后里面有几个 dmg 格式的镜像文件,最大的 dmg 文件就是系统镜像。
b. 从 Firmware_Keys (见文末参考链接)找到对应固件的解密 key (页面上 Root Filesystem 字段的 key )
c. 用 dmg 工具进行解密。cd 到解压后的 iPSW 文件夹,执行 ./dmg extract xxx-xxxx-xxx.dmg dec.dmg -k
- iOS10 以及 iOS10 之后
下载对应版本的 iPSW 固件,直接解压,解压后里面有几个 dmg 格式的镜像文件,最大的dmg 文件就是系统镜像。
2. 系统库符号提取
从 iPhone OS 3.1 开始,所有的系统库都打包成一个文件:dyld_shared_cache_xxx ,其中 xxx 表示具体的架构,此文件位于:/System/Library/Caches/com.Apple.dyld 目录。dyld_shared_cache_xxx 文件的解压可以使用 dyld 中的 dsc_extractor.cpp 代码,但做一定的改动。
a. 首先在 Apple 开源网站下载源码dyld库的源码,注意,这里需要下载 dyld-7 的源码。
b. 下载之后,将文件 dsc_extractor.cpp,main 函数前后的代码改为如下代码:
#if 1
c. 在终端上 cd 到 dyld 源码目录 launch-cache 下,在终端命令行编译并生成 dsc_extractor 工具。
clang++ -o dsc_extractor dsc_extractor.cpp dsc_iterator.cpp
d. 从Xcode的包中 /Applications/Xcode.App/Contents/Developer/Platforms/iPhoneOS.platform/usr/lib 中提取出dsc_extractor.bundle 文件。dsc_extractor.bundle 和要提取的 iOS 系统强关联,比如 iOS14 的系统符号需要导出 Xcode12 里的 dsc_extractor.bundle,而 iOS15 的需要 Xcode 13 Beta 里的。如果不匹配的话,有可能不能提取出系统符号。
e. 调用如下命令提取出系统符号;如下,最终提取的系统库在目录 17C81 下,我们解析系统符号需要的文件基本为 dylib 和 framework。
dsc_extractor dsc_extractor.bundle /System/Library/Caches/com.Apple.dyld/dyld_shared_cache_arm64 17C81
4. 在线符号化
丨4.1 为什么要实现在线符号化
-
打包时候符号文件是由持续集成打包机产生,本地获取有成本。
-
方便研发人员快速符号化崩溃日志。很多时候,崩溃都是在非研发人员(产品,QA 等)使用应用的时候发生的;同步到研发人员之后,因为本地环境的差异,在没有打包环境的情况下,研发人员也需要能迅速符号化崩溃堆栈
-
线上用户上传的崩溃日志规模大。大多数崩溃都是发版之后用户使用过程中发生的,如果大量线上日志未经符号化就同步到研发人员,就会增加研发人员的负担,降低问题解决的效率。
-
用户系统多,收集难度大。用户的系统从 iOS9 到 iOS14 都有,千奇百怪,靠研发人员本地想要解析所有的系统符号纯属臆想。
丨4.2 在线 App /动态库符号化
通常情况下,我们只需要符号化极少部分崩溃日志,这种情况下我们在本地就可以符号化了。但当我们的应用上线发版后,崩溃日志日均收集量级可能超百万以上,此时就不适合在 Mac OS 上使用脚本/工具符号化了( 在 Mac OS 上使用 symbolicatecrash 命令符号化单个日志时,耗时基本 1 秒以上)。此时,就需要更通用,快速的符号化方式了。
为了能够在 Linux 服务器上极速符号化 iOS 崩溃日志,我们深入调研了 iOS 本地符号化的原理,在和平台方多次就技术方案进行了调研磋商之后,最终采取了如下方案:
生成mapping文件
将 dSYM 文件通过脚本提取生成一个 mapping 文件,格式如下:
Format: Mach-O/64-Bit
提取操作会涉及到 DWARF 中 debug_line 段数据的符号化,相关提取算法可以参考 DWARF 官方的资料。debug_line 段包含有详细的代码偏移量地址和文件名称,按照 DWARF 的算法就可以解析出来,然后与 Symbol Table 的函数符号一一匹配,就能生成代码地址偏移量与函数、文件、行数的映射关系。需要注意的是,苹果的 Mach-O 现在大部分格式都是使用 DWARF2 和 DWARF4 版本,提取的时候需要重点关注这两种格式的兼容和算法不同。最终,可以看到 Symbol table 每一行对应一个符号的偏移量。可以发现 7464 刚好处于 7454 - 7478 之间,匹配出来的符号刚好是 -[AppDelegate Application:didFinishLaunchingWithOptions:] (in TestBacktrace) (AppDelegate.m:23) ,与 Mac OS 上使用 symbolicatecrash 脚本符号化的结果一致。
根据 mapping 文件符号化
借助于脚本工具提取的符号 mapping 文件,服务端就能够脱离平台限制,根据崩溃日志中的 UUID 去匹配映射文件,在 Linux 上极速符号化崩溃日志,提供高效实时的符号化服务。
丨4.3 在线符号化 iOS 系统库符号
在 Mac OS 平台上,我们可以直接使用系统库的符号直接使用脚本去符号化符号,但是一旦要符号化所有用户上传的崩溃日志,这一套机制就难免被速度和平台限制。并且iOS 系统从 2.0 开始,一直到现在 iOS 14 ,发出的版本几百个,要手动提取出系统库符号几乎是不可能的事情。为了解决这个问题,在借鉴了 dSYM 跨平台符号化方案之后,我们做了一套系统符号自动化符号化的方案,最终实现了在 Linux 平台上高效实时的符号化系统堆栈。
1. 定时从 theiphonewiki 网站上导出各个系统以及最新发布系统的 iPSW 文件下载地址。
2. 解压 iPSW 并加载系统镜像 dmg 文件,找到 dyld_shared_cache_xxx 文件。
3. 使用工具 dsc_extractor 将系统库符号文件导出,导出文件基本为后缀为 dylib 和 framework 的 Mach-O 类型文件。
4. 将所有的 dylib 和 framework 使用工具提取生成如下格式的 mapping 文件。这一步与 dSYM 提取操作会有一定差别,通常来说,系统库只有符号表段,不需要对 debug_line 段做提取,相对比较简单。
20b8 20fa +[ZoomServicesUI enableZoomServices] (in AccessibilitySettingsLoader)
5. 崩溃日志上传到符号化服务器之后,服务器根据崩溃日志中系统库的 UUID 和 mapping 文件中的 UUID 唯一确定 mapping 文件并符号化系统堆栈。
5. 效果
1. 在线符号化系统上线之后,用户的日志经过崩溃组件自动上传到性能之后,符号化解析系统直接将崩溃日志符号化并聚类,最终符号化的崩溃日志详情如下。应用程序的地址最终表现为 函数 + 文件 + 行数,系统堆栈会具体显示出崩溃的函数,整个过程实时且高效。
Last Exception Backtrace:
2. 部分来自研发和测试的崩溃日志,平台提供了在线符号化的入口,只需要手动上传崩溃日志到平台,立刻就能把符号化后的崩溃日志下载给相应人员。
6. 收益
1. 线上问题定位速度获得极大提升,从线上发生新增卡顿/崩溃问题到具体研发响应时间大大缩减,从发生崩溃到定位问题,基本都在 10 分钟以内。
2. 目前,性能平台日均在线符号化崩溃/卡顿日志超百万次,厂内接入产品线超 30+,符号化功能做到了上传即解析,整个过程无需研发人员干预。真正做到了自动化、在线、实时的符号化崩溃、卡顿日志,并实时根据符号化的问题代码定位到具体开发人员,高效的响应并解决线上问题。
参考资料
[1] iOS Crash 分析必备:符号化系统库方法https://zuikyo.github.io/2016/12/18/iOS%20Crash%E6%97%A5%E5%BF%97%E5%88%86%E6%9E%90%E5%BF%85%E5%A4%87%EF%BC%9A%E7%AC%A6%E5%8F%B7%E5%8C%96%E7%B3%BB%E7%BB%9F%E5%BA%93%E6%96%B9%E6%B3%95/
[2] 聊聊从 iOS 固件提取系统库符号 http://crash.163.com/#news/!newsId=31
[3] Xcode 中和 symbols 有关的几个设置 https://www.jianshu.com/p/11710e7ab661
[4] iOS_SDK https://en.wikipedia.org/wiki/IOS_SDK
[5] IOS_version_history https://en.wikipedia.org/wiki/IOS_version_history#iOS_14
[6] dyld 源码下载地址 https://opensource.apple.com/tarballs/dyld/
[7] The DWARF Debugging Standard http://www.dwarfstd.org/
[8] iOS9 之前的Firmware_Keys https://www.theiphonewiki.com/wiki/Firmware_Keys
[9] dmg 工具下载地址 https://github.com/Zuikyo/iOS-System-Symbols/blob/master/tools/dmg
[10] 系统符号下载地址索引 wiki https://www.theiphonewiki.com/wiki/Firmware