低于0.01%的极致Crash率是怎么做到的?

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




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参考手册:infocenter.arm.com/help/topic/…


2.技术博客:blog.cnbluebox.com/blog/2017/0…


3. 分析objc_msgSend的汇编代码:www.cocoachina.com/ios/2017080…


4. ARM64汇编约定:infocenter.arm.com/help/topic/…


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

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


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