前言
处理 crash 时,有两类问题比较棘手,一个是今天要讨论的野指针,另一个是 OOM 崩溃。
这次主要用图解的形式,以便于理解野指针处理的核心概念。
本篇文章按以下顺序进行:
- 异常类型
- 野指针定义和分类
- Xcode 处理方案:Malloc Scribble
- NSObject 释放的流程
- Xcode 处理方案:Zomibe Objects
- 项目现行的野指针处理流程
异常类型
先了解一下异常问题的类型,已经了解的同学可以略过。
异常可以直接分为 2 类:
- 软件异常:软件异常主要来自 kill(),pthread_kill()。iOS 中的 NSException 未捕获,abort 都属于这种情况。
- 硬件异常:硬件的信号始于处理器 trap,是和平台相关的。野指针崩溃大部分是硬件异常。
而我们在处理异常时,绕不开 2 个概念: Mach 异常 / UNIX 信号。
Mach层捕获 Mach 异常,BSD层转换为 Unix 信号。iOS中的 POSIX API 就是通过 Mach 之上的 BSD 层实现的:
硬件异常的流程是:
硬件异常 -> Mach异常 -> Unix信号.
软件异常的流程是:
软件异常 -> Unix信号.
总结它们的处理流程和关系如图:
根据《Mac OS X Internals》中说到的 bsd
相关的处理代码 ux_exception.c ,可以看到 Mach 异常和 Unix 信号存在的对应关系。
为了更直观,做了一个表格:
Mach 异常
- EXC_BAD_ACCESS: 不能访问的内存
- EXC_BAD_INSTRUCTION: 非法或未定义的指令或操作数
- EXC_ARITHMETIC: 算术异常(例如除以0)。iOS 默认是不启用的,所以我们一般不会遇到
- EXC_EMULATION: 执行打算用于支持仿真的指令
- EXC_SOFTWARE:软件生成的异常,我们在 Crash 日志中一般不会看到这个类型,苹果的日志里会是 EXC_CRASH
- EXC_BREAKPOINT:跟踪或断点
- EXC_SYSCALL: UNIX 系统调用
- EXC_MACH_SYSCALL: Mach 系统调用
UNIX 信号:
- SIGSEGV,段错误。访问未分配内存、写入没有写权限的内存等。
- SIGBUS,总线错误。比如内存地址对齐、错误的内存类型访问等。
- SIGILL,执行了非法指令,一般是可执行文件出现了错误。
- SIGFPE ,致命的算术运算。比如数值溢出、NaN数值等。
- SIGABRT,调用
abort()
产生,通过 pthread_kill() 发送。 - SIGPIPE,管道破裂。通常在进程间通信产生。比如采用FIFO(管道)通信的两个进程,读管道没打开或者意外终止就往管道写,写进程会收到SIGPIPE信号。根据苹果相关文档,可以忽略这个信号。
- SIGSYS,系统调用异常。
- SIGKILL,此信号表示系统中止进程。崩溃报告会包含代表中止原因的编码。exit(), kill(9) 等函数调用。iOS 系统杀进程,如 watchDog 杀进程。
- SIGTRAP,断点指令或者其他trap指令产生。
野指针介绍
根据 维基百科:野指针 中野指针的定义:
当所指向的对象被释放或者收回,但是对该指针没有作任何的修改,以至于该指针仍旧指向已经回收的内存地址,此情况下该指针便称迷途指针(即通常说的野指针)。
若操作系统将这部分已经释放的内存重新分配给另外一个进程,而原来的程序重新引用现在的迷途指针,则将产生无法预料的后果。因为此时迷途指针所指向的内存现在包含的已经完全是不同的数据。
可以看出,野指针所指向的内容存在很大的 不确定 ,可能是未使用的内存,也可能是已经被覆盖的内存。
这也是野指针问题难处理的原因。
野指针情况分类
按腾讯 Bugly 团队的 OC 野指针文章 来对野指针情况做一下分类:
从上面的图可以看到,野指针能导致各种异常问题,像是无法控制的野孩子,够野的…
野指针的解决思路
观察上面的一些野指针问题,有些必现问题不是关注重点(相对而言,在全面的测试场景下是可复现的),比如图中的分支之一,向释放后的对象再次发消息。
关注重点放在 随机 Crash 的情况上做功夫,因为 Crash 的场景随机,才让开发头疼。随机 Crash 的场景如果能变成必现场景 ,就能让我们更好的找到野指针场景。
那怎么将随机 Crash 变成必现的呢?
我们可以借鉴在 Xcode 中 2 种处理方案: Malloc Scribble 和 Zombie Objects
Malloc Scribble
Malloc Scribble
的定义可以由苹果官方文档看到:
在 Mac 下输入
1 | man malloc |
往下翻找,也可以看到 Malloc Scribble 相关的说明:
总的来说,苹果在 Xcode 中的 Malloc Scribble 做法是:
申请内存 alloc 时在内存上填
0xAA
,释放内存 dealloc 在内存上填0x55
。
这对应了 2 种访问情况:
没有做初始化就被访问
是释放后被访问
当访问到填充的
0xAA
或者0x55
,程序就会出现异常。
按照 Malloc Scribble 思想,对于野指针问题,在对象释放时做数据填充0x55
就可以了。
对象释放的流程
因为要选择在对象释放时做处理,所以了解对象释放的流程是一个关键。
下面开始从 runtime
的 源码来看看对象释放的过程,可以直接进入苹果开源的仓库查看,目前我选择的是 objc4-818.2
版本的。
NSObject 的 dealloc 相关方法 如下:
从上面的代码再找到 rootDealloc() 相关代码 如下:
这里面判断对象的引用关系,符合条件的话直接调用 free()
,否则进入 object_dispose
。
查看 object_dispose() 可以发现的相关实现:
而 objc_destructInstance()
根据源码里注释:
1 | //Destroys an instance without freeing memory. |
即:在不释放内存的情况下销毁实例,所以 objc_destructInstance()
没有做释放内存的事情,只是拿来解除对象相关联的引用关系。
这里总结一下对象释放的流程:
按照 Malloc Scribble 原理的解决方案
我们可以选择一个阶段做处理,将对象进行数据填充。
如果想对 C 一起做处理,可以选择 free()
,图上也可以看到最后对象也都使用到了 free()
。
如果只是 OC 对象,也可以在 dealloc()
/rootDealloc()
或者 object_dispose()
上去做。
以 free()
做处理为例子,我们可以利用 fishHook 做 hook :
1 | void safe_free(void* p){ |
做完上述处理后,减少了一部分问题。然而实际当中,还有部分情况需要考虑:
当对象填充
0x55
后,内存又被别的内存覆盖了,如果覆盖的数据没问题,就不会 Crash。
对于这种情况的处理, Bugly 团队的方案 就是不再调用 free()
,相当于一直持有着对象,但这产生新的问题:在什么样的时机去释放内存?
并且为了在访问时得到对象信息,还做了 isa 的替换等操作。
做完了上述的事情之后,其实和 Zombie Objects 的方案很类似。
因为我们项目也采用了 Bugly 这种方式,具体流程会放在后面结合我们项目方案进行说明。
Zombie Objects
从 苹果的文档 中可以看到对 Zombie Objects 的定义:
Once an Objective-C or Swift object no longer has any strong references to it, the object is deallocated. Attempting to further send messages to the object as if it were still a valid object is a “use after free” issue, with the deallocated object still receiving messages called a zombie object.
简略的说,一个对象解除了它的引用,已经被释放掉,但仍可以接收消息,就叫 zombie object 。
Zombie Object
,僵尸对象,可以用来检测内存错误(EXC_BAD_ACCESS)。给僵尸对象发送消息的话,它仍然可以响应,然后将会发生崩溃,并输出错误日志来显示野指针对象调用的类名和方法。
Zombie Objects 重点工作就是把释放的对象,全都转为僵尸对象🧟♂️。
苹果的 Zombie Objects 检测方案
先看一下 Xcode 具体是怎么做 zombie object 的,具体的探究过程可以参考 《iOS Zombie Objects(僵尸对象)原理探索》 去试一下。
前面在 runtime 源码里,dealloc
方法有一句注释: “Replaced by NSZombie”,对象释放时, NSZombie 将在 dealloc
里做替换:
当僵尸对象🧟♂️再次被访问时,将进入消息转发的流程,开始处理僵尸对象访问,输出日志并发生 Crash:
结合上面 Xcode 的 zombie objects 实现流程,第一是做好dealloc
的替换工作,关键是调用 objc_destructInstance
来解除对象的关联引用。
僵尸对象相对 Scribble 填充 0x55
的优势,就是不用考虑不会崩溃的情况,只要野指针指向僵尸对象,那再访问就一定会崩溃。
僵尸对象的劣势,就是不如 Scribble 方案中覆盖的地方广,可以通过 hook free()
的方式将 C 也纳入进去。
项目现行的野指针方案
目前我们项目也是采用了 Malloc Scribble
的方式进行野指针探测。
Malloc Scribble
方式内存会一直上涨,重点在于什么时机去释放对象。另外为了获取问题对象的相关信息,还需要进行对象的 isa 替换,以便于访问野指针时打印出需要的信息。
不过我们与 Bugly 团队不同的是,并未采用对 free()
做替换的方式,项目基本都是写 OC 代码,所以只针对 OC 对象的 dealloc
替换,其实思想上大同小异。
主要分为 3 个阶段说明:
- 开启检测
- dealloc 替换
- 触发调用对象
开启检测
在开启检测阶段,主要是对一些策略选项做设置,还可以监听 UIApplicationDidReceiveMemoryWarningNotification
通知在内存紧张时释放对象,以及最重要的事 – 交换 NSObject 的 dealloc 方法
。
需要注意做方法交换时,获取 dealloc 的 SEL 方式,参考 ReactiveObjc
中 NSObject+RACDeallocating.m 的做法:
1 | SEL deallocSelector = sel_registerName("dealloc"); |
否则 Xcode 提示 ARC 不能修改 dealloc。
开启检测后,当 OC 对象调用 dealloc ,会被进入到我们的 zombieDetector 类中做处理。
dealloc 替换
当 OC 对象开始释放,就进入了我们的处理逻辑:
再次强调,就是 objc_destructInstance()
会清理与对象相关联的引用,但并不释放该对象的内存。
真正释放内存要在清理的函数里调用 free()
。
触发调用对象
这个地方比较简单,首先确定 zombie object 响应调用时做什么:
- 打印类名信息
- 打印方法名
- 打印dealloc 释放调用栈
- 打印触发时的当前线程调用栈
- 是否中断程序
我们把处理逻辑写入 [ handleZombie:zombieStack:deallocStack:]
方法,在处理的方法里都去调用它。
要能让 zombie 对象响应所有的调用,除了消息转发流程做处理,还需要重写 NSObject 对象的几个方法:
- retain
- copy
- mutableCopy
- release
- autorelease
- dealloc
以 copy 为例:
1 | - (id)copy |
做完以上的工作,我们的 zombie 对象就能基本响应任何调用,并输出我们定位问题需要的信息。
总结
这里主要说了 2 种方案,即 Malloc Scribble
和Zomibe Objects
。
对于两种方案的选择,虽然之前团队使用 Malloc Scribble 的方式, 但现在我看来直接使用 Zombie Objects 会更加简单直观。清晰的逻辑也意味着好维护,不用考虑太多其它问题, Zombie Object 只要访问到了就会崩溃。当然项目现行的方案,替换了 isa 后和僵尸对象方式也很类似了。
在做完指针探查工作后,野指针问题会因为被覆盖到而被揪出来,减少相当一部分,但是也非万能的,很多时候可能场景没到,逻辑也都进不去,或者像 C / C++ 我们有的管控不到。另外的话,最好都是 debug 开启,线上关闭,避免内存消耗,也可以按一定策略去开启。
实际工作中,就我们发现的野指针,排查后发现很多是使用不当造成的,例如对象的属性修饰符使用了 assign 等。建议做好 Code Review 工作能在一定程度上避免这类问题。
参考
如何定位Obj-C野指针随机Crash(一):先提高野指针Crash率
如何定位Obj-C野指针随机Crash(二):让非必现Crash变成必现