iOS底层原理探索-01- alloc&init&new

519 阅读11分钟

《目录-iOS & OpenGL & OpenGL ES & Metal》

一、探索底层原理的思路

1、一切从main函数入手

我们想着手开始探索iOS的底层,但又不知道从哪里开始,怎么办?

那就从main函数入手!

我们先开启上帝视角!来观察一个粗略的加载流程。进行准备工作:

  1. 在main函数中直接打断点
  2. 下一个符号断点 _objc_init
  3. 关闭左侧debug栏下面的第一个按钮

运行!来看一下调用堆栈的信息:

我们可以从下到上看出一个大致的流程:

当然,这其中还进行了很多我们看不到的操作,ImageLoader中包含了我们的类、分类、属性、协议、方法、方法编号等等,我们以后再慢慢探索~ 这里想说的是:这一套下来,就是底层探索的主思路!

2、如何找到源码中具体的实现方法

2.1 引发思考

我们先看这么一段代码

  Person *p1 = [Person alloc];
  Person *p2 = [p1 init];
  Person *p3 = [p1 init];
  NSLog(@" %@ - %p",p1,&p1);
  NSLog(@" %@ - %p",p2,&p2);
  NSLog(@" %@ - %p",p3,&p3);

打印结果:

<Person: 0x600003b3fc30> - 0x7ffee88cc138
<Person: 0x600003b3fc30> - 0x7ffee88cc130
<Person: 0x600003b3fc30> - 0x7ffee88cc128

结论:

  • 打印三个对象,结果一样,但是并不能说明他们就是同一个对象;
    我们打印三个指针,得出三个指针不一样
    因此说明三个不是同一个对象,但是他们的指针指向同一片内存空间
  • 也就是说,alloc开辟了内存空间,但是init并没有对这份内存进行修改

于是,就产生了去看一下alloc方法是怎么实现的想法,但是直接command进去并没有实现方法啊。

那我们就要学会怎么找到源码!

2.2 源码探索方式

