8

KVO在不同的二进制中多个符号并存的Crash问题

 3 years ago
source link: http://satanwoo.github.io/2017/09/11/KVO-CRASH/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

现在各大公司的App容纳的功能越来越多,导致应用包大小越来越大。而苹果对于text段的大小现在在60MB,为了避免无法上架的问题,所以很多App都开始用了动态库来避免这个问题。

这两天在帮支付宝开发一个功能的时候,由于支付宝许多模块的代码保密设计,因此只能采用动态库注入的方式进行调试。

一开始都没啥问题,但是当我在调试一个API接口的时候,却出现了一个必现的和MBProgressHUD有关的Crash问题。今天就让我用这个Crash开始,来探讨下KVO在不同的二进制中多个符号并存的Crash问题

不同产物中同名符号的处理问题

我们都知道,在同一个编译->Link的最终产物中,符号(类、MetaClass、甚至是全局的函数符号)定义是不能重复的(当然,我们需要排除weak symbol)。否则在ld期间,就会报duplicate symbol这样的错误。

但是在不同的最终产物里,比如一个主二进制和其相关的动态库,由于这两种MachO类型为产物完全脱离,因此在这两个产物中分别定义相同的符号是完全行得通的。

有人会问了,那我们在主二进制中定义一个类,在动态库中又定义了一个同名的类,当我在主二进制中加载了动态库后,两个同名的类会冲突吗?

答案是不会的,其原因在于苹果使用的是two level namespace的技术。在这种形式下,符号所在的“库”的名称也会作为符号的一部分。链接的时候,staic linker会标记住在这个符号是来自于哪个库的。这样不仅大大减少了dyld搜索符号所需要的时间,也更好对后续库的更新进行了兼容。

熟悉runtime的人都知道,iOS中的类和其metaClass都是objc_class对象,这些“类”所代表的结构体,在编译期间都存在于Mach-O文件中了,位于objc_data这个section中。

kvo_crash_1.png?raw=true

而这个对象所包含的如方法、协议等等,则是以class_ro_t的形式存在于objc_const节中。

struct class_ro_t {
    uint32_t flags;
    uint32_t instanceStart;
    uint32_t instanceSize;
#ifdef __LP64__
    uint32_t reserved;
#endif

    const uint8_t * ivarLayout;

    const char * name;
    method_list_t * baseMethodList;
    protocol_list_t * baseProtocols;
    const ivar_list_t * ivars;

    const uint8_t * weakIvarLayout;
    property_list_t *baseProperties;

    method_list_t *baseMethods() const {
        return baseMethodList;
    }
};
kvo_crash_5.png?raw=true

无论Mach-O的产物如何,这都是静态的数据。当我们在程序使用的过程中想调用这些类,都需要将这些类从二进制中读取并进行realize变成一个正确的类。而整个realize的过程,是在主二进制程序和其依赖的动态库加载完成后进行调用的,realize的过程如下:

static Class realizeClass(Class cls)
{
    runtimeLock.assertWriting();

    const class_ro_t *ro;
    class_rw_t *rw;
    Class supercls;
    Class metacls;
    bool isMeta;

    if (!cls) return nil;

    // 1. 如果realize过了,就直接返回了
    if (cls->isRealized()) return cls;
    assert(cls == remapClass(cls));

    // fixme verify class is not in an un-dlopened part of the shared cache?

     // 2. 读取刚刚提到的read only data,将其变成rw的data。
    ro = (const class_ro_t *)cls->data();
    if (ro->flags & RO_FUTURE) {
        // This was a future class. rw data is already allocated.
        rw = cls->data();
        ro = cls->data()->ro;
        cls->changeInfo(RW_REALIZED|RW_REALIZING, RW_FUTURE);
    } else {
        // Normal class. Allocate writeable class data.
        rw = (class_rw_t *)calloc(sizeof(class_rw_t), 1);
        rw->ro = ro;
        rw->flags = RW_REALIZED|RW_REALIZING;
        cls->setData(rw);
    }

    isMeta = ro->flags & RO_META;

    rw->version = isMeta ? 7 : 0;  // old runtime went up to 6


    // Choose an index for this class.
    // Sets cls->instancesRequireRawIsa if indexes no more indexes are available
    cls->chooseClassArrayIndex();

    if (PrintConnecting) {
        _objc_inform("CLASS: realizing class '%s'%s %p %p #%u", 
                     cls->nameForLogging(), isMeta ? " (meta)" : "", 
                     (void*)cls, ro, cls->classArrayIndex());
    }

    // Realize superclass and metaclass, if they aren't already.
    // This needs to be done after RW_REALIZED is set above, for root classes.
    // This needs to be done after class index is chosen, for root metaclasses.

    // 注意点3:对父类和metaClass先进行realize
    supercls = realizeClass(remapClass(cls->superclass));
    metacls = realizeClass(remapClass(cls->ISA()));

#if SUPPORT_NONPOINTER_ISA
    // Disable non-pointer isa for some classes and/or platforms.
    // Set instancesRequireRawIsa.
    bool instancesRequireRawIsa = cls->instancesRequireRawIsa();
    bool rawIsaIsInherited = false;
    static bool hackedDispatch = false;

    if (DisableNonpointerIsa) {
        // Non-pointer isa disabled by environment or app SDK version
        instancesRequireRawIsa = true;
    }
    else if (!hackedDispatch  &&  !(ro->flags & RO_META)  &&  
             0 == strcmp(ro->name, "OS_object")) 
    {
        // hack for libdispatch et al - isa also acts as vtable pointer
        hackedDispatch = true;
        instancesRequireRawIsa = true;
    }
    else if (supercls  &&  supercls->superclass  &&  
             supercls->instancesRequireRawIsa()) 
    {
        // This is also propagated by addSubclass() 
        // but nonpointer isa setup needs it earlier.
        // Special case: instancesRequireRawIsa does not propagate 
        // from root class to root metaclass
        instancesRequireRawIsa = true;
        rawIsaIsInherited = true;
    }

    if (instancesRequireRawIsa) {
        cls->setInstancesRequireRawIsa(rawIsaIsInherited);
    }
// SUPPORT_NONPOINTER_ISA
#endif

    // Update superclass and metaclass in case of remapping
    // 更新当前类的父类和meta类
    cls->superclass = supercls;
    cls->initClassIsa(metacls);

    // Reconcile instance variable offsets / layout.
    // This may reallocate class_ro_t, updating our ro variable.
    // 如果有的话,对ivar进行重新的布局
    if (supercls  &&  !isMeta) reconcileInstanceVariables(cls, supercls, ro);

    // Set fastInstanceSize if it wasn't set already.
    cls->setInstanceSize(ro->instanceSize);

    // Copy some flags from ro to rw
    if (ro->flags & RO_HAS_CXX_STRUCTORS) {
        cls->setHasCxxDtor();
        if (! (ro->flags & RO_HAS_CXX_DTOR_ONLY)) {
            cls->setHasCxxCtor();
        }
    }

    // Connect this class to its superclass's subclass lists
    // 简单理解就是构建层次结构的拓扑关系
    if (supercls) {
        addSubclass(supercls, cls);
    } else {
        addRootClass(cls);
    }

    // Attach categories
    // 把category里面的东西也合并进来进来
    methodizeClass(cls);

    return cls;
}

从上述代码不难看出,整个过程非常简单,分为几个步骤:

  • 把从二进制里面读取的readonly data变成rw data,这也是我们在iOS编程中很多运行时黑魔法的基础。
  • 把父类和metaclass都realize一下,然后建立合理的层次依赖关系。
  • 根据父类的布局,把自己的ivar布局动态更新,这也是大名鼎鼎的non-fragile layout
  • category里面的东西都加载进来。
  • 整个过程结束。

KVO的机制

说了这么多铺垫的知识,我们来开始分析下我们程序在加载动态库后会KVO Crash的原因。处于公司数据保密的原因,我构造了一个最简单的场景,这个主二进制和动态库都包含了MBProgressHUD对应的代码,

我们可以通过nm来查看下符号:

kvo_crash_6.png?raw=true

MBProgressHUD里面,有如下一段代码:

- (void)registerForKVO {
    for (NSString *keyPath in [self observableKeypaths]) {
        [self addObserver:self forKeyPath:keyPath options:NSKeyValueObservingOptionNew context:NULL];
    }
}

它会分别对所有的对应属性进行KVO监听,由于KVO本身的机制是通过创建一个“xxxNotify_KVO类”,所以,整体的调用顺序如下图所示:

kvo_crash_2.png?raw=true

概括如下:

  • 整个流程会为MBProgressHUD这个类以NSKVONotifying_MBProgressHUD的名称,动态添加一个类。
  • 对这个类构建和原先类的父子关系,注册到全局的类表中。
  • 对KVO中使用到的监听的属性进行setter方法的覆写。

这几个流程的代码分别如下:

  1. 创建类代码非常简单,逻辑上就是这父类-子类的关系构建一个新的类出来:

    Class objc_allocateClassPair(Class superclass, const char *name, 
                                 size_t extraBytes)
    {
        Class cls, meta;
    
        rwlock_writer_t lock(runtimeLock);
    
        // Fail if the class name is in use.
        // Fail if the superclass isn't kosher.
        if (getClass(name)  ||  !verifySuperclass(superclass, true/*rootOK*/)) {
            return nil;
        }
    
        // Allocate new classes.
        cls  = alloc_class_for_subclass(superclass, extraBytes);
        meta = alloc_class_for_subclass(superclass, extraBytes);
    
        // fixme mangle the name if it looks swift-y?
        objc_initializeClassPair_internal(superclass, name, cls, meta);
    
        return cls;
    }
    
  2. 当创建完成后,就会对这个类进行registerClassPair的工作,这一步的目的很简单,就是将类注册到一个全局的map中gdb_objc_realized_classes

  3. 重写setter, class, description之类的

Crash原因

知道了原理,我们来分析Crash的原因就非常简单了,我们先看Crash的堆栈。

kvo_crash_3.png?raw=true

从汇编中不难看出,[x19, #0x20]对应的地址是个非法访问地址,导致了Crash。而x19寄存器又是从x0中赋值而来,根据函数objc_registerClassPair的参数,x0Class,那很明显,就是从Class对象的0x20,即32 bytes偏移地方的数据。根据定义,

struct objc_class : objc_object {
    // Class ISA; // 8byte
    Class superclass; // 8byte
    cache_t cache;             // formerly cache pointer and vtable // 4 + 4 + 8
    class_data_bits_t bits;    // class_rw_t * plus custom rr/alloc flags

我们要获取的数据就是bits。通过输出寄存器,我们发现x0为0,也就是nil。而x0又是从哪来的呢?

倒推堆栈,我们发现,在函数_NSKVONotifyingCreateInfoWithOriginalClass,我们首先调用了objc_allocateClassPair,将其返回值传入objc_registerClassPair(ARM64 Calling Convention)

kvo_crash_4.png?raw=true

所以,问题的本质就出现在allocateClassPair返回了nil,而allocateClassPair只有在如下场景下才会返回nil。

if (getClass(name)  ||  !verifySuperclass(superclass, true/*rootOK*/)) {
    return nil;
}

通过LLDB调试,在根据name查询NSKVONotifying_MBProgressHUD时,由于全局的类表已经存在了对应的类,所以在getClass就会返回之前注册的类,从而使得allocate直接返回了nil。

NXMapTable *gdb_objc_realized_classes;  // exported for debuggers in objc-gdb.h

static Class getClass_impl(const char *name)
{
    runtimeLock.assertLocked();

    // allocated in _read_images
    assert(gdb_objc_realized_classes);

    // Try runtime-allocated table
    Class result = (Class)NXMapGet(gdb_objc_realized_classes, name);
    if (result) return result;

    // Try table from dyld shared cache
    return getPreoptimizedClass(name);
}

static Class getClass(const char *name)
{
    runtimeLock.assertLocked();

    // Try name as-is
    Class result = getClass_impl(name);
    if (result) return result;

    // Try Swift-mangled equivalent of the given name.
    if (char *swName = copySwiftV1MangledName(name)) {
        result = getClass_impl(swName);
        free(swName);
        return result;
    }

    return nil;
}

当两个产物都有相同的类名时,这两个类都会被realize,都能够被正常调用。但是由于全局类表的存在,在动态创建KVO的子类时,只能产生一个。所以就导致allocate失败,从而引发register过程的Crash问题。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK