在上文中,我们提到了有个神秘的__OBJC
段,Runtime的许多机制就是依赖于它。但是无论我怎么搜索网上相关的资料、苹果的官方文档,都发现找不到这个段了。
一脸懵逼。没事,打开class-dump,看看它怎么处理的。嘿嘿,果不其然,在Class-Dump的代码里,有着如下注释:
@0xced Old ABI has an OBJC segment. New ABI has a DATA,__objc_info section
通俗解释来说,我们先如今使用的都是Objective-C2.0,所以原先的__OBJC
段的东西都不存在了,而是存入了__DATA
段里。所以,我们就以如下这张图来探究下这些与Runtime加载有关的节。
__objc_imageinfo
节
这个节可以看作是区别Objective-C 1.0与2.0的区别。从苹果的OBJC源码中能看到这个节的数据结构定义(去除Swift相关)如下:
typedef struct {
uint32_t version; // currently 0
uint32_t flags;
} objc_image_info;
其中version
这个字段目前永远为0。flags
是用于做表示需要支持的特性的,比如是否需要/支持 Garbage Collection
。
SupportsGC = 1<<1, // image supports GC
RequiresGC = 1<<2, // image requires GC
if (ii.flags & (1<<1)) {
// App wants GC.
// Don't return yet because we need to
// check the AppleScriptObjC exception.
wantsGC = YES;
}
__objc _classlist
节
这个节列出了所有的class
(metaclass自身也是一种class)。
以计算器举例:我们先从MachoView找出一段数据,这个数据代表的就是class结构体所在的地址,如下图:
通过hopper查看地址:000000010002A128
,得到如下结果:
内存地址(还没rebase过)中包含一个类本身的含义是什么意思呢?这都需要从Runtime里面来说起。
我们假设说我们有个类A,其父类为AA。有两个A类型的实例a1, a2
。
我们都知道在真正调用[a haha]
的方法的时候,实质上是通过objc_msgSend
执行一系列的函数查询来找到真正的函数IMP,进而产生函数调用的。
由于objc_msgSend的调用返回值是不确定的,需要根据不同的状态来返回,比如ARM64下的Indirect Result Location。因此其本身的实现需要通过汇编来,我们截取最终要的一段ARM64的汇编如下:
// 1. 定义全局函数符号 _objc_msgSend
ENTRY _objc_msgSend
// 2. 为Exception做准备
UNWIND _objc_msgSend, NoFrame
MESSENGER_START
// 3. 逻辑实现体
cmp x0, #0 // nil check and tagged pointer check
b.le LNilOrTagged // (MSB tagged pointer looks negative)
ldr x13, [x0] // x13 = isa
and x16, x13, #ISA_MASK // x16 = class
LGetIsaDone:
CacheLookup NORMAL // calls imp or objc_msgSend_uncached
- X0是函数调用者,即Self,比较其和nil的关系,如果是nil(或者tagged pointer)就走另外一种分支。通过此,我们也不难理解为什么可以对nil发送消息了
- 根据self所在的地址,取其成员变量
isa
。 x16 = x13 & MASK
,也就意味着x16
指向了内存里面的对应A class
对象(注意:不是A class的实例对象)- 上述为什么要对
ISA
进行一个mask的位与操作,主要原因和Tagged Pointer类似,理由就不再赘述。 执行
CacheLookUp
,具体的代码流程简要如下:.macro CacheLookup // x1 = SEL, x16 = isa ldp x10, x11, [x16, #CACHE] // x10 = buckets, x11 = occupied|mask and w12, w1, w11 // x12 = _cmd & mask add x12, x10, x12, LSL #4 // x12 = buckets + ((_cmd & mask)<<4) // x9 = key, x17 = _imp ldp x9, x17, [x12] // {x9, x17} = *bucket 1: cmp x9, x1 // if (bucket->sel != _cmd) b.ne 2f // scan more CacheHit $0 // call or return imp 2: // not hit: x12 = not-hit bucket CheckMiss $0 // miss if bucket->sel == 0 cmp x12, x10 // wrap if bucket == buckets b.eq 3f ldp x9, x17, [x12, #-16]! // {x9, x17} = *--bucket b 1b // loop 3: // wrap: x12 = first bucket, w11 = mask add x12, x12, w11, UXTW #4 // x12 = buckets+(mask<<4) // Clone scanning loop to miss instead of hang when cache is corrupt. // The slow path may detect any corruption and halt later. ldp x9, x17, [x12] // {x9, x17} = *bucket 1: cmp x9, x1 // if (bucket->sel != _cmd) b.ne 2f // scan more CacheHit $0 // call or return imp 2: // not hit: x12 = not-hit bucket CheckMiss $0 // miss if bucket->sel == 0 cmp x12, x10 // wrap if bucket == buckets b.eq 3f ldp x9, x17, [x12, #-16]! // {x9, x17} = *--bucket b 1b // loop 3: // double wrap JumpMiss $0 .endmacro
我们接着再来读读这段汇编。
x16
承接上段汇编,是A class
的实体,取出其cache
成员变量。按照
_cmd
和mask
的位运算,找出其在bucket数组中的偏移量。取出的数据结构是个bucket_t
,如下:struct bucket_t { private: cache_key_t _key; IMP _imp; }
从上述数据结构不难理解,
cache
对象里面存了一个bucket数组,用于进行SEL
对应的IMP
,缓存。key
是SEL
对应的地址。- 如果地址相同,就代表命中,执行CacheHit,其实就是简单的
br x17
。由于此时x17
是IMP,即对应的函数地址,直接跳过去就完事了,这个分支下的objc_msgSend
就执行完成了。 - 那如果不相同,即命中的bucket里面不是我们要的
SEL
,就检查这个命中的桶是不是没有SEL
,如果是空的,执行__objc_msgSend_uncached
。这步后续开始就是去查找类方法列表->父类方法列表了。 - 如果不为空,否则就执行循环,进行查询。
**一些细节知识:
- .macro可以在汇编里面定义一段可以被复用的代码段。
- .1b 代表的是向回找label定义为1的代码片段起始;1f代表向下找label定义为1的代码片段起始。
- 为什么在计算isa的时候先要位与一个mask,其原因在于现在的isa是一个兼具多种含义的指针。
**
本文重点不在讲述Runtime上,所以objc_msgSend的细节就不去更深入的探究了。
所以,按照上述步骤来理解,我们可以发现,苹果实例对象的objc_msgSend
的机制可以简要抽象如下图例子:
__objc _catlist
节
该节顾名思义,代表的就是程序里面有哪些Category
。我们还是通过MachoView和Hopper来看一看:
从Hopper里面看出的内容我们不难得到,catlist
也对应着一个Category_t
的实体,会在程序运行的过程中存在于内存中。这个结构体的数据定义如下:
struct category_t {
const char *name;
classref_t cls;
struct method_list_t *instanceMethods;
struct method_list_t *classMethods;
struct protocol_list_t *protocols;
struct property_list_t *instanceProperties;
// Fields below this point are not always present on disk.
struct property_list_t *_classProperties;
}
__objc_protolist
该节的理解也非常简单,代表的就是程序里面有哪些Protocol
。数据结构定义如下:
struct protocol_t : objc_object {
const char *mangledName;
struct protocol_list_t *protocols;
method_list_t *instanceMethods;
method_list_t *classMethods;
method_list_t *optionalInstanceMethods;
method_list_t *optionalClassMethods;
property_list_t *instanceProperties;
uint32_t size; // sizeof(protocol_t)
uint32_t flags;
// Fields below this point are not always present on disk.
const char **_extendedMethodTypes;
const char *_demangledName;
property_list_t *_classProperties;
}
__objc_classrefs
一开始这个节的意义我实在是没看懂。实在不理解在已经存在classlist这个数据节的情况下,为啥还是需要用这个类。后来经过一番实验发现,该节的目的是为了标记这个类究竟有没有被引用
那有没有被引用的意义是什么?可以包瘦身。如果在MachoView中都能直观告诉我们没有引用的类甚至是方法,都可以直接剔除了。
但是,作为一名经常奋战在包瘦身一线的同学,我可以直接告诉你,上述的想法是大错特错的。苹果这种可以利用字符串拼接从而调用大量runtime的方法,绝对坑哭了做包瘦身的人。
嘿嘿,不过其实这样也没啥难度,下一篇我会写一个基于Macho的包瘦身方案,绝对轻便简洁,不用基于AST来分析各种调用关系,这里卖个关子。
__objc_selrefs
这节的原理同上,告诉你究竟有哪些SEL对应的字符串被引用了。
__objc_superrefs
这节虽然中字面意义上我们知道,是对超类(即父类)的引用,但是没理解啊,为什么要有这么一个破玩意。
不懂就一点点摸索,从MachoView里面来看,数据对应的地址还是指向一个个在classlist出现的类实体。
通过和classlist里面出现的数据进行diff对比,如下图所示:
可以发现,所有出现的objc_superrefs
都是会被继承的类。那么,为什么要单独设计这样一个来存放这样的信息呢?
真正的原因如下:
我们知道,我们在子类调用一个方法的时候,为了调用上层的父类的实现(如果有),常常会写出一个[super message]
的代码。而这样的代码,在底层是会转换成调用objc_msgSendSuper2
。而其接受的参数,第一个为结构体objc_super2
,第二个为SEL
。其中objc_super2
的定义如下:
struct objc_super2 {
id receiver;
Class current_class;
};
为了构造这样的数据结构体,在汇编层面会将[super message]
转换成如下的汇编指令:
注意看红框内的汇编代码,我们来分步骤解释下整体的汇编结构:
- 首先在调用
[ViewController viewDidLoad]
的时候,x0是self(ViewController的实例),x1是@selector(viewDidLoad)。 - 0x1000046c0 偏移的地方将
sp
向下申请了48
(0x30)bytes的空间。 - 0x1000046c4 将
SP
的地址存到的x8
寄存器中。这个X8寄存器会很关键 - 0x1000046d0 通过adrp指令加载内存数据中的一个page,根据这个page的offset找到对应的
viewDidLoad
方法的ref。存入x9
。 - 0x1000046f8 通过
x9
寄存器中ref指向的地址,以该地址为内存读取真正的SEL,存入x1
。
至此,调用objc_msgSendSuper2的第二个参数准备完毕,我们再来看看第一个的参数是如何设置的。
0x1000046d8 同样的方式,加载一个page的0x78的偏移位置的数据,点进去会发现是个class地址,存到
x10
中。然后,就轮到我们的栈空间出场了。我们先把
x0
存到sp处,然后再把x10
,也就是上面说的class地址存入sp+8 (str x10, [sp, #0x8]
。- 最后,还记得我们之前提到的
x8
寄存器吗?我们之前可是将sp
的值赋予了x8
了。所以,在1000046fc x0, x8
这个地方,我们将x8
的值赋予了x0
。至此,调用objc_msgSendSuper2的第一个参数也准备完毕
最后附上objc_msgSendSuper2
的代码供参考,逻辑非常简单,不再赘述。
ENTRY _objc_msgSendSuper2
UNWIND _objc_msgSendSuper2, NoFrame
MESSENGER_START
ldp x0, x16, [x0] // x0 = real receiver, x16 = class
ldr x16, [x16, #SUPERCLASS] // x16 = class->superclass
CacheLookup NORMAL
END_ENTRY _objc_msgSendSuper2
等等,心急的读者会问:你说了那么一大堆,你还是没解释到底为什么要存在superrefs?
在Objective-C的设计里面,函数就是函数,它并不知道自己属于哪个类里面。换句通俗的话来说,必须是你(编译器)说去哪个class实体的方法列表里面寻找调用,才会真正的去找对应的方法,函数自身不知道是父类还是子类。同时,由于苹果的设计原因,一个类初始化的实例,是不具备了解superclass的条件的,只有通过isa对应的类实体才能获得。因此,在构建objc_msgSendSuper2
的第一个参数的时候,就不如指在编译期定其对应的current_class
,以方便后续的superclass
方法列表查找。
而且,也必须在编译期间,根据当前的类,去定义current_class
这个字段的值,不然当我们有多个层级的继承关系时,在运行时如何从单一的self参数构建正确的向上查找层级,就当前的OC设计里,就做不到了。
C++里面,对于函数来说,是可以明确知道对应的所属类的。究其原因,在于C++的不同类,都是不同的命名空间,调用父类的方法时,需明确指定父类的命名空间,如BASE::method。
__objc_const
这个节的含义是所有初始化的常量的都显示在这。但是很多人都对此节有着巨大的误解,认为const int k = 5
对应的数据会存放在__objc_const
节中。
但是这是大错特错的,在代码里声明的const
类型,实质上都属于__TEXT
段,并属于其中的const
节。而在__objc_const
中存放的,是一些需要在类加载过程中用到的readonly data
。具体这个readonly data
包含了如下(但不限于)的数据结构:
// 只读数据
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;
}
};
// 方法列表
struct method_list_t:entsize_list_tt {
uint32_t entsizeAndFlags;
uint32_t count;
Element first;
}
// 方法实体
struct method_t {
SEL name;
const char *types;
IMP imp;
}
关于readonly data
后续会再开一个章节单独讲解。
结尾
基本上MachO 关于Runtime涉及的主要的类就分析到这了,下一次继续剖析其他细枝末节。