|
贝壳找房iOS 疑难Crash治理实践
贝壳找房iOS 疑难Crash治理实践
中平、许博
贝壳产品技术
贝壳产品技术 “贝壳产品技术公众号”作为贝壳官方产品技术号,致力打造贝壳产品、技术干货分享平台,面向互联网/O2O开发/产品从业者,每周推送优质产品技术文章、技术沙龙活动及招聘信息等。欢迎大家关注我们。 242篇内容
2021年09月08日 19:32
前言质量是App的生命线,而Crash是严重威胁到这条生命线的不定时“炸弹”。它会打断业务流程,伤害用户体验,增加用户投诉,引发用户流失等严重问题。而解决Crash是App开发者不得不面对的挑战。一方面,业务迭代、系统升级都可能造成Crash爆发,如果不能及时治理,会积重难返。另一方面,相比于线上千万甚至亿级用户,再充分的线下测试,也无法避免上线后依旧有未知Crash发生。2021年,友盟+U-APM数据显示:App整体崩溃率为0.293%,其中Android崩溃率为0.32%,iOS 崩溃率为0.1%。其中,iOS崩溃Top3分别是:NSInvalidArgumentException、NSGenericException、NSRangeException。原理1、异常异常可能是硬件或软件触发的。硬件产生的异常包括但不限于:CPU无效指令、无效的地址或无权限的访问。软件引发的异常包括但不限于被系统强杀、语言类异常及断言等。其中,系统强杀会调用kill函数,转化为SIGKILL信号引发应用被强杀;语言类异常及断言触发的崩溃会通过abort函数转化为SIGABRT信号引发应用终止。需要说明的是,这里提到的异常是同步(synchronous)中断,主要有处理器探测异常(processor-detected exception)和编程异常(programmed exception)两类。其中,处理器探测异常是CPU执行指令时探测到一个反常条件所产生的异常,包括:故障(fault)、陷阱(trap)和中止(abort)。故障(fault)一般可以纠正,一旦纠正,程序就可以继续执行,陷阱(trap)主要用于调试程序,而中止(abort)是用于报告发生的严重错误,如硬件故障等。编程异常(programmed exception)是开发者发出请求时发生。控制单元把编程异常作为trap来处理,又称为软中断(software interrrupt)。2、Mach异常和SignalCrash异常类型主要包括Mach 异常、Signal异常、OC/C/C++语言类异常三类。其中,Mach异常是最底层的内核级异常,整个异常机制是构建在Mach异常之上的。硬件产生的信号可以被Mach层捕获,然后转换为对应的Signal。用户层面的异常会转成Mach异常是因为:iOS为了统一异常处理流程,将用户层面的异常先下沉转换为Mach异常,再转换为Signal。Mach异常转成Signal流程包括:异常的封装、转换、发送,异常消息接收处理,异常消息转成Signal。内核线程循环接收异常消息,当接收到异常消息后,会调用mach_exc_server函数;该函数调用catch_mach_exception_raise函数来捕获异常消息,获得异常消息后,在handle_ux_exception中利用ux_exception将异常消息转换为Signal,通过threadsignal函数将信号投递到出错的线程。我们可以通过方法signal(x, SignalHandler)来捕获Signal。细节见XNU(https://github.com/apple/darwin-xnu)//bsd/uxkern/ux_exception.ckern_return_thandle_ux_exception(thread_tthread,intexception,mach_exception_code_tcode,mach_exception_subcode_tsubcode){//略...//Mach异常消息转为Signalintux_signal=ux_exception(exception,code,subcode);uthread_tut=get_bsdthread_info(thread);if(code==KERN_PROTECTION_FAILURE&ux_signal==SIGBUS){user_addr_tsp=subcode;user_addr_tstack_max=p->user_stack;user_addr_tstack_min=p->user_stack-MAXSSIZ;if(sp>=stack_min&spp_sigacts;if((p->p_sigignore&mask)||(ut->uu_sigwait&mask)||(ut->uu_sigmask&mask)||(ps->ps_sigact[SIGSEGV]==SIG_IGN)||(!(ps->ps_sigonstack&mask))){p->p_sigignore&=~mask;p->p_sigcatch&=~mask;ps->ps_sigact[SIGSEGV]=SIG_DFL;ut->uu_sigwait&=~mask;ut->uu_sigmask&=~mask;}}}//将信号投递到出错的线程if(ux_signal!=0){ut->uu_exception=exception;ut->uu_subcode=subcode;threadsignal(thread,ux_signal,code,TRUE);}proc_rele(p);returnKERN_SUCCESS;}3、调用栈内存布局每个进程都有独立的进程地址空间,一个iOS App 对应的进程地址空间包括栈、堆区、全局区、常量区、代码区。其中代码区、常量区、静态区这三个区域都是自动加载,并且在进程结束之后被系统释放,不需要开发者关注。栈区一般存放局部变量、临时变量,由编译器自动分配和释放,每个线程运行时都对应一个栈。而堆区用于动态内存的申请,由程序员分配和释放。进程地址空间如下:CPU在执行指令时,会将一个函数映射一个栈桢(Stack Frame),栈桢是一个按照方法调用顺序, 从栈的高地址向低地址依次存放的一组数据, 所有函数的Stack Frame串起来就组成了一个完整的栈。FP寄存器存储的是方法栈底,而LR寄存器指向方法结束阶段返回的上层方法的地址。基于此,可以通过调用栈的内部布局获取方法的调用栈。这些调用栈将大大帮助Crash的排查和定位。4、恢复调用栈崩溃发生时,通过task_threads获取所有线程,然后利用thread_get_state获取线程上下文信息,根据调用栈布局和寄存器获取函数调用栈,最后符号化调用栈,符号化主要分三步:定位镜像: 遍历Mach-O中的LC_SEGMENT_64中的各个Segment的起始地址及其范围,比较来定位内存地址是否在该Segment中,进而确定该内存地址是否在该image中。符号查找:通过LC_SYMTAB加载命令获取符号表及字符串表的信息,如地址、数量及大小,从而获取符号表中的所有符号及字符串表中对应的函数名称。定位符号:遍历符号表中的所有符号地址来匹配与当前函数地址最接近的,即为要寻找的函数符号,并通过符号表中的String Table Index字符串表偏移量来获取函数符号名称。5、防护、捕获和处理线上防护:对一些高频的Crash做防护,包括但不限于:unrecognized selector crash、KVO crash、Container crash、Can't add self as subview Crash、Bad Access Crash。在防护Crash同时,捕获其他Crash,来帮助发现线上问题。捕获Mach异常、Signal 、C++异常等可分为三步:替换原来的捕获处理、将异常信息保存;暂停非崩溃采集线程,获取其他线程的调用栈;恢复原本的捕获处理如:捕获Mach异常,需要先注册自己的 port,来接收这个异常,等到捕获到信息后,还需要恢复原来的port。针对捕获到的Crash,处理办法一般分三步:获取上下文:尽可能多地掌握问题的上下文信息,如Crash日志,用户行为日志、问题发生时间,API服务等;原因回溯:大胆假设,小心求证。根据收集到的问题上下文信息,找到可疑的地方、复现的路径,尽可能还原现场,结合源码,一步步调试,找到根本原因。回归问题:根据问题情况,制定合理的修复方案。6、iOS 14 WKWebView Crash获取上下文:iOS 14系统上发生,线下未能复现,调用栈如下://WebKit堆栈(部分)WebKit::ShareableBitmap::createGraphicsContext()(inWebKit)WebKit::ShareableBitmap::makeCGImageCopy()(inWebKit)WebKit::ShareableBitmap::makeCGImageCopy()(inWebKit)-[WKContentView(WKInteractionPreview)assignLegacyDataForContextMenuInteraction](inWebKit)-[WKContentView(WKInteractionPreview)continueContextMenuInteraction:]_block_invoke(inWebKit)原因回溯:根据堆栈去查看WebKit2源码(WKContentViewInteraction.m、ShareableBitmapCG.cpp),得到大致推断:在WKWebView长按图片,触发图片绘制引起的Crash。Crash发生在iOS14上。回归问题:结合日志埋点,找到问题URL,本地未能复现,和业务方沟通,禁止这个业务下WKWebView长按图片效果。这是WebView默认效果,但实际上业务并不需要。7、iOS 14 ImageIO Crash获取上下文:iOS 14上发生,常见于图片解码部分。原因回溯:锁定是ImageIO的API CGImageSourceCopyPropertiesAtIndex使用问题,崩溃于其内部实现。/*Returnthepropertiesoftheimageat`index'intheimagesource*`isrc'.Theindexiszero-based.The`options'dictionarymaybeused*torequestadditionaloptions;seethelistofkeysaboveformore*information.*/IMAGEIO_EXTERNCFDictionaryRef_iio_NullableCGImageSourceCopyPropertiesAtIndex(CGImageSourceRef_iio_Nonnullisrc,size_tindex,CFDictionaryRef_iio_Nullableoptions)IMAGEIO_AVAILABLE_STARTING(10.4,4.0);回归问题:替换CGImageSourceCopyPropertiesAtIndex API 或 在APP退出时,将图片解码的子线程终止。此处,可以用atexit函数注册进程结束回调函数。8、iOS 14.5+ fishhook Crash获取上下文:fishhook是目前应用最广的C函数hook方案,然而在iOS 14.5+上出现Crash,Crash代码位置:indirect_symbol_bindings[i]=cur->rebindings[j].replacement;原因回溯:iOS 14.5之后,不少系统库的 __DATA_CONST段都从之前的可读写变成了只读,同mprotect提升读写权限失效,所以产生了 Crash。回归问题:mprotect提升读写权限,传入的地址要按页对齐。实战1、获取上下文iOS 15 Beta版本发出后,遇到了打开WebView容器必现的Crash,而在iOS 15以下没有任何问题。崩溃在主线程,调用栈都是系统调用栈。2、原因回溯阶段一1)根据SIGSEGV和调用栈CFRelease函数可以初步判断是无效内存访问,但是根据复现路径,配合Zoombie等工具没能定位问题,需要更进一步排查。2)根据崩溃位置的汇编指令ldr x1, [x19, #0x28]判断,崩溃发生在将x19+0x28位置数据读入寄存器 x1时候。回溯汇编命令发现,x19寄存器的值来自x0寄存器,而x0寄存器一般存函数的入参1,__CFURLDeallocate的入参实质是CFURLRef url,而x19其他偏移读取的数据是OK的,猜测是CFURLRef url某个成员被释放了。3)挖掘__CFURLDeallocate的实现,结合汇编命令,判断大致问题发生在__CFURLDeallocate中的CFRelease(sanitizedString)代码;而sanitizedString是url->_extra->_sanitizedString。//__CFURLDeallocate源码staticvoid__CFURLDeallocate(CFTypeRefcf){CFURLRefurl=(CFURLRef)cf;CFAllocatorRefalloc;__CFGenericValidateType(cf,CFURLGetTypeID());alloc=CFGetAllocator(url);#ifDEBUG_URL_MEMORY_USAGEnumDealloced++;#endifif(url->_string)CFRelease(url->_string);//GC:3879914if(url->_base)CFRelease(url->_base);//url->_extra->_sanitizedStringCFStringRefsanitizedString=_getSanitizedString(url);if(sanitizedString)CFRelease(sanitizedString);if(url->_extra!=NULL)CFAllocatorDeallocate(alloc,url->_extra);if(_getResourceInfo(url))CFRelease(_getResourceInfo(url));}4)挖掘CFURLRef结构,可以发现第40(0x28)个字节确实是_sanitizedString成员的起始位置。_sanitizedString的描述是:The fully compliant RFC string. This is only non-NULL if ORIGINAL_AND_URL_STRINGS_MATCH is false。//__CFRuntimeBase&_CFURLAdditionalData&__CFURL结构typedefstruct__CFRuntimeBase{uintptr_t_cfisa;uint8_t_cfinfo[4];}CFRuntimeBase;struct_CFURLAdditionalData{void*_reserved;CFStringRef_sanitizedString;UInt32_additionalDataFlags;};struct__CFURL{CFRuntimeBase_cfBase;UInt32_flags;CFStringEncoding_encoding;CFStringRef_string;CFURLRef_base;struct_CFURLAdditionalData*_extra;void*_resourceInfo;CFRange_ranges[1];};5)_sanitizedString是如何来的呢?逆向反推发现是通过computeSanitizedString触发sanitizedString的赋值:computeSanitizedString(CFURLRef url) => _setSanitizedString((struct __CFURL*) url, sanitizedString) => url->_extra->_sanitizedString = CFStringCreateCopy(CFGetAllocator(url), sanitizedString); 到了此时,问题似乎陷入僵局。3、原因回溯阶段二1) 阶段1线索突然断了,继续观察调用栈,我们发现:崩溃发生在当前Runloop结束时,执行autoreleasepoolpop,触发了_CFRelease操作,而后引发的崩溃。此时,我们尝试hook了NSURL的release方法。当执行复现步骤时,果然崩溃到了swizzle_release方法中,此时调用栈对比原来的仅多出swizzle_release一行。2) 根据第一步,我们判断,问题发生在NSURL的release时,NSURL对象是存在的,但是它的成员变量中的_extra成员不正常,而_extra起始地址是0x281662200,而_extra结构中的_sanitizedString成员比_extra起始地址偏移8字节,即0x281826308,访问该内存发现值是0x00001d80,而sanitizedString对应的是CFStringRef结构,CFRelease(sanitizedString)会访问到无效地址。3) 对比正常情况下NSURL对象release下,其cfURL存储数据和崩溃下区别,发现:正常情况下cfURL的_extra为NULL,_string是正常的字符串,而Crash情况下_extra不为NULL,_string显示的是0xdeadbeef。问题似乎再次陷入僵局,不清楚是什么造成这些反常。正常release下cfURL结构如下图。4、原因回溯阶段三1)阶段一和阶段二都没能确定问题代码位置,此时,不得不回到最开始。利用剪枝策略和源码调试,逐步将可能发生问题的源码范围从业务WebView容器的viewDidAppear之前,进一步缩小到基础WebView容器的viewDidLoad中,再缩小到基础WebView容器的loadRequestWithUrl:方法中。而loadRequestWithUrl方法真正执行的是WKWebview的loadRequest方法,难道loadRequest有问题,这个疑问不自觉冒出来。2)疑惑再次上头,WKWebview的loadRequest是最常用的API,即便有问题,也不至于如此明显。而且执行完loadRequest后并没有崩溃,崩溃发生在整个ViewDidLoad完成;但是删除loadRequest,崩溃就不会出现,到此时,结合之前的探索,似乎问题清晰了。3) 综合之前的分析,问题和loadRequest有关,且在ViewDidLoad完成后,当前Runloop结束引起的,探索WebKit源码和单步汇编调试,让怀疑聚焦到WebKit的内部。另一方面,根据autoreleasepool发生pop操作,NSURL对象收到release消息,而在执行__CFURLDeallocate清理NSURL底层数据结构CFURLRef时,发生了清理_sanitizedString成员,无效内存访问。此时,又将问题指向了NSURL。5、原因回溯阶段四1)至此,诞生了新猜测:WebKit对NSURL一些不为人知的操作,导致了Crash。根据猜测,新建项目,使用WebView容器打开对应的URLString,结果在iOS 15.0设备中,没有崩溃。2)新的疑惑发生,难道是项目中对WKWebView和NSURL做了什么hook,根据LinkMap找到了NSURL和WKWebView所有Category文件和对应的库位置,拉取源码,逐步排查到NSURL的可以hook方法实现,代码如下:3)验证问题:删除57,58,59行代码,崩溃不再发生。既然是hook,就尽可能保持原有实现,于是删除57,58,59代码也可以,测试是OK,提交代码。似乎问题终于算告一段落。6、回归问题1)崩溃虽然解决了,但是更大疑惑发生了:为什在此之前,没有发生此类Crash,而在iOS 15上必崩的,是Crash监控上报有问题,还有另有玄机呢?2)继续探索,恢复57,58,59行代码,并设置调试断点,调试发现:iOS 15以上,WKWebview在loadRequest,内部最终执行到WebKit框架的[WKSecureCodingURLWrapper initWithURL:]方法,而NSURL是通过NSURL的initWithString:生成的。不仅如此,此时的URLString是空字符串,导致能走到第58行代码中。如果没有57,58,59行判断,返回的是WKSecureCodingURLWrapper,即便URLString是空字符,并不会崩溃。3)测试中还发现,iOS 15以下,WKWebview在loadRequest不会走到[WKSecureCodingURLWrapper initWithURL:],并不会走到58行代码,猜测iOS 15上,WebKit有些新增的修改。这也印证之前的猜测。4)到此,Webview容器崩溃的问题排查和解决告一段落,所谓“问题代码”在iOS 15之前是OK的,存在5年之久,但是却在iOS 15上猛然爆发Crash,这也告诉我们:在系统新版本来临之际,尽快投入人力回归测试,及时发现问题。平时要苦练基本功,遇到疑难问题要迎难而上。总结本文介绍了Crash原理和部分贝壳找房iOS疑难Crash治理实践,并重点介绍了iOS 15上WebView容器Crash的排查和解决过程。经过阶段性治理,App稳定性得到极大的提升(Crash率下降了60%+)。然而,治理Crash并非一劳永逸,需要我们在解决和防护方面继续探索、实践。参考https://github.com/apple/darwin-xnuhttps://opensource.apple.com/source/CF/https://opensource.apple.com/tarballs/WebKit2/
预览时标签不可点
移动端37大前端69移动端 · 目录#移动端上一篇【GMTC·贝壳】贝壳找房 iOS 冷启动优化实践下一篇贝壳&掌链64位架构适配实践关闭更多小程序广告搜索「undefined」网络结果
|
|