Objective-C 是一门动态语言。所谓动态,在于消息发送和转发,在于 Method Swizzling,同时也在于 Non-fragile ivar。之前有一篇文章简单介绍过消息发送和转发(iOS教程(二)消息发送),这一篇主要介绍下 Non-fragile ivar 特性及其实现方式。
什么是 ivar ?
ivar(instance variable)就是类成员变量。一般情况下,property 都会自动生成一个成员变量。
假设有一个 MyObject 类,有 array 和 color 两个成员变量, MyObject 实例的内存结构如下:
如果要获取 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 给 NSView 的成员变量 color 预留了内存空间。在创建 NSCustomView 的实例时,总是申请 12 字节的内存给 isa、color 和 array。
如果苹果希望给 NSView 添加字体配置功能,并引入一个新的成员变量 font,NSView 成员变量增加至两个。在不重新编译的情况下,MyCustomView 只为 NSView 申请了一个成员变量的空间。
显然,NSView 增加新功能后,NSCustomView 申请的内存空间不够用了,运行起来会造成不可预知的错误。究其原因,是因为子类所申请的内存空间数量在编译期已经确定了,NSView 改变之后需要重新编译 NSView 的所有子类,才能够有足够的内存空间来适应 NSView 的新功能。
当然,我们现在不再需要担心这个问题了。在 32 位的 Mac 上运行的是 Legacy Runtime,存在 fragile ivar 的问题,而 iPhone 以及 64 位 Mac 上运行的 runtime 是 Modern Runtime,已经解决了这个问题,具有 Non-fragile ivar 的特性。
在 Modern Runtime 下,NSCustomView 的内存结构如下:
Non-fragile ivar
在上面的例子中,NSCustomView 实例有两个变化点。
- 创建实例所需内存由 12 字节增长到 16 字节
- array 成员变量的 offset 由 8 变为 12
很自然的想到,解决了以下两个问题,那么自然 fragile ivar 的问题也就解决了,也就有 Non-fragile ivar 的特性。
- 申请实例内存数量可以动态调整。当父类变化时,需要动态修改申请内存的数量。
- 成员变量的 offset 可以动态调整。
⚠️ 注意,这里所说的动态不是指运行时的动态,是指每次启动时动态调整。在当次 App 真正运行起来后,是不会动态调整的,这也是 OC 不支持运行时动态添加 ivar 的原因。
底层实现
为了探索 ivar 的底层实现,我们先创建 IvarDemo 类,继承自 IvarDemoBase。(IvarDemoBase 这个基类内没有逻辑,但 IvarDemoBase 是必须的,之后会解释为什么需要这个基类。)
1 | // IvarDemo.h |
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 | static NSString * _Nonnull _I_IvarDemo_demoName(IvarDemo * self, SEL _cmd) { |
这两个函数是 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); |
这里有两个信息点需要留意:
OBJC_IVAR_$_IvarDemo$_demoName
变量是一个全局变量。OBJC_IVAR_$_IvarDemo$_demoName
变量存储在 Mach-O 文件中的__DATA,__objc_ivar
section,之后可以利用 otool 命令来查看。
那么可以猜测在 App 启动时,检查父类是否有变化,动态的修改这个全局变量,那么就可以达到「动态调整 offset」的目的。事实上,runtime 就是这么做的。
接下来我们继续通过汇编分析来证实我们的猜测,会比较繁琐,不想看的朋友可以直接跳过。
调试汇编分析
先贴一下用于调试的代码。
1 | + (void)start { |
解释一下,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:
的汇编代码中。
setDemoName:
最终是调用到 objc_setProperty_nonatomic_copy
函数中。
1 | void objc_setProperty_nonatomic_copy(id self, SEL _cmd, id newValue, ptrdiff_t offset) { |
objc_setProperty_nonatomic_copy
函数中的第 4 个参数传递的是 ivar 的 offset。
在 ARM64 架构的函数调用中,x0 ~ x7 寄存器用来传递调用参数,x0是第一参数,依次类推。同时 x0 寄存器也用于传递返回值。
断点断到 bl 0x10298e470
这一行,通过 register
命令来看下寄存器中的值。
1 | register read x3 |
也就是说,offset 传递的是 16,跟我们的理解是一致的(isa 和 _baseName 分别占 8 个字节)。
再往回看汇编源码,可以看到 ldrsw x3, [x8]
,这一句可以简单理解为 x3 = *x8
,也就是 x8 解引用后赋值给 x3。
再运行一次,断点断到 ldrsw x3, [x8]
这句,看下寄存器 x8 的值。
1 | // ivar name: _demoName |
这一步可以很明显的看到,寄存器 x8 中存的地址跟 ivar->offset 中是一样的,在 mach-o 文件中的地址是 0x10000d644
。
最后一步,用 otool 命令来看下 __DATA,__objc_ivar
中的数据。
1 | otool -s __DATA __objc_ivar IvarDemo |
上一步得到的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 | struct ivar_t { |
在 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 特性的:
- 每个成员变量都有一个与之对应的全局变量,用于记录成员变量的偏移量。
- 类信息保存在 class_ro_t 结构体中,其中 ivars 保存所有成员变量的偏移量等信息,同时用 instanceSize 记录类的大小。
- App 启动时,调用 moveIvars 方法更新 ivar->offset 和 instanceSize,创建实例时根据 instanceSize 来申请所需内存。
参考
- Hamster Emporium: objc explain: Non-fragile ivars
- Dynamic ivars: solving a fragile base class problem
- Objective-C类成员变量深度剖析 | 王晓磊贴代码用的Blog
- Mach-O文件介绍之ASLR(进程地址空间布局随机化) - 简书
- ARMv8-aarch64寄存器和指令集 | Winddoing’s Blog
- iOS开发同学的arm64汇编入门 - 刘坤的技术博客
- 64位汇编参数传递 - kk Blog —— 通用基础
汇编函数调用参数传递:
- 在真机 ARM64 架构中,x0 ~ x7 用于传递参数
- 在64位模拟器中,rdi, rsi, rdx, rcx, r8, r9 用于传递参数