是否能够在运行时修改 Ivar Layout?
虽然我们已经破译了 oc runtime 如何存储变量的内存修饰符的秘密,但是我们是否能够在运行时通过修改 Ivar Layout 的方式来改变变量的内存管理方式呢?例如 assgin
变为 weak
?仔细推敲Objective-C Class Ivar Layout 探索的细节后,我们不难得出一个简单直接的办法——调用 class_setIvarLayout 和 class_setWeakIvarLayout 重新设置 Ivar Layout 不就达成目标了么?看起来简单可行,我们新建了一个测试类 MCAssignToWeak 来模拟 UIScrollView 的场景:
@interface MCAssignToWeak : NSObject
@property (nonatomic, strong) id s1;
@property (nonatomic, assign) id delegate;
@property (nonatomic, weak) id w1;
- (void)notifyDelegate;
@end
// MCAssignToWeak.m
@implementation MCAssignToWeak
- (void)notifyDelegate {
NSLog(@"===== notify %@", [self.delegate class]);
}
- (void)setDelegate:(id)delegate {
_delegate = delegate;
NSLog(@"===== setDelegate:");
}
@end
并将里面的 delegate 属性从assign
设置为weak
,直接 hardcode 在纸上算好的 ivarLayout 和 weakIvarLayout 的新值赋给 MCAssignToWeak,调用后立马被 runtime 无情地打了脸。
*** Can't set ivar layout for already-registered class 'MCAssignToWeak'
无奈之下, 我们只好回过头来翻出 class_setIvarLayout 的源码看一下:
/***********************************************************************
* class_setIvarLayout
* Changes the class's ivar layout.
* nil layout means no unscanned ivars
* The class must be under construction.
...
**********************************************************************/
void
class_setIvarLayout(Class cls, const uint8_t *layout)
{
...
// Can only change layout of in-construction classes.
// note: if modifications to post-construction classes were
// allowed, there would be a race below (us vs. concurrent object_setIvar)
if (!(cls->data()->flags & RW_CONSTRUCTING)) {
_objc_inform("*** Can't set ivar layout for already-registered "
"class '%s'", cls->nameForLogging());
return;
}
...
}
注释里明确说了 The class must be under construnction, 而我们看到的那行 log 则来自于第 15 行的 if 判断失败。我们只好继续在源代码里搜索使用RW_CONSTRUCTING
的地方,接着就找到了下面代码:
static void objc_initializeClassPair_internal(Class superclass, const char *name, Class cls, Class meta)
{
...
cls->data()->flags = RW_CONSTRUCTING | RW_COPIED_RO | RW_REALIZED | RW_REALIZING;
meta->data()->flags = RW_CONSTRUCTING | RW_COPIED_RO | RW_REALIZED | RW_REALIZING;
...
}
原来只有调用了 objc_initializeClassPair 的类才会有这个RW_CONSTRUCTING
的标志位,而这意味着只有在运行时由开发者动态添加的类在 objc_registerClassPair 调用之前才能修改 Ivar Layout,一旦调用了 objc_registerClassPair 就意味着这个类已经对修改关闭,不再接受任何对 Ivar 的修改了,而那些编译时就已确定的类根本就没有任何机会修改 Ivar Layout。回想Objective-C Class Ivar Layout 探索里,大神需要解决的问题确实是如何为一个动态添加的类添加 weak
属性的 Ivar,和我们所处的场景不一样。难道我们探索了这么久最终还是走进了一条根本行不通的死胡同?
幸亏我们有 runtime 的源代码,让我们知道这个标志位的定义以及作用。我们尝试在调用 class_setIvarLayout 之前,将这个类的 flags 加上RW_CONSTRUCTING
标志,调用完成后再重置。因为设置 flags 需要使用到 runtime 源码内关于 object_class、class_data_bits_t 以及 class_rw_t 的结构体定义,于是我们偷懒地在大神的代码基础上进行再加工,那些我们暂时还不需要知道细节的指针一律使用了void *
:
static void _fixupAssginDelegate(Class class) {
struct {
Class isa;
Class superclass;
struct {
void *_buckets;
#if __LP64__
uint32_t _mask;
uint32_t _occupied;
#else
uint16_t _mask;
uint16_t _occupied;
#endif
} cache;
uintptr_t bits;
} *objcClass = (__bridge typeof(objcClass))class;
#if !__LP64__
#define FAST_DATA_MASK 0xfffffffcUL
#else
#define FAST_DATA_MASK 0x00007ffffffffff8UL
#endif
struct {
uint32_t flags;
uint32_t version;
struct {
uint32_t flags;
uint32_t instanceStart;
uint32_t instanceSize;
#ifdef __LP64__
uint32_t reserved;
#endif
const uint8_t *ivarLayout;
const char *name;
void *baseMethodList;
void *baseProtocols;
void *ivars;
const uint8_t *weakIvarLayout;
} *ro;
} *objcRWClass = (typeof(objcRWClass))(objcClass->bits & FAST_DATA_MASK);
#define RW_CONSTRUCTING (1<<26)
objcRWClass->flags |= RW_CONSTRUCTING;
// delegate从assign变为weak,需要将weakIvarLayout从\x21修改为\x12
uint8_t *weakIvarLayout = (uint8_t *)calloc(3, 1);
*weakIvarLayout = 0x21; *(weakIvarLayout+1) = 0x12;
class_setWeakIvarLayout(class, weakIvarLayout);
// 完成后清除标志位
objcRWClass->flags &= ~RW_CONSTRUCTING;
}
一次失败的尝试
既然我们已经有了如何修复的假设,接下来就需要验证我们的假设是不是正确的。这段代码应该放在哪里执行呢?我们知道 runtime 在启动的时候会依次调用所有类以及所有分类的+ (void)load
方法,我们为了展示 UIScrollView 这种没有源码的系统类应该如何进行修改,特意为 MCAssignToWeak 创建了一个新的分类 fixup,然后在这个分类重写+ (void)load
方法:
@interface MCAssignToWeak (fixup)
@end
@implementation MCAssignToWeak (fixup)
+ (void)load {
_fixupAssginDelegate(self);
}
@end
为了验证我们的代码是否真的将delegate对象从assign
变为了weak
,我们还需要下面的验证代码:
int main(int argc, const char * argv[]) {
@autoreleasepool {
MCAssignToWeak *atw = [MCAssignToWeak new];
{
NSObject *proxy = [NSObject new];
atw.delegate = proxy;
[atw notifyDelegate]; // 这里不会崩溃
}
// 如果delegate仍然是assign,那这里会崩溃
[atw notifyDelegate];
}
return 0;
}
运行之后,我们期望的输出应该是这样的:
2017-07-21 11:06:31.157609+0800 demo[38605:16165704] ===== notify NSObject
2017-07-21 11:06:31.157691+0800 demo[38605:16165704] ===== notify (null)
但事与愿违,执行程序后崩溃在第二个 notifyDelegate 处,看起来 delegate 对象依然是个野指针。这是为什么呢?仔细推敲assign
或着说_unsafe_unretained
的实现原理,这个修饰符会在编译时告诉编译器赋值和取值的时候,不需要运行时做任何内存管理,直接操作内存地址即可,这些操作可以直接在编译时确定,无需再依赖运行时。所以编译器插入的 setter 里面,对_delegate = delegate
会直接转化为指针拷贝( getter 同理),这样就算我们在运行时动态修改了 _delegate 的 layout 也无济于事,因为代码早就确定了。难道我们又走进了死胡同吗?
继续深入
既然编译器生成的 getter 和 setter不能用,那我们就自己写一套吧。在这之前我们需要搞清楚编译器如何为一个weak对象生成 getter 和 setter。还是 MCAssignToWeak,我们先来看一下 delegate 是assign
的时侯 setter 的汇编代码:
; 附上oc代码方便对照
; @property (nonatomic, assign) id delegate;
; - (void)setDelegate:(id)delegate {
; _delegate = delegate;
; }
demo`::-[MCAssignToWeak setDelegate:](id):
...
0x100001af4 <+36>: movq -0x18(%rbp), %rdx
0x100001af8 <+40>: movq -0x8(%rbp), %rsi
0x100001afc <+44>: movq 0x21ad(%rip), %rdi ; MCAssignToWeak._delegate
0x100001b03 <+51>: movq %rdx, (%rsi,%rdi)
...
编者注:这是模拟器运行的 x86_64 汇编,AT&T 的汇编语法。ARM 与 AT&T 不同,但原理都一样。如果你对 ARM 汇编有兴趣,可以参考iOS汇编教程:理解ARM。我们这里就以模拟器来做为分析样本了。
我们为了节省篇幅,省略了获取 self 引用的过程,几乎所有的对象方法都有这一段。跳过这里来到第 8 行到第 11 行,这就是我们要找的_delegate = delegate
所对应的汇编代码。那这四行都做了什么呢:
0x100001af4 <+36>: movq -0x18(%rbp), %rdx ; $rbp-0x18里存放delegate的地址
0x100001af8 <+40>: movq -0x8(%rbp), %rsi ; $rbp-0x8里存放self对象的起始地址
0x100001afc <+44>: movq 0x21ad(%rip), %rdi ; $rip-0x21ad里存放_delegate相对于self的偏移
0x100001b03 <+51>: movq %rdx, (%rsi,%rdi) ; $rsi+rdi = $rdx => _delegate = delegate
这四句代码印证了我们的推断,对于一个标记为assign
的成员变量来说,setter 就是直接进行指针拷贝。那么我们再来看看如果 delegate 是weak
的时候是什么样子:
debug-objc`::-[MCAssignToWeak setDelegate:](id):
...
0x100001a74 <+36>: movq -0x18(%rbp), %rsi ; delegate
0x100001a78 <+40>: movq -0x8(%rbp), %rdx ; self
0x100001a7c <+44>: movq 0x2235(%rip), %rdi ; offset
0x100001a83 <+51>: addq %rdi, %rdx ; $rdx = self + offset
0x100001a86 <+54>: movq %rdx, %rdi ; $rdi = $rdx
0x100001a89 <+57>: callq 0x100002952 ; symbol stub for: objc_storeWeak
...
和assign
的汇编差不多,唯一不同的是assign
的时候,直接进行了指针拷贝,而weak
则调用了 objc_storeWeak 方法去拷贝指针。这是因为对于弱引用对象,赋值的时候需要首先在 runtime 全局维护的一张弱引用表中更新记录,维持正确的引用关系,最后才会进行指针拷贝,这一系列操作都要加锁保证线程安全,所以它的代码看起来很长很复杂。objc_storeWeak 也可以在源代码中找到,我们忽略那些对我们完成目标没有直接关系的代码,直接看指针拷贝的那段代码即可:
template <HaveOld haveOld, HaveNew haveNew, CrashIfDeallocating crashIfDeallocating>
static id
storeWeak(id *location, objc_object *newObj) {
...
// Assign new value, if any.
if (haveNew) {
newObj = (objc_object *)
weak_register_no_lock(&newTable->weak_table, (id)newObj, location,
crashIfDeallocating);
// weak_register_no_lock returns nil if weak store should be rejected
// Set is-weakly-referenced bit in refcount table.
if (newObj && !newObj->isTaggedPointer()) {
newObj->setWeaklyReferenced_nolock();
}
// Do not set *location anywhere else. That would introduce a race.
*location = (id)newObj;
}
...
}
通过第 18 行我们最终确认,在更新弱引用记录表后,最后和assign
一样也会进行指针拷贝。我们可以由此得出推论,对于任意一个 setter,我们都可以通过替换它的 setter 方法来完成对Ivar变量的内存管理方式的修改。幸运的是,runtime 将 objc_storeWeak 方法公开了出来, 我们只要替换原有 setter 后,先调用原 setter 实现,再调用 objc_storeWeak 方法,即可将 setter 变为一个可以操作weak
变量的方法。同理,getter 也可以通过方法替换的方式来完成对 objc_loadWeak 的调用。
(待续)