WeTest腾讯质量开发平台 低于 0.01%的极致 Crash 率是怎么做到的?

腾讯WeTest · 2018年06月29日 · 1019 次阅读

作者:卢子填, 腾讯移动互联网 高级开发工程师
商业转载请联系腾讯 WeTest 获得授权,非商业转载请注明出处。
原文链接:http://wetest.qq.com/lab/view/393.html

WeTest 导读

看似系统 Bug 的 Crash 99% 都不是系统问题!本文将与你一起探索 Crash 分析的科学方法。


在移动互联网闯荡多年的 iOS 手机管家,经过不断迭代创新,已经涵盖了隐私(加密相册)、安全(骚扰拦截、短信过滤)、工具(网络检测、照片清理、极简提醒等)等等各个方面,为千万用户提供安全专业的服务。但与此同时,工程代码也越来越庞大(近 30 万行),一丁点的问题都会影响大量的用户,所以手管一直在质量上下狠功夫,对 Crash 率更是追求极致。近几个迭代对 Crash 做了专项分析,Crash 率在原本 0.02% 的基础上稳定降到 0.01%,7.7.1 版本逼近 0.009%,此文将对两类典型的 Crash 案例进行分析总结。

一、案例分析

Crash 主要产生在 Objective-C 方法调用或系统方法调用,所以本文的两个典型案例正是针对 OC 和 C 方法调用来展开:

1.1. Crash 发生在 objc_msgSend

Crash 堆栈长这样!Σ(゚д゚lll)

图 1

是的,看到这个堆栈我也很方,一眼望去只有一行是工程的代码堆栈,还是个 main,但深入分析了 Objective-C 的消息机制后我们还是能找到问题的突破口的。

Crash 类型

首先我们看到这是一个 SEGV_ACCERR 类型的 Crash,访问了错误的地址。

其次,通过汇编代码分析 objc_msgSend 方法,我们可以得知 objc_msgSend + 16 这一行代码(如下图 2)是在读取当前 OC 方法的 receiver 的 isa 指针偏移 0x10 的处的值(见附录推荐的 objc_msgSend 链接文章),由于对象已经被释放了,所以读取该地址导致了读取错误地址,也即产生了野指针 Crash。

图 2

查找寄存器

于是,我们查看 Crash 时各寄存器的值(见图 3),其中 x0 是发生 Crash 的函数的第一个参数,针对 objc_msgSend 来说 x0 同时表示指向发生 Crash 的对象的地址,x1 是 Crash 的函数的第二个参数,在 objc_msgSend 中表示 Crash 的对象调用的 selector,RDM 很贴心,已经帮我们查询出该 selector 为 respondsToSelector:。如果 x1 是我们工程中自己写的一个方法就很容易分析问题了,直接查找工程代码,定位到该函数即可找到原因,可是 respondsToSelector:调用的地方太多了,怎么办呢?我们还要继续往里挖。

因为 respondsToSelector:的参数是一个 selector,所以只要再查出这个 selector 是什么(对应查询 x2 在符号表中的符号),也可以马上定位到问题代码。但是很遗憾,x2 不在 Crash 报告中 Binary Images 中的任一个模块的地址范围内,那,还有办法吗?

图 3

办法还是有的,我们知道 lr 寄存器是当前函数的上一层函数调用地址,如果能知道 lr 寄存器执行的方法就可以进一步确定问题,很幸运,lr 的值刚好就是 Binary Images 中管家模块地址范围内(见图 3,lr 是 0x000000010508be44,管家模块范围是 0x104c24000 - 0x1055affff),于是在符号表中搜索 lr 对应的符号,得到如下的信息:(下图中的 MQQABC 为你的 app 的符号表文件,在 xcode 打包提交时需要保存下来,对应 XXX.app.dSYM/Contents/Resources/DWARF/XXX)

图 4

至此,我们知道图 1 那个只有 main 信息的堆栈产生的 Crash 是在-[MQQAlertView didDismissWithButtonIndex:] 的第 530 行,产生 Crash 的原因是调用了 respondsToSelector:,已经十分接近答案了,但是 MQQAlertView 是管家一个通用的弹窗组件,所以还需要知道是哪个页面出现了这个 Crash。

定位问题页面

手管利用 RDM 的 Crash 上报组件可以在 Crash 产生时上报附件的特性,将一些关键的信息存储到了附件上(当前的 ViewController 堆栈、上一次释放的 ViewController、applicationState 等),可以在 RDM 平台上查看这些附件信息,于是我们查看附件信息,发现是在用户退出某页面 A 时产生的 Crash。

得出问题原因→→

至此,Crash 的路径已经很清楚了:用户进入页面 A,页面 A 弹出一个弹窗,在弹窗未弹出前用户快速退出页面,退出页面时没有把弹窗关掉,然后用户点击了弹窗,由于弹窗的 delegate 是页面 A,而页面 A 已经释放,所以导致了访问了野指针。

问题原因查明,问题代码定位精确,问题也就不难修复了。

注:objc_msgSend + 16 是典型的野指针导致的 Crash 堆栈,遇到这类问题,基本上按照上述思路都可以顺利解决。

1.2. Crash 发生在 C 函数

棘手的 Crash 通常关键堆栈都是落在系统函数上,这也为我们把锅甩给系统找到一个很好的借口,但想办法解决问题才是目标,毕竟系统是没办法帮你背这个锅的¯_(ツ)_/¯下面这个例子是结合 Crash 报告提供的信息分析解决问题的典型案例:

图 5

从 Crash 报告可以看到几个关键信息:

1)Crash 类型同样是访问了非法地址 SEGV_ACCERR,非法地址是 0x68

2)Crash 发生在子线程(Thread 7)

3)Crash 是落在 flockfile + 24 的位置上

于是我们通过 Xcode 调试到 flockfile 函数,并定位到 + 24 的位置(如下图 6 断点的位置)

图 6

ldr x8, [x19, #0x68] 这句汇编代码的含义是从 x19 偏移 0x68 的地址上加载数据存储到 x8 中。结合 SEGV_ACCERR,我们知道这个地址非法了,而且非法地址是 0x68,也就是说 x19 + 0x68 = 0x68,推出=> x19 = 0,再往上看到第 5 行:mov x19, x0,可以知道 x19 的值是由 x0 赋值得来的,所以 x0 = 0,又因为 x0 是函数的第一个参数,所以可以得出 flockfile 的入参为 0,查看 flockfile 的定义:

void flockfile(FILE *);

可见,这里的 FILE * 指针为空了。结合堆栈中管家工程中的代码调用:

- [MQQCBKAsdfUpdater mgPchAsdfCfgFileWithOFP:pFP:toFP:result:error:]

可以看到,传入了三个文件路径,所以问题必定是其中一个文件不存在了。至此就是我们从 Crash 报告中能分析出来的信息,再结合查看工程代码得出:问题代码最初是在主线程执行,中间 dispatch 到子线程(从 Crash 报告得出),线程间状态没有控制好导致切换到子线程执行的过程中文件被删除了而导致了 Crash。

二、方法总结

以上分析仅是对过程的回顾,略去了许多细节,这一节进行补充。因为 Crash 分析主要就是要搞清楚发生 Crash 时函数调用发生了什么,所以这一节主要分为几个部分:

1)ARM64 的函数调用约定

2)常用汇编指令

3)Objective-C 函数调用的特点

4)查找符号表

5)Crash 报告关键信息

2.1. ARM64 函数调用约定

由于目前主流机型都是 iPhone 5s 以上的机型了,所以这里只介绍 ARM64。

2.1.1. ARM64 指令集的寄存器

图 7(摘自 ARM64 参考手册)

ARM64 指令集有 31 个 64bit 的通用整形寄存器:x0 到 x30(w0 到 w30 表示只取这些寄存器的低 32 位)

x0 到 x7 用来做参数传递,以及从子函数返回结果(通常通过 x0 返回,如果是一个比较大的结构体则结果会存在 x8 的执行地址上)

LR:即 x30 寄存器,也叫链接寄存器,一般是保存返回上一层调用的地址

FP:即 r29,栈底寄存器

外加一个栈顶寄存器 SP

2.1.2. 栈

栈是从高地址到低地址延伸的,栈底是高地址,栈顶是低地址

fp 指向当前栈帧的栈低,即高地址

sp 指向当前栈帧的栈顶,即低地址

下图 8 是_funcA 调用_funcB 的栈帧情况:

图 8(摘自技术博客)

_funcB 的前三行代码如图 8 的汇编代码所示:

第 1 行 stp 指令是表示将_funcA 的栈底指针 fp、链接寄存器 lr 存到_funcA 的栈顶 sp - 0x10 的地址上,并将 sp 设置为 sp - 0x10(图中 fp_B),方便后续从_funcB 返回_funcA,并恢复_funcA 的栈帧

