12

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

 3 years ago
source link: http://satanwoo.github.io/2017/06/29/Macho-2/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

深入理解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加载有关的节。

mach2_1.png?raw=true

__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结构体所在的地址,如下图:

mach2_2.png?raw=true

通过hopper查看地址:000000010002A128,得到如下结果:

mach2_3.png?raw=true

内存地址(还没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来看一看:

mach2_4.png?raw=true

mach2_5.png?raw=trueg

从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对比,如下图所示:

mach2_5.png?raw=true
mach2_6.png?raw=true

可以发现,所有出现的objc_superrefs都是会被继承的类。那么,为什么要单独设计这样一个来存放这样的信息呢?

哈哈哈:我上面的分析都是错的!!!!
哈哈哈:我上面的分析都是错的!!!!

哈哈哈:我上面的分析都是错的!!!!

真正的原因如下:
我们知道,我们在子类调用一个方法的时候,为了调用上层的父类的实现(如果有),常常会写出一个[super message]的代码。而这样的代码,在底层是会转换成调用objc_msgSendSuper2。而其接受的参数,第一个为结构体objc_super2,第二个为SEL。其中objc_super2的定义如下:

struct objc_super2 {
    id receiver;
    Class current_class;
};

为了构造这样的数据结构体,在汇编层面会将[super message]转换成如下的汇编指令:

mach2_7.png?raw=true

注意看红框内的汇编代码,我们来分步骤解释下整体的汇编结构:

  • 首先在调用[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中。

    mach2_8.png?raw=true
  • 然后,就轮到我们的栈空间出场了。我们先把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设计里,就做不到了。

mach2_9.png?raw=true

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涉及的主要的类就分析到这了,下一次继续剖析其他细枝末节。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK