『极致』的 iOS Zombie

1,122 阅读7分钟

Zombie 问题导致的崩溃对开发者来说是比较棘手的问题之一。主要是因为崩溃时有效信息缺少,对此 Xcode 在 Diagnostics 中提供了 Zombie Objects 功能,用于检测开发阶段的 Zombie 问题,能够提供 Zombie 对象的 Class、Method 以及 Address 信息帮助开发者找到问题原因。

image.png

但 Apple 提供的 Zombie Objects 功能仅能在线下使用,无法监控线上 Zombie 问题。对此业界仿照 Zombie Objects 的实现原理,实现了线上 Zombie 的功能。但开启 Zombie 功能会额外带来 CPU、内存、磁盘I/O 等消耗,本文探讨如何将 Zombie side effect 最小化以及如何提高功能丰富度。(关于如何实现 Zombie,网上文章很多,本文不再赘述。)

线上 Zombie 应该根据对象的类型做不同的处理,分为:NS 对象和 CF 对象。网上基本都是基于 NS 对象处理,鲜有方案会考虑 CF 对象。

NS 对象

hook 方法

一般会选择 hook free 函数或者 dealloc 方法。因为 free 函数更底层,影响范围更大,所以从性能角度思考选择 hook dealloc 方法会更合适。

类型过滤

选择 hook dealloc 方法通常会基于 OC 中的基类:NSObject、NSProxy。其实只需要关心我们使用到的 OC 类型即可。使用到的 OC 类型一般包括以下两类:

  • 自定义的类,包括动态库;
  • 使用到的系统类;
自定义类

判断是否是自定义类,只需要获取每个动态库 __DATA 的 vmaddr 和 vmsize 以及运行时的 ASLR 即可。如果对象所属的类在某个 [slide + vmaddr, slide + vmaddr + vmsize] 区间范围内,则可以认为该对象所属的类是自定义类。

struct load_command* load_cmd = (struct load_command*)cmd_ptr;
switch(load_cmd->cmd) {
    case LC_SEGMENT:
    {
        struct segment_command* seg_cmd = (struct segment_command*)cmd_ptr;
        if(strcmp(seg_cmd->segname, SEG_DATA) == 0) {
            seg_data_size += seg_cmd->vmsize;
            seg_data_addr = seg_cmd->vmaddr;
        }
        break;
    }
    case LC_SEGMENT_64:
    {
        struct segment_command_64* seg_cmd = (struct segment_command_64*)cmd_ptr;
        if(strcmp(seg_cmd->segname, SEG_DATA) == 0) {
            seg_data_size += seg_cmd->vmsize;
            seg_data_addr = seg_cmd->vmaddr;
        }
        break;
    }
}
系统类

所使用到的系统类可以通过 __DATA, __objc_classrefs 获取:

unsigned long size = 0;
uintptr_t *classrefs = (uintptr_t *)getsectiondata((mach_header_64 *)header, SEG_DATA, "__objc_classrefs", &size);

for (size_t i = 0; i < size / sizeof(uintptr_t); i++) {
    Class cls = (Class)classrefs[i];
    if (cls) {

    }
}
其它

还有一些其它需要考虑的细节:类簇、动态创建的类。

1.类簇

对于 NSArray、NSDictionary 等类型,Apple 对开发者隐藏了其内部实现。当我们创建不同类型的数组时,其内部的实现子类会有所区别。所以针对此类型需要获取其父类,根据父类的类型判断是否需要过滤:

Class super_cls = class_getSuperclass(cls);
while (super_cls) {
    if (CFSetContainsValue(g_class_cluster_list, super_cls)) {

    }
    super_cls = class_getSuperclass(super_cls);
}

2.动态创建的类

运行时创建的类,无法通过以上逻辑获取,所以需要单独处理:

malloc_zone_t *malloc_zone = malloc_zone_from_ptr((__bridge const void *)cls);
if (malloc_zone) {

}

另外有文章提到需要对 TaggedPointer 做额外判断,其实不需要,因为 TaggedPointer 类型将信息存储在指针地址中,不会存在 Zombie 问题,也不会调用 dealloc 方法。

信息存储

Zombie 的关键信息是:类名和 dealloc 时的线程信息(堆栈和线程名等)。这些信息都可以绑定在未被释放的 Zombie 对象上。 iOS 中对象的最小大小为 16 字节,其中 8 字节用来存储 ISA 指针。为了减少内存占用,需要尽可能的用另外 8 字节存储类名和堆栈指针。这时可以用 union 结构存储:

typedef union {
    Class originalCls;
    ThreadStack *threadStack;
} Info;

将类名获取和堆栈获取分开,在数据消费时通过崩溃时堆栈进行关联。

