0%

Objective-C 动态之 Non-fragile ivar

Objective-C 是一门动态语言。所谓动态,在于消息发送和转发,在于 Method Swizzling,同时也在于 Non-fragile ivar。之前有一篇文章简单介绍过消息发送和转发(iOS教程(二)消息发送),这一篇主要介绍下 Non-fragile ivar 特性及其实现方式。

什么是 ivar ?

ivar(instance variable)就是类成员变量。一般情况下,property 都会自动生成一个成员变量。

假设有一个 MyObject 类,有 array 和 color 两个成员变量, MyObject 实例的内存结构如下:
ivar-layout

如果要获取 color 的值,self 指针加上偏移量,再解引用就可以了,这也是 runtime 获取成员变量的方式。

1
UIColor *color = *(UIColor *)((char *)self + 16);

当然,runtime 的逻辑比这复杂,例如 atomic 加锁等。

什么是 Non-fragile ivar ?

fragile 的含义是脆弱的,Non-fragile ivar 解决的是 fragile ivar 的问题。

fragile ivar 问题

在 32 位的 Mac 上,假定有一个 NSCustomView 继承自 NSView,NSCustomView 有两个成员变量。(至于为什么是 32 位的 Mac,接下来会介绍。)

NSCustomView

我们可以看到,NSCustomView 给 NSView 的成员变量 color 预留了内存空间。在创建 NSCustomView 的实例时,总是申请 12 字节的内存给 isa、color 和 array。

如果苹果希望给 NSView 添加字体配置功能,并引入一个新的成员变量 font,NSView 成员变量增加至两个。在不重新编译的情况下,MyCustomView 只为 NSView 申请了一个成员变量的空间。

custom-view-fragiled

显然,NSView 增加新功能后,NSCustomView 申请的内存空间不够用了,运行起来会造成不可预知的错误。究其原因,是因为子类所申请的内存空间数量在编译期已经确定了,NSView 改变之后需要重新编译 NSView 的所有子类,才能够有足够的内存空间来适应 NSView 的新功能。

当然,我们现在不再需要担心这个问题了。在 32 位的 Mac 上运行的是 Legacy Runtime,存在 fragile ivar 的问题,而 iPhone 以及 64 位 Mac 上运行的 runtime 是 Modern Runtime,已经解决了这个问题,具有 Non-fragile ivar 的特性。

在 Modern Runtime 下,NSCustomView 的内存结构如下:
custom-view-non-fragile

Non-fragile ivar

在上面的例子中,NSCustomView 实例有两个变化点。

  1. 创建实例所需内存由 12 字节增长到 16 字节
  2. array 成员变量的 offset 由 8 变为 12

很自然的想到,解决了以下两个问题,那么自然 fragile ivar 的问题也就解决了,也就有 Non-fragile ivar 的特性。

  1. 申请实例内存数量可以动态调整。当父类变化时,需要动态修改申请内存的数量。
  2. 成员变量的 offset 可以动态调整。

⚠️ 注意,这里所说的动态不是指运行时的动态,是指每次启动时动态调整。在当次 App 真正运行起来后,是不会动态调整的,这也是 OC 不支持运行时动态添加 ivar 的原因。

底层实现

为了探索 ivar 的底层实现,我们先创建 IvarDemo 类,继承自 IvarDemoBase。(IvarDemoBase 这个基类内没有逻辑,但 IvarDemoBase 是必须的,之后会解释为什么需要这个基类。)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// IvarDemo.h
#import <Foundation/Foundation.h>

@interface IvarDemoBase : NSObject
@property (nonatomic, copy) NSString *baseName;
@end

@interface IvarDemo : IvarDemoBase
@property (nonatomic, copy) NSString *demoName;
@end

// IvarDemo.m
#import "IvarDemo.h"

@implementation IvarDemoBase

- (void)dealloc {
[_baseName release];

[super dealloc];
}

@end

@implementation IvarDemo

- (void)dealloc {
[_demoName release];

[super dealloc];
}

@end

clang rewrite 源码分析

clang rewrite 是常规操作。通过 clang 命令,将 OC 代码转化 C++ 代码,看下具体实现。

1
clang -rewrite-objc -x objective-c -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk -fobjc-weak -fobjc-runtime=ios-13.2.2 IvarDemo.m

得到的 IvarDemo.cpp 文件就是转化后的 C++ 代码。我们来看下其中关键的几处代码。

