iOS使用代码排查野指针错误

Posted by 开不了口的猫 on December 25, 2017

前言

如果一个指针先前指向一个对象,但这个对象随后被释放了,如果该指针没有做任何的修改,导致仍然指向着那块内存地址,则该指针已成为了野指针

对于非ARC项目而言,野指针问题简直被视为crash中的常客。而对于如今几乎所有类都是ARC来自动管理内存的项目来说,野指针问题就没那么常见了,不过常见是不常见,一旦见着,够你喝一壶了,个个都可能是疑难杂症。野指针导致的崩溃往往隶属于前文讲到的mach exception或者signal错误。但是你很难在堆栈信息中找到有用的线索来追溯到野指针的源头。而且野指针导致的错误千奇百怪,这里引用腾讯Bugly的一张描述野指针的图: ctdpic

不过好在Xcode为我们内置了一些用于检测野指针错误的插件。 ctdpic Xcode提供了Malloc Scribble对已释放内存进行数据填充,从而保证野指针访问是必然崩溃的,这是一种增加野指针的必现概率从而提高野指针曝光率的机制。
同时也提供了Zombie Objects这一种完全不同的野指针调试机制,将释放的对象标记为Zombie对象,再次给Zombie对象发送消息时,发生crash并且输出相关的调用信息。这套机制同时定位了发生crash的类对象以及有相对清晰的调用栈。
我们可以很好的在这两种排查机制下找到野指针的一些蛛丝马迹。

然而,我们并不能总依赖于连接Xcode进行调试,如果野指针问题发生在真机,而为了能在现场复盘并排查野指针问题的根源,我们是否可以直接通过代码来实现这些机制呢?

腾讯Bugly借鉴Malloc Scribble机制原理,通过修改free函数,对已释放对象进行非法数据填充来提高野指针的崩溃率。 而我们则可以选择借鉴Zombie Objects机制用代码来实现一套野指针调剂机制。这种机制的实现更为简单,无须去hook底层的free函数。

另外,我实现的自动化野指针排查工具作为子工具组件已被集成到iOS真机桌面级调试工具中,可以直接clone或通过Cocoapods集成,欢迎Star🤓

实现思路

为了效仿XcodeZombie Objects这一机制,我们可以利用dealloc方法会自动实现父类的dealloc方法的特性,hook住NSObjectNSProxy两个oc的根类的dealloc方法,然后在调剂方法中将本来即将释放的对象的isa指针改为指向我们创建的一个新的僵尸类,然后外界对这个僵尸类发送任何消息(objc_msgSend)都会向程序发送我们手动抛出的应用级异常-NSException,然后在抛出的异常的reason中我们将非法调用对象的实际类型和调用方法输出,帮助使用者更好地定位野指针错误的根源。

具体实现

首先像下面这样直接hookNSObjectNSProxy两个根类的dealloc方法,并将原始的dealloc的IMP实现保存下来,便于后面释放这些可能造成泄漏的对象。

SEL deallocSelector = @selector(dealloc);
self.rootSwizzledClasses = @[[NSObject class], [NSProxy class]];

for (Class rootClass in self.rootSwizzledClasses) {

    if (!class_addMethod(rootClass, deallocSelector, newDeallocIMP, "v@:")) {
        void (*originalDeallocIMP)(__unsafe_unretained id, SEL) = NULL;
    
        Method deallocMethod = class_getInstanceMethod(rootClass, deallocSelector);
        originalDeallocIMP = (__typeof__(originalDeallocIMP))method_getImplementation(deallocMethod);
        originalDeallocIMP = (__typeof__(originalDeallocIMP))method_setImplementation(deallocMethod, newDeallocIMP);
        
        [deallocImpMaps setObject:[NSValue valueWithBytes:&originalDeallocIMP objCType:@encode(typeof(originalDeallocIMP))] forKey:NSStringFromClass(rootClass)];
    }
}

然后Run一下发现,Xcode直接编译报错了,提示我们ARC环境下不能修改dealloc方法。然后参考了大名鼎鼎的ReactiveObjC中的NSObject+RACDeallocating.m对于dealloc的调剂细节,果断改成sel_registerName解决了这个问题。

SEL deallocSelector = sel_registerName("dealloc");

然后后期调试发现,直接使用NSSelectorFromString就完事儿了😅。

然后我们重新实现dealloc的方法实现,这也是整个方案最核心的部分。

self.newDeallocBlock = ^void(__unsafe_unretained id target) {
            
    __weak typeof(weak_self) strong_self = weak_self;
    
    @synchronized(strong_self) {
        Class currentClass = [target class];
        
        object_setClass(target, [TDFSDWPCZombieProxy class]);
        ((TDFSDWPCZombieProxy *)target).originClass = currentClass;
    }
};

我们将本要释放的对象的isa指针重新指向了我们新创建的僵尸类TDFSDWPCZombieProxy。然后再通过这个僵尸类的originClass属性去保存下原始对象的class

紧接着我们看一下TDFSDWPCZombieProxy这个类的实现。