这里介绍三种方式:
注意一点:真机调试打断点找arm64、模拟器找的是x86

  1. 直接断点ctrl+in(↓) 跳到汇编,找到 libobjc.A.dylib动态库里的源码

  1. 下符号断点:先断点(防止跳到同名方法,先准确断点比较安全),然后下符号断点。就是上面提到过的(不再截图,把_objc_init换成alloc即可)找到Symbolic Breakpoint选项,在Symbol中输入要断点的内容,然后运行,就会跳到汇编里,找到 libobjc.A.dylib`+[NSObject alloc]:

  1. 汇编:断点,运行,然后在Xcode顶部找到Debug菜单,下面Debug WorkFlow中勾选 Always Show Disassembly,就来到汇编,在下面bl跳转行可以看到会进入objc_alloc里面

通过这三种方法,我们都能得出,要想看alloc的源码,要找到libobjc这个动态库,我们去官网找一下是可以找到的。

objc4-756.2源码

直接下载无法运行,去调试一下:objc4-756.2源码编译调试

【注意:小编之前是在配置好的 756.2 上调试研究的,最新版的 779.1 针对callalloc方法进行了优化,后续有时间再进行更新,有兴趣的同学,请自行探索哦~】

目前最新的是 objc4-779.1 ,下载地址: 苹果开源-10.15

直接下载无法运行,去调试一下:objc4-779.1源码编译调试

二、开始探索alloc

我们知道,alloc是用来创建对象,申请内存空间(申请内存空间是让它有了相应的指针。此时对象就有了相应的指针地址,也就意味着它就拥有了这片内存空间)。

我们可以通过卡断点,卡到alloc里面,然后通过LLDB调试,读寄存器register read。看x0是否返回一个指针地址,就知道alloc是否有申请内存空间的能力。(这里有一项规定:x0 是第一个参数的传递者,在返回的时候,也是返回值的存储地方

有兴趣的,可以自己去调试一下试试,结果当然是肯定的!alloc是具有为对象申请内存空间的能力。

而我们这里是在 配置好的libObjc-756.2源码中探索,比在项目中调试汇编要爽多了~

1、准备工作

在libObjc源码中,创建新的target自定义项目文件来跑。执行下面代码(注意这个是动态库,要在mac上跑,无法跑进iOS的沙盒,不能再iOS上跑)

Person *p = [Person alloc];

我们可以先command进去所有关联的方法都进行断点,然后具体看alloc的都进了哪些函数,就知道了它的执行流程。

2、alloc在源码中的执行流程

2.1 alloc

首先command选中来到alloc方法

+ (id)alloc {
    return _objc_rootAlloc(self);
}

2.2 _objc_rootAlloc

第二步来到_objc_rootAlloc方法

// Base class implementation of +alloc. cls is not nil.
// Calls [cls allocWithZone:nil].
id
_objc_rootAlloc(Class cls)
{
    return callAlloc(cls, false/*checkNil*/, true/*allocWithZone*/);
}

2.3 callAlloc

第三步来到callAlloc方法,但是!!!出现了岔路了!不要急,我们慢慢看,它会走到class_createInstance方法 (objc有2个版本:objc、objc2 。我们现在用的都是最新的objc2)


// Call [cls alloc] or [cls allocWithZone:nil], with appropriate 
// shortcutting optimizations.
static ALWAYS_INLINE id
callAlloc(Class cls, bool checkNil, bool allocWithZone=false)
{
	/*
    #define fastpath(x) (__builtin_expect(bool(x), 1))  表示 x 的值为真的可能性更大
	#define slowpath(x) (__builtin_expect(bool(x), 0))  表示 x 的值为假的可能性更大
    我们仔细解读一下:
    fastpath(x) 表示执行if后面的操作的可能性大
    slowpath(x) 表示执行else后面的操作的可能性大,意思就是不执行if
    
    这里是一个判空操作
    */
    if (slowpath(checkNil && !cls)) return nil;

#if __OBJC2__
	 
    if (fastpath(!cls->ISA()->hasCustomAWZ())) {
        // No alloc/allocWithZone implementation. Go straight to the allocator.
        // 大概意思:既没有实现 alloc,也没有实现 allocWithZone 就会来到这里,下面直接进行内存开辟操作。
        // fixme store hasCustomAWZ in the non-meta class and 
        // add it to canAllocFast's summary
        //大概意思:修复没有元类的类,也就是没有继承于 NSObject
        //判断当前类是否可以快速开辟内存,注意,这里永远不会被调用,因为 canAllocFast 内部返回的是false 
        if (fastpath(cls->canAllocFast())) {
            // No ctors, raw isa, etc. Go straight to the metal.
            bool dtor = cls->hasCxxDtor();
            id obj = (id)calloc(1, cls->bits.fastInstanceSize());
            if (slowpath(!obj)) return callBadAllocHandler(cls);
            obj->initInstanceIsa(cls, dtor);
            return obj;
        }
        else {
            // Has ctor or raw isa or something. Use the slower path.
            id obj = class_createInstance(cls, 0);
            if (slowpath(!obj)) return callBadAllocHandler(cls);
            return obj;
        }
    }
#endif

    // No shortcuts available.
    if (allocWithZone) return [cls allocWithZone:nil];
    return [cls alloc];
}



2.4 class_createInstance

第四步来到class_createInstance方法,继续!

id 
class_createInstance(Class cls, size_t extraBytes)
{
    return _class_createInstanceFromZone(cls, extraBytes, nil);
}

2.5 _class_createInstanceFromZone

第五步来到_class_createInstanceFromZone方法。这里接着往下读,会发现calloc点不进去了!因为这部分代码在malloc的源码当中,我们之后再说。

cls->instanceSize 拿的class需要的内存大小
obj = (id)calloc(1, size); calloc开辟了这么大的空间,这个空间就是obj
obj->initInstanceIsa 通过isa把obj和class关联起来
最后返回obj,就完成了整个流程

static __attribute__((always_inline)) 
id
_class_createInstanceFromZone(Class cls, size_t extraBytes, void *zone, 
                              bool cxxConstruct = true, 
                              size_t *outAllocatedSize = nil)
{
	// 对 cls 进行判空操作
    if (!cls) return nil;
	// 断言 cls 是否实现了
    assert(cls->isRealized());

    // Read class's info bits all at once for performance
    // cls 是否有 C++ 的初始化构造器
    bool hasCxxCtor = cls->hasCxxCtor();
    // cls 是否有 C++ 的析构器
    bool hasCxxDtor = cls->hasCxxDtor();
    // cls 是否可以分配 Nonpointer,如果是,即代表开启了内存优化
    bool fast = cls->canAllocNonpointer();

	// 这里传入的 extraBytes 为0,然后获取 cls 的实例内存大小
    size_t size = cls->instanceSize(extraBytes);
    // 这里 outAllocatedSize 是默认值 nil,跳过
    if (outAllocatedSize) *outAllocatedSize = size;

    id obj;
    // 这里 zone 传入的也是nil,而 fast 拿到的是 true,所以会进入这里的逻辑
    if (!zone  &&  fast) {
    
    /*
    来看这个方法,calloc就是开辟的意思,源码在malloc中
    calloc开辟了obj的空间,并且obj是这个方法的返回值
    通过initInstanceIsa方法,把obj和class(我们传进来的第一个参数)通过isa关联起来,于是就成功为我们的对象开辟了空间
    那calloc中的size呢?开辟了多少内存空间呢?是这个cls->instanceSize方法实现的
    */
    	// 根据 size 开辟内存
        obj = (id)calloc(1, size);
        // 如果开辟失败,返回 nil
        if (!obj) return nil;
        // 将 cls 和是否有 C++ 析构器传入给 initInstanceIsa,实例化 isa
        obj->initInstanceIsa(cls, hasCxxDtor);
    } 
    else {
    	// 如果 zone 不为空,经过测试分析,一般来说调用 alloc 不会来到这里,只有 allocWithZone
        // 或 copyWithZone 会来到下面的逻辑
        if (zone) {
       		// 根据给定的 zone 和 size 开辟内存
            obj = (id)malloc_zone_calloc ((malloc_zone_t *)zone, 1, size);
        } else {
        	// 根据 size 开辟内存
            obj = (id)calloc(1, size);
        }
        // 如果开辟失败,返回 nil
        if (!obj) return nil;

        // Use raw pointer isa on the assumption that they might be 
        // doing something weird with the zone or RR.
        // 初始化 isa
        obj->initIsa(cls);
    }

	// 如果有 C++ 初始化构造器和析构器,进行优化加速整个流程
    if (cxxConstruct && hasCxxCtor) {
        obj = _objc_constructOrFree(obj, cls);
    }

    return obj;
}

2.6 alloc流程总结

我们是根据自定义一个类去执行alloc方法查找流程的,如果是其他情况可能出现不同的流程走向。那么我们对初始化的大致流程进行总结一下:

2.7 allocWithZone

我们根据一步步点击进去看实现方法(就是2.1~2.5的流程),但是当我们细心一点,在所以alloc有关函数打断点再运行调试会发现,首先进入的是objc_alloc这个方法

// Calls [cls alloc].
id
objc_alloc(Class cls)
{
    return callAlloc(cls, true/*checkNil*/, false/*allocWithZone*/);
}

然后在callAlloc方法中,因为传入参数checkNil = true, allocWithZone = false ,然后直接走到return [cls alloc]方法,然后就是我们2.1~2.5的流程。

我也去查阅了一些资料,有大神指出,是在macho生成的时候会绑定一个symbol,里面会把sel_alloc的编号绑定到了objc_alloc上,这部分没有真正开源。但是objc_alloc只会走一次,最终也是会走到alloc的正常流程。并且还找出验证猜想的地方:

/***********************************************************************
* fixupMessageRef
* Repairs an old vtable dispatch call site. 
* vtable dispatch itself is not supported.
**********************************************************************/
static void 
fixupMessageRef(message_ref_t *msg)
{    
    msg->sel = sel_registerName((const char *)msg->sel);

    if (msg->imp == &objc_msgSend_fixup) { 
    	//***看这里!!!这个if判断里
        if (msg->sel == SEL_alloc) {
            msg->imp = (IMP)&objc_alloc;
        } else if (msg->sel == SEL_allocWithZone) {
            msg->imp = (IMP)&objc_allocWithZone;
        } else if (msg->sel == SEL_retain) {
            msg->imp = (IMP)&objc_retain;
        } else if (msg->sel == SEL_release) {
            msg->imp = (IMP)&objc_release;
        } else if (msg->sel == SEL_autorelease) {
            msg->imp = (IMP)&objc_autorelease;
        } else {
            msg->imp = &objc_msgSend_fixedup;
        }
    } 
    else if (msg->imp == &objc_msgSendSuper2_fixup) { 
        msg->imp = &objc_msgSendSuper2_fixedup;
    } 
    else if (msg->imp == &objc_msgSend_stret_fixup) { 
        msg->imp = &objc_msgSend_stret_fixedup;
    } 
    else if (msg->imp == &objc_msgSendSuper2_stret_fixup) { 
        msg->imp = &objc_msgSendSuper2_stret_fixedup;
    } 
#if defined(__i386__)  ||  defined(__x86_64__)
    else if (msg->imp == &objc_msgSend_fpret_fixup) { 
        msg->imp = &objc_msgSend_fpret_fixedup;
    } 
#endif
#if defined(__x86_64__)
    else if (msg->imp == &objc_msgSend_fp2ret_fixup) { 
        msg->imp = &objc_msgSend_fp2ret_fixedup;
    } 
#endif
}

fixupMessageRef 实际上是在_read_images里调用的,是在启动后读取文件时候,大致就是出现问题了,然后走到这里进行修复,然后进行了绑定,才会调用objc_alloc这个方法。(期待后面苹果会开源出来让我们继续研究)

从苹果官方文档也能看出,其实 allocWithZone 本质上和 alloc 是没有区别的,只是在OC开发的早期,程序员需要使用诸如 allocWithZone 来优化对象的内存结构,但是现在,我们写 alloc 和 allocWithZone 在底层其实是一模一样的。

三、开始探索init

看完了alloc,我们再来看看init。你会发现,init就是懒省事,最终直接返回self。其实就是把大部分工作交给了alloc完成。

init的唯一作用就是规范代码,交给子类去自定义重写,其他没有实质性的功能作用 看下源码:

+ (id)init {
    return (id)self;
}

- (id)init {
    return _objc_rootInit(self);
}

id
_objc_rootInit(id obj)
{
    // In practice, it will be hard to rely on this function.
    // Many classes do not properly chain -init calls.
    return obj;
}

四、开始探索new

点到源码,你会发现,它比init还懒,相当于直接返回alloc+init方法。

+ (id)new {
    return [callAlloc(self, false/*checkNil*/) init];
}

五、内存的Size 及 字节对齐

我们在刚刚探索alloc的时候,cls->instanceSize方法拿到对象的大小,那么这个大小是怎么计算出来的呢?也就是说我们的对象需要开辟多少内存空间,来存放它的属性?就好比,要自己建房子,根据都有谁住,总共住多少人来规划要有多少个房间?

我们一步步分析:

1、首先从这个size方法入手

//这个方法计算的size,extraBytes是传进来的,是0,在callAlloc方法里面可以看见
size_t size = cls->instanceSize(extraBytes);

2、接着来到instanceSize方法, 先不管计算的size,看后面一句,size最小是16字节

size_t instanceSize(size_t extraBytes) {
        size_t size = alignedInstanceSize() + extraBytes;
        // CF requires all objects be at least 16 bytes.
        //最小返回16字节
        if (size < 16) size = 16;
        return size;
    }

3、接着来到alignedInstanceSize方法,看名字就是知道:要和我们的实例对象进行对齐

	// Class's ivar size rounded up to a pointer-size boundary.
    uint32_t alignedInstanceSize() {
    
    	//把没有字节对齐的东西,进行字节对齐 之后,返回出去
        return word_align(unalignedInstanceSize());
    }

4、接着来到word_align方法,它是一个字节对齐的算法:表示内存是以8字节为倍数对齐的(代码里有演算注释)

补充一下:数据存储以字节为单位,数据传输以为单位。一个就代表二进制的一个0或者1。每8个位组成一个字节

指针:
在64位下,是8字节(64个位 = 8个字节)。
在32位下,是4字节(32个位 = 4个字节)。

#   define WORD_MASK 7UL


static inline uint32_t word_align(uint32_t x) {
 	/*
    	WORD_MASK是7,当前对象没有声明属性,只有一个指针,而指针是8字节,所以x=8
    	(x + WORD_MASK) = 15 二进制: 0000 1111
        WORD_MASK = 7 二进制: 0000 0111
        ~WORD_MASK = ~7 二进制取反: 1111 1000
        (x + WORD_MASK) & ~WORD_MASK = 15 & ~7 二进制计算:
        
        0000 1111
        &
        1111 1000
        =
        0000 1000  (其实就是8!)
        
        这里~7 是为了补齐8字节, +7 是为了向上取整,拿到8的倍数。
        
        这里再随便举个例子,x=12,   x + WORD_MASK = 19 二进制:0001 0011
        0001 0011
     	&
        1111 1000
     	=
        0001 0000  (就是16!)
        所以说:这个算法,就是拿到以8为倍数的结果!
        也就是说:内存是以8字节为倍数对齐的!!!
    */
     
    return (x + WORD_MASK) & ~WORD_MASK;
}

5、最后还有unalignedInstanceSize()方法,这个方法主要是拿到实例对象data段里面的一些信息,也就是要计算这些信息需要的size,然后进行字节对齐,返回最终的size

	// May be unaligned depending on class's ivars.
    uint32_t unalignedInstanceSize() {
        assert(isRealized());
        return data()->ro->instanceSize;
    }

小扩展

为什么要以8字节对齐?

举个例子:我有5个属性,分别是3、5、2、4、6个字节,存储在内存当中。但是,当我们去读的时候,就会发现很难受,我怎么知道你第一个属性有几个字节?或者说,我取多少个字节,才能拿到你的第一个属性呢? 即便,我知道了几个字节,第一次查3个字节,读出来。然后再查5个字节,读出来。也是很费时的操作~

于是,CPU就有个“以空间换取时间”的方式,存的时候8字节一段一段地存,读的时候也8字节一段一段地读。这样易读性就提高了很多!

六、总结

1、alloc底层流程是:alloc -> _objc_rootAlloc -> callAlloc -> class_createInstance -> _class_createInstanceFromZone 进行计算内存大小instanceSize、开辟空间calloc、isa把空间指向对象initInstanceIsa

2、init底层分析:init -> _objc_rootInit()直接返回self。它的意义实际上是交给子类去自定义重写。

3、new底层分析:它的底层是直接返回[callAlloc() init]方法。其中callAllocalloc中是一模一样的,也相当于交给 alloc + init去做的。

4、内存分配: (64位下)内存中对象的属性是以8字节为倍数对齐的,最小开辟16字节(大概是为了防止一些越界操作,如果小于16字节的,会预留一些位置)


今天探索alloc的底层流程,还有一些疑虑点,比如:真正的内存对齐原则?calloc里面的实现?isa如何关联对象和类? 这些问题留在下个篇章去探索~