Objective-C 和 Runtime

运行时 (Runtime) 本身是一个非常普通的概念,每个编程语言都会有运行时,非 iOS Objective-C 特有。
概念上理解,
运行时,就是程序执行的过程
,每个编程语言,只有运行后执行特定的任务才有价值,所以每个编程语言,都有运行时。
而 Objective-C 的 Runtime 显然不仅仅是程序执行过程这么简单,它一举将基于面向过程的语言 C 而实现的面向对象的语言 Objective-C 变成了动态语言
为什么 Objective-C 的 Runtime 有些难理解,因为运行时从概念上理解非常简单,但是 Java、Python、C 它们的运行时都是不一样的,运行时期间可以做很多事情,所以运行时理解起来还是比较抽象和高阶。
如果把 Runtime 改名为 OCDR(Objective-C Dynamic Resolution,即 Objective-C 动态决议),剥离运行时和运行时库的概念,那么很多人都会轻松掌握 Objective-C Runtime 的核心。而 Objective-C Runtime 本质上的确就在践行动态决议这么一个过程。

Objective-C Runtime 使得函数调用变成了动态消息传递,并且可以在执行过程中对 Object 进行增删改查,所以更准确的说,Runtime 使得 Objective-C 变成了动态语言。

动态语言分析

首先理解一下各类语言的一个区分分界,那就是动态语言静态语言的区分(非动态类型语言和静态类型语言)。
关于编译型语言 & 解释型语言,可以查看 Shell 和进程

静态语言

静态语言比较容易理解,我们通过高级语言写好的代码,经过预处理、编译、汇编、链接后,就形成了机器码(二进制文件)。这个时候,函数名、变量名都保存在符号表中,并拥有对应的虚拟地址。而我们调用一个函数,就是通过机器码调用的,汇编示例如:’call 0X12345678’,这里 call 代表函数调用,0X12345678 代表函数的虚拟地址。
这个机器码执行的过程中,PC 指令寄存器会不断的记录下一行将要执行的指令,然后不停的执行下去 (延伸一个知识点,如果下一行指令不再内存中,操作系统会发现页缺失,然后从硬盘中将对应缺失页的指令拷贝到对应的真实内存中,然后继续执行)。
这里我们可以发现,我们通过高级语言写好的函数,在编译后,就已经不可改变了。程序执行后,CPU 内部的运算单元、控制单元、数据单元,就有条不紊的按照机器码执行就好了。
那么如果我们想改变一个函数的实现呢?比如本身调用的 A 函数,运行期间想调用 B 函数,有办法实现吗?对于静态语言来说,不行!
那么如果我们想改变一个对象的结构体呢?对于静态语言来说,不行!
所以静态语言,对于函数的定义,在编译期就必须是明确的,如果找不到函数的实现,就会编译错误。
举例来说,如 C 语言。不过 C 语言也有一个动态化的能力,就是 hook,可以做一定程度的动态能力,不过这并不是语言级别支持的,只是上层业务的补救。

动态语言

动态语言相比静态语言,也比较容易理解了,在运行时代码可以根据某些条件改变自身结构,如函数的调用,自定义类的生成和对象的创建等。那这样的语言就是动态语言。
举例来说,如 Objective-C 语言。

分析

C 是静态的,Objective-C 是动态的,而 Objective-C 是基于 C 实现的。那 Objective-C 是如何基于 C 实现动态特性的呢?

C 有它的运行时,不过程序执行过程中,CPU 完全掌控机器码执行流程,所以 C 的运行时不能多样化,基本上就是按照程序员写的代码执行顺序执行。这也是 C 快的原因,因为 CPU 直接执行,不用考虑那么多复杂的情况。

那 Python 如何实现动态的呢?Python 也有它的运行时,不过说来巧妙,它不是编译性语言,不会像 C 一样打包成机器码直接执行。Python 是解释性语言,代码执行到哪一行,就即时编译该行形成机器码执行,所以 Python 非常容易实现动态,只要代码运行过程中适当的添加 if 语句对将要执行的对象进行替换,就能很好的实现动态,鸭子 Duck 模型也就来源于此。

那 Objective-C 呢?Objective-C 和 C 一样是编译性语言,项目打包后会经过编译链接等处理变成机器码。
Objective-C 实现动态就要运行时了,因为在运行时做了很多操作,以至于很多人叫它 “运行时系统”。
之所以前面说 Objective-C 的 Runtime 应该改名为”Objective-C Dynamic Resolution,Objective-C 动态决议”,就是因为 Objective-C 的 Runtime 主要实现的两个点都更加符合 “动态决议” 这个命名:

  1. 通过 objc_msgsend (a_object,a_object_method,xx) 这个中间者函数,动态解析被调用的函数。
  2. 通过指针、数组的指针、链表指针、全局数据,间接操作函数列表、属性列表等,动态的对 Object 进行增删改查。

