探索OC对象的本质(下)

250 阅读8分钟

callAlloc为什么会调用两次?

我们上篇文章对alloc的调用流程进行了梳理,可我们通过汇编发现调用了objc_alloc:

截屏2022-04-16 下午3.59.22.png

我们分别在源码的objc_allocalloc处打断点,发现断点会先走objc_alloc,再走alloc。 我们跟着objc_alloc这条线走,会发现它的调用流程应该是这样的: 绘图1.png

我们发现callAlloc方法走了两次,一次是objc_msgSend,一次是_objc_rootAllocWithZone,而且alloc的调用并没有直接去走alloc的源码处,而是先去了objc_alloc处,这是为什么呢?

调用alloc的时候,并没有走alloc的实现,而是走了其他方法的实现。我们联想到sel以及implement,我们可以通过sel找到函数的地址,进而找到函数的实现。那现在这种情况肯定是在某个地方系统对allocimplement进行了改写。我们在源码中发现了如下代码: 截屏2022-04-16 下午7.04.16.png 果不其然,系统对其implement进行了改写。我们找到该方法的调用之处: 未命名.png _read_images的注释写到对以headerList开头的链表中的头进行初始化处理。该方法我们猜想应该是在llvm后端编译的时候进行了调用,如果想要继续探究下去,可以在llvm的源码中的到答案。大致的意思是,苹果在编译期间对alloc这种常用的特殊方法进行了Hook(HOOK,中文译为“挂钩”或“钩子”。在iOS逆向中是指改变程序运行流程的一种技术。通过hook可以让别人的程序执行自己所写的代码。在逆向中经常使用这种技术),然后做了自己的一些事情以便后续的处理。

成员变量会影响实例对象的大小,方法不会影响。

我们前面已经知道成员变量或者属性会影响对象开辟内存的大小,那同样也会影响实例对象的大小(class_getInstanceSize) 截屏2022-04-16 下午9.11.30.png

截屏2022-04-16 下午9.12.29.png

当只有一个字符串类型的name属性的时候,实例大小为16。

截屏2022-04-16 下午9.16.27.png

截屏2022-04-16 下午9.15.17.png

实例大小变为了24。

截屏2022-04-16 下午9.18.13.png

截屏2022-04-16 下午9.20.47.png

我们添加了一个实例方法,发现对其大小并无影响。

截屏2022-04-16 下午9.21.59.png

截屏2022-04-16 下午9.23.12.png

类方法也不会对其大小造成影响。

结论:成员变量会影响实例对象的大小,而实例方法和类方法并不会。

结构体内存对齐

对齐规则如下: 截屏2022-04-17 上午9.50.33.png

我们定义几个结构体进行探索: 截屏2022-04-17 上午9.57.08.png

我们按照对齐规则对第一个结构体进行分析:

struct Struct1 {

    double a; // 8字节,[0...7]
    
    char b;  // 1字节,从8开始,是1的倍数。[8]
    
    int c; // 4字节,从9开始,不是4的倍数,所以从12开始。(9,10,[11,12,13,14]
    
    short d; // 2字节,从15开始,不是2的倍数,从16开始。[16,17]
    
}struct1;

由规则3得知,计算出来的结果需要是8的倍数,大于17的最小的8的倍数是24。所以该结构体的内存大小为24。 我们可以对此进行验证:

截屏2022-04-17 上午10.06.02.png 没毛病

第二个结构体:

struct Struct2 {

    double a; // 8字节,[0...7]

    int b; // 4字节,从8开始,是4的倍数。[8,9,10,11]

    char c; // 1字节,从12开始,是1的倍数。[12]

    short d; // 2字节,从13开始,不是2的倍数,所以从14开始。(13,[14,15]

}struct2;

由规则3得知,计算出来的结果需要是8的倍数,大于15的最小的8的倍数是16。所以该结构体的内存大小为16。 我们可以对此进行验证:

截屏2022-04-17 上午10.14.23.png

struct1struct2成员相同,顺序不同,得到的内存大小也不同,也就是说顺序会影响结构体的内存大小。

第三个结构体:

struct Struct3 {

    double a; // 8 [0...7]

    int b; // 4 [8...11]

    char c; // 1 [12]

    short d; // 2 [13,14]

    int e; // 4 (15,[16,17,18,19]

    struct Struct1 str; 

}struct3;

struct Struct1 {

    double a; // 8 (20,21,22,23,[24...31]
    
    char b;  // 1 [32]
    
    int c; // 4 (33,34,35,[36...39]
    
    short d; // 2 [40,41]
    
}struct1;

由规则3得知,计算出来的结果需要是8的倍数,大于41的最小的8的倍数是48。所以该结构体的内存大小为48。 我们可以对此进行验证:

截屏2022-04-17 上午10.21.01.png

系统内存开辟分析

我们定义了Person类

截屏2022-04-17 上午10.51.35.png

我们对其sizeofclass_getInstanceSizemalloc_size进行输出:

截屏2022-04-17 上午10.53.09.png

我们可以发现sizeof()的值为8,地址指针大小为8字节;class_getInstanceSize()的值为24,一个字符串属性的大小为8,一个int类型的大小为4,再加上isa指针的大小88+4+8=208字节对齐,所以为24。可是为什么malloc_size的值为32,我们找到其源码libmalloc的核心代码进行探索:

截屏2022-04-17 下午4.49.52.png