堆栈其实就是 64 位的地址集合,arm64 的最大虚拟地址上限为 0x0000000FC0000000ULL,只使用了 36 位空间。所以在存储堆栈信息时,只需要使用 36 位信息存储即可。

image.png

CF 对象

很多创建的 NS 对象底层都会转换成 CF 对象,比如 NSString:

image.png

可以看到 obj 是 __NSCFString 类型,__NSCFString 是 Core Foundation 框架中的 CFString 类型的桥接实现,类似的还有很多,比如:__NSCFData、__NSCFNumber、__NSCFArray、__NSCFDictionary 等。

hook 方法

因为 CF 对象的释放是通过 CFRelease 函数,而 CFRelease 函数无法被 hook,所以只能通过其它方式实现。

AutoreleasePool 会在 Pop 的时候对 Pool 中的对象调用 release 方法,__NSCFString 也实现了 release 方法:

image.png

所以可以对 __NSCFString 等桥接类 hook release 方法。

类型过滤

因为这里是针对典型的类进行 hook,所以不需要增加额外的类型过滤逻辑。

信息存储

因为是对于具体的典型类型,所以可以获取更多的信息。比如对于 __NSCFString,可以 dump 字符串的内容,类似 CoreDump 的原理,从内存中获取额外信息。

以 __NSCFString 为例,可以通过指针的地址偏移来获取字符串具体内容,根据字符串内容长短需要区分偏移量,可以通过获取 user_tag 来判断:

vm_address_t address = (uintptr_t)self;
vm_size_t size = 0;
natural_t nesting_depth = 0;
struct vm_region_submap_info_64 info;
mach_msg_type_number_t count = VM_REGION_SUBMAP_INFO_COUNT_64;

kern_return_t ret = vm_region_recurse_64(mach_task_self(), &address, &size, &nesting_depth, (vm_region_info_64_t)&info, &count);
if (ret != KERN_INVALID_ADDRESS) {

    if (info.user_tag == VM_MEMORY_MALLOC_NANO) {
        reason = [NSString stringWithFormat:@"%@, str: %s", reason, (const char *)((uintptr_t)self + 17)];
    } else if (info.user_tag == VM_MEMORY_MALLOC_TINY) {
        reason = [NSString stringWithFormat:@"%@, str: %s", reason, (const char *)((uintptr_t)self + 24)];
    } else {

    }
}

因为需要获取 __NSCFString 具体存储内容,所以 8-16 字节无法用来存储类和线程信息。但 OC 对象是以 16 字节对齐,所以可能存在末位 8 字节为 0x0 的情况,这时就可以利用未使用的空间存储类和线程信息。

获取末位 8 字节地址:

(uintptr_t)obj + malloc_size(obj) - sizeof(void *)

如果为 0x0,则将原类信息复制到该地址:

if ((uintptr_t)*(void **)address == POINTER_ZERO) {
    memcpy((void *)address, (void *)self, sizeof(void *));
}

因为存在 nonpointer isa 机制,所以在获取类信息时,需要进行判断处理:

uintptr_t ptr = (uintptr_t)*(void **)address;
// nonpointer : 1
if (ptr & 1) {
    originalClass = (Class)(ptr & ISA_MASK);
} else {
    originalClass = (Class)(ptr);
}

最终效果:

*** Terminating app due to uncaught exception 'HSZombieException', reason: '(-[__NSCFString retain]) was sent to a zombie object at address: 0x3016e71c0, thread_name: main-thread, str: hello zombie!'
*** First throw call stack:
(0x19562c7cc 0x1928ff2e4 0x100705ba8 0x1007052f8 0x1006584d0 0x19804cc60 0x19809b800 0x19809b3d8 0x197f2fb70 0x197f3009c 0x197f39f3c 0x197e32c60 0x197e309d8 0x197e30628 0x197e3159c 0x195600328 0x1956002bc 0x1955fddc0 0x1955fcfbc 0x1955fc830 0x1e15dc1c4 0x198162eb0 0x1982115b4 0x1006585c8 0x1bafeaec8)
libc++abi: terminating due to uncaught exception of type NSException

其它

因为线上 Zombie 是性能有损的,所以需要针对线上用户进行采样开启,同时可以将监控的范围进行拆分独立,比如部分用户监控 NS 对象,另外部分用户监控 CF 对象,甚至可以针对某个具体类型进行拆分,最大程度避免对一个用户产生较大的性能问题。

另外可以通过 Zombie 监控实现 Zombie 防护,考虑下发白名单,当触发 Zombie 问题时,不抛出异常使程序继续运行。

有些情况下当崩溃堆栈和释放堆栈都在 Autorelease 中时还是比较难定位的,需要采集 alloc 的堆栈,但这种无疑会导致更大的性能损耗。下期分享如何利用 CoreDump 获取更多有用信息。