Objective-C Runtime 函数调用动态性

之所以很多人说 Objective-C 的函数调用并非函数调用,而是消息传递,其原因就是 Objective-C 的函数调用很不一样。
在 C 中,我们调用一个函数,会这样写:

1
2
3
Node *head = NULL;
...生成链表...
reverseLinkList(&head);// 反转链表

这里,reverseLinkList 函数的调用,在汇编里面为:call 0x12345678,其中 0x12345678 就是 reverseLinkList 这个符号名的虚拟地址。
这里我们可以发现,reverseLinkList 函数的虚拟地址在编译的时候就已经定下来了,后期无法做到动态性。
再来看一下 Objective-C 的实现,因为 Objective-C 是面向对象的语言,所以我们调用对象的一个方法:

1
2
Person p = Person.new;
[p realAge];

这里,[p realAge] 这个函数的调用,p 有自己的虚拟地址,realAge 函数也有自己的虚拟地址,但是在汇编之前,该函数已经被编译成了这样:
objc_msgsend(p, realAge)。
如此,Objective-C 通过 objc_msgsend 这个完全汇编写成的中间函数,在函数内部通过运行时库对 p 进行 superclass 和 isa 的访问,乃至最后实现三次动态函数转发操作。
所以 Objective-C 实现消息传递,就是依靠 objc_msgsend 这个中间函数来实现,如果 objc_msgsend 的虚拟地址为 0x87654321,那么 [p realAge] 函数被执行的时候,实际汇编为:call 0x87654321。

上面说完了 objc_msgsend 这个中间函数完成动态解析的操作,那它是如何进行 superclass 和 isa 的访问,又是如何访问属性和方法列表的呢?

Objective-C 面向对象的本质:C 的 struct(结构体)

通过 Runtime 源码,我们可以发现,Objective-C 里面一切皆对象,乃至 UILabel、NSTimer 等。而这些对象是以什么样的数据结构存在呢?
都是 struct(结构体)! 我们的成员变量,成员属性,方法等,都保存在结构体里面。
所以这里我们可以深刻理解一下,为什么说 Objective-C 是 C 的超集,因为面向对象的 Objective-C 语言完全就是依靠面向过程的 C 语言发展起来的,乃至于我们写的面向对象的代码,最后都会变成面向过程的执行流程
我们可以查看苹果公开的 runtime.h 文件,都是面向过程的函数调用。如:

objc_setAssObjective-CiatedObject(id _Nonnull object, const void * _Nonnull key, id _Nullable value, objc_AssObjective-CiationPolicy policy)

你看,这完全就是面向过程的编程。

因为 struct 是我们对象的存储结构,所以我们可以通过 * class_getProperty (Class _Nullable cls, const char * _Nonnull name)* 等库函数直接对 struct 内部保存的数据进行访问。
比如我们要访问 a_object 的 a_object_method 方法,那么就要通过 a_object_struct,根据 superclass 找到类 A_struct,然后遍历 A_struct 里面的 methods 数组,找到 a_object_method 方法并执行。

struct 结构体在编译后就已经确定,那如何做到动态性如增加方法呢?struct 的实现是 C 和 C++ 混编的,但大体逻辑不变,都是通过指针实现的。
比如说,struct 里面有一个方法的数组指针,那么我们动态添加方法的时候,只要通过这个数组指针就可以添加到对应的方法数组中,这样就完全不会改变 struct 的结构 (甚至如果数组需要扩容,也和 struct 没有任何关系)。

因为 struct 在编译后就已经确定,所以能做动态性的方案不外乎这几点:

  1. 通过数组指针,如方法列表等
  2. 通过链表指针,如 NSNotification 等
  3. 通过全局对象,如关联对象等

所以指针才是 C 和 Objective-C 最终执行的终极形态,不断通过指针间接的对数据区进行增删改查。
Objective-C 的 Runtime 就是不断通过指针对 struct 结构体进行增删改查数据处理来实现动态特性

Objective-C Runtime 总结

Objective-C Runtime 用的最多的是 objc_msgsend,所有的函数调用都会通过这个消息传递完成。
编译成机器码的函数调用,本身无法实现动态性,通过 objc_msgsend 这个中间层巧妙的实现了。
而 objc_msgsend 的汇编执行又得反过来操作运行时库,又回到了对象的 struct 结构体上面。
Objective-C Runtime 本质就是通过指针操作 struct 的增删改查来实现 Objective-C 的动态特性