#import "TDFSDWPCZombieProxy.h"

@implementation TDFSDWPCZombieProxy

#define __ZombieBlew  [self zombieProxyBlew:_cmd]
#define __ZombieBlewWithSelector(selector)  [self zombieProxyBlew:selector]

#pragma mark - override
- (void)forwardInvocation:(NSInvocation *)invocation { __ZombieBlewWithSelector(invocation.selector); }
- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel { __ZombieBlewWithSelector(sel); return nil; }

- (BOOL)isEqual:(id)object { __ZombieBlew; return NO; }
- (Class)class { __ZombieBlew; return nil; }
- (instancetype)self { __ZombieBlew; return nil; }
- (id)performSelector:(SEL)aSelector { __ZombieBlew; return nil; }
- (id)performSelector:(SEL)aSelector withObject:(id)object { __ZombieBlew; return nil; }
- (id)performSelector:(SEL)aSelector withObject:(id)object1 withObject:(id)object2 { __ZombieBlew; return nil; }
- (BOOL)isProxy { __ZombieBlew; return NO; }
- (BOOL)isKindOfClass:(Class)aClass { __ZombieBlew; return NO; }
- (BOOL)isMemberOfClass:(Class)aClass { __ZombieBlew; return NO; }
- (BOOL)conformsToProtocol:(Protocol *)aProtocol { __ZombieBlew; return NO; }
- (BOOL)respondsToSelector:(SEL)aSelector { __ZombieBlew; return NO; }
- (instancetype)retain { __ZombieBlew; return nil; }
- (oneway void)release { __ZombieBlew; }
- (instancetype)autorelease { __ZombieBlew; return nil; }
- (NSUInteger)retainCount { __ZombieBlew; return NSNotFound; }
- (struct _NSZone *)zone { __ZombieBlew; return NULL; };
- (void)dealloc { __ZombieBlew; [super dealloc]; }
- (NSString *)description { __ZombieBlew; return nil; }
- (NSString *)debugDescription { __ZombieBlew; return nil; }
- (NSUInteger)hash { __ZombieBlew; return NSNotFound; }
- (Class)superclass { __ZombieBlew; return nil; }

#pragma mark - private
- (void)zombieProxyBlew:(SEL)selector {
    @throw [NSException exceptionWithName:NSInternalInconsistencyException reason: \
            [NSString stringWithFormat:@"[TDFScreenDebugger.PerformanceMonitor.WildPointerChecker] find a wild pointer error about \' message \" [%@ %@] \" was sent to a zombie object at address: %p \'", NSStringFromClass(self.originClass), NSStringFromSelector(selector), self] userInfo:nil];
}

@end

因为考虑到这个僵尸类需要在接收到任何消息的时候抛出我们预设的NSException异常,所以我们需要将这个类的Automatic Reference Counting设置成NO,即MRC模式。 然后覆写所有的方法包括消息转发的关键方法中抛出我们预设的异常,将野指针指向对象的类名内存地址以及调用方法输出给开发者以供进一步调试。

最后我们需要一个对象缓存池机制,在内存过高收到系统通知或达到预设的阈值时将自动将那些僵尸类对象isa指针还原成原来的类并调用根类原本的dealloc方法实现来正确地释放资源。

- (void)clearCachePool {
    // 1.取出所有缓存的target...
    self.cacheTargets = @[...];

    // 2.遍历target,还原isa指向并释放资源
    for (id target in self.cacheTargets) {
        [self invokeOriginDealloc:target];
    }
}

- (void)invokeOriginDealloc:(__unsafe_unretained id)target {
    Class currentCls = [target class];
    Class rootCls = currentCls;
    SEL deallocSelector = sel_registerName("dealloc");
    
    rootCls = ([rootCls isSubclassOfClass:[NSProxy class]] ? [NSProxy class] : [NSObject class]);
    
    NSString *clsName = NSStringFromClass(rootCls);
    void (*originalDeallocIMP)(__unsafe_unretained id, SEL) = NULL;
    [[self.rootClassesOriginImps objectForKey:clsName] getValue:&originalDeallocIMP];
    
    if (originalDeallocIMP != NULL) {
        originalDeallocIMP(target, deallocSelector);
        target = nil;
    }
}

因为博主已在前文实现了自动化崩溃捕获组件工具,而这边的处理恰巧是通过NSException引发异常使程序崩溃,不出意料地会被崩溃捕获工具捕获。所以如果你使用的是博主的这一整套组件的话,那么在真机调试遇到野指针错误时调试效果会更佳哟~

一些疑问

在后来的调试过程中,发现一个很神奇的现象,当我们建立了缓存池,并小心翼翼地通过NSValue将每一个target的弱引用包装起来,在内存峰值达到阈值时统一清理,但Xcode的内存显示数值并没有下降,反而上涨的更严重了,然后多次试验均以失败告终,目前感觉代码并没有什么任何问题,但内存始终不得成功释放,这也成了我的唯一的疑惑点,希望能有人为我解惑,万分感谢~

参考