iOS不必现崩溃的点对点解析以及治理

00

引言

客户端应用中崩溃类型有多种,包括普通崩溃,主线程卡死,野指针崩溃,后台崩溃等等。当进程发生崩溃后系统会自动生成相应的崩溃信息,我们可以根据符号表解析崩溃日志,线上用户可以通过Bugly等第三方工具收集并解析堆栈。但是在解析的过程中大家可以发现解析一个崩溃日志操作非常繁琐,有时候出现解析失败的情况,甚至会解析错误。本文章主要介绍多个不同系统崩溃日志的解析方案。


01

背景

当重要客户或测试人员在App使用过程中反馈在某场景下发生了一个闪退现象,研发人员收到反馈后尝试复现又是一个不必现的问题。那我们的第一个反应就是从线上Bugly等第三方平台查询该设备的记录或者直接用户的设备中将崩溃日志导出来,对它进行解析并分析堆栈。但是在解析过程中就会发现可能会存在很多限制以及问题。

1.1 遇到的问题

那我们来详细的分析一下,在解析过程中可能会出现的一系列问题。

问题1:Bugly平台等三方工具解析结果不正确

我们会先从Bugly中查看是否记录了该崩溃问题,但是在通过Bugly平台中提供的崩溃堆栈分析问题的过程中发现崩溃堆栈总是指向一个空函数或与用户的实际路径不一致的函数,无法定位崩溃问题。如图(崩溃原因与崩溃堆栈无法对应):

图1.1 Bugly解析堆栈解析结果

那如果Bugly平台中堆栈解析错误,我们就尝试从发生崩溃的设备的系统日志中导出崩溃日志来解析。

问题2: 符号表问题

拿到崩溃日志后,大家最容易想到的解析崩溃日志的方案就是通过Xcode中symbolicate命令来解析崩溃日志,它是需要有崩溃日志文件和它对应的符号表文件。App包在每次生成时均会有包的唯一标识uuid和它对应的符号表文件。在通过symbolicate命令来解析崩溃日志,崩溃日志与符号表的uuid必须要一致,否则会无法解析成功。我们就应该找到与发生崩溃的App包的uuid一致的符号表文件。获取某一个符号表文件的uuid是通过执行一个命令,并与崩溃日志中的uuid进行比较。但是我们在开发以及发版过程中打App包的操作通常是无数次,依次查询所有符号表文件不太现实,难以管理符号表文件之间映射。

问题3: 日志解析失败

那么我们再假设,我们已经找到了对应的符号表文件,尝试来解析日志发现日志解析失败无法正常还原堆栈。打开崩溃日志一看,库名出现异常符号,iOS15+发生的崩溃日志里面连基地址被丢失均变成异常符号,如图:

图 1.2 进程名变成异常符号

图1.3 基地址变成异常符号

通过Xcode中symbolicate命令解析在这种情况下均无法进行修正,最终是解析失败导致找不到堆栈中找不到任何崩溃线索。

1.2 解决崩溃日志解析问题—点对点崩溃日志解析

既然在解析崩溃日志的过程中会遇到以上几个问题,我们应该如何正确又快速地进行崩溃日志的解析呢?此时需要一个点对点崩溃日志解析工具,自动匹配不同崩溃类型对应的解析方式,最终还原出原始堆栈给开发者提供具体线索。
下面我们来分两个部分去解决每一个问题,简单来说,第一部分为如何匹配日志对应的符号表文件(2.1),第二部分为既有日志又有符号表文件,但是日志解析错误或解析失败怎么解决(2.2).


02

崩溃日志解析

崩溃日志解析的过程包括获取符号表,崩溃日志文件解析,异常日志修正以及最终还原原始堆栈,我们慢慢来看一下每一步解析的步骤。


图2.3 崩溃日志解析过程

2.1 日志如何匹配符号表文件

符号表是包含App包中的文件,方法以及行数指令信息的文件,通常是在构建包时可以通过DWARF将调试信息从可执行文件中剥离到DSYM文件。我们在打包平台构建App包时自动将该Dsym文件进行剥离,并根据该App包的uuid来进行存储,在需要符号表文件时通过崩溃日志中获取uuid值,从打包平台中下载对应的符号表文件,这样就简单的解决了日志匹配符号表文件的问题。但是又出现了一个问题,通过DSYM符号表文件要依赖symbolicate命令进行解析,通过该命令解析崩溃日志会有以下两个问题。

