野指针是指指向未知或者无效内存地址的指针。出现野指针的场景是实例对象被释放之后,指向对象的指针并没有被置空,还是指向原来的内存地址,这时候访问这个指针就可能会出现野指针错误。野指针错误对应的 Mach Exception 类型 EXC_BAD_ACCESS
,对应的 Signal 是 SIGSEGV
和 SIGBUS
。
在 iOS 开发中内存管理从 MRC 向 ARC 以及编程语言从 OC 向 Swift 过度的过程中,野指针问题已经很少出现了,但也还是可能会有相关的野指针的问题,比如 delegate 使用 assign 修饰、使用 _unsafe_unretain
去修饰对象指针,以及 ARC 对 self 的内存管理(参考Sunny老师的这篇文章 (opens new window)),ARC 的机制不光是要保证内存安全,也同时要兼顾性能,所以像上面一些极个别场景下还是会出现内存问题。
所以如何排查定位野指针问题呢?要知道有的时候野指针并不一定会触发崩溃,原因在于 iOS 运行时候去调用析构执行(dealloc)后,只是告诉系统,这片内存我不用了,而系统并没有就让这片内存真的不能访问,所以如果对象释放后内存没有别修改(被其他操作覆盖),则可能不会出现 Crash 或者其他错误。
参考这篇文章 (opens new window),这里列举对象被释放后,它的内存可能出现的一些变化
如果是遇到必现的 Crash 的话还好,能查看内存地址排查,如果遇到非必现的 Crash,排查难度会比较大。
举个例子我们看到的崩溃调用栈不一定是出问题部分的代码,比如 A 部分的代码造成了野指针 P,然后 B 部分代码尝试访问该野指针的时候发生 Crash,则崩溃调用栈显示的是 B 部分代码的调用栈,我们还需要去分析野指针到底是什么时候出现的,参考这里 Memory corruption (opens new window)。
系统提供了一些帮助我们定位排查内存问题的工具。
# Enable Malloc Scribble
MallocScribble 本质上是 malloc 库提供的调试用的环境变量。Scribble 的中文含义是涂鸦,这里的含义是为已经分配好的内存填充 0xAA 字符,为已经释放的内存填充 0x55 字符。**MallocScribble 这么做的目的是为了让应该崩溃的场景提早崩溃。**开启位置 Edit Scheme > Diagnostics > Malloc Scribble。
在我们上面描述的场景里,如果对方被释放后对应的内存没有改过,这时候有可能不会 Crash,比如我们向已经释放掉的对象发消息,有可能不会崩溃。我自己并没有制造出类似的崩溃,不过 stackoverflow 上有人遇到过类似的场景 Sending a message to deallocated object is working (opens new window)。
向被释放的实例发送消息是未定义行为,实例被释放了,我们继续向它发送消息,但是执行效果不会复合我们的预期,或者如果系统将被释放的内存改写为别的实例的内存地址,则向该内存地址发送一些 NSObject
通用的方法并不一定会造成崩溃,但是大概率会造成逻辑错误。这种情况我们一定要尽早发现,这也就是 Malloc Scribble 的使用场景。当对象被释放后,填充 0x55,则向被释放对象发送任何消息一定会触发崩溃。
Xcode 提供了这样的调试机制,但是如果交付给测试同学的话,测试同学是没有办法享受到同等的调试待遇的。所以 Bugly 团队 (opens new window)通过 fishhook 来动态hook C 语言的 free 方法,hook后的 free方法如下
void safe_free(void* p){
size_t memSiziee=malloc_size(p);
memset(p, 0x55, memSiziee);
orig_free(p);
return;
}
这种做法和 Malloc Scribble 的作用几乎是一模一样的,不过不排除在涂鸦之后,系统将这部分内存再次改写。
# Zombie Objects
关于将野指针变为僵尸对象调试有两种方式,关于 Zombie Objects 定义如下
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.
一个对象它死了,又好像没死…
Instrument Zombie
我们可以通过 Instrument 提供的 Zombie 工具来发现野指针。
举个例子,有如下按钮点击响应事件的代码(ARC),当向 stu 指向实例发送消息的时候,stu 指针已经处于野指针状态了,因为 __unsafe_unretained 不会强持有实例,同时当实例释放的时候指针也不会被置为 nil。
- (IBAction)trigger:(id)sender {
__unsafe_unretained Person *stu = [[Person alloc] init];
[stu printName];
}
开启 Instrument 的 Profile 功能后,我们点击按钮触发上面的逻辑,会看到 Zombie 能检测到向已经释放的对象发消息的时机,同时应用崩溃,记录停止。
An Objective-C message was sent to a deallocated 'Person' object (zombie) at address: 0x60000002c840.
我们点击弹出框中的箭头能看到对应的代码的位置,能看到更详细的关于野指针的信息
所以这个 Zombie 工具对于我们调试时排查野指针相关的问题还是挺有帮助的。
Zombie Objects
通过开启 Zombie Objects(Edit Scheme > Diagnostics > Zombie Objects)之后,在调试的时候遇到崩溃,我们能直接定位到崩溃的位置,同时能获取到相对比较明确的调用栈。
看DEMO 中例子,本来是 Person 类型的 stu 实例在开启 Zombie Objects 之后,变成了 _NSZombie_Person 类型,所以一定是在运行时修改了 stu 的 isa 指针。
本质上是运行时 HOOK 了原来的 dealloc 方法,替换为了 __dealloc_zombie
的实现,就是在此实现中修改了 stu 的 isa 指针,同时调用了 objc_destructInstance
方法,这个方法的作用是在不释放实例的前提下,移除和实例相关联的引用,和原始 dealloc
的区别在于,__dealloc_zombie
并没有真正去调用 free 函数去释放实例内存。
可以打
__dealloc_zombie
符号断点看里面的汇编代码
同时 isa swizzle 之后 _NSZombie_Person
类型是没有父类的,并且没有任何方法,所以给他发消息的时候会走消息转发机制,同时会报异常如下
-[Person printName]: message sent to deallocated instance 0x108a08180
不管是通过 Xcode 直接开启 Zombie Objects 还是通过 Instrument→Zombie 进行僵尸对象检测,本质上 Zombie 是运行时的环境变量,它将环境变量 NSZombieEnabled
设置为 true
。
开启了 Zombie 的注意事项
- 因为 Zombie 机制是基于运行时实现,所以对于不继承自
NSObject
的 Swift 实例是不生效的。 - Zombies 模板会导致持久内存增长,因为它会更改您的环境,因此从技术上讲,释放的对象永远不会被释放,这是预期的行为。
Instrument 的 Zombie 工具比环境变量 Zombie 的好处在于能看到野指针的分配和释放历史。
# Guard Malloc
Guard Malloc 是 malloc
库的特殊版本 libgmalloc
,在调试期间替代标准库。
libgmalloc
是在标准系统 malloc
的位置使用的,它利用虚拟内存系统来识别内存访问错误。每个 malloc
分配都被放置在其自己的虚拟内存页面(或页面)上。
libgmalloc
只能用在模拟器上,我们可以通过man libgmalloc
命令详细查看libgmalloc
的工作方式。
使用这个工具可以捕获平常不容易发现的越界错误,默认情况下,分配的缓冲区的结束位置位于最后一页的末尾,并且在其后的下一页保持未分配状态。因此,超出缓冲区末尾的访问会立即引发错误。
同时也能捕获野指针相关的错误,当释放内存时,libgmalloc会释放其虚拟内存,因此对已释放的缓冲区的读取或写入会引发错误。难以隔离的错误立即变得明显,我们将确切地知道是哪段代码引起了问题。
下面这段代码如果不开启 Gurad Malloc,运行时候不会出现异常,但并不代表代码没问题,因为这里显然已经越界了。
unsigned *buffer = (unsigned *)malloc(sizeof(unsigned) * 100);
unsigned i;
for (i = 0; i < 200; i++) { buffer[i] = i; }
for (i = 0; i < 200; i++) { printf ("%d ", buffer[i]); }
通过开启 Edit Scheme > Diagnostics > Guard Malloc 后,运行程序,断点会直接打在越界的代码位置,而此时代码正要访问数组界外的内存。
# Address Sanitizer
Address Sanitizer 工具可检测不属于已分配块的内存访问尝试。它是通过自定义实现替换了 malloc(_:)
和 free(_:)
函数。
自定义的 malloc
函数将请求的内存块包围在特殊的禁止访问区域中,并报告试图访问这些区域的尝试。free
函数将释放的块放入特殊的隔离队列中,并报告试图访问该隔离内存的尝试。
我们还是沿用上面(Guard Malloc)部分的例子,开启 Edit Scheme > Diagnostics > Address Sanitizer 后运行代码,定位到了越界的位置。
以上基本上就是系统层面提供的一些处理野指针的方式… 感觉系统的的一些监控野指针的方式就是通过各种 HOOK 系统函数的方式,总结一下就是,Malloc Scribble 是通过 hook malloc 和 free 修改内存填充数据;Zombie Objects 通过 hook dealloc 方法不去真正释放对象并修改对象的 isa 指针;Address Sanitizer 也是 hook malloc 和 free 方法来检测内存越界和野指针的错误;Guard Malloc 是干脆替换了 malloc 内存分配库。
参考地址:
- iOS 野指针处理 (opens new window)
- 如何定位Obj-C野指针随机Crash(一):先提高野指针Crash率 (opens new window)
- 浅谈 iOS 中的 Crash 捕获与防护 (opens new window)
- Xcode-Investigating memory access crashes (opens new window)
- Memory Usage Performance Guidelines-Configuring the Malloc Environment Variables (opens new window)
- Technical Note TN2239-iOS Debugging Magic (opens new window) #bsd
- Xcode-Investigating crashes for zombie objects (opens new window)
- Instruments-Finding zombies (opens new window)
- iOS Zombie Objects(僵尸对象)原理探索 (opens new window)
- Why does object become NSZombie only when inherit from NSObject? (opens new window)
- 野指针扑获理论篇 (opens new window)
- man-libgmalloc (opens new window)
- Xcode - scribble, guard edges and guard malloc (opens new window)
- 初识Enable Guard Malloc (opens new window)
- Xcode Debugging-Diagnosing memory, thread, and crash issues early (opens new window) #Address Sanitizer
- Using the Address Sanitizer (opens new window)
- WWDC 2015 Session 413 Advanced Debugging and the Address Sanitizer
- Friday Q&A 2015-07-03: Address Sanitizer (opens new window)
- iOS 线上野指针探测实践与展望 (opens new window)