debug能力是程序员重要能力,也能真实反映程序员的水平,也是晋升重要的能力!尤其崩溃排查分析解决能力!这节课将透过现象看透崩溃的本质!
崩溃长什么样子
比如断点调试崩溃:
App Store上应用崩溃,可通过Xcode中的Oragnizer
来获取,如下图:
或者直接连接崩溃的设备通过Xcode->Device->View Device Logs来获取,如下图:
对于Mac App可通过控制台中的”崩溃报告“来获取,如下图:
还可以通过第三方崩溃平台来获取,比如bugly
、友盟、KSCrash
等,如下图:
崩溃为什么会发生
那崩溃发生有哪些具体原因:
-
cpu无法执行的代码
比如无效指令或操作、访问无效地址及不具有权限的内存地址、除以0等;
有可能是苹果bug,会产生非法指令错误,如下图所示:
僵尸对象,如下图:
比如64位系统,访问0~4GB地址空间会报无效地址,就会报段错误
Segmentation fault
,如下图:
64位系统对应的__PAGEZERO
段地址空间为0~4GB
,在这个范围内所有访问权限-读、写和执行-都被撤销,因此若访问该地址就会引发MMU
的硬件页错误,进而产生一个异常。
代码如下:
小技巧:那如何调试过程中直接让应用崩溃呢?是因为本身工程默认开启了
Debug excutable
选项,具体选项如图:关闭该选项就可以直接让应用崩溃产生崩溃日志报告。
除以0,就会导致算术异常,导致EXC_ARITHMETIC
错误,如下图:
-
被系统强杀
-
应用内存消耗过高
OOM
-
主线程长时间无法响应
ANR
为了防止一个应用占用过多的系统资源,开发iOS的苹果工程师门设计了一个“看门狗”
Watchdog
的机制。在不同的场景下,“看门狗”会监测应用的性能。如果超出了该场景所规定的运行时间,“看门狗”就会强制终结这个应用的进程。开发者们在crashlog
里面,会看到诸如0x8badf00d
这样的错误代码。Watchdog
机制是iOS为了保持用户界面的响应引入的一种机制。如果我们的应用未能及时的响应一些用户界面事件,如启动、暂停、恢复和终止,Watchdog
就会杀死程序并生成一个Watchdog
超时崩溃报告。Watchdog
超时时间并没有明文规定,但通常会少于网络超时。比如App应用启动时同步请求启动配置数据,如在网络差的情况下就会导致被
Watchdog
强杀,需要在真机上运行应用且不能xcode调试模式; -
资源异常
-
线程频繁唤醒
Wakeups
是“资源异常”下的一个子类,指的是频繁唤醒线程,消耗cpu资源并增加功耗,在超过阈值并处于FATAL CONDITION
的条件下触发崩溃;如果300秒内的总wakeup数超过45000(300 * 150)就会被判定为超出阈值。 -
进程中的线程过多的占用了cpu,限制为
50%
,时间不超过180秒
; -
线程短时间过多的磁盘写入
-
-
死锁
比如互斥锁同一线程多次上锁就会导致死锁
NSLock *_lock = [[NSLock alloc]init]; [_lock lock]; [_lock lock];//多次上锁
-
非法的应用签名
-
后台执行超时
App退至后台后若执行时间过长就会导致被系统被杀,比如
Backgroud Task
方式可以在后台执行3min,若超过3min还未运行完成就会被系统强杀,实例代码:- (void)applicationDidEnterBackground:(UIApplication *)application { //通过backgroud task方法延长后台时间 这个方法必须与endBackgroundTask一一对象 UIBackgroundTaskIdentifier _backgroundTaskIdentifier = [application beginBackgroundTaskWithExpirationHandler:^{ }]; //此处为执行任务代码 通常用来保存应用程序关键数据数据 //执行关键任务 //当任务执行完成时 调用endBackgroundTask方法 调用后就会将app挂起 //如果在最后时间到了之前仍然没有调用endBackgroundTask方法 就会执行此回调 //通常时间为3分钟,如果时间到期之前调用endBackgroundTask方法 就会强制杀掉进程,就会造成崩溃 [application endBackgroundTask:_backgroundTaskIdentifier]; }
-
设备总内存紧张
因为Mac平台存在内存交换机制,而iOS平台没有,就导致整个设备内存吃紧的时候,系统就会杀掉优先级不高且占用内存多大的应用;
-
设备过热
一般见于低端设备
-
-
语言触发异常
-
OC语言抛出异常
具体的异常如下:
常见的如下:
NSArray
越界访问产生的异常NSRangeException
,调试下会看到如下消息:*** Terminating app due to uncaught exception 'NSRangeException', reason: '*** -[__NSSingleObjectArrayI objectAtIndex:]: index 3 beyond bounds [0 .. 0]'
未找到的方法或者
NSDictionary
添加nil
对象等导致的非法参数异常NSInvalidArgumentException
,如:*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[ViewController hello]: unrecognized selector sent to instance 0x7fc65b604e30'
kvc
未找到相对应的key
抛出NSUnknownKeyException
,结果如下:*** Terminating app due to uncaught exception 'NSUnknownKeyException', reason: '[<ViewController 0x7fd3f6806450> setValue:forUndefinedKey:]: this class is not key value coding-compliant for the key hello.'
-
C++抛出异常
语言异常抛出后最后都会调用到
abort
来终止应用,调用栈如下图所示:
-
-
开发者触发 比如断言,
NSAssert
或者assert
函数;
崩溃如何发生
说了这么多场景,那崩溃时如何发生的呢?其底层机制是如何运作的呢?只有你理解了崩溃的机制才能更有效的防护及排查!接下来给大家一一剖析!首先,要明白“中断”是什么?
中断
中断是重要的异步处理事件机制,否则对于外设需要通过轮询来确认外设的事件,太浪费cpu。中断的引入让外设能够“主动”通知操作系统,及打断操作系统和应用的正常执行,让操作系统完成外设的相关处理,然后在恢复操作系统和应用的正常执行,同时也是实现进程/线程抢占式调度的一个重要基石。
(不管是用户态还是内核态)程序运行时,若中断发生就会打断现在的程序,进行上下文切换转入内核态并进入中断服务程序,中断服务程序执行完成后恢复被打断的程序继续执行,如下图所示:
中断的类型及中断服务程序由“中断向量表”来决定,在系统启动时由内核负责加载这个向量。中断类型又分为:
- 外部中断
由CPU外部设备引起的外部事件如I/O中断、时钟中断等是异步产生的(即产生的时刻不确定),与CPU的执行无关,我们称之为异步中断(asynchronous interrupt)也称外部中断,简称中断。(interrupt)
-
异常
此异常非语言中的异常,虽然语言异常会转化称为中断异常;
把在CPU执行指令期间检测到不正常的或非法的条件(如除零错、地址访问越界)所引起的内部事件称作同步中断(synchronous interrupt),也称内部中断,简称异常(exception)。
Intel
架构中,中断向量表中前20个单元定义为异常
,具体如下:从表中可以看出,异常有以下类型:
-
错误(fault)
指令遇到一个可以纠正的异常,并且处理器可以重新启动这条出现异常的指令,这种异常称为错误,比如“页错误”。
-
陷阱(trap)
类似于错误,但是错误处理完成后返回发生陷阱指令之后的那条指令。
-
中止(abort)
不可重启指令,比如上图中的#8同一条指令发生两次错误。
-
-
系统调用
把在程序中使用请求系统服务的系统调用而引发的事件,称作陷入中断(trap interrupt),也称软中断(soft interrupt),**系统调用(system call)**简称trap。
比如write函数;
XNU
的中断通常为“陷阱”,这不同于异常中的“异常”;
自愿的内核转换,包括异常、系统调用,intel
架构可以通过INT
指令触发,在INT
指令的参数传入异常的编号即可(64位架构使用SYSCALL
指令),arm
架构通过SVC/SWI
指令来触发。比如INT 3
指令(是一条单独的指令),可以来触发断点
一张图就可以了解整体过程,如下图所示:
摘自清华大学《操作系统学习》课程
那中断中的异常和崩溃有何关系?
程序的崩溃都会转换为异常被cpu通过中断向量表指定的异常类型捕获,进而触发异常处理程序处理,比如cpu无效指令、无效的地址或者无权限的访问,这些都是硬件产生的异常;被系统强杀的崩溃最终会调用到kill
函数发送SIGKILL
信号进而引发应用被强杀;语言及开发者触发崩溃最终会通过abort
函数毅然会最终调用到kill
函数发送SIGABRT
信号引发应用被杀,这都是软件引发的异常。
有哪些异常?
那崩溃具体有哪些异常?下面带大家来一一探索,如下:
-
Mach
异常这个后面跟大家详解,暂且不用管
-
BSD
Signal
信号信号是什么?信号是一种异步处理的软中断,内核会发送给进程某些异步事件,这些异步事件可能来自硬件,比如除0或者访问了非法地址;也可能来自其他进程或用户输入,比如
ctrl+c
,就会产生SIGINT
信号由内核发送至当前终端执行进程,若进程未处理该信号,就会导致进程退出;ctrl+\
就会产生SIGQUIT
退出信号来终止进程执行,并且会产生崩溃日志报告,如下图所示:具体的demo如下:
#include <stdio.h> #include <signal.h> #include <errno.h> #include <string.h> #define BUF_LEN 1024 //信号处理函数 static void sig_int(int signo) { printf("handler signo:%d \n", signo); } int main(int argc, char **argv) { char buf[BUF_LEN] = {0}; //处理信号,SIGINT默认终止进程 if (signal(SIGINT, sig_int) == SIG_ERR) { printf("signal error:%s \n", strerror(errno)); } //接收终端输入并打印输出 while (fgets(buf, sizeof(buf), stdin) != NULL) { if (fputs(buf, stdout) == EOF) { printf("fputs error:%s \n", strerror(errno)); return 1; } } return 0; }
-
语言异常,
OC/C++
前面已提到语言异常最终会转化为信号
SIGABRT
进而引发应用终止; -
用户抛出的异常
比如前面提到的断言也会调用
abort
函数转化为SIGABRT
异常终止信号,该信号默认是终止进程并生成崩溃日志报告,如下图:
那最终不管是硬件异常还是语言异常及用户抛出异常都会产生信号,那信号与Mach异常有何关系?首先来带大家了解下什么是
Mach
异常。
Mach异常是什么?
从名字来看就包含了Mach
和“异常”,那Mach
是什么?大家有没有这样的疑问,我这里给大家讲解一下iOS/Mac操作系统框架,如下图所示:
整个操作系统核心包括了Mach
微内核、BSD层(大家熟悉的POSIX
接口就在这一层)、I/O Kit设备驱动框架以及核心库,其中Mach
微内核负责进程和线程抽象、虚拟内存管理、任务管理以及进程间通信和消息传递机制,所以Mach
微内核就是整个操作系统的核心。
鸿蒙系统也是基于微内核
与大家熟知的Runloop
中的Mach port
消息就是基于Mach
微内核的消息机制的体现,那Mach
异常是什么呢?如下图所示:
RunLoop 的核心就是一个 mach_msg() ,RunLoop 调用这个函数去接收消息,如果没有别人发送 port 消息过来,内核会将线程置于等待状态。例如你在模拟器里跑起一个 iOS 的 App,然后在 App 静止时点击暂停,你会看到主线程调用栈是停留在 mach_msg_trap() 这个地方。
Mach
异常是在已有的消息传递架构上实现的一种独有的异常处理方法,是一种轻量级的架构
听起来很抽象,其实很简单,一句话概括就是整个的异常机制是构建在
Mach
异常之上的,所有的硬件/软件异常都会首先转换为Mach
异常,进而转换为信号。
我们看到的崩溃日志报告中的异常错误码是不是包含了EXC_xxx
,这里对应的就是Mach
异常,如下图:
那Mach
异常是如何转化为BSD
信号的呢?
Mach异常与信号转换机制
一张图概述:
流程主要分三个步骤:
-
异常封装、转换、发送
- 异常(比如硬件错误、软件异常)发生时,就会由内核异常处理程序同步接收;
- 内核异常处理程序不会针对不同异常单独处理,而是统一通过
exception_triage
来处理,该函数会将异常信息(比如所在任务、线程、异常类型等)转化为Mach
消息; - 再调用
exception_deliver
函数封装封装异常端口(包括线程、任务异常端口)、异常、异常错误码、线程寄存器状态等信息; - 再调用
mach_exception_raise
函数,该函数会通过mach_msg
发送mach
异常消息到异常端口;
-
异常消息接收处理
-
内核启动创建第一个用户态进程
launchd
的同时,会将进程的Mach
异常消息重定向到异常端口;因用户态进程都是通过
launchd
进程fork
克隆出来,同时也随之继承了异常端口; -
调用
ux_handler_init
来创建一个内核线程开启ux_hanlder
异常处理函数; -
ux_handler
函数会开启消息循环来接收异常线程的Mach
异常消息;
-
-
异常消息转换为BSD信号
- 内核线程循环接收异常消息,当接收到异常消息后,会调用
mach_exc_server
函数; - 该函数会调用
catch_mach_exception_raise
函数来捕获异常消息,同时会调用ux_exception
函数将异常消息转换为BSD
信号; - 通过
threadsignal
函数来抛出信号;
- 内核线程循环接收异常消息,当接收到异常消息后,会调用
其中关键点就是Mach
异常消息通过消息发送的形式,发送到指定的异常端口,而该异常端口被内核线程持有,进而接收异常消息并转换为BSD
信号。
信号处理
那最终信号如何处理呢?
上面我们讲到用户态进程若指定了信号处理函数(比如SIGINT
)则可以自己来处理,若未指定呢?比较有意思的地方开始了,内核发现信号未存在异常处理函数,就会将其抛给崩溃报告守护进程ReportCrash
,这里可以查看Mac的进程就会发现该进程,如下:
该进程负责来获取异常消息及信号信息来生成崩溃日志报告。那问题来了,既然异常消息是通过
Mach
消息的形式发送出去的,那我是不是可以截获这个消息呢?
答案是肯定的!具体就是来注册自己的异常端口来截获异常消息,具体见demo。