项目相关-野指针调试

野指针是指指向未知或者无效内存地址的指针。出现野指针的场景是实例对象被释放之后,指向对象的指针并没有被置空,还是指向原来的内存地址,这时候访问这个指针就可能会出现野指针错误。野指针错误对应的 Mach Exception 类型 EXC_BAD_ACCESS,对应的 Signal 是 SIGSEGVSIGBUS

在 iOS 开发中内存管理从 MRC 向 ARC 以及编程语言从 OC 向 Swift 过度的过程中,野指针问题已经很少出现了,但也还是可能会有相关的野指针的问题,比如 delegate 使用 assign 修饰、使用 _unsafe_unretain 去修饰对象指针,以及 ARC 对 self 的内存管理(参考Sunny老师的这篇文章 (opens new window)),ARC 的机制不光是要保证内存安全,也同时要兼顾性能,所以像上面一些极个别场景下还是会出现内存问题。

所以如何排查定位野指针问题呢?要知道有的时候野指针并不一定会触发崩溃,原因在于 iOS 运行时候去调用析构执行(dealloc)后,只是告诉系统,这片内存我不用了,而系统并没有就让这片内存真的不能访问,所以如果对象释放后内存没有别修改(被其他操作覆盖),则可能不会出现 Crash 或者其他错误。

参考这篇文章 (opens new window),这里列举对象被释放后,它的内存可能出现的一些变化

Untitled

如果是遇到必现的 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,则向被释放对象发送任何消息一定会触发崩溃。

Untitled

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.

Untitled

我们点击弹出框中的箭头能看到对应的代码的位置,能看到更详细的关于野指针的信息

Untitled

所以这个 Zombie 工具对于我们调试时排查野指针相关的问题还是挺有帮助的。

Zombie Objects

通过开启 Zombie Objects(Edit Scheme > Diagnostics > Zombie Objects)之后,在调试的时候遇到崩溃,我们能直接定位到崩溃的位置,同时能获取到相对比较明确的调用栈。

看DEMO 中例子,本来是 Person 类型的 stu 实例在开启 Zombie Objects 之后,变成了 _NSZombie_Person 类型,所以一定是在运行时修改了 stu 的 isa 指针。

Untitled

本质上是运行时 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

Untitled

不管是通过 Xcode 直接开启 Zombie Objects 还是通过 Instrument→Zombie 进行僵尸对象检测,本质上 Zombie 是运行时的环境变量,它将环境变量 NSZombieEnabled 设置为 true

开启了 Zombie 的注意事项

  1. 因为 Zombie 机制是基于运行时实现,所以对于不继承自 NSObject 的 Swift 实例是不生效的。
  2. 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 后,运行程序,断点会直接打在越界的代码位置,而此时代码正要访问数组界外的内存。

Untitled

# Address Sanitizer

Address Sanitizer 工具可检测不属于已分配块的内存访问尝试。它是通过自定义实现替换了 malloc(_:) 和 free(_:) 函数。

自定义的 malloc 函数将请求的内存块包围在特殊的禁止访问区域中,并报告试图访问这些区域的尝试。free 函数将释放的块放入特殊的隔离队列中,并报告试图访问该隔离内存的尝试。

我们还是沿用上面(Guard Malloc)部分的例子,开启 Edit Scheme > Diagnostics > Address Sanitizer 后运行代码,定位到了越界的位置。

Untitled

以上基本上就是系统层面提供的一些处理野指针的方式… 感觉系统的的一些监控野指针的方式就是通过各种 HOOK 系统函数的方式,总结一下就是,Malloc Scribble 是通过 hook malloc 和 free 修改内存填充数据;Zombie Objects 通过 hook dealloc 方法不去真正释放对象并修改对象的 isa 指针;Address Sanitizer 也是 hook malloc 和 free 方法来检测内存越界和野指针的错误;Guard Malloc 是干脆替换了 malloc 内存分配库。

参考地址:

  1. iOS 野指针处理 (opens new window)
  2. 如何定位Obj-C野指针随机Crash(一):先提高野指针Crash率 (opens new window)
  3. 浅谈 iOS 中的 Crash 捕获与防护 (opens new window)
  4. Xcode-Investigating memory access crashes (opens new window)
  5. Memory Usage Performance Guidelines-Configuring the Malloc Environment Variables (opens new window)
  6. Technical Note TN2239-iOS Debugging Magic (opens new window) #bsd
  7. Xcode-Investigating crashes for zombie objects (opens new window)
  8. Instruments-Finding zombies (opens new window)
  9. iOS Zombie Objects(僵尸对象)原理探索 (opens new window)
  10. Why does object become NSZombie only when inherit from NSObject? (opens new window)
  11. 野指针扑获理论篇 (opens new window)
  12. man-libgmalloc (opens new window)
  13. Xcode - scribble, guard edges and guard malloc (opens new window)
  14. 初识Enable Guard Malloc (opens new window)
  15. Xcode Debugging-Diagnosing memory, thread, and crash issues early (opens new window) #Address Sanitizer
  16. Using the Address Sanitizer (opens new window)
  17. WWDC 2015 Session 413 Advanced Debugging and the Address Sanitizer
  18. Friday Q&A 2015-07-03: Address Sanitizer (opens new window)
  19. iOS 线上野指针探测实践与展望 (opens new window)