前言
质量是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异常和Signal
Crash异常类型主要包括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.c
kern_return_t
handle_ux_exception(thread_t thread,
int exception,
mach_exception_code_t code,
mach_exception_subcode_t subcode)
{
//略...
//Mach异常消息转为Signal
int ux_signal = ux_exception(exception, code, subcode);
uthread_t ut = get_bsdthread_info(thread);
if (code == KERN_PROTECTION_FAILURE &&
ux_signal == SIGBUS) {
user_addr_t sp = subcode;
user_addr_t stack_max = p->user_stack;
user_addr_t stack_min = p->user_stack - MAXSSIZ;
if (sp >= stack_min && sp < stack_max) {
ux_signal = SIGSEGV;
int mask = sigmask(ux_signal);
struct sigacts *ps = p->p_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);
return KERN_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() (in WebKit)
WebKit::ShareableBitmap::makeCGImageCopy() (in WebKit)
WebKit::ShareableBitmap::makeCGImageCopy() (in WebKit)
-[WKContentView(WKInteractionPreview) assignLegacyDataForContextMenuInteraction] (in WebKit)
-[WKContentView(WKInteractionPreview) continueContextMenuInteraction:]_block_invoke (in WebKit)
原因回溯:根据堆栈去查看WebKit2源码(WKContentViewInteraction.m、ShareableBitmapCG.cpp),得到大致推断:在WKWebView长按图片,触发图片绘制引起的Crash。Crash发生在iOS14上。
回归问题:结合日志埋点,找到问题URL,本地未能复现,和业务方沟通,禁止这个业务下WKWebView长按图片效果。这是WebView默认效果,但实际上业务并不需要。
7、iOS 14 ImageIO Crash
获取上下文:iOS 14上发生,常见于图片解码部分。
原因回溯:锁定是ImageIO的API CGImageSourceCopyPropertiesAtIndex使用问题,崩溃于其内部实现。
/* Return the properties of the image at `index' in the image source
* `isrc'. The index is zero-based. The `options' dictionary may be used
* to request additional options; see the list of keys above for more
* information. */
IMAGEIO_EXTERN CFDictionaryRef _iio_Nullable CGImageSourceCopyPropertiesAtIndex(CGImageSourceRef _iio_Nonnull isrc, size_t index, CFDictionaryRef _iio_Nullable options) 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源码
static void __CFURLDeallocate(CFTypeRef cf) {
CFURLRef url = (CFURLRef)cf;
CFAllocatorRef alloc;
__CFGenericValidateType(cf, CFURLGetTypeID());
alloc = CFGetAllocator(url);
#if DEBUG_URL_MEMORY_USAGE
numDealloced ++;
#endif
if (url->_string) CFRelease(url->_string); // GC: 3879914
if (url->_base) CFRelease(url->_base);
//url->_extra->_sanitizedString
CFStringRef sanitizedString = _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结构
typedef struct __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-xnu
https://opensource.apple.com/source/CF/
https://opensource.apple.com/tarballs/WebKit2/