C 语言中一切皆指针,基于 C 发展起来的 Objective-C,也是一样。
而指针也是中间层思想的体现。本身无法直接操作的数据,通过指针这个中间层,间接的对数据进行操作。

延伸

神话 Runtime 的结果就是越发觉得自己的无知和不知所以。
Runtime 是啥?从编译上来看,Runtime 不是和我们项目一起打包成 ipa 包的,那 Runtime 还能是啥?
Runtime 说的直白点,仅仅是个动态库而已,也叫运行时库
它和我们项目里面使用的自行编译或者三方编译的动态库一样,也需要在应用启动后加载到进程中(Runtime 和系统 framework 一样,属于系统动态库)。

iOS 和 Mac 使用的可执行文件是 Mach-o,与 Linux 的 ELF 和 Window 的 PE 一样,都是基于 COFF 格式扩展的。
所以 Mach-o 可执行文件的运行流程和 ELF 等都大差不差,当然具体到细节有一些差异。但总体都是差不多的,比如 segment 和 section、section head、内存分区等,都一样。

应用启动后,首先操作系统通过 fork 开辟进程,开启用户态后,然后加载 Mach-o head。head 里面有很多重要信息,如 Magic Number (Mach-o 通过 0xfeedface/0xfeedfacf 表示 32/64 位,ELF 则有’E’’L’’F’标记、32/64 位、版本、大小端等)、版本、支持 CPU、文件类型等。

然后找到 Load Commands 区,这里面有几个重要的信息,其中有三个为 Segment (通过 Segment 将相同读取及可执行权限的 section 汇总到一起)DyldDylib
通过 Segment 可以找到具体执行的指令和数据的 Section,和 ELF 里面的 section head (段表) 类似。应用启动的时候还没有到执行 Segment 那一步,还有一些重要的信息没有完成,其中一个就是动态库的符号解析和重定位。
PC 指令寄存器首先指向了 Dyld (动态链接器),将 Dyld 运行起来后,开始将所有动态库复制到内存(已经在内存的无需重复加入,如系统库)。
所有项目里面所有用到的动态库,都会在这个时候拷贝到内存里面,一个都不会少。像一般项目,系统库都会有 200-500 个动态库要添加,因为已经在内存,所以不会耗费时间(手机重启后打开的第一个应用,会比较耗时,就是因为很多系统库都不在内存中)。
Dyld 将所有需要的动态库拷贝到内存后,Runtime 库也就拷贝好了。所以这是 Runtime 第一次展示的时机。而它第二次展示,就要等很多准备操作之后了。

因为 iOS 在 4.3 系统之后,升级了虚拟地址方案,采用了动态虚拟地址,就是每个应用程序启动后,起始地址都是动态的。那么已经编译好的 Mach-0 就需要重新修正指令地址了。
这里的指令地址修复,分为两个环节,一个是本身 Mach-0 内部的地址修复,如函数调用、数据使用。这个就是 Rebase 阶段,总体是 I/O 耗时,但没有太多复杂的。

第二个环节是对动态库对引用修正。因为对动态库的使用本身就是地址为 0,需要在应用启动后重定位的,所以动态虚拟地址和动态库重定位在第二个环节就一套带走了。
这里有个特殊点,就是动态库也是程序,同样包含指令和数据。所有应用程序对动态库的指令都是共享的,如函数调用,但是对数据都是私有的,需要应用程序自己处理。
第二个环节里面,会把所有需要修正的数据全部修正掉,而函数调用,则是真正运行到这行指令的时候,才能修正。没有运行之前,仅仅做了一层映射,并没有真正处理。
这个映射就是动态库的数据和指令的映射,是一个 section 保存在 Data 区,因为这个区的数据可读写。这个映射就是 got section。
而第二个环节,也就是 Bind 阶段,更多的是 CPU 操作。

Rebase 和 Bind 过后,又到了 Runtime 大显身手的时候了,PC 指令寄存器指向了 Runtime 的指令位置,开始进行 class 的遍历、method 的遍历、load 的执行等等操作。

过后,才是项目的 main 函数的执行,然后是 application 的执行。当然,项目运行起来后的所有 objc_msgsend,都是要走一遍 Runtime 的。也就是 struct 那一套了。

所以 Runtime 是一个动态库,也叫运行时库。这也是我们项目里面要使用 Runtime 的时候,都要先#import <objc/runtime.h> 的原因。