第 2 行是把 sp 赋给 fp,即设置_funcB 的栈底指针(图中 fp_B)

第 3 行是把 sp 设置为 sp - 0x30。由此完成了_funcA 对_funcB 的调用。

2.1.3. 实例分析

下面通过一个实例来分析函数的参数传递

图 9

如图 9 有两个方法,OC 方法是一个按钮点击事件,点击后调用上面的 C 方法,为了调试方便 C 方法有 11 个参数,本例中入参的值是 1 到 11,可以观察到超过 8 个参数时是怎么传参的。

为了看到调用过程的汇编代码,我们需要在- (IBAction) testCmethodCall1:(id) sender 中设置断点,然后在 Xcode 中设置 Always Show Disaasembly(见图 10),这样调试过程中看的就是汇编代码了

图 10

我们断点到 OC 方法,汇编代码如图 11

图 11

函数调用状态切换

第 1 行:sub sp, sp, #0x40 设置新的栈顶寄存器(sp)

第 2 行:stp x29, x30, [sp, #0x30] 把栈底寄存器(x29 即 fp)、链接寄存器(x30 即 lr)保存起来

第 3 行:add x29, sp, #0x30 把 fp(x29)设置为 sp + 0x30,即设置新的栈底寄存器

这 3 行,完成了系统对按钮点击事件方法的调用所需的状态切换工作

为 C 函数准备入参

接下来直到 str w13, [sp, #0x8] 都是在为调用 C 方法准备参数,因为没有经过优化所以显得很啰嗦。

orr w8, wzr, #0x1 是一个或指令,把零寄存器或上 1 的值赋给 w8 寄存器,就是 w8 = 1,下面的类似,分别把 2 到 11 赋给 w9-w10、w3-w7、w11-w13

stur x0, [x29, #-0x8] 把 x0 保存到 x29 - 0x8 上

stur x1, [x29, #-0x10] 把 x1 保存到 x29 - 0x10 上

str x2, [sp, #0x18] 把 x2 保存到 sp + 0x18 上

mov x0, x8 把前面赋值为 1 的 x8(orr w8, wzr, #0x1)赋给 x0

mov x1, x9,同理,把 2 赋给 x1

mov x2, x10,同理,把 3 赋给 x2,由此可见前面 w8、w9、w10 只是中转用的,至此 x0-x7 已经将可以直接传值的寄存器都赋上了正确的值,接下来的 3 行则可以看到是怎么处理超过 8 个整形参数的情况

通过栈传参

str w11, [sp] 把前面赋值为 9(mov w11, #0x9)的 w11 存到栈顶位置

str w12, [sp, #0x4] 把前面赋值为 10(mov w12, #0xa)的 w12 存到栈顶偏移 0x4 的位置

str w13, [sp, #0x8] 把前面赋值为 11(mov w13, #0xb)的 w13 存到栈顶偏移 0x8 的位置

调用 C 函数

至此,入参全部准备完毕,接下来调用 bl 0x104fc237c 就可以调用 C 函数了

图 12

进入 C 函数的汇编代码,我们先明确下这段 C 函数的任务是:return a1 + a2 + a11,所以应该是把 OC 函数中 w0、w1、w13(w13 存在栈上 [sp, #0x8])的值拿出来相加,得到的结果存到 x0 上,然后返回,所以:

sub sp, sp, #0x30 把栈顶指针设置为 sp - 0x30,这样的话,之前 w13 存在的栈的位置就变成了 [sp, #0x38],所以你会看到图 12 最后一个红圈 ldr w1, [sp, #0x38] 其实就是把之前保存 w13 的值 load 到 w1 中

str w0, [sp, #0x2c] 和 str w1, [sp, #0x28] 把 w0、w1 的值存到栈上,然后又用 ldr w0, [sp, #0x2c] 和 ldr w1, [sp, #0x28] 把 w0、w1 的值取出来,没优化的汇编真的很啰嗦

add w0, w0, w1 把 w0、w1 的值加起来存到 w0(即计算了 a1 + a2)

ldr w1, [sp, #0x38] 前面说过取出 w13 的值存到 w1

add w0, w0, w1 把 w0、w1 的值加起来存到 w0(即计算了 a1 + a2 + a11),现在计算完的结果存到了 w0 中。

由上面的分析过程我们可以看到:

子函数开头的汇编代码会调整 fp、sp 指针

参数传递少于 8 个的使用 x0-x7 寄存器

超过 8 个的则使用栈来传递

子函数的返回值一般存在 x0 中

因为 x0、x1、x29、x30 等寄存器有特殊含义,所以有时候会把这些寄存器的值先存到栈上,然后再使用它们

2.2. 常用汇编指令

2.1 节已经接触了几个汇编指令,下面整理下常用的几个汇编指令:

mov a, b 即 a = b

ldr a, [b] 将 b 指针所在地址上的内容加载 a 寄存器中

str a, [b] 将 a 寄存器存储到 b 指针指向地址上

ldr a, [b, #0x10] 从 b 寄存器地址 +0x10 的地址上加载内容到 a 寄存器中

ldr a, [b, #0x10]! 带感叹号的意思是把内容加载到 a 寄存器中,并且修改 b 寄存器为 b = b + 0x10

cmp a, b 比较 a、b 寄存器的值,会修改 cpsr

cbz xd, addr 判断 xd 寄存器是否为 0,是则跳转到 addr 地址处执行

cbnz xd, addr 判断 xd 寄存器是否不为 0,不为 0 则跳转到 addr

b 跳转指令,不修改 lr 寄存器,所以子函数调用过程不会出现在堆栈中

bl 跳转指令,修改 lr 寄存器,所以子函数调用过程会出现在堆栈中

stp a, b, [c] 从 c 地址中取出两个 64 位值分别存储到 a、b 两个寄存器中

ldp a, b, [c] 把 a、b 两个寄存器的值存储到 c 地址中

2.3. Objective-C 函数调用的特点

Objective-C 函数调用是一种特殊的函数调用,但最终也是转化为 C 函数调用的方式。

我们都知道 Objective-C 调用最终都会调用 objc_msgSend(id self, SEL selector, ...),然后再用前面的知识分析 objc_msgSend 即可

可以看到,x0 就是调用的 receiver,x1 就是调用的 selector,后面则是参数。具体可以查看附录中相关的文章。

2.4. 查找符号表

图 13

Crash 报告中有 Binary Images:

1)模块的起止地址:比如图 13 中 MQQABC 模块的起始地址是 0x104c24000,结束地址是 0x1055affff,所以我们可以通过这些模块的起止地址来判断一个我们感兴趣的寄存器的地址是属于哪个模块的

2)模块的 UUID,如图 13 中 MQQABC 的 UUID 是 f130b043a0c832d9958d89dab8339961,通过它可以判定你的符号文件是正确的,如图 14 用 dwarfdump

图 14

3)用 atos 查找地址对应的符号,-l 需要提供 1)中提到的模块起始地址

图 15

4)如果用 atos 查找出来的结果仍然是个地址,还需要在 mach-O 文件的TEXT 段或RODATA 段的__objc_methname 中进一步查找(注意:第一个红框中查询出来的 0x 在 otool 查找 Mach-O 文件中要去掉)

图 16

2.5. Crash 报告关键信息

图 17

图 18 结合寄存器值查找关键信息

图 19 确定符号表 UUID 及起止地址

三、附录参考

1.ARM64 参考手册:http://infocenter.arm.com/help/topic/com.arm.doc.ihi0055b/IHI0055B_aapcs64.pdf

2.技术博客:https://blog.cnbluebox.com/blog/2017/07/24/arm64-start/?hmsr=toutiao.io&utm_medium=toutiao.io&utm_source=toutiao.io

  1. 分析 objc_msgSend 的汇编代码:http://www.cocoachina.com/ios/20170802/20102.html

  2. ARM64 汇编约定:http://infocenter.arm.com/help/topic/com.arm.doc.ihi0055b/IHI0055B_aapcs64.pdf


腾讯 WeTest 是由腾讯官方推出的一站式质量开放平台。十余年品质管理经验,致力于质量标准建设、产品质量提升。腾讯 WeTest 为移动开发者提供兼容性测试、云真机、性能测试、安全防护、企鹅风讯(舆情分析)等优秀研发工具,为百余行业提供解决方案,覆盖产品在研发、运营各阶段的测试需求,历经千款产品磨砺。金牌专家团队,通过 5 大维度,41 项指标,360 度保障您的产品质量。

腾讯互娱为提高苹果应用的审核通过率,专门成立了苹果审核测试团队,打造出 iOS 预审工具这款产品。经过长时间的内部运营和磨炼,腾讯苹果应用审核通过率从平均 35% 提升到 90%+。点击链接;http://wetest.qq.com/product/ios 邀您立刻体验。

如果使用当中有任何疑问,欢迎联系腾讯 WeTest 企业 QQ:800024531

暂无回复。
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册