现在各大公司的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
方法的覆写。
这几个流程的代码分别如下:
创建类代码非常简单,逻辑上就是这父类-子类的关系构建一个新的类出来:
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; }
当创建完成后,就会对这个类进行
registerClassPair
的工作,这一步的目的很简单,就是将类注册到一个全局的map中gdb_objc_realized_classes
。重写
setter, class, description
之类的
Crash原因
知道了原理,我们来分析Crash的原因就非常简单了,我们先看Crash的堆栈。
从汇编中不难看出,[x19, #0x20]
对应的地址是个非法访问地址,导致了Crash。而x19
寄存器又是从x0
中赋值而来,根据函数objc_registerClassPair
的参数,x0
为Class
,那很明显,就是从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问题。