1)操作比较繁琐
通过symbolicate命令解析日志在命令行中先执行export命令,否则解析失败,如图:


图2.4 需要先执行export

之后通过./symbolicate. <日志地址> <符号表文件地址> -o <输出文件名称>来进行解析。解析结果在命令所执行的目录上生成。

2)经常会出现解析失败的情况
它存在多种日志格式无法
解析的问题以及部分异常符号无法修正的问题,我们就不能依赖该命令来进行解析。

为了我们不依赖symbolicate命令就需要把DSYM符号表文件转换成我么能识别的符号表文件,我们将从DSYM格式的符号表文件中剥离一个轻量级符号表,输出一个包含每个文件每个函数每一行代码的汇编指令区间的文本文件,我们直接在文本中寻找崩溃堆栈所对应的汇编指令区间来寻找对应的函数以及文件名。那么如何从DSYM文件中剥离轻量级符号表呢?

在DWARF文件中,可以看到存在一个debug_line的section,这里存储的是行信息。dump看下行信息从打印片段中可以能看出每个文件的每一行代码都存储了起始地址以及行号。


图2.5 行信息

确定起始地址,行号以及文件名后,获取函数名是通过debug_info信息片段中DW_TAG_subprogram可以获取到这个函数的文件名、函数名、函数起始地址、函数终止地址。因此获取到行信息后,可以查看当前这个行的起始地址位于同文件下那个函数的指令区间内,即可得知函数名,最终我们输出成一个轻量级符号表文件。

图2.6 轻量级符号表文件

拿到符号表文件后我们就不需要依赖任何系统命令,自主解析崩溃日志并还原原始堆栈。

2.2 如何正确地解析崩溃日志

那么我们既然已经拿到了符号表文件,我们只要拿到对应的崩溃日志文件就可以开始对崩溃日志进行解析,日志包括系统崩溃日志和Bugly堆栈,下面来详细的讲一下解析方式。

2.2.1 系统日志

系统日志我们要具备解析各种不同格式的能力并支持扩展,包括普通系统日志、线程异常唤醒,库名变成异常符号的日志以及iOS14+json格式的日志。

2.2.1.1 崩溃日志中获取关键基本信息

首先,将系统日志中的文本中,获取该崩溃日志的最关键的基本信息,它包括进程名,崩溃发生时间,slice_uuid等基本信息,它一般在系统日志中的头部标记。

图2.7 系统崩溃日志头部信息

其中slice_uuid信息非常重要,它在上面所述是每个App包的唯一标识uuid,我们要根据它来寻找对应的符号表文件。这里的uuid就是在上面符号表文件匹配中说到的唯一标识,打包平台中每次生成包都自动剥离DSYM符号表文件后再获取轻量级符号表,用于后面的日志解析中。

之后,我们就开始解析崩溃日志中的堆栈,这时要具备崩溃日志扩展能力,每次推出新的格式我们就可以一直扩展。

2.2.1.2 解析不同崩溃日志的格式

系统日志目前存在多种格式,包括普通代码崩溃,存在LastBacktrace堆栈,线程过多唤醒格式,json格式堆栈,下面来看一下几个崩溃日志的格式。

普通堆栈格式:

图2.8 普通堆栈格式

这种格式通常头部有基本信息,之后标记了发生崩溃的线程以及所有线程的堆栈详细信息,每个线程中的堆栈有堆栈顺序,库名,函数地址,基地址都用于解析日志。

存在Last Backtrace格式

图2.9 存在Last Backtrace格式

与普通堆栈格式类似,只不过在第一个线程展示之前多一个Last Exception Backtrace堆栈,它就是崩溃发生的堆栈。同样每个线程中均可以获取堆栈顺序,库名,函数地址,基地址都用于解析日志。

Wakesup类型:

图2.10 Wakesup格式

它也是堆栈的格式,与普通代码崩溃格式有些不同,它展示的是Heaviest stack就是异常的堆栈信息,其中可以获取堆栈的顺序,库名,函数地址用于解析日志。堆栈中不包含基地址,如果没有基地址无法计算函数对应的偏移量。因此它可以从底部镜像信息中获取当前的进程对应的基地址。Binary Images中找到当前进程,第一个地址为它的基地址。

图2.11 Binary Images

iOS14+后json格式:

 

图2.12 json格式和格式化json格式堆栈

该格式是iOS14+后的新出现的json格式。在这里iOS14和iOS15的json格式也有一定的差异(iOS14+中每行堆栈为数组格式,iOS15+中每行堆栈为字典格式),整体思路是相通的,下面以iOS14为例分析如何解析崩溃日志。先把数据格式进行格式化来分析一下。

日志中的threads字段中通过数组的方式包含所有堆栈的信息,数组中的每个元素为每个线程的所有堆栈信息。“triggered”:true表明崩溃出现在当前线程,"queue"或"name"表明当前线程的名称。"frame"中包含每行堆栈的信息,其中第一个元素中的数字刚开始以为是堆栈的顺序,后来看在同一个线程内有重复的数字,后面猜测可能表明的是镜像顺序(这块在iOS15+的字典格式中已明确该数字表示的是imageIndex)。这里说的镜像顺序是指一般每个崩溃日志的末尾中都包含所有库的基地址,末尾地址等库相关信息,这里是有展示顺序的。

 

图2.14 镜像顺序,名称,大小等信息

在"usedImages"中每条镜像数据中第一个元素为该镜像的uuid,第二个元素为基地址,再从"imageExtraInfo"中对应的顺序中获取"size"来计算末尾地址,"name"来获取该镜像的名字就可以完整恢复镜像的数据了。再回到日志格式中"frame"中第一个元素找到了是哪一个镜像,第二个元素表示的是偏移地址,我们就根据该偏移地址从符号表中确定函数的名称,行号以及文件名。这里因为苹果在这种格式中仅提供了偏移地址,没有提供堆栈的实际地址,可能会存在一定的误差(这个误差后面再详细说明)。这样我们也拿到了堆栈的顺序,库名,偏移地址,基地址用于解析日志。

我们都拿到了这些崩溃日志中所有堆栈信息,那么我们就开始进行解析堆栈。

2.2.1.3 还原堆栈

按照上面步骤获取每个线程中的堆栈有堆栈顺序,库名,函数地址,基地址后,首先需要计算每个堆栈对应的偏移地址。通过函数实际地址减去基地址来计算偏移地址后,从符号表中寻找该函数的指令区间,就可以完美恢复我们的崩溃堆栈。

图2.15 崩溃堆栈

那我们来恢复一下以上的堆栈,它的实际地址为0x107ee819c,基地址为0x100ee808c,偏移地址为0x107ee819c - 0x100ee808c = 0x7000110。

图2.16 符号表中寻找指令区间

根据该偏移地址在符号表中寻找该函数的指令区间,我们就可以还原原始的崩溃堆栈了。

2.2.2 Bugly堆栈

在Bugly平台中抓取到的崩溃日志中,我们经常发现崩溃发生原因和崩溃堆栈根本对不上的情况,比如:崩溃发生的原因是TableView高度相关的问题,但是堆栈中指向的是react native相关的代码中(如图)。

图2.17 Bugly平台堆栈解析结果

Bugly堆栈解析错误的主要原因是在于他是根据崩溃日志中的偏移地址来直接寻找函数和文件名,其实手动计算就可以发现通过崩溃堆栈中实际地址减去起始地址的值与崩溃堆栈中偏移地址并不是相等的,必须以实际地址来计算偏移地址才是正确的。上面在系统日志中获取解析崩溃日志的时候,没有直接获取堆栈中的偏移地址,是基于函数地址减去基地址的方式获取真正偏移地址的原因也是同理的。因此对Bugly堆栈修正时,堆栈中点击原始按钮,使堆栈包含函数的实际地址。再根据App包的uuid自动下载符号表文件并剥离轻量级符号表,再根据基地址来解析,才能正确地还原原始的崩溃堆栈。

图2.18 获取原始堆栈

2.2.3 堆栈解析失败

通过以上方式我们解析了许多紧急的崩溃问题,使用一段时间后发现当前进程名会变成”???”异常符号,并且在iOS15以上发生的崩溃连基地址也丢失变成”???”异常符号。如果基地址变成异常符号,我们就无法计算每个函数的偏移地址,导致无法解析。

那我们先来看一下为什么突然会出现异常符号的情况呢?其实是App存在段迁移的时候会存在以上情况,一般Mach-O文件中苹果会对__TEXT端加密并压缩,段迁移是在这个__TEXT端中的部分段移动至重新命名的段中,减少苹果的加密范围,从而使压缩效率提升,可以减少较大的包大小。我们的App在经过段迁移之后发生崩溃日志都是以下格式,如图:

2.19 库名异常

堆栈中的库名修正比较容易,在其他库均不存在段迁移的前提下,遇到异常库名就直接修正为头部中获取到的当前进程名。

图2.20 基地址异常

那进程名已经修正了,但是基地址也被替换成异常符号,我们该怎么着?修正镜像基地址我们采用了根据入口函数main函数来推算的方案,下面可以看一下主线程的堆栈。

图2.21 主线程堆栈

通常主线程堆栈中倒数第二行为main函数,那么我们可以在符号表中找main函数的偏移地址,再基于实际main函数的地址减去偏移地址,就可以正确的计算出进程的基地址。但是main函数内部可能有多行代码,如何在符号表中找出主线程中main函数的正确的找出偏移地址呢?

图2.22 轻量级符号表中查询main函数

我们在App跑起来之后能够正常运行的情况下打了一下断点,它停留在UIApplicationMain函数后adrp指令上,也就是UIApplicationMain函数后偏移+4上。

图2.23 main函数偏移

一般main函数中UIApplicationMain是在return行,return后面通常没有函数只有两行的大括号。因此我们在符号表中找到main函数中倒数第三行的起始地址加4的位置认为是主线程中导数第二行的main函数偏移地址。之后,我们就根据该函数的实际地址减去偏移地址来计算进程的基地址,就可以正常解析崩溃日志了!


03

应用效果

3.1 工具展示-系统日志以及Bugly堆栈修正工具

工具在MacOS中运行,只需要在工具中拖入系统日志或者直接拷贝Bugly堆栈即可,如图:

图3.1 拖入日志一键解析

解析成功后直接还原原始日志的格式以文本的方式显示到工具中,并自动生成结果文件到文件夹中便于传输,如图:

图3.2 崩溃日志解析结果

图3.3 崩溃日志结果文件生成

该工具我们目前已开源,支持系统的崩溃日志,包括普通日志类型,wakesup日志类型,在段迁移情况下自动修正异常符号并自动解析。欢迎大家来体验:https://github.com/wuba/WBBlades

3.2 应用案例

3.2.1 解决了较多无法解析成功的崩溃日志

无法通过系统日志解析的日志以及新版本的多种格式的崩溃日志,我们均能够快速又正确地还原原始的堆栈,在下载完成符号表的情况下我们仅需要10秒钟就可以得到结果。

原始崩溃日志:

图3.4 崩溃日志解析前

解析日志后:

图3.5 崩溃日志解析后

3.2.2 Bugly错误堆栈的修正

我们解决了很多线上Bugly中遗留的崩溃问题以及线下开发测试过程中出现的崩溃问题,原先在58同城App中线上Bugly解析错误概率占用总崩溃的10%~20%,在解析问题得到解决后目前均已修复。比如线上较多崩溃均指向react native的问题,通过该工具均查出真正的崩溃问题。该工具在集团内多个App中均已使用,帮助大家解决较大的问题。


04

总结

通过以上方案我们解决了很多线上和线下中出现的多种崩溃问题,我们还具备自动检测Bugly中解析错误的数量并能输出正确的堆栈的能力,并且后续我们会持续跟进系统日志格式的变化。


05

欢迎体验

项目已开源,欢迎大家来体验我们的开源工具:https://github.com/wuba/WBBlades


参考文献

https://www.jianshu.com/p/cc06e38ce972
https://mp.weixin.qq.com/s/4-4M9E8NziAgshlwB7Sc6g

本文分享自微信公众号 - 58技术(architects_58)。
如有侵权,请联系 [email protected] 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

{{o.name}}
{{m.name}}

猜你喜欢

转载自my.oschina.net/u/5359019/blog/5558811