(size+16-1) >> 4 << 4这个算法似曾相识,我们之前在探索alloc流程的时候申请内存空间用的就是这个算法,16字节对齐。

针对对象内部是8字节对齐,针对不同的对象是16字节对齐

接下来我们对其属性赋值并打上断点: 截屏2022-04-19 上午11.16.11.png

运行程序来到断点,通过lldb命令x/4gx打印对象p的内存分布,并对地址相应地址进行打印,结果没毛病:

截屏2022-04-19 上午11.20.26.png

我们再添加一个int属性并对其赋值,打印对象的内存分布,并对地址进行打印:

截屏2022-04-19 下午9.00.13.png

截屏2022-04-19 下午5.26.19.png

我们发现int类型的_age成员变量和int类型的_height存放在了同一个8字节里,因为苹果对其进行了优化重排,两个int类型刚好是8字节,这样可以节省空间。这样说明属性的书写顺序以及它们的赋值顺序都不会影响其内存大小。

接下来我们看看子类继承父类的内存申请以及分布情况,我们定义以下类,Teacher类继承自Person类,内存分布以及相关地址值为:

截屏2022-04-19 下午7.54.14.png

846f926f139147f088f6745f4c1fd78f~tplv-k3u1fbpfcp-watermark.image.png

我们调换父类的成员变量的位置并查看其内存分布: 截屏2022-04-19 下午7.52.37.png 截屏2022-04-19 下午7.56.11.png

我们发现,子类不能重排父类的成员变量,因为父类已经定型,子类只有在父类的基础上进行优化。

位域与联合体

截屏2022-04-19 下午8.47.47.png

我们为其加上位域,发现其大小变为了1。因为一个BOOL类型不是代表0就是1,只需要占用1位,位域的作用就是如此,规定其占用几位,但是不能大于其类型的大小。4BOOL类型就是4位,不足1字节也就是1字节。(百度解释:位段,C语言允许在一个结构体中以位为单位来指定其成员所占内存长度,这种以位为单位的成员称为“位段”或称“位域”。利用位段能够用较少的位数存储数据。)

截屏2022-04-19 下午8.49.34.png

联合体在进行某些算法的C语言编程的时候,需要使几种不同类型的变量存放到同一段内存单元中。也就是使用覆盖技术,几个变量互相覆盖。这种几个不同的变量共同占用一段内存的结构,在C语言中,被称作“共用体”类型结构,简称共用体,也叫联合体

截屏2022-04-19 下午9.13.17.png

说明联合体成员变量之间是互斥的,共享同一块内存区域。相对于结构体的共存,粗放的特性来说,联合体能更节省内存空间。

isa指针结构探索

我们在探索alloc的流程后知道了alloc在底层做了申请空间以及关联类的操作,那么是如何进行关联的?

如图是alloc内部流程中的_class_createInstanceFromZone方法,我们打上如图两个断点,对obj进行打印,发现第二个断点携带了Person信息,说明中间的代码对内存和类进行了关联。

截屏2022-04-22 下午4.05.20.png

两个分支中的方法都调用了objc_objectinitIsa方法:

截屏2022-04-22 下午4.15.14.png

截屏2022-04-22 下午4.15.44.png

我们对initIsa方法进行探索:

截屏2022-04-22 下午4.18.36.png

我们发现其都是对newIsa这个变量进行赋值,其类型为isa_t,我们查看其结构:

截屏2022-04-22 下午4.22.16.png

这个玩意是个联合体,联合体的特点是成员变量之间是互斥的,共享同一块内存区域。我们对其中的ISA_BITFIELD进行研究,发现其是个宏定义,而且这里也会区分不同的架构(intel芯片的x86_64arm64的真机和模拟器,请根据自己的架构进行分析,这个目前是arm64真机模式):

截屏2022-04-22 下午4.29.36.png

所以其原来的样子是这样的,是我们熟知的位域:

struct {
    uintptr_t nonpointer        : 1;                                       \
    uintptr_t has_assoc         : 1;                                       \
    uintptr_t has_cxx_dtor      : 1;                                       \
    uintptr_t shiftcls          : 33; /*MACH_VM_MAX_ADDRESS 0x1000000000*/ \
    uintptr_t magic             : 6;                                       \
    uintptr_t weakly_referenced : 1;                                       \
    uintptr_t unused            : 1;                                       \
    uintptr_t has_sidetable_rc  : 1;                                       \
    uintptr_t extra_rc          : 19
}

这些空间加起来刚好64位,8个字节,也刚好是一个isa指针的大小。我们之前说过isa指针存放着指针地址,64位的空间对于一个地址来说绰绰有余,所以其也存储了其他的东西:

截屏2022-04-22 下午4.42.55.png

我们知道isa指针中的地址指向了类对象,而这个地址存储在了shiftcls中,占了33位。我们对其进行解析,并找出类对象的地址,那该怎么做呢,我们通过位运算来进行解析,其内存分布应该是这样的:

截屏2022-04-22 下午4.50.28.png

我们对p对象的内存分布进行打印,第一个地址应该就是我们需要操作的地址,根据上图所示,我们需要对该地址进行先右移3位,再左移31位,最后右移28位就能得到isa的真正地址,也就是类对象所在的地址。Person.class返回类对象,p/x打印类对象的地址进行验证,验证正确。

截屏2022-04-22 下午4.59.58.png