iOS 野指针处理

前言

处理 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 ScribbleZombie 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() 相关代码 如下:

image-20210218155542873

这里面判断对象的引用关系,符合条件的话直接调用 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
2
3
4
5
6
void safe_free(void* p){
size_tmemSiziee=malloc_size(p);
memset(p,0x55, memSiziee);
orig_free(p);
return;
}

做完上述处理后,减少了一部分问题。然而实际当中,还有部分情况需要考虑:

当对象填充 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 方式,参考 ReactiveObjcNSObject+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
2
3
4
5
6
7
8
9
10
- (id)copy
{
@autoreleasepool {
//调用统一的 zombie object 响应逻辑并传入参数
[self handleZombie:NSStringFromSelector(_cmd)
zombieStack:getCurrentStack()
deallocStack:deallocThreadStack];
}
return nil;
}

做完以上的工作,我们的 zombie 对象就能基本响应任何调用,并输出我们定位问题需要的信息。

总结

这里主要说了 2 种方案,即 Malloc ScribbleZomibe Objects

对于两种方案的选择,虽然之前团队使用 Malloc Scribble 的方式, 但现在我看来直接使用 Zombie Objects 会更加简单直观。清晰的逻辑也意味着好维护,不用考虑太多其它问题, Zombie Object 只要访问到了就会崩溃。当然项目现行的方案,替换了 isa 后和僵尸对象方式也很类似了。

在做完指针探查工作后,野指针问题会因为被覆盖到而被揪出来,减少相当一部分,但是也非万能的,很多时候可能场景没到,逻辑也都进不去,或者像 C / C++ 我们有的管控不到。另外的话,最好都是 debug 开启,线上关闭,避免内存消耗,也可以按一定策略去开启。

实际工作中,就我们发现的野指针,排查后发现很多是使用不当造成的,例如对象的属性修饰符使用了 assign 等。建议做好 Code Review 工作能在一定程度上避免这类问题。

参考

浅谈 iOS 中的 Crash 捕获与防护

iOS Crash 分析攻略

iOS监控-野指针定位

iOS野指针定位总结

如何定位Obj-C野指针随机Crash(一):先提高野指针Crash率

如何定位Obj-C野指针随机Crash(二):让非必现Crash变成必现

如何定位Obj-C野指针随机Crash(三):加点黑科技让Crash自报家门

Finding zombies