1
2
3
4
5
6
7
static NSString * _Nonnull _I_IvarDemo_demoName(IvarDemo * self, SEL _cmd) {
return (*(NSString * _Nonnull *)((char *)self + OBJC_IVAR_$_IvarDemo$_demoName));
}

static void _I_IvarDemo_setDemoName_(IvarDemo * self, SEL _cmd, NSString * _Nonnull demoName) {
objc_setProperty (self, _cmd, __OFFSETOFIVAR__(struct IvarDemo, _demoName), (id)demoName, 0, 1);
}

这两个函数是 demoName 属性的 getter 和 setter。

  • getter 中的 offset 是从变量 OBJC_IVAR_$_IvarDemo$_demoName 中获取的
  • setter 中的 offset 是通过 __OFFSETOFIVAR__ 宏计算得到,是编译期确定的常量数值。

⚠️ 注意,实际上 setter 中的 offset 也是从变量 OBJC_IVAR_$_IvarDemo$_demoName 中获取的,这里 clang 转化后的 setter 代码与实际情况不符合。后面我们可以从汇编代码中验证这一点。

我们继续看一下 OBJC_IVAR_$_IvarDemo$_demoName 的定义。

1
extern "C" unsigned long int OBJC_IVAR_$_IvarDemo$_demoName __attribute__ ((used, section ("__DATA,__objc_ivar"))) = __OFFSETOFIVAR__(struct IvarDemo, _demoName);

这里有两个信息点需要留意:

  1. OBJC_IVAR_$_IvarDemo$_demoName 变量是一个全局变量。
  2. OBJC_IVAR_$_IvarDemo$_demoName 变量存储在 Mach-O 文件中的 __DATA,__objc_ivar section,之后可以利用 otool 命令来查看。

那么可以猜测在 App 启动时,检查父类是否有变化,动态的修改这个全局变量,那么就可以达到「动态调整 offset」的目的。事实上,runtime 就是这么做的。

接下来我们继续通过汇编分析来证实我们的猜测,会比较繁琐,不想看的朋友可以直接跳过。

调试汇编分析

先贴一下用于调试的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
+ (void)start {
struct FakeIvar {
int32_t *offset;
const char *name;
const char *type;
uint32_t alignment_raw;
uint32_t size;
};

unsigned int count = 0;
Ivar *ivarList = class_copyIvarList(self, &count);
assert(count > 0);

struct FakeIvar *firstIvar = (struct FakeIvar *)ivarList[0];
intptr_t slide = _dyld_get_image_vmaddr_slide(0);
NSLog(@"ivar name: %s, offset ptr: %p, offset ptr in mach-o: %p", firstIvar->name, firstIvar->offset, (char *)firstIvar->offset - slide);

IvarDemo *demo = [[IvarDemo new] autorelease];
demo.demoName = @"Hello, Non-fragile ivar";
}

解释一下,FakeIvar 其实就是 Ivar 在 runtime 中结构。系统并没有暴露 Ivar 的实现,所以这里为了方便的取出 Ivar 中的数据,定义了一个一模一样的结构体。

其次,iOS 系统用了 ASLR (Address Space Layout Randomization)技术,这是一种安全机制。简单来说,mach-o 文件中的地址,与实际运行时获取到的内存地址,总是存在一个偏移量 slide。而且,每次启动这个偏移量都是会变化的。_dyld_get_image_vmaddr_slide 函数就是获取这个偏移量的。

在最后一行代码中打一个调试断点,同时在 Xcode 中勾选 Debug -> Debug Workflow -> Always Show Disassembly。这样运行后,就可以看到运行时的汇编代码。

真机(iPhone XS)运行后,step into 一次就可以进入到 setDemoName: 的汇编代码中。
ivar-disassemblly

setDemoName: 最终是调用到 objc_setProperty_nonatomic_copy 函数中。

1
2
3
void objc_setProperty_nonatomic_copy(id self, SEL _cmd, id newValue, ptrdiff_t offset) {
reallySetProperty(self, _cmd, newValue, offset, false, true, false);
}

objc_setProperty_nonatomic_copy 函数中的第 4 个参数传递的是 ivar 的 offset。

在 ARM64 架构的函数调用中,x0 ~ x7 寄存器用来传递调用参数,x0是第一参数,依次类推。同时 x0 寄存器也用于传递返回值。

断点断到 bl 0x10298e470 这一行,通过 register 命令来看下寄存器中的值。

1
2
register read x3
// x3 = 0x0000000000000010

也就是说,offset 传递的是 16,跟我们的理解是一致的(isa 和 _baseName 分别占 8 个字节)。

再往回看汇编源码,可以看到 ldrsw x3, [x8],这一句可以简单理解为 x3 = *x8,也就是 x8 解引用后赋值给 x3。

-w779

再运行一次,断点断到 ldrsw x3, [x8] 这句,看下寄存器 x8 的值。

1
2
3
4
5
6
// ivar name: _demoName
// offset ptr: 0x104f71644
// offset ptr in mach-o: 0x10000d644

register read x8
// x8 = 0x0000000104f71644 IvarDemo`IvarDemo._demoName

这一步可以很明显的看到,寄存器 x8 中存的地址跟 ivar->offset 中是一样的,在 mach-o 文件中的地址是 0x10000d644

最后一步,用 otool 命令来看下 __DATA,__objc_ivar 中的数据。

1
2
3
4
5
6
otool -s __DATA __objc_ivar IvarDemo

执行结果:
IvarDemo:
Contents of (__DATA,__objc_ivar) section
000000010000d640 00000008 00000010 00000008

上一步得到的0x10000d644地址就对应 00000010 这一段数据,也就是 16。

小结

在代码中访问成员变量,或者是属性 property 的 setter / getter 中,都是通过一个全局变量来获取 offset 的,runtime 中维护的 ivar->offset 也是指向这个全局变量的。

全局变量和成员变量是一对一的。但苹果对此做了一个优化,如果一个对象直接继承自 NSObject,访问成员变量时 offset 是编译期直接确定的(hardcode),省去了全局变量解引用这一步。这就是前面说 IvarDemoBase 是必需的原因。直接继承自 NSObject,我们就看不到前面所分析的汇编逻辑了。

这一逻辑为实现「动态调整 offset」的目标奠定了基础,只需要在 App 启动时动态的计算这些 offset。

Runtime 源码

在 runtime 中,ivar_t 结构存储 ivar 相关信息,其中 offset 是一个指向全局变量的指针,这个全局变量中存储着 ivar 的偏移量。

class_ro_t 结构存储类的各种信息,其中 instanceStart 记录 ivar 开始的偏移量,instanceSize 记录类的大小,ivars 是 ivar_t 数组,记录所有的 ivar 信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
struct ivar_t {
#if __x86_64__
// *offset was originally 64-bit on some x86_64 platforms.
// We read and write only 32 bits of it.
// Some metadata provides all 64 bits. This is harmless for unsigned
// little-endian values.
// Some code uses all 64 bits. class_addIvar() over-allocates the
// offset for their benefit.
#endif
int32_t *offset;
const char *name;
const char *type;
// alignment is sometimes -1; use alignment() instead
uint32_t alignment_raw;
uint32_t size;

uint32_t alignment() const {
if (alignment_raw == ~(uint32_t)0) return 1U << WORD_SHIFT;
return 1 << alignment_raw;
}
};

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;
}
};

在 App 启动时会加载所有的类,每个类都会调用到 moveIvars 方法。在 moveIvars 方法中,遍历 class_ro_t.ivars 中的所有 ivar_t,更新 offset。最后,更新 class_ro_t.instanceStart 和 class_ro_t.instanceSize。之后创建实例时,根据 instanceSize 来申请内存空间。

对 moveIvars 的具体实现感兴趣的朋友,可以自己下载 runtime 源码来阅读。
GitHub - opensource-apple/objc4

至此,动态申请内存空间和动态更新 offset 的目标都达到了。

总结

总结一下 OC 是如何实现 Non-fragile ivar 特性的:

  1. 每个成员变量都有一个与之对应的全局变量,用于记录成员变量的偏移量。
  2. 类信息保存在 class_ro_t 结构体中,其中 ivars 保存所有成员变量的偏移量等信息,同时用 instanceSize 记录类的大小。
  3. App 启动时,调用 moveIvars 方法更新 ivar->offset 和 instanceSize,创建实例时根据 instanceSize 来申请所需内存。

参考

汇编函数调用参数传递:

  1. 在真机 ARM64 架构中,x0 ~ x7 用于传递参数
  2. 在64位模拟器中,rdi, rsi, rdx, rcx, r8, r9 用于传递参数