Objective-C 底层类的探究-上

1,169 阅读20分钟

“我正在参加中秋创意投稿大赛,详情请看:中秋创意投稿大赛

目录

1. 背景

学习不迷茫,无阻我飞扬!大家好我是Tommy!首先跟大加说一声抱歉,本月个人工作较多导致严重拖更了。本来计划更新2篇内容的,结果紧紧张张的才更新了一篇,希望大家能够谅解。那么废话就不说了,我们继续上路吧!~(每一篇文章都是用心做的)

本篇是Objective-C 底层类的探究,经过上面3篇文章我们已经对底层对象有了重新的认识,接下来我们开启新的内容来探究一下我们平常最熟悉的类(Class)到底是个什么东西。

2. 从isa到类再到元类

  • 两种获取类信息的方式
    • (1)我们在之前的学习中介绍过通过x/4gx 可以得到对象的isa,再通过isa跟掩码进行与操作就可以直接拿到类地址,或者还可以直接通过p/x ZXPerson.class一样可以得到类地址。 图片.png
    • (2)再扩展一种方式( ☆☆☆☆☆星装逼方式 (▼へ▼メ) ),我们可以打开MachO文件查看里面数据段中__DATA,\__objc_classrefs (类引用列表)数据,找到我们想查看的ZXPerson类,记住Offset偏移量的数值,在通过LLDB命令image list获取到ASLR,然后用ASLR + Offset得到的地址就是ZXPerson类的地址,在用 x 命令得到存放的值,最后用过po命令打印值,结果跟我们上面得到的结果一致。 图片.png 图片.png 图片.png ps:有关MachO文件说明和ASLR的知识会在文章结尾处补充说明
  • isa引出元类
    • 接下来我们可以做一下思路梳理,通过x/4gx + 对象 我们可以了解到对象的内存结构,那么我们将命令作用到类上会发生什么呢?接下来我们就一起来实验一下。 图片.png
    • 我们先x/4gx zxp 获取到对象的isa地址,再进行与掩码进行与操作获取到类地址,2个地址的值都是一样的。
    • 然后我们对得到的类地址进行x/4gx操作,得到结果后用po命令对第一个位置的值进行打印,得到的是ZXPerson,第二个位置的值得到的是NSObject。虽然这两个打印结果都显示ZXPerson类名,但是我们可以通过验证发现这两个的地址与我们自己创建的类不同。那么这个名字跟我们创建的一样,值又不同的类就是“元类” (metaclass)。 图片.png
    • 我们知道zxp对象的isa0x0000000102099540 它指向对象的类,而类的0x0000000102099518也是isa,它指向的就是这个类的元类。我们可以通过掩码来验证一下
    • 最后元类是在编译时自动创建出来的,我们可以通过查看MachO文件就可以来验证。 图片.png
  • 节点小结:
    • 我们通过验证得到了一个逻辑关系,那就是:类对象的isa指向的是类,类的isa指向的是元类。到此就会引出一张图即isa经典走位图,我们会在下一小节进行说明,那么本小结内容到此结束。

3. 经典的isa走位与元类的继承关系(面试必问)

  • isa走位图
    • 通过上面的小节解到我们自己创建的类在编译时同系统也会对应的创建一个mateClass即我们所说的元类,接下来我们一起来看一张图: 图片.png

    • 这张图我相信大家多少就是看过的,原图是出自苹果官方文档中的一张示意图。本意是对isa和类继承链的一个说明。但是你们是否真正的能看懂这张图?我相信一定有一些小伙伴还是会有一些迷惑,那么接下来我将带着你一起梳理一下,让你对isa走位的概念清晰起来。

    • isa走位概念分析:我们先来强化一下整体的概念,这一点搞明白了也就差不多了,请看下面的几个概念分析:

    • ☆每一个类都会存在一个对应的元类。

    • ☆每一个元类的isa都会指向根元类(Root class)。

    • ☆根元类是什么?怎么理解?可以理解为是NSObject的元类。

    • 那么我们来对这个走位图进行一下验证,来看看是否与图中所画的一致;首先我们可以使用代码object_getClass()打印的方式来显示每个类的值,但是记得要引用<objc/runtime.h>的头文件。 图片.png 打印结果与使用LLDB方式的效果是一样的。 图片.png

    • 到这一步,我们已经将ZXPerson的类相关的信息打印出来了,接下来打印一下NSObject信息,来看看图中的RootClass(mate)是否就是NSObject的元类。我们增加如下代码打印看看结果。 图片.png 图片.png

    • 经过输出的打印结果我们已经确信了isa走位图的真实性,那么可以对我们自己验证的结论画一个走位图便于更好的理解。 图片.png

  • isa走位的总结:
    • ☆所有实例、类、与元类的isa走位都是一致的,都是实例的isa指向类、类的isa指向对应的元类、元类的isa指向根元类(NSObject的元类)
    • ☆最后只有NSObject的元类(根元类)比较特殊,他的isa指向的是他自己。
    最后isa的走位以及关系我们已经梳理清晰了,下面我们来梳理下继承链条。
  • 元类的继承:
    • 对,你没看错!元类虽然是在编译时自动生成创建的,但他也是一个Class也有类相关的特性,其中就包含继承特性,元类也是跟普通类一样存在继承关系的。

    • 元类的继承关系其实很好理解,它与他所对应的普通类的继承关系是保持一致的。例如:ZXPerson类继承与ZXHuman类,而ZXPerson_MateClass类就继承于ZXHuman_MateClass类,从这一点我们仿佛就已经完全掌握了,But!当继承链条走到NSObject的时候还是会有特殊现象发生。我们下面还是通过代码方式来进行验证与探索。

    • 我们先分别打印普通类与元类继承链条的信息。 图片.png

    • 然后查看一下输出结果: 图片.png

    • 根据打印结果我们发现ZXPerson的元类(红框)的父类是ZXHuman的元类;ZXPerson的元类2级父类(蓝框)是NSObject的元类;ZXPerson的元类3级父类(绿框)是NSObject的类。ZXPerson的元类4级父类直接就是nil了说明已经没有父类了。 图片.png

  • 节点小结:
    • 经过上面的验证与说明我想你应该对isa走位、元类的理解有了更深一个层次的感悟了。在前几篇中我们介绍了对象的底层结构,接下来我们来一起探索一下类在底层的结构是什么样子的,那么本小结内容到此结束。

类的结构

  • 通过LLDB查看类的内存结构
    • 经过上面的学习我们已经对isa、元类有了一个大概的认知,下面我们将对类的结构进行探索,在之前探索对象结构的时候我们先是通过LLDB查看对象的内存,然后再通过分析源码进行探究,那么对于类来说也可以采用同样都思路来进行。
    • 我们还是用我们的老伙计“x/4gx”来对类进行查看,我这里还是以ZXPerson为例子,首先第一个8字节不出意外就是类的isa指针。 图片.png
    • 第二个8字节打印后发现是指向父类的指针,再往后的信息我们目前还无法得知,不过我们可以根据查看源码来探索。 图片.png (ps:我希望我给大家的一种学习的方式,日后你们如果再遇到类似问题的时候也算是提供了一种思路)
  • 通过源码查看类结构
    • 在前面的篇章中我介绍过可以通过xcrunclang命令对OC文件进行c++编译,编译后我们当时就发现了OC中的Class类型在底层编译之后会已objc_class结构体进行代替,那么我们可以通过源码直接搜索objc_class关键字来开启我们的探索。
    • 打开objc源码后command+shift+O 搜索objc_class,之后选择下图所示的结果。 图片.png
    • 点击进去后我们就能看到objc_class结构体的全貌了,红框部分就是我们通过“x/4gx”打印的成员属性,默认第一个是从objc_object继承过来的isa,第二个就是我们打印的superclass,后面两个分别是cache_t和class_data_bits,再下面就是一些方法。整体上结构体就包含了4个成员属性,其中2个是class指针,那么这里就可以大胆的猜测一下在Class中存放关键数据的就只有剩下的cache_t和class_data_bits了。 图片.png
    • 我们可以继续再往下。发现很多方法中有用到了bits这个变量,那么大概率重要的数据都是由bits来保存的。 图片.png
  • 扩展小知识
    • 在我们继续往下探索之前可以再扩展下这个__has_feature(ptrauth_calls)这段代码在我们实际开发中可能也可以用到。
    • _has_feature(ptrauth_calls): 是判断编译器是否支持指针身份验证功能,主要是针对苹果A12处理器及以上是否支持arm64e相关的能力;其中ptrauth_calls是针对指针身份的验证。
  • 节点小结:
    • 本小结对Class的结构做了一个简单探索,其中bits是比较关键的一个属性,本小结对class的结构做了一个简单探索,其中bits是比较关键的一个属性,但是这个属性我们现在不知道怎么获取到,这就需要我们具备另一个知识点,也就是了解指针与内存平移这个概念。

指针与内存平移

  • 内存平移这个概念其实不难理解,我们再第一小节中利用ASLR+MachO文件中偏移量找到我们想要的类信息的时候,就已经再使用内存平移的概念了。其原理就是通过改变当前的内存指针的便宜位置来获取我们想要的结果,下面我们来通过几个例子来更好的理解。

  • 普通类型的内存地址:
    • 我们先来段简单代码,我们分别定义3int类型的变量,其中将一个变量赋值为10后分别赋值给其他2个变量,再打印值与内存地址。 图片.png 图片.png
    • 我们发现虽然3个变量的值都是10但是其地址都是不一样的,我们可以再分别打印地址看看值是怎么存放的。 图片.png 图片.png
    • 打印之后 地址 0x7ffeefbff3fc0x7ffeefbff3f80x7ffeefbff3f4 的值都是0x0a 也就是10,对于普通类型来说值是在内存中分别存放的,并不是公用一个的。
  • 指针类型的内存地址:
    • 下面来看指针类型的内存地址,我们创建了NSObject的实例对象,然后分别打印输出。再通过LLDB进行分析,我就得到了他们之前的关系,已aObjc为例,&aObjc地址0x7ffeefbff3e8保存的值是a0 59 54 00 01 00 00 (小段模式从右往左看)得到的正行是aObjc的地址0x1005459a0,而aObjc地址保存的值是89 13 7a 80 ff ff 1d 01 正好就是aObjc对象的isa的地址0x011dffff807a1389图片.png 图片.png 图片.png
  • 数组类型的内存地址:
    • 最后我们来看一下数组的内存地址,我们还是创建一个数组。打印之后我们发现数组类型的地址就是数组首位元素的地址。那么我们是不是可以通过对内存的操作来打印数组中其他元素的值呢?答案当时是可行的。 图片.png 图片.png
  • 内存平移测试:
    • 我们对刚才是数组array[10]进行一个内存平移的测试,代码如下: 图片.png 图片.png
    • 我们通过循环对point指针进行+操作,每次+1相当于移动一个类型大小,我们数组的类型是int型,占4个字节大小,那么每次对指针+1相当于内存平移4个步长。(每个地址都间隔4个大小) 图片.png
  • 节点小结:
    • 通过上面的学习,我们再回头来看刚才获取objc_class结构体其他成员属性的问题,我们现在就可以通过内存平移的方式来进行探索了,不过还有一个问题需要搞清楚,就是我们需要平移多少才可以拿到我们想要的数据,也就是下一小结我们要搞清楚的问题即类结构的内存计算。

类的结构与内存计算

  • 看到这里我们先进行一下思路梳理,目的是对前面我们得到的结果与后面我们要达到的目在明确下。

  • 思路梳理:

    图片.png

    • 1、通过源码探究我们发现bits这个成员变量中保存的一些重要的数据信息,但是我们目前无法用过LLDB去打印它;

    • 2、我们了解了内存平移的方法,可以通过获取对象首地址+offset偏移量的方式一样可以获取到想要的数据;

    • 3、在类结构中我们已知ISAsuperclass的大小分布占用8字节,如果我们再知道cache的大小就可以通过内存平移获取到bits的数据了。

  • cache的内存大小:
  • 我们现在就来通过源码来看看objc_classcache成员的大小是多少吧,cache的类型是名为一个cache_t的结构体,我们可以点进去查看一下。 图片.png

  • 在我们上几篇内容中已经介绍过了,影响结构体大小的只有内部的成员变量;

  • 第一个变量:_bucketsAndMaybeMask类型是uintptr_t,uintptr_t类型无符号存放指针地址,所以占8字节;

  • 第二个变量:整体是一个共用体结构,所以我们需要观察共用体内部最大的占用的空间是多少。

    • 共用体内结构体第一个变量:_maybeMask类型是mask_t,点进去观察发现是一个typedef uint32_t mask_t ,实际是uint32_t类型占4字节;
    • 共用体内结构体第二个变量:_flags是uint16_t类型占用2字节;
    • 共用体内结构体第二个变量:_occupied也是uint16_t类型占用2字节;
    • 共用体内部第二个变量:_originalPreoptCache(preopt_cache_t *)指针类型所以肯定是占8字节;cache内部总共是16字节;
  • 总到此,我们如果想拿到bits的数据,需要通过首地址偏移8+8+16个字节(isa+superclass+cache),共32个字节,用16进制表示即:0x20

  • 验证:
  • 1、先获取到ZXPerson.class的首地址;

  • 2、对首地址采取内存平移,偏移0x20

  • 3、找到class_data_bits_t的指针;

  • 4、打印并调用class_data_bits_tdata()函数,看是否可以正确调用成功; 图片.png

  • 节点小结:

    至此我们探索class底层的所有障碍都已经扫清了,下面我们就可以通过LLDB对类的详细结构进行分析,本小结到此结束。

用LLDB分析类的结构

  • 开始本节之前我们再对class_data_bits_t bits 进行下详细的分析,上面我们只是粗略的了解bits中有一些重要数据,但是这些数据具体都包含了什么内容,我们还不得而知。接下来我们就先来探索一下。

  • class_data_bits_t bits 中存放了那些重要信息
    • 我们仔细观察class_data_bits_t结构体,发现其中有个data()方法,这个方法返回的是一个class_rw_t的结构体(有关rw、ro的会在后续篇章详细介绍),追踪class_rw_t我们发现该结构体中存放着methods()、properties()、protocols()这些信息,这些就是我们平常类中定义的内容,下面我就使用LLDB的方式来分析类的结构。 图片.png
  • 结构分析
    • 我们通过上面的流程已经可以通过LLDB获取到bitsdata()内容了,即class_rw_t结构体。我们通过LLDB调用class_rw_t结构体中的方法看看会有什么情况发生。 图片.png

    • 在打印properties()后可以正常返回数据,说明问我们已经获取到了properties()返回对象了,接下来我们再回到源码中,来分析是否有什么方法可以让我们打印成员属性的内容。查看源码我们知道了properties()返回的protocol_array_t是一个叫结构体。(上面通过LLDB也打印出来了) 图片.png

    • 跟进protocol_array_t源码,其内部就是一个list_array_tt(上面通过LLDB也打印出来了) 图片.png

    • 根据LLDB打印的结果list_array_tt内部应该存在一个名为list的集合,并且集合内部还有一个叫做ptr的对象, 带着这些问题我来看list_array_tt源码,首先我们先看一下注释。 图片.png

    • 可通过类别扩充的元数据的通用实现;

    • 参数Element:Element是基础元数据类型(例如,method_t);

    • 参数List:是元数据的列表类型(例如,方法列表);

    • 参数Ptr:是应用于元素的模板,用于生成元素*,有益于将限定符应用于指针类型。(Ptr参数也就是在protocol_array_t中传递的RawPtr,根据注释可以得知是一个模板,大概率是用于封装用的)

    图片.png

    • 再往下看发现内部有一个Ptr<List> list类型与LLDB打印的内容一致。我们看看是否可以通过LLDB来进行获取。

    图片.png 图片.png

    • 我们对$28.list.ptr进行打印后可以得到结果,然后再打印p *$34得到了property_list_t的数据,发现内部还有一个叫entsize_list_tt的结构体,那么property_list_tentsize_list_tt有什么关系呢?
    • 我们再回到property_array_t方法,发现调用list_array_tt时会传入3个参数,分别对应上面我们看到的注释内容中的 Element、List、Ptr。那我们就看一下property_list_t是如何定义的。 图片.png
    • 这里直接点击好像是无法跳转的,我们使用搜索方式 command+shift+O 图片.png
    • 这里发现原来property_list_t是继承与entsize_list_tt的,他自己并没有任何实现。那么我们直接来看entsize_list_tt图片.png
    • 刚才我们打印property_list_t时,结果显示出entsize_list_tt内部的count大小是4,这说明entsize_list_tt本身也属于是一个集合结构,通过源码发现果然内部存在一个迭代器(iterator)并且内部还提供了一个获取内部元素的方法,OK!,历尽千辛万苦终于找到了!我们通过get方法是否可以把我们类中的属性打印出来呢? 图片.png
    • 我们通过LLDB调用$35.get(i)``(注意i这里是下标)我们分别从0-3进行打印,0-3是我们定义的属性,zxName、zxAge、zxSex、zxHeight(打印到4的时候就会提示越界了) 图片.png
    • 属性列表我们看完了,下面我们来看看方法列表;
  • 打印方法列表数据
    • 按照同样的方式但是得到的结果却不一样,调用get()方法却无法打印方法的信息,这是为什么呢?请看下一个小结。 图片.png
  • 节点小结:

    图片.png

    • 这里附上一个UML图方便理解结构体之间的关系。
    • 本小结我们通过LLDB与源码结合的方式,对class_rw_t结构体进行的详细分析,并能够成功将属性信息进行打印输出,但是当用同样方法对方法信息打印时,却无法达到预期。那么,后续我们在下一个小结中再来探究,本小结到此结束。

成员变量与类方法

  • 继续上面的遗留问题,我们按照一样的方式可以正确获取到属性信息,但是方法信息都是空的,跟我们预想的结果不太一样,那么我们先思考一下定位问题。

    • 1、首先我们根据上面UML图来分析,当我们打印 p $6.get(0) 时,返回的是method_t的结构体,其实并不是空的,而是结构体并没有按照我们设想的那样进行输出。
    • 2、那么问题是否就出在method_t结构体中呢?我们再来看看源代码。
  • method_t源码分析
    • 发现在method_t中有一个叫big的内部结构体,并且这个结构体内部有3个成员变量。如果我们尝试打印这个是否可以达到我们预期的效果?来试一试! 图片.png
    • 我们输入p $7.get(1).big(),恩!果然将方法的名称打印出来了。 图片.png
  • 类方法与成员变量
    • 我们先定义一个含有成员变量、属性、类方法、实例方法的类,来做一个实验。目的是观察methods()properties() 是否包含这些信息。 图片.png

    • properties() 中的结果:只含有一个zxName属性,成员变量没有显示出来。 图片.png

    • methods()中的结果:含有5个方法,两个是实例方法drink、init、接着一对儿zxName属性的gettersetter,还有一个cxx_destructC++的。但是类方法却没有显示出来。

    图片.png

    • 我们先来看看如何找到成员变量nikeName,我们还先是回到class_rw_t这里,因为这里我们已知存放着methods()properties() 数据,所以成员变量一定也在这里,只不过可能藏得很深,我们从methods()往上一一排查总会又发现,先来看ro()、set_ro

    图片.png

    • set_ro函数只是一个赋值的没有意义可以过掉,再看ro()这里返回了一个class_ro_t结构体,进去看看是否有意外发现!

    图片.png

    • 来打印下看看,哈哈果然存到这里了!而且zxName属性会自动生成一个_zxName成员变量。成员变量问题解决了,下面来看看类方法在哪里?

    图片.png

    • 还是在class_rw_t里面有一个叫做baseMethods()函数,这个不会就是吧?!经过我个人试验发现里面并没有类方法的信息。(验证步骤这里我就省略了)我们分析一下,实例方法可以从类中获取到,类方法是否可以从元类中获取到呢?下面我们来验证一下。

    图片.png

    • 先通过代码打印下ZXPerson的元类

    图片.png

    • 然后按步骤进行打印,最后验证了我们设想没有问题。

    图片.png

  • 节点小结:
    • 本小结,经过对class_rw_t的源码分析,成功的将成员变量与类方法的信息找了出来,到此我们应该已经对类中存放的数据有了一个整体的认知与了解了。本小结到此结束。

9. 总结

  • 1、本篇首先引导大家从isa推导出元类,并且详细的分析并验证了isa走位、元类继承等概念。(这种概念不要硬背,自己玩一遍就理解了)
  • 2、通过采用内存平移的方式,对类的结构进行了整体的探究,主要对类中属性、方法是通过哪些结构体存放的进行了探究,并且通过LLDB来进行验证。
  • 3、最后我们初识了class_rw_t这个结构体,那么这个结构体的具体意义是什么呢?那么请看下一篇。
补充扩展
  • MachO文件:MachO属于一种文件格式,其中包含了可执行文件、静态库、动态库、dyld等;其中包含的可执行文件是集合了多种架构的,例如包含了 armv7、arm64等;

  • ASLR:在iOS系统中打开一个App的时候是会将App的二进制数据从硬盘copy到内存里,那么这时候二进制数据就会对应一个内存地址,由于考虑安全等因素的问题,内存的地址都是由虚拟缓存地址替代,而且地址的起始位置都是动态的,每次启动的时候都会不一样,这个技术就是ASLR所以当DYLD加载MachO的时候最先一步要做的就是对数据进行重定位,而这个重定位就称为rebase图片.png

写到最后
导航: