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

现在各大公司的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中。

而这个对象所包含的如方法、协议等等,则是以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;
    }
};

无论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来查看下符号:

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

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

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

概括如下:

  • 整个流程会为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的堆栈。

从汇编中不难看出,[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)

所以,问题的本质就出现在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问题。