“杀死” App 上的疑难崩溃!

4,813 阅读20分钟

问题与背景

在移动应用性能方面,崩溃带来的影响是最为严重的,程序崩溃可以打断用户正在进行的操作体验,造成关键业务中断、用户留存率下降、品牌口碑变差、生命周期价值下降等影响。很多公司将崩溃率作为优先级最高的技术指标,因此程序崩溃的监控与收集就成为了一项必不可少的工作, 目前58同城App使用腾讯Bugly作为发布环境下App异常数据的收集工具。

我们的崩溃率一直在优化,每个版本都有专门负责监控线上崩溃以及解决问题的同学,经过我们不断的优化,目前 58同城iOS App的崩溃率维持在一个比较优秀的水准, Bugly上收集的崩溃大部分都是野指针崩溃和疑难崩溃。但是遗留的疑难崩溃优化手段比较有限,一个主要的原因是Bugly上的崩溃不能正常解析,定位不到真正原因。我们拿一个简单的例子来说明一下。

RN的HOOK函数问题

0 CoreFoundation  0x00000001804f504c 	0x000000018045c000 + 626764
1 Foundation      0x0000000181dae6cc 	0x0000000181c7e000 + 1246924
2 UIKit           0x0000000198e5cf30 	0x0000000198e57000 + 24368
3 AppName         0xe622388106d79fcc 	RCTFBQuickPerformanceLoggerConfigureHooks + 16244
4 CoreTelephony   0x0000000198e5e628 	0x0000000198e57000 + 30248
5 CoreTelephony   0x0000000108f68fe4 	0x0000000198e57000 + 78455260
6 CoreTelephony   0x00000001061ed870 	0x0000000198e57000 + 30763624
7 CoreTelephony   0x0000000108f657ec 	0x0000000198e57000 + 78440932
8 AppName         0x0000000108f67024 	_ZN6tflite19AcquireFlexDelegateEv + 78447132
9 Foundation      0x0000000108f67024 	_NSGetUsingKeyValueGetter + 88

在近几个版本中,我们发现Bugly上有大量的崩溃日志都会携带一个来自RN的函数调用栈: RCTFBQuickPerformanceLoggerConfigureHooks,这是一个RNHOOK函数。多条崩溃日志的堆栈都指向这个函数,且这个函数是一个空函数,没有任何实现,这让我们比较困扰。用过Bugly的同学都知道,Bugly每条崩溃日志都有个跟踪数据,记录着这个崩溃发生之前页面的跟踪日志,通过页面的跟踪日志我们发现这些崩溃中用户浏览的页面大多数都不涉及RN业务,与RN没有任何关系。而且每条崩溃的页面跟踪日志也不相同。既然程序崩溃之前浏览的业务不涉及RN但Bugly上的堆栈确指向RN,因此我们怀疑这种崩溃不是崩溃在RNHOOK函数上并且它们是不同错误导致的崩溃。带着这种疑问,我们开始验证这个猜想,来看一看我们的怀疑是否准确。

如何验证Bugly解析错误

因为Bugly无法拿到应用崩溃后所产生的ips文件,无法利用symbolicatecrash等工具符号化日志。因此我们采用atos命令来验证我们的怀疑是否正确。

1. atos验证

atos工具会输出崩溃的代码语句和它所在的文件以及行数,前置条件是需要拿到dSYM文件,确定手机架构是arm64还是armv7,还需要拿到atos需要的load-addressaddress,根据这些信息就能够找到问题所在。aots命令格式如下:

atos -o yourAppName.app.dSYM/Contents/Resources/DWARF/yourAppName -arch arm64/armv7 -l <load-address> <address>

怎么获取dSYM文件与架构这里就不做详细介绍了,我们来看一下怎么在Bugly的崩溃日志中拿到load-addressaddress

一般以app命名的地方就是崩溃的位置,例如:正常的一个崩溃日志格式为:

0x0000000103ef6970 0x0000000102728000 + 30252

其中0x0000000103ef6970为运行地址,就是atos需要的address0x0000000102728000为运行起始地址,就是atos需要的load address302522为偏移量,一般来说,偏移量 + 运行起始地址 = 运行地址。

介绍完atos需要的load address(运行起始地址)与address(运行地址)之后,再来看一下RCTFBQuickPerformanceLoggerConfigureHooks这个函数的崩溃,根据图中示例我们看到这个崩溃的运行地址为0xe622388106d79fcc,但是这个崩溃地址是错误的,一般地址小于0xFFFFFFFFFF,示例中明显大很多。因此我需要将高位地址清洗,清洗后此地址为0x106d79fcc。因此address0x106d79fcc

接下来我们打开Bugly其他信息一栏,看到App base addr(基地址):0x0000000102604000,这个就是atos需要需要的load addressimage20211116110000581.png 通过上述信息,我们以RCTFBQuickPerformanceLoggerConfigureHooks这个函数为例验证一下Bugly的解析结果是否正确

➜ atos -o AppName.app.dSYM/Contents/Resources/DWARF/AppName -arch arm64 -l 0x0000000102604000 0x0000000106d79fcc
-[NSMutableDictionary(YJKit) yjKit_setObject:forKey:] (in AppName) (YJKit.m:432)

结果发现atos符号化后的结果与Bugly给我们的结果确实不一致。再根据Bugly的页面跟踪数据我们确认atos符号化后的结果是正确的,这与我们的怀疑是一致的。

既然Bugly的堆栈错误的指向了这个RN的空函数,那么我们就来看一看源码中RCTFBQuickPerformanceLoggerConfigureHooks是怎样的存在。

**2. 源码中的RCTFBQuickPerformanceLoggerConfigureHooks**函数

RCTFBQuickPerformanceLoggerConfigureHooks函数在源码中的声明如下:

image-20211116110100992.png 源码中,这个函数没有任何实现,完全是一个空函数。将RCT__EXTERN 展开后为__attribute__((visibility("default"))),其作用为将RCTFBQuickPerformanceLoggerConfigureHooks向外界暴露,如果外界存在同名函数,那么RCTFBQuickPerformanceLoggerConfigureHooks会报符号冲突的错误。这里利用__attribute__((weak))RCTFBQuickPerformanceLoggerConfigureHooks声明为弱符号,当外界有同名函数时,SDK内部调用外届的函数,否则调用内部空函数,这个弱符号在RN里起到了HOOK的作用 ,接下来我们就详细的了解一下弱符号。

3. 弱符号__attribute__ ((weak))

在一个程序中,无论是变量名,还是函数名,在编译器的眼里,就是一个符号而已,符号可以分为强符号和弱符号。对于C/C++来说,编译器默认函数和初始化了的全局变量为强符号,未初始化的全局变量是弱符号,强符号和弱符号在程序编译连接过程中一般遵循下面三个规则:

  1. 不允许强符号被多次定义。如果有多个强符号,会报符号重定义错误

  2. 如果有一个强符号,其他定义都是弱符号,则选择强符号

  3. 如果一个符号在所有文件中都是弱符号,则选择其中一个占用空间最大的

强弱符号规则定义摘选自:强符号和弱符号,强引用和弱引用

duplicate symbol '_OBJC_CLASS_$_XXX'这个错误大家应该都比较熟悉,通过错误的描述我们很容易就可以知道这是因为在链接的时候有重复的符号。在编译时,编译器向汇编器输出每个全局符号,若两个或两个以上全局符号(函数或变量名)名字一样,且都是强符号就会出现符号重定义错误,如果有一个是弱符号(weak symbol),则不会出现问题。

一个程序内同时存在强符号与弱符号时,链接器会忽略弱符号,去使用普通的全局符号来解析所有对这些符号的引用,但当普通的全局符号不可用时,链接器会使用弱符号。当有函数或变量名可能被用户覆盖时,该函数或变量名可以声明为一个弱符号。可以通过__attribute__((weak))来定义弱符号。

4. 弱符号的使用

在开发中,假如我们不确定外部模块是否提供一个函数func,但是我们不得不用这个函数,即自己模块的代码必须用到func函数:

extern int func(void);
...int a = func;
...

我们不知道func函数是否被定义了,这会导致2个结果:

  1. 外部存在这个函数func,那么在我自己的模块使用这个函数func,正确。
  2. 外部如果不存在这个函数,那么我们使用func,程序直接崩溃。

所以这个时候,__attribute__((weak)) 派上了用场,在自己的模块中定义:

int __attribute__((weak)) func(......)
{
     return 0;
}

将本模块的func转成弱符号类型,如果遇到强符号类型(即外部模块定义了func),那么我们在本模块执行的func将会是外部模块定义的func。如果外部模块没有定义,那么,将会调用这个弱符号。

我们发现Bugly对某些没有解析正确的崩溃,堆栈都会定位到项目中的弱符号上,同时我们还发现在58同城App中,Bugly不单单定位到RCTFBQuickPerformanceLoggerConfigureHooks这一个弱符号上,还有大量的崩溃定位到了其他的弱符号上。

上面我们通过atos还原了正确的日志,并定位到了是弱符号的问题,下面我们结合符号表来看一下日志符号化的原理。

如何处理bugly解析异常的数据

Crash 日志在被符号化之前是不可读的,所谓符号化就是把堆栈信息解释成源码里可读的函数名或方法名,也就是所谓的符号。只有符号化成功后,Crash 日志才能更好的帮助开发者定位问题。日志的解析需要用到dSYM文件,dSYM指的是 Debug Symbols, 也就是调试符号。

DWARF是一种被众多编译器和调试器使用的用于支持源码级别调试的调试文件格式,该格式是一个固定的数据格式,dSYM就是按照DWARF格式保存调试信息的文件,我们常常称为符号表文件。

日志的符号化有很多种方式,例如xcode分析、symbolicatecrashatosdwarfdump等,本质其实就是查找崩溃指令在符号表哪个函数的指令区间。今天我们主要讲一下Bugly解析不准的日志怎么在符号表里查找出正确的堆栈。

1. Bugly还原正确堆栈的原理

以弱符号RCTFBQuickPerformanceLoggerConfigureHooks函数为例,还原一下日志的解析原理。

0 CoreFoundation  0x00000001804f504c 	0x000000018045c000 + 626764
1 Foundation      0x0000000181dae6cc 	0x0000000181c7e000 + 1246924
2 UIKit           0x0000000198e5cf30 	0x0000000198e57000 + 24368
3 AppName         0xe622388106d79fcc 	RCTFBQuickPerformanceLoggerConfigureHooks + 16244
4 CoreTelephony   0x0000000198e5e628 	0x0000000198e57000 + 30248
5 CoreTelephony   0x0000000108f68fe4 	0x0000000198e57000 + 78455260
6 CoreTelephony   0x00000001061ed870 	0x0000000198e57000 + 30763624
7 CoreTelephony   0x0000000108f657ec 	0x0000000198e57000 + 78440932
8 AppName         0x0000000108f67024 	_ZN6tflite19AcquireFlexDelegateEv + 78447132
9 Foundation      0x0000000108f67024 	_NSGetUsingKeyValueGetter + 88
  • 上图中我们看到 RCTFBQuickPerformanceLoggerConfigureHooks 这行调用栈的虚拟内存地址存在异常,一般地址地址小于0xFFFFFFFFFF ,示例中明显大很多。我们将高位地址清洗后来保证堆栈正常。调整后,地址为 0x106d79fcc,但当然不是每个Bugly解析错误的日志虚拟内存地址都异常,如果是正常的,则不用改变
  • 查看其他信息,找到基地址App base addr,此处为 0x102604000。如果崩溃发生在其他动态库,那么查找下方对应动态库的地址。
  • 经过第一步和第二步,我们获取到了 0x106d79fcc0x102604000
  • 指令偏移地址为:0x4775FCC = (步骤1)0x106d79fcc - (步骤2)0x102604000
  • 找到此次打包对应的Bugly符号表,并以文本的方式打开
  • 查找0x4775FCC在哪一行符号区间内
  • 最终查找到其在 0x4775fb40x4775FCC < 0x4775fd0,即3997407行的符号,符号区间遵循前闭后开原则 image20211112210415260.png 通过以上步骤我们找到了RCTFBQuickPerformanceLoggerConfigureHooks函数的实际崩溃位置,并且与我们用atos工具验证后的结果一致,说明这个结果是正确的。

上面我们在符号表里查找到Bugly解析错误的日志的正确堆栈,那如果没有符号表怎么呢,这就涉及到了提取符号表。

2. 如何提取符号表

如果符号表丢失了,但是代码没有改动,那么可以尝试在相同的环境下重新编译和提取符号表,这个步骤有两个前提 1. 代码要与之前保持一致 2. 编译和链接环境都相同,防止由于Debug/Relase对最终包有影响。如果是Debug包,可以用过dsymutil xxx.app/xxx -o xxx.dSYM 来提取符号表

有了以上两个前提就可以通过dSYM文件来提取符号表了,目前我们实现了Bugly轻量符号表的提取,并且文件体积相对于Bugly符号表体积减少到60%。推动ICI(58项目管理平台)按照一定规则输出符号表,目前可以做到根据崩溃日志的UUID直接下载对应的符号表,日志解析和问题排查效率极大提高。

3. 无符号表符号化日志

如果既找不到符号表(dSYM文件或symbol文件),也无法恢复到原先的代码重新生成符号表,那么可以考虑借助无符号表符号化工具 WBBlades 来还原日志:github.com/wuba/WBBlad…

WBBlades是基于Mach-O文件解析的工具集,包括未使用代码检测(支持ObjCSwift)、应用程序大小分析、不需要dSYM文件的日志恢复。

由于方案自身的限制,目前还不能解析除了OC方法以外的崩溃日志,如:block的崩溃、自定义C函数的崩溃。后续需要考虑如何将block的崩溃日志进行符号化。

优化成果与收益

现在我们知道了当Bugly解析不准的时候,我们可以利用Bugly给我们提供的其他信息在符号表里找到正确的答案。通过以上研究,我们通过自研解析工具重新对Bugly的日志进行符号化,通过工具我们在集团内部解决了除RNHOOK函数问题以外还解决了多个遗留已久的历史版本崩溃问题,这里简单的介绍几个比较有代表性的。

1. 拿不到基地址的问题

通过RCTFBQuickPerformanceLoggerConfigureHooks函数的崩溃的介绍,我们可以在Bugly的其他信息里获取到日志的基地址,通过这个地址我们不论是用atos验证还是手动在符号表里查找都可以还原正确的堆栈,但是如果Bugly的其他信息里没有基地址怎么办,我们来看一下下面的这种崩溃日志。

0 CoreFoundation 0x00000001835891b8 0x0000000183459000 + 1245624
5 UIKit 0x000000018963a660 0x000000018942f000 + 2143840
6 AppName 0x00000001075c9904 str_to_integral_8ExpectedIT_NS_14Conversion + 1950052
7 AppName 0x000000010627f94c RCTFBQuickPerformanceLoggerConfigureHooks + 3098344
8 AppName 0x00000001062015a0 RCTFBQuickPerformanceLoggerConfigureHooks + 2581308
9 AppName 0x00000001061fe498 RCTFBQuickPerformanceLoggerConfigureHooks + 2568756
10 AppName 0x00000001061fed38 RCTFBQuickPerformanceLoggerConfigureHooks + 2570964
11 AppName 0x00000001061ed900 RCTFBQuickPerformanceLoggerConfigureHooks + 2500252
12 AppName 0x0000000105231bd8 _ZZGetAppIdTableEvE12arAppIdTable + 57325128
13 libdispatch.dylib 0x00000001824121fc 0x0000000182411000 + 4604
21 UIKit 0x00000001894a4534 UIApplicationMain + 208
22 AppName 0x00000001085b73e8 _ZN15CTXAppidConvert13GetAppIdTableEv + 8521236
23 libdyld.dylib 0x00000001824455b8 0x0000000182441000 + 17848

通过上面的堆栈信息我们看到崩溃的调用栈也停留在了弱符号RCTFBQuickPerformanceLoggerConfigureHooks上,但与我们上面举的例子的不同点是这个崩溃在Bugly上的其他信息一栏里是空的,也就是拿不到基地址,因此我们使用atos命令是不可行的,所以只能在符号表里查找,但是我们要首先要拿到基地址。下面我们来看一下遇到这种情况该怎样拿到基地址。

  1. 首先我们看到 22 AppName 0x00000001085b73e8 str_to_integral_8ExpectedIT_NS_14Conversion + 8521236 这一行信息,熟悉Bugly与crash 日志的同学一定知道, 这一行大概率是main函数,那么我们就在这里找到突破口。
  2. 我们看到main函数的调用栈符号是_ZN15CTXAppidConvert13GetAppIdTableEv,这个函数运行地址是0x00000001085b73e8
  3. 那这个函数的运行起始地址为 0x00000001085b73e8 - 0x8521236 = 0x107D96DD4
  4. 打开符号表,找到_ZN15CTXAppidConvert13GetAppIdTableEv这个符号的偏移地址为 0x7d86dd4
  5. App base addr(基地址): 0x107D96DD4 - 0x7d86dd4 = 0x100010000

这样我们就拿到了这个日志的基地址,然后利用上面的方式在符号表里找到正确的堆栈,因此也就能将这个日志正确的解析了。

2. 百度地图SDK的崩溃问题

除了RNHOOK函数问题,我们还发现有大量的崩溃日志调用栈都指向了百度地图SDK。 image20211116110252571.png 首先我们通过Bugly显示的堆栈信息以为是百度地图SDK的崩溃,这个崩溃在某几个版本中占58同城App总崩溃率的40%左右,是58App内崩溃率最高的一个模块,在更换了新的SDK后崩溃率也并没有下降,而这么高的崩溃率,我们在开发与测试中却从未遇到过。通过Bugly上的跟踪数据我们看到最后的页面记录停留在了金融业务内,而金融业务与百度地图没有任何关系。因此这个崩溃应该与上面描述的一样,解析错误。拿到基地址与运行地址,通过我们自研的工具拿到了正确的堆栈。结果为金融业务使用的一个人脸识别SDK的崩溃,文件名称与Bugly上的跟踪日志也相同。

3. 安居客IM登录问题

我们编写了脚本文件,利用脚本文件定位了一个安居客存在很久的问题。在Bugly上排名比较靠前,崩溃占比很高,Bugly上的堆栈显示异常,因此这个崩溃之前并没有定位到具体原因。脚本协助安居客定位是IMSDK的原因。 image20211116110315205.png

以上是几个比较具有代表性的Bugly解析错误的日志,我们通过研究分析将这些错误的堆栈还原正确并解决了问题。

目前我们支持按版本自动排查出Bugly上前200名崩溃中解析异常的日志,并且可以将异常日志自动符号化成正确的日志。整个过程在符号表已经提前下载并解析好的前提下,只有10秒左右,大大提升了我们日常研发以及解决问题的效率。通过对Bugly上的疑难崩溃的治理,目前为止我们修复了Bugly上70%左右的疑难崩溃,大大降低了58 App的崩溃率。 image20211125150625544.png 除了上述我们研究的Bugly解析异常的日志可以正确解析外,58同城还支持其他异常日志的解析。 例如App内存在段迁移发生崩溃后的日志,段迁移崩溃日志中的库名变成了异常字符、丢失了进程的起始地址,获取到错误的偏移地址,这种情况下我们可以进行自动修正并解析出正确的堆栈信息。

总结与展望

文本首先介绍了我们使用Bugly遇到的RNHOOK函数问题,通过这个问题我们提出Bugly可能解析存在错误的疑问,后续用atos命令以及符号表排查找到了正确的答案,过程中又发现了弱符号的问题。按照这个研究方向我们在集团内做了一系列工具并解决了多个版本的历史遗留问题,大大的降低了58同城iOS App的崩溃率,也提高了日常工作研发效率。

App的性能优化对用户的体验十分重要,而崩溃作为其中最重要的一个环节需要我们持续的钻研与探索。后续我们将持续优化App的性能给用户带来最好的体验。

首发自CSDN:“杀死” App 上的疑难崩溃!