前言
应用程序发生 Crash 现象会给用户带来极差的使用体验,本文将从 iOS 系统的底层出发,梳理核心知识点,讲解各类 Crash 的收集以及 OOM(out of memory) 的监控与分析,以及在 APM 系统中所呈现出来的效果
一、异常处理
1.1 OSX/iOS 系统架构
在苹果给出的文档中,所展示的抽象的系统架构分层图中, OSX 跟 iOS 的系统架构分层是相同,分为4个层次:
用户体验层:包括Aqua、Dashboard/Spotlight 等;
应用框架层:Cocoa、Carbon、Java;
核心框架:有时候也称之为图形和媒体层。包括核心框架、OpenGL;
Darwin:包括内核和 UNIX shell 环境
4个层次中,Darwin 是完全开源的,是整个系统的基础,提供了底层API。
图1 OSX 和 iOS 系统架构图
OSX 跟 iOS 的系统框架在抽象上都是可以用图1表示,但实际上他们之间还是有一些细节上的差异,这里不过多介绍。这里比较重要的是 Darwin 框架
图2 Darwin 框架图 来源《深入解析 Mac OS X & iOS 操作系统》
Darwin 的内核是 XUN,它也是 OS X 本身的核心。从图2可知,XUN以下几种组件构成:
Mach
BSD
LibKern
I/O Kit
这其中最为重要的是 Mach 跟 BSD。
1.2 Mach层
Mach是一个微内核,这个微内核仅能处理操作系统最基本的职责:
进程和线程抽象
虚拟内存管理
任何调度
进程间通信和消息传递机制
Mach 本身的 API 非常有限,但是这些 API 非常基础,如果没有这些 API,其他工作无法实施,而 Mach 的异常处理也是基于以上的4项能力进行设计的。
在Mach中,异常是通过内核中的消息传递,异常由出错的任务或线程通过 msg_send() 抛出,由一个处理程序通过 msg_recv() 捕捉。处理程序可以处理异常,也可以清除异常(将异常标记为已完成并继续),还可以终止线程。
Mach的异常处理程序在不同的上下文运行,出错的线程向预先指定好的异常端口发送消息,然后等待应答。每一个任务都可以注册一个异常端口,这个异常端口会对同一任务中的所有线程起效。此外,单个线程还可以通过 thread_set_exception_ports 注册自己的异常端口。通常情况下,任务和线程的异常端口都是 NULL,也就是说异常不会被处理。而一旦创建异常端口,这些端口就像系统中的其他端口一样,可以转交给其他任务甚至其他主机。
在发生异常时,会按照以下步骤执行:
尝试将异常抛给线程中的异常端口
尝试抛给任务的异常端口
尝试抛给主机的异常端口(即主机注册的默认端口)。
如果没有一个端口返回 KERN_SUCCESS,那么整个任务被终止,根据前文的叙述,Mach 不提供异常处理逻辑——只是提供异常通知的框架。
1.3 BSD层
BSD 层建立在Mach之上,这一层是一个很可靠且更现代的 API,提供了 POSIX 兼容性,并提供了更高层次的抽象,包括但不限于:
UNIX 进程模型
POSiX 线程模型(pthread)以及相关的同步原语
UNIX 用户和组
网络协议栈
文件系统访问
设备访问
在处理异常上,Mach 已经通过异常机制提供了底层的陷阱处理,而 BSD 则在异常机制之上,构建了一个信号处理机制。硬件产生的信号被 Mach 层捕捉,然后转换为对应的 UNIX 信号。为了维护一个统一的机制,操作系统和用户产生的信号首先被转换为 Mach 异常,然后再转换为信号。
BSD 进程被 bsdinit_task() 函数启动时,还调用了 ux_handle_init() 函数,这个函数设置了一个名为ux_handle 的 Mach 内核线程。只有在 ux_handle_init() 函数返回之后,bsdinit_task() 才能够注册使用ux_exception_port。bsdinit_task() 将所有的Mach异常消息都重定向到ux_exception_port,这个端口由 ux_handle 线程持有。遵循 Mach 异常消息传递的方式,PID 为1的进程异常处理会在进程之外由 ux_handle() 线程处理。由于所有后创建的用户态进程都是 PID1 的后代,所以这些进程会自动继承这个异常端口,相当于 ux_handle() 线程要负责处理系统上 UNIX 进程产生的每一个Mach异常。ux_handle() 函数非常简单,这个函数在进入时会首先设置好 ux_handle_port,然后进入一个无限循环的 Mach 消息循环。消息循环接受 Mach 异常消息,然后调用mach_exc_server() 处理异常,整个流程图如下所示:
图3 Mach 异常处理以及转换为 UNIX 信号的流程
要了解 Crash,我们应该先清楚几个基本概念以及它们之间的关系:
软件异常:主要来源于两个API的调用kill()、pthread_kill(),而 iOS 中常遇到的 NSException 未捕获、abort()函数调用都属于这种情况。
硬件异常:此类异常始于处理器陷阱,如访问野指针崩溃。
Mach异常:Mach 异常处理流程的简称
UNIX信号:如 SIGBUS、
SIGSEGV
、SIGABRT
、SIGKILL
等。
这是一个 App 的崩溃日志,从日志中的 Exception Type: EXC_CRASH (SIGABRT) 可以知道这是 Mach 层发生了EXC_CRASH异常,被转换 SIGABRT 信号。那么你可能有一个疑问?既然 Mach 层可以捕获异常,注册 UNIX 信号也能捕获异常,那么这两种方法系统是如何选择?而且从图3中可以看出 Mach 异常最终都会转换成 UNIX 信号,那么是不是只需要拦截 UNIX 信号就可以了?
其实不是的,这里面有两个原因:
因为并不是所有的 Mach 异常类型都有相对应的 UNIX 信号进行映射
UNIX 信号在崩溃线程回调,如果遇到栈溢出,那么就没有栈空间可以执行回调代码了。
那么是否只需要拦截 Mach 异常?答案一样是否定的,因为用户态的软件异常是直接走信号流程的,如果不拦截,会导致这部分 Crash 丢失。
因此,在 Crash 的收集上,监控系统应该具备多种异常处理能力的,市面上有许多诸如此类的工具,其中一款有 KSCrash,该工具也是目前最热门,最完善的 Crash 收集工具,大部分源码基于C语言编写,微信的开源项目 Matrix 也是基于 KSCrash 开发,而我们的 APM 系统中的 iOS 崩溃监控也是基于该工具上进行编写的。
读源码是了解工具最快的方式,让我们看一下 KSCrash 是如何处理Mach层异常的核心源码(KSCrashMonitor_MachException.c)如下:
根据源码可以总结出以下的流程图:
在1.2中,提到过 Mach 层对异常的处理流程,因此在 Mach 层的异常捕获也是根据这一处理流程进行的,思路是先申请一个异常处理端口,为该端口申请权限,再设置异常端口,新建一个内核线程,在线程中循环等待异常,但发生异常时,会挂起线程并组装发生 Crash 时的信息进 JSON 文件。但为了防止自己注册的异常端口抢占其他 SDK、或者开发者设置的逻辑,需要先保存其他异常端口,等到收集逻辑结束后将异常处理交给其他端口内的逻辑处理。
信号异常的捕获是在 KSCrashMonitor_signal.c 中的 installSignalHandler 函数中,具体的方案为首先先使用 sigaltstack 函数在堆上分配一块内存,设置信号栈区域,目的是替换信号处理函数的栈,因为一个进程可能有n个线程,每个线程都有自己的任务,假如某个线程执行出错,会导致整个进程奔溃,所以为了信号处理异常函数正常运行,需要设置单独的运行空间。
其次设置信号处理函数 sigaction,然后遍历需要处理的信号数组,将每个信号的处理函数绑定到 sigaction,另外用 g_previousSignalHandlers 保存当前信号的处理函数,在信号处理时,保存线程的上下文信息。
最后等到 KSCrash 信号处理后还原之前的信号处理权限。
核心代码如下:
c++异常处理依靠了标准库的 std::set_terminate(CPPExceptionTerminate) 函数。
在iOS中,如果C跟C++异常如果能被转换成NSException,则会走Objective-C异常处理,如果不能,则是default_terminate_handler。这个C++异常默认default_terminate_handler函数调用了abort_message函数,系统产生一个SIGABRT信号。
2.4 Objective-C 异常处理
对于 OC 层面的 NSException 异常处理较为容易,可以通过注册 NSUncaughtExceptionHandler 来捕获异常信息,通过 NSException 参数来做 Crash 信息的收集,交给数据上报组件。如
KSCrash.sharedInstance.uncaughtExceptionHandler = &handleException;
三、OOM 相关概念
OOM 就是 out of memory 的简称,指的是在 iOS 设备上当前应用因为内存占用过高而被操作系统强制终止,在用户侧的感知就是 App 一瞬间的闪退,与普通的 Crash 没有明显差异。但是当我们在调试阶段遇到这种崩溃的时候,在设备中分析与改进是找不到普通的崩溃日志。可以找到以Jetsam开头的日志,这种日志就是 OOM 崩溃之后系统生成的一种专门反映内存异常问题的日志。
按照程序的运行状态一般把OOM分为以下两种类型:
Foreground Out Of Memory
应用正在前台运行的状态而出现OOM崩溃
Background Out Of Memory
应用程序在后台发生的 OOM 崩溃
Jetsam 是 iOS 操作系统为了控制内存资源过度使用而采用的一种资源管理机制。不同于 MacOS, Linux,Windows等桌面操作系统,出于性能方面的考虑,iOS 系统并没有设计内存交换空间的机制,所以在 iOS 中,如果设备整体内存紧张的话,系统只能将一些优先级不高或占用内存过大的进程直接终止掉。
下面截取的部分日志信息:
Jetsam机制清理策略分为两种情况:
单个 App 进程超过内存上线
设备的物理内存占用受到压力时会按照优先级完成清理:
- 后台应用 > 前台应用
- 内存占用高的应用 > 内存占用低的应用
- 用户应用 > 系统应用
OOM预警功能主要是在内存到达预定的阀值时,上报APM平台内存状态相关信息。流程图如下:
基于系统内核提供一个表示内存信息的结构体
通过 task_info 方法可以获得内存的相关使用情况
监控内存大小代码如下:
即当超过设定的阀值时,就上报当时的内存信息!
OOM监控是在App由于OOM导致的崩溃时,及时记录当时的堆栈信息,上报到APM平台进行后续的问题分析。
Jetsam机制终止进程的时候是通过发送SIKILL异常信号,但它是不可以被当前进程捕获,用监听异常信号常规的Crash捕获方案是不行的。那如何监控呢?2015年facebook提出一个思路,利用排除法。
每次App 启动的时候判断上一次启动进程终止的原因,已知的有:
App 更新了版本
App 发生了崩溃
用户手动退出
操作系统更新了版本
App 切换后台之后进程终止
如果上一次的启动进程终止不是以上的原因,就判定为上次启动发生了 OOM 崩溃。
核心代码逻辑如下:
内存画像,就是程序在达到触顶情况时,对内存进行快照,导出内存节点引用情况,从而找到内存大的原因在哪!要做的事情有两个:
1.内存节点的获取
内存节点的获取要通过mach内核的vm_region_recurse/vm_region_recure64函数扫描进程中的所有VM Region,通过vm_region_submap_info_64结构体获取详细信息。
2.分析节点之间的引用关系
这里又分为两种情况:libmalloc维护的堆所在的VM Region包含的OC对象、C/C++对象、buffer等可以获取详细的引用关系,需要单独处理。而非libmalloc维护的VM Region单独的内存节点,仅记录了起始地址和Dirty、Swapped内存大小,以及与其他节点之间的引用关系。
获取节点的核心代码如下:
堆内存节点引用关系的核心代码如下:
通过类成员变量的地址与引用类的isa指针地址进行匹配,从而发现是否存在引用关系!
采用了倒序输出引用关系,所以看上去是阶梯型形式!
取出其中部分数据借用工具分析其引用关系如图:
这样可以很清晰的看到其堆内存节点间的引用关系及所占内存大小。
以上便是APM系统中关于 OOM 的功能的介绍,主要包含三大功能点:
OOM 预警可以发现线上App发生超过内存阀值时记录,以标识存在 OOM 导致 crash 的风险。
OOM 监控则在发生 OOM 时及时记录案发现场,给后续开发者问题查找提供线索。
内存画像则在发生OOM 时导出其引用关系,记录节点大小等信息,更直观的查找内存大在何处。
项目集成 APM,SDK 初始化时,会默认打开 Crash 监控,当发生 Crash 时,将按照以下步骤执行:
KSCrash 收集到崩溃日志后执行 APM 的 crashCallBack函数
在 crashCallBack 函数中将日志写入 APMLog 并缓存起来
当下次启动时,APM 初始化成功后按照上报流程上报Crash 文件到服务器中
APM的 iOS 端SDK只需要将日志成功上报后,服务端会根据Crash 的信息,如版本号,binaryImage 以及 UUID 等信息进行符号工作,符号化成功后,就可以在管理后台查看到相应的日志。
1.Black, David L. The mach Exception Handing Facility.
2.iOS Crash 分析攻略 https://developer.aliyun.com/article/766088
3.《深入解析Mac OSX & iOS操作系统》