野指针定位

原文连接html

当所指向的对象被释放或者收回,可是对该指针没有做任何的修改,以致于该指针仍旧指向已经回收的内存地址,此状况下该指针便称野指针git

野指针异常堪称crash界的半壁江山,相比起NSException而言,野指针有这么两个特色:github

  • 随机性强web

    尽管大公司已经有各类单元、行为、自动化以及人工化测试,尽可能的去模拟用户的使用场景,但野指针异常老是能巧妙的避开测试,在线上大发神威。缘由毫不仅仅在于测试没法覆盖全部的使用场景算法

    形成野指针是多样化的:首先内存被释放后不表明内存会马上被覆写或者数据受到破坏,这时候访问这块内存也不必定会出错。其次,多线程技术带来了复杂的应用运行环境,在这个环境下,未加保护的数据多是致命的。此外,设计不够严谨的代码一样也是形成野指针异常的重要缘由之一安全

  • 难以定位多线程

    NSException是高抽象层级上的封装,这意味着它能够提供更多的错误信息给咱们参考。而野指针几乎出自于C语言层面,每每咱们能得到的只有系统栈信息,单单是定位错误代码位置已经很难了,更不要说去重现修复架构

定位

解决野指针最大的难点在于定位。一般线上出现了crash须要修复时,开发者最重要的一个步骤是重现crash。而上文提到了野指针的两个特性会阻碍咱们定位问题,对于这两个特性,确实也能作一些对应的处理来下降它们的干扰性:ide

  • 采集辅助信息函数

    辅助信息包括设备信息、用户行为等信息,每每能够用来重现问题。好比用户行为能够造成用户使用路径,从而重现用户使用场景。而在发生crash时,采集当前页面信息,配合用户使用路径能够快速的定位到问题发生的大概位置。通过验证,辅助信息确实有效的减小了系统栈对于问题重现的干扰

  • 提升野指针崩溃率

    因为野指针不必定会发生崩溃这一特性,即使咱们经过堆栈信息辅助信息肯定了大体范围,不表明咱们能顺利的重现crash。一个优秀的野指针崩溃能够形成一天开发,三天debug,假如野指针的崩溃不是随机的,那么问题就简单的多

    Xcode提供了Malloc Scribble对已释放内存进行数据填充,从而保证野指针访问是必然崩溃的。另外,Bugly借鉴这一原理,经过修改free函数,对已释放对象进行非法数据填充,也有效的提升了野指针的崩溃率

  • Zombie Objects

    Zombie Objects是一种彻底不一样的野指针调试机制,将释放的对象标记为Zombie对象,再次给Zombie对象发送消息时,发生crash而且输出相关的调用信息。这套机制同时定位了发生crash的类对象以及有相对清晰的调用栈

解决方案

整理一下上述的内容,能够看到目前存在辅助信息+对象内存填充以及Zombie Objects这两种主要的应对方式。拿前者来讲,填充已释放对象的内存风险高,通过尝试Xcode9Malloc Scribble启动后已经不会填充对象的内存地址。其次,填充内存须要去hook更加底层的API,这意味着对代码能力要求更高。所以,借鉴Zombie Objects的实现思路去定位野指针异常是一个可行的方案

转发

转发是一项有趣的机制,它经过在通讯双方中间,插入一个中间层。发送方再也不耦合接收方,它只须要将数据发送给中间层,由中间层来派发给具体的接收方。基于转发的思想,能够作许多有趣的东西:

  • 消息转发

    iOS的消息机制让咱们能够给对象发送一个未注册的消息,一般这会引起unrecognized selector异常。可是在抛出异常以前,存在一个消息转发机制,容许咱们从新指定消息的接收方来处理这个消息。正是这一机制实现了防unrecognized selector crash的可行化

  • 打破引用环

    循环引用是ARC环境下最容易出现的内存问题,当多个对象之间的引用造成了引用环时,极有可能会致使环中的对象都没法被释放。借鉴Proxy的方式,能够实现破坏引用环的做用。XXShield以插入WeakProxy层的方式实现了防crash

  • 路由转发

    组件化是项目体量达到必定程度时必须考虑的架构方案,将项目拆分基础组件和业务组件,加入中间层实现组件间解耦的效果。因为业务组件之间互不依赖,所以须要合适的方案实现组件通讯,路由设计是一种经常使用的通讯方式。各个模块实现canOpenURL:接口来判断是否处理对应的跳转逻辑,模块将参数信息拼接在url中传递:

消息发送

都说消息发送Objective-C的核心机制,任何一个对象方法调用都会被转换成objc_msgSend的方式执行。这一过程当中涉及到一个重要的变量:isa指针。多数开发者对isa指针停留在它指向了类的类结构自己的地址,用来表示对象的类型。可是实际上isa指针要比咱们想一想的复杂的多,好比objc_msgSend依赖于isa来完成消息的查找,经过阅读经过汇编解读 objc_msgSend能够了解更详细的匹配过程:

union isa_t {
    isa_t() { }
    isa_t(uintptr_t value) : bits(value) { }
    Class cls;
    uintptr_t bits;
    struct {
        uintptr_t indexed           : 1;
        uintptr_t has_assoc         : 1;
        uintptr_t has_cxx_dtor      : 1;
        uintptr_t shiftcls          : 33; 
        uintptr_t magic             : 6;
        uintptr_t weakly_referenced : 1;
        uintptr_t deallocating      : 1;
        uintptr_t has_sidetable_rc  : 1;
        uintptr_t extra_rc          : 19;
    };
};
复制代码

因为方法调用与isa指针相关,所以若是咱们修改一个类的isa指针使其指向一个目标类,那么能够实现对象方法调用的拦截,也能够称做对象方法转发。咱们并不能直接修改isa指针,但runtime提供了一个object_setclass接口容许咱们动态的对某个类进行重定位

ClassA被重定位成ClassB须要保证两个类的内存结构是对齐的,不然可能会发生超出意外的问题

通常来讲咱们都不该该违背重定位类的内存结构对齐原则。但在野指针问题中,对象拥有的内存被释放后是不肯定状态,所以作适当的破坏并不必定是坏事,只是记住在最终释放对象内存时,应当再次重定位回来,防止内存泄漏的风险

代码实现

借鉴于Zombie Objects的机制,咱们能够实现一套类Zombie Proxy机制。经过重定位类型的作法,在对象dealloc以前将其isa指针指向一个目标类,实现后续调用的转发。而目标类中全部的方法调用都采用NSException的机制抛出异常,而且输出调用对象的实际类型和调用方法帮助定位:

重定位后的类因为其实际用于转发的用途,更符合Proxy的属性,所以我将其设置为NSProxy的子类,多数人可能不知道iOS一共有NSProxyNSObject两个根类。另外,为了实现对retain等内存管理相关方法的重写,目标类应该设置为不支持ARC

@interface LXDZombieProxy : NSProxy

@property (nonatomic, assign) Class originClass;

@end

@implementation LXDZombieProxy

- (void)_throwMessageSentExceptionWithSelector: (SEL)selector
{
    @throw [NSException exceptionWithName:NSInternalInconsistencyException 
                                   reason:[NSString stringWithFormat:@"(-[%@ %@]) was sent to a zombie object at address: %p", NSStringFromClass(self.originClass), NSStringFromSelector(selector), self] 
                                 userInfo:nil];
}

#define LXDZombieThrowMesssageSentException() [self _throwMessageSentExceptionWithSelector: _cmd]

- (id)retain
{
    LXDZombieThrowMesssageSentException();
    return nil;
}

- (oneway void)release
{
    LXDZombieThrowMesssageSentException();
}

- (id)autorelease
{
    LXDZombieThrowMesssageSentException();
    return nil;
}

- (void)dealloc
{
    LXDZombieThrowMesssageSentException();
    [super dealloc];
}

- (NSUInteger)retainCount
{
    LXDZombieThrowMesssageSentException();
    return 0;
}

@end
复制代码

因为iOS的方法其实是以向上调用的链式机制实现的,所以只须要hook掉两个根类的dealloc方法就能保证对对象类型的重定位。在hookdealloc以后有几个须要注意的点:

  • 对象的释放

    因为咱们须要实现转发机制,这表明着本该释放的对象在类型重定位后不能被释放。随着时候时间的推移,重定位类对象的数量会愈来愈多。根据经验来讲,通常的野指针在30s内被再次访问的几率很大,所以咱们能够在类型重定位完成后延后30s释放对象。或者能够构建一个Zombie Pool,当内存占用达到必定大小时,使用恰当的算法淘汰

  • 白名单机制

    并非全部的类对象都被监控,好比系统私有类监控相关工具类明确不存在野指针的类等。咱们须要一个全局的白名单系统,来确保这些类的dealloc是正常执行的,无需被转发

  • 潜在的crash

    经过method_setImplementation替换dealloc的代码实现,因为我采用blockIMP的方式来实现的方式,会对捕获的外界对象进行引用。而对象在重定位后,任何调用都会引起crash,所以须要针对这种状况作对应的处理

为了知足保证对象可以在达成释放条件完成内存的回收,须要存储根类的dealloc原实现,以根类类名做为key存储在全局字典中。而且提供接口__lxd_dealloc来完成对象的释放工做:

static inline void __lxd_dealloc(__unsafe_unretained id obj) {
    Class currentCls = [obj class];
    Class rootCls = currentCls;
    
    while (rootCls != [NSObject class] && rootCls != [NSProxy class]) {
        rootCls = class_getSuperclass(rootCls);
    }
    NSString *clsName = NSStringFromClass(rootCls);
    LXDDeallocPointer deallocImp = NULL;
    [[_rootClassDeallocImps objectForKey: clsName] getValue: &deallocImp];
    
    if (deallocImp != NULL) {
        deallocImp(obj);
    }
}

NSMutableDictionary *deallocImps = [NSMutableDictionary dictionary];
for (Class rootClass in _rootClasses) {
    IMP originalDeallocImp = __lxd_swizzleMethodWithBlock(class_getInstanceMethod(rootClass, @selector(dealloc)), swizzledDeallocBlock);
    [deallocImps setObject: [NSValue valueWithBytes: &originalDeallocImp objCType: @encode(typeof(IMP))] forKey: NSStringFromClass(rootClass)];
}
复制代码

在对象的dealloc被调起以后,检测对象类型是否存在白名单中。若是存在,直接继续完成对对象的释放工做。不然的话,延后30s进行释放工做。为了解除block引用形成的crash,使用NSValue存储对象信息以及使用__unsafe_unretained来防止临时变量的引用:

swizzledDeallocBlock = [^void(id obj) {
    Class currentClass = [obj class];
    NSString *clsName = NSStringFromClass(currentClass);
    /// 若是为白名单,则不重定位类的类型
    if ([__lxd_sniff_white_list() containsObject: clsName]) {
        __lxd_dealloc(obj);
    } else {
        NSValue *objVal = [NSValue valueWithBytes: &obj objCType: @encode(typeof(obj))];
        object_setClass(obj, [LXDZombieProxy class]);
        ((LXDZombieProxy *)obj).originClass = currentClass;
        
        /// 延后30秒释放对象,避免形成内存的浪费
        dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(30 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
            __unsafe_unretained id deallocObj = nil;
            [objVal getValue: &deallocObj];
            object_setClass(deallocObj, currentClass);
            __lxd_dealloc(deallocObj);
        });
    }
} copy];
复制代码

具体的实现代码能够下载LXDZombieSniffer

疑难问题

野指针问题是访问了非法内存致使的crash,也就是说要符合两个条件:内存非法以及指针地址不为NULL。在iOS中存在三种不一样修饰的指针:

  • __strong

    默认修饰符。修饰的指针在赋值以后,会对指向的对象执行一次retain操做,指针不因对象的生命周期变化而改变

  • __unsafed_unretained

    非安全对象指针修饰符。修饰的指针不会持有指向对象,也不因对象的生命周期发生变化而改变,等同于assign

  • __weak

    弱对象指针修饰符。修饰的指针不会持有指向对象,在对象的生命周期结束而且内存被回收时,修饰的指针内容会被重置为nil

根据野指针异常的引起条件来讲,三种修饰指针只有__strong__unsafed_unretained能够致使野指针访问异常。可是在使用类别重定位以后,本该释放的对象会被延时或者不释放,也就是本该被重置的弱指针也不会发生重置,这时使用弱指针访问对象应该会被转发到ZombieProxy当中发生crash

__weak id weakObj = nil;
@autoreleasepool {
    NSObject *obj = [NSObject new];
    weakObj = obj;
}
/// The operate should be crashed
NSLog(@"%@", weakObj);
复制代码

然而在上面的测试中,发现即使对象被重定位为Zombie而且被阻止释放以后,weakObj依旧被成功的设置成了nil。而后通过objc_runtime源码运行和添加断点测试以后,也没有weak指针被重置的调用。甚至使用了LLVMwatch set var weakObj监控弱指针,依旧没法找到调用。但weakObjdealloc调用以后,无论对象有没有被释放,都被重置成了nil。这也是截止文章出来为止,匪夷所思的疑难杂症

参考

如何定位Obj-C野指针随机Crash(一)

如何定位Obj-C野指针随机Crash(二)

如何定位Obj-C野指针随机Crash(三)

关注个人公众号获取更新信息
相关文章
相关标签/搜索