深入理解Macho文件(二)- 消失的__OBJC段与新生的__DATA段

在上文中,我们提到了有个神秘的__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

这个节列出了所有的classmetaclass自身也是一种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成员变量。
  • 按照_cmdmask的位运算,找出其在bucket数组中的偏移量。取出的数据结构是个bucket_t,如下:

    struct bucket_t {
    private:
        cache_key_t _key;
        IMP _imp;
    }
    
  • 从上述数据结构不难理解,cache对象里面存了一个bucket数组,用于进行SEL对应的IMP,缓存。keySEL对应的地址。

  • 如果地址相同,就代表命中,执行CacheHit,其实就是简单的br x17。由于此时x17是IMP,即对应的函数地址,直接跳过去就完事了,这个分支下的objc_msgSend就执行完成了。
  • 那如果不相同,即命中的bucket里面不是我们要的SEL,就检查这个命中的桶是不是没有SEL,如果是空的,执行__objc_msgSend_uncached。这步后续开始就是去查找类方法列表->父类方法列表了。
  • 如果不为空,否则就执行循环,进行查询。

**一些细节知识:

  1. .macro可以在汇编里面定义一段可以被复用的代码段。
  2. .1b 代表的是向回找label定义为1的代码片段起始;1f代表向下找label定义为1的代码片段起始。
  3. 为什么在计算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涉及的主要的类就分析到这了,下一次继续剖析其他细枝末节。