iOS系统崩溃的捕获
相信大家在开发iOS步伐的时候肯定写过各种Bug,而其中最为严重的Bug就是会导致崩溃的Bug(一般来说妥妥的P1级)。在应用软件大大小小的各种异常中,崩溃确实是最让人难以接受的行为。究竟崩溃意味着用户将丢失应用步伐运行中的所有上下文情况,丢失其所有未生存的数据,会带给用户最糟糕的使用体验。
以是在应用的开发阶段,我们一定要杜绝此类可能造成应用步伐无法使用的崩溃。但是许多崩溃并不是自己在开发阶段就能预推测的,此时就必要一种能够线上获取崩溃日记而且上报的机制,这就是所谓的崩溃捕获和上报体系。
本日我们不研究SuperApp中的崩溃上报,主要研究一下崩溃捕获是如何实现的。
iOS系统中如何捕获崩溃
首先,iOS系统中,并没有通用的能够捕获所有崩溃的处理函数。捕获崩溃主要有以下三种方式:
- NSSetUncaughtExceptionHandler
- Unix Signal捕获函数
- Mach(读音为[mʌk])异常捕获函数
关于如何用上述的方式捕获崩溃,不是本次分享的重点,大家可以自行查阅博客中的代码。我们主要必要明白的是这三者各自的原理和应用场景。
NSSetUncaughtExceptionHandler
首先我们写一个会导致崩溃的Objective-C代码片段:- NSDictionary *userinfo = @{
- @"username": @"TP-LINK",
- @"email": @"admin@tp-link.com.cn",
- @"tel": @"15015001500"
- };
- NSMutableArray<NSDictionary *> *memberarray = [NSMutableArray arrayWithArray:@[userinfo]];
- for (NSDictionary *dic in memberarray) {
- if ([[dic valueForKey:@"username"] isEqualToString:@"TP-LINK"]) {
- [memberarray removeObject:dic];
- }
- }
复制代码 运行步伐,不出意外的话,步伐在执行到片段的时候就会立刻崩溃,然后我们会在控制台里面看到如下打印:- *** Terminating app due to uncaught exception 'NSGenericException', reason: '*** Collection <__NSArrayM: 0xb550c30> was mutated while being enumerated.'
复制代码 相信不少同学都会这串提示很熟悉,从字面上来看,步伐崩溃是因为有个异常没有被捕获到,异常的类型是NSGenericException,导致异常的缘故原由则是因为在遍历集合的时候尝试去修改里面的元素。
NSGenericException是一个继承自NSException的类,表示触发的是一种通用的异常,除了这个类,尚有许多其他的子类,像是NSRangException、NSInvalidArgumentException等,根本上只要看到名称就知道异常大致是什么缘故原由导致的。
NSException是可以被我们手动捕获的,比方:- @try {
- for (NSDictionary *dic in memberarray) {
- if ([[dic valueForKey:@"username"] isEqualToString:@"TP-LINK"]) {
- [memberarray removeObject:dic];
- }
- }
- @catch (NSException *exception) {
- NSLog(@"Caught %@: %@", [exception name], [exception reason]);
- }
复制代码 但是在写现实项目标时候,我们通常不会手动写这类处理异常的代码,Objective-C也并没有强制要求我们写此类的异常处理步伐。
其实,这主要是因为异常代表的通常是我们编写的步伐存在逻辑错误,通常不可恢复,必要我们在发布给用户使用之前由开发者进行处理,以是NSException又被称为应用级异常。NSSetUncaughtExceptionHandler现实上是给我们提供了一个手段,对这些我们未捕获的异常进行一个最终的处理,但如果这些错误是在用户使用的时候发生的,我们也无法立刻进行处理。
大概也是因为这个缘故原由,Swift语言抛弃了NSException,而只生存了Error。
由于NSSetUncaughtExceptionHandler不是万能的,比如我们写一段Swift的强制解包代码:- var userName= fetchUserName()
- printUserName(userName!)
复制代码 上述代码假设fetchUserName()函数返回nil,而且printUserName()函数只接受非空参数,那么在步伐运行时,由于强制解包失败,应用步伐会崩溃而且NSSetUncaughtExceptionHandler也无法捕获此类崩溃,这时候就必要其他的机制来捕获此类异常。
Mach异常
要相识Mach异常,首先要相识什么是Mach!首先上一张mac OS X的架构图:
mac OS X的焦点操作系统被称为“Darwin”,其由系统组件和内核构成。其中内核被称为"xnu",他是一个混合型的内核,包罗了Mach和BSD两个部分,其中BSD实现了文件系统、网络、NKE(Network Kernel Extension,实现注入通信加密、虚拟网络接口等网络方面的扩展功能)、POSIX接口等功能,而Mach则实现了I/O组件和驱动步伐。xnu内核是开源的。
从图里面可以看到,内核的下面就是硬件,以是由Mach内核抛出的异常也被称为是最底层的异常,造成异常的缘故原由通常是硬件导致的异常,比如:
- 试图访问不存在的内存
- 试图访问违背地址空间掩护的内存
- 由于非法或未定义的操作代码或操作数而无法执行指令
- 产生算术错误,比方被零除、上溢、或者下溢
- ……
关于Mach抛出异常的流程,我们可以联合以下图来明白:
如果出错的线程触发了一个硬件级别的错误,处于内核的陷阱处理步伐就会调用exception_deliver()函数依次尝试将异常投递到thread、task和host。
这里插入一个小话题,在Mach内核中,为了和thread、task和host打交道,或者他们互相之间打交道,提供了一种基于端口的IPC手段,这个手段在Cocoa上层也有对应的抽象,就是NSMachPort。这个mach port大家可能听说过,不知大家是否有印象?
当异常发生的时候,一条包罗异常的mach message,比方异常类型、发生异常的线程等等,都会被发送到一个异常端口。而thread、task、host都会维护一组异常端口,当Mach Exception机制通报异常消息的时候,它会按照thread → task → host 的顺序通报异常消息。这是通过上面的mach_exc_raise()类函数来实现的。
如果thread、task都没有处理异常,那么就会由host也就是操作系统内核来处理异常,操作系统处理异常的方式就是上图Exception Handler中的流程,可以看到,handler是一个循环处理消息的机制,mach_msg_receive()函数负责接受消息;mach_exc_server()函数内有catch_mach_exception_raise()函数,这个函数通过ux_exception()将mach异常转换为Unix的Signal,并通过threadsignal()将其发送到对应的线程上去。
这一系列过程中,我们可控的部分是thread,我们可以新建一条thread而且通过mach port监听异常端口来实现崩溃的捕获。
有时候,Debugger会在步伐崩溃的时候,给出Mach异常的类型:
上述代码试图给一个assign类型的property赋值,由于引用计数为0,对象在赋值之后就被立刻开释了,以是这行代码就崩溃了
Debugger给出的标红信息,可以这么明白:
一些其他常见的Mach异常类型及其对应的缘故原由如下表:
ExceptionNotesEXC_BAD_ACCESS访问了不该访问的内存EXC_BAD_INSTRUCTION线程执行非法指令EXC_ARITHMETIC算术异常EXC_SOFTWARE软件生成的异常EXC_BREAKPOINT跟踪或者断点关于code大家可能会存在疑惑,它代表的其实是内核函数的返回值,其中,code=1代表的是地址不可用,其定义如下:- #define KERN_INVALID_ADDRESS 1
复制代码 由于code的种类有许多,其他code对应的含义,可以翻阅kern_return.h头文件进行查阅
以下为苹果的崩溃日记,里面也包罗类似信息:- Exception Type: EXC_BAD_ACCESS (SIGSEGV)
- Exception Subtype: KERN_INVALID_ADDRESS at 0x00000000000000b8
复制代码 将两者进行联合,一般就可以判断崩溃的缘故原由究竟是什么。相识以上知识相信会对大家日后办理Bug带来一定的资助。
Unix Signal
Signal是Unix、类Unix以及其他POSIX兼容的操作系统中进程间通讯的一种有限制的方式。它是一种异步的通知机制,用来提示进程一个事件已经发生。信号的作用有许多,比如可以用来进程间通信(IPC)、用于Debugger调试等,固然也可以用来陈诉异常。
既然Mach已经实现了硬件导致的异常,为什么还必要将其转化为Unix Signal,继续陈诉一次呢?
缘故原由很简单,因为xnu包罗了BSD和Mach,为了实现POSIX兼容,让用户可以使用BSD提供的POSIX API,就必要做如许一层转换。
Mach异常和Unix Signal两者的转换关系如下表:
Mach 异常Unix Signal缘故原由EXC_BAD_INSTRUCTIONSIGILL非法指令,比如数组越界,强制解包可选形等等EXC_BAD_ACCESSSIGSEVG、SIGBUSSIGSEVG、SIGBUS两者都是错误内存访问,但是两者之间是有区别的:SIGBUS(总线错误)是内存映射有效,但是不允许被访问,比如访问一个结构体但是起始地址有误; SIGSEVG(段地址错误)是内存地址映射都失效,比如野指针EXC_ARIHMETICSIGFPE运算错误,比如浮点数运算异常EXC_BREAKPOINTSIGTRAPtrace、breakpoint等等,比如说使用Xcode的断点EXC_SOFTWARESIGABRT、SIGPIPE、SIGSYS、SIGKILL软件错误,其中SIGABRT最为常见。问1:既然Mach异常可以转换为unix异常,而signal也是可以由我们自由处理的,那是否可以不处理Mach异常,只处理unix的signal就可以了?
答案是不行,因为某些异常,比如EXC_GUARD 异常(这是一种违背了受掩护资源的防护而导致的异常,比如访问SQLite文件的时候关闭了它的文件形貌符),是没有映射到Unix Signal的,这种异常就没法通过signal处理。
问2:那是不是处理了Mach异常,就不必要处理signal异常了呢?
答案是也不行,因为如果底层有些异常类型只能通过signal处理,比如直接调用了 __pthread_kill函数直接向某条线程发送了SIGABRT这个signal,这类异常不能被Mach所捕获
为什么没有通用的异常处理函数
现在我们可以回答这个问题了。总结一下,iOS系统中,崩溃有可能是以下两种方式产生的:
- 应用级异常,比如NSException
- 硬件级异常,比如野指针访问
对于前者,我们只能使用NSSetUncaughtExceptionHandler进行捕获,对于后者,我们必要使用以下机制:
- Mach异常处理机制
- Unix Signal异常处理机制
因为以上两者作用域也无法互相覆盖,以是以上两者也必要联合使用。
正是因为这三种处理机制覆盖了不同的领域,而且处理机制也不尽相同,因此iOS中没有通用的异常处理函数。
然而,事情没有那么简单
上述三个函数的功能非常强大,但是现实上计划一个崩溃捕获系统没有那么容易。一般来说,捕获系统除了捕获崩溃,还必要记录崩溃时的现场信息,比如崩溃时的iOS系统版本、应用版本、崩溃时间、异常信息、步伐堆栈等等:- {"app_name":"TP-LINK物联","timestamp":"2023-02-16 15:40:40.00 +0800","app_version":"4.12.1","slice_uuid":"d146125f-f904-3e39-940a-0f7dd32d6071","adam_id":0,"build_version":"41201","platform":2,"bundleID":"net.tplink.surveillancesystem","share_with_app_devs":0,"is_first_party":0,"bug_type":"109","os_version":"iPhone OS 14.0.1 (18A393)","incident_id":"70E8ABFF-6F0F-4094-BF31-EE929EFA78DD","name":"TP-LINK物联"}Incident Identifier: 70E8ABFF-6F0F-4094-BF31-EE929EFA78DDCrashReporter Key: 8c905de38d4cd4ff6ad692cc4ca4f6b1f41a50afHardware Model: iPhone12,8Process: TP-LINK物联 [2002]Path: /private/var/containers/Bundle/Application/F17C1188-8ED4-4C72-8E46-FE7ABE28DDA1/TP-LINK物联.app/TP-LINK物联Identifier: net.tplink.surveillancesystemVersion: 41201 (4.12.1)Code Type: ARM-64 (Native)Role: ForegroundParent Process: launchd [1]Coalition: net.tplink.surveillancesystem [564]Date/Time: 2023-02-16 15:40:39.7011 +0800Launch Time: 2023-02-16 15:37:15.6262 +0800OS Version: iPhone OS 14.0.1 (18A393)Release Type: UserBaseband Version: 2.00.01Report Version: 104Exception Type: EXC_BAD_ACCESS (SIGSEGV)
- Exception Subtype: KERN_INVALID_ADDRESS at 0x00000000000000b8VM Region Info: 0xb8 is not in any region. Bytes before following region: 4375183176 REGION TYPE START - END [ VSIZE] PRT/MAX SHRMOD REGION DETAIL UNUSED SPACE AT START---> __TEXT 104c80000-1077ac000 [ 43.2M] r-x/r-x SM=COW ...app/TP-LINK物联Termination Signal: Segmentation fault: 11Termination Reason: Namespace SIGNAL, Code 0xbTerminating Process: exc handler [2002]Triggered by Thread: 15Thread 0 name: Dispatch queue: com.apple.main-threadThread 0:0 libsystem_kernel.dylib 0x00000001d0bbfdd0 0x1d0bbc000 + 158241 libsystem_kernel.dylib 0x00000001d0bbf184 0x1d0bbc000 + 126762 CoreFoundation 0x00000001a4bb6cf8 0x1a4b19000 + 6463923 CoreFoundation 0x00000001a4bb0ea8 0x1a4b19000 + 6222484 CoreFoundation 0x00000001a4bb04bc 0x1a4b19000 + 6197085 GraphicsServices 0x00000001bb635820 0x1bb632000 + 143686 UIKitCore 0x00000001a7554734 0x1a69d7000 + 120481807 UIKitCore 0x00000001a7559e10 0x1a69d7000 + 120704168 TP-LINK物联 0x0000000104c89ff0 0x104c80000 + 409449 libdyld.dylib 0x00000001a4877e60 0x1a4877000 + 3680……
复制代码 在iOS系统中,如果直接在上述的崩溃处理函数中进行这些信息的记录,并不安全,这主要是因为iOS中App被限制在一个进程中运行,如果应用崩溃,那崩溃的线程将会立刻停息执行,那就会导致如下问题:
- 内存可能被粉碎(比如某些数值溢出导致的崩溃,内存会被溢出的数据覆盖)
- 锁可能正在被停息执行的线程持有着
- 数据结构可能只更新一半
如许的不稳定情况,大部分函数都不能保证能够精确运行,导致崩溃处理步伐能够调用的库函数非常有限,你将无法做到:
- 通过malloc等函数分配堆内存
- 通过backtrace函数获取调用栈信息
如果破解这些限制?我们不妨研究下SuperApp中集成的Breakpad是怎么操作的。
Breakpad的整体构成
如上图所示,Breakpad主要由三部分构成:
- symbol dumper:符号提取器。应用步伐在构建的时候会包罗debug干系的信息,它能够提取这些信息并生成专属的符号文件。
- client:客户端是一种包罗在你应用步伐里面的第三方库,它能够捕获当前各线程的状态以及当前加载的共享库等信息,将其写入minidump文件中。
- processor:处理器主要用来读取minidump文件和符号文件,将其翻译为人类可读的格式
符号文件是步伐编译的产物,里面会包罗函数或数据的名称、地址、大小、类型等。由于Breakpad是一个跨平台的方案,因此没有接纳XCode编译产生的符号表文件,而是使用了自定义的格式。minidump则是一种微软开发的文件格式,它被用在微软的崩溃上传体系中,包罗了可执行文件和共享库的列表、进程中的各线程列表信息、调用栈信息等。
Breakpad如何办理上述问题
1.如何安全分配内存
以下为Breakpad启动代码:- ProtectedMemoryAllocator* gMasterAllocator = NULL;
- ProtectedMemoryAllocator* gKeyValueAllocator = NULL;
- ProtectedMemoryAllocator* gBreakpadAllocator = NULL;
- BreakpadRef BreakpadCreate(NSDictionary* parameters) {
- try {
- gMasterAllocator =
- new ProtectedMemoryAllocator(sizeof(ProtectedMemoryAllocator) * 2);
- gKeyValueAllocator =
- new (gMasterAllocator->Allocate(sizeof(ProtectedMemoryAllocator)))
- ProtectedMemoryAllocator(sizeof(LongStringDictionary));
-
- int mutexResult = pthread_mutex_init(&gDictionaryMutex, NULL);
- if (mutexResult == 0) {
- int breakpad_pool_size = 4096;
- gBreakpadAllocator =
- new (gMasterAllocator->Allocate(sizeof(ProtectedMemoryAllocator)))
- ProtectedMemoryAllocator(breakpad_pool_size);
- NSAutoreleasePool* pool = [[NSAutoreleasePool alloc] init];
- Breakpad* breakpad = Breakpad::Create(parameters);
- if (breakpad) {
- gMasterAllocator->Protect();
- gKeyValueAllocator->Protect();
- gBreakpadAllocator->Protect();
- [pool release];
- return (BreakpadRef)breakpad;
- }
- [pool release];
- }
- } catch(...) {
- fprintf(stderr, "BreakpadCreate() : error\n");
- }
- ...
- }
复制代码 上述代码片段已经包罗了对内存分配问题的办理,其 焦点思路是:既然崩溃时无法分配内存,那么只要在崩溃前提前分配好崩溃处理步伐所需的内存并将其掩护起来制止被崩溃所粉碎即可。
ProtectedMemoryAllocator这个类相当于一个内存池,它允许分配内存,但是分配内存无法被回收。此外,它还提供了一个Protect()方法用于将内存池设置为只读,如许一来这块内存就不会在崩溃发生的时候被各种缘故原由覆盖。
通过源码,我们可以一窥其实现的原理,首先是构造函数:- ProtectedMemoryAllocator::ProtectedMemoryAllocator(vm_size_t pool_size)
- : pool_size_(pool_size),
- next_alloc_offset_(0),
- valid_(false) {
-
- kern_return_t result = vm_allocate(mach_task_self(),
- &base_address_,
- pool_size,
- TRUE
- );
-
- valid_ = (result == KERN_SUCCESS);
- assert(valid_);
- }
复制代码 vm_allocate是一个内核函数,用于申请虚拟内存,由于Breakpad必要直接申请一块较大的内存,用于整个模块的内存使用,因此它直接使用了该函数,而不是malloc。该类申请的内存大小是由参数pool_size决定的,内存分配之后,base_address_指向内存池的起始地址。
再看看Protect()方法的实现:- kern_return_t ProtectedMemoryAllocator::Protect() {
- kern_return_t result = vm_protect(mach_task_self(),
- base_address_,
- pool_size_,
- FALSE,
- VM_PROT_READ);
-
- return result;
- }
复制代码 其同样调用了内核函数vm_protect,将申请的虚拟内存设置为只读,如许就实现了内存的掩护。
2.如何获取调用栈信息
Breakpad内部有一个MinidumpGenerator类专门用于写入minidump,其中包罗了我们关心的线程调用栈信息。由于涉及到minidump格式问题,我们不深入分析这个类,只是简单介绍下原理。
首先,我们必要明白线程调用栈的结构:
线程的调用栈分为多少栈帧(stack frame),每个栈帧对应一个函数调用。上图包罗了两个栈帧,DrawLine和DrawSquare。
栈帧主要由三部分组成:函数参数、返回地址、帧内的当地变量。上述DrawSquare函数调用DrawLine函数的时候,首先函数的参数入栈,然后把返回地址入栈,最后是函数内部当地变量。
这里要注意的是,有两个特别的指针:Stack Pointer指向了调用栈的栈顶,Frame Pointer则指向了当前栈帧。
此外,我们还必要相识一下iOS系统中虚拟内存的干系知识:
我们知道,操作系统会对虚拟内存进行分页。在iOS系统中,为了更好的管理内存页,系统会将一组连续的内存页关联到一个VMObject上,也称为VM Region。我们可以通过XCode的Instruments工具,查看当前App的虚拟内存分配情况,其中就包罗了VM Region的干系信息:
可以看到,VM Region被分为不同的Category,其中有一种Category叫做VM Stack,其包罗的就是线程调用栈的信息。
为了获取VM Stack中的信息,Breakpad大致做了以下操作:
- 通过内核函数task_threads获取当进步程的所有线程
- 通过内核函数thread_get_state获取目标线程的thread_state_t,这个结构中包罗了该线程调用栈的栈顶指针Stack Point
- _STRUCT_ARM_THREAD_STATE64
- {
- __uint64_t __x[29]; /* General purpose registers x0-x28 */
- __uint64_t __fp; /* Frame pointer x29 */
- __uint64_t __lr; /* Link register x30 */
- __uint64_t __sp; /* Stack pointer x31 */
- __uint64_t __pc; /* Program counter */
- __uint32_t __cpsr; /* Current program status register */
- __uint32_t __pad; /* Same size for 32-bit or 64-bit clients */
- };
复制代码 - 由Stack Pointer的地址作为起始地址,获取下一个VM Region,如果其Category为VM Stack,将此块内存的信息记录下来,写入minidump
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。 |