阅读 799

iOS底层--方法查找流程分析(附面试坑点)

欢迎阅读iOS底层系列(建议按顺序)

iOS底层 - alloc和init探索

iOS底层 - 包罗万象的isa

iOS底层 - 类的本质分析

iOS底层 - cache_t流程分析

iOS底层 - 方法查找流程分析

iOS底层 - 消息转发流程分析

iOS底层 - dyld是如何加载app的

iOS底层 - 类的加载分析

本文概述

本文主要分析方法在底层的本质,方法发送的几种情况,方法查找流程等,结合cache_t,对消息发送流程有一个更宏观的理解。

面试坑点

先抛出一个面试题:

为什么子类可以调用类方法来实现NSObject的对象方法?

如果不深入了解方法查找流程,可能会有被卡住。下面就是对方法查找流程的分析(最后附加答案)。

runtime简述

上篇文章iOS底层-cache_t流程分析说明了cache_t缓存的是方法,那方法是什么,调用方法实际是在做什么。这些都和runtime有密切关系。

a.runtime是什么

我们都知道oc具有运行时特性,可是oc底层是编译成cc++这样的静态语言,是不具备运行时的。这时候iOS底层就封装了一套由cc++汇编写的api,用来给oc提供运行时功能,这就是runtime

b.runtime版本

runtime是有个两个版本的:

  • legacy

  • modern

底层源码中使用!__OBJC2____OBJC2__来区分它们。现在使用的一般都是__OBJC2__,所以我们基本可以忽略legacy版本。

c.runtime的调用类型

runtime的调用只有三种类型

  • Objective - C Code (例:@Selector())

  • NSObject的方法 (例:performSelector())

  • runtime api(例:sel_registerName())

方法的本质

方法其实只是静静躺在class_rw_t中的代码段,严格来说,这里应该是调用方法的本质。

先创建一个CJPerson类,初始化并调用方法,下面同时调用一个自定义函数。

void play(){
    NSLog(@"%s",__func__);
}

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        CJPerson *person = [CJPerson alloc];
        [person work];
        
        play();
    }
    return 0;
}
复制代码

之前探索类的本质时,使用了clang编译,这里也故技重施

clang -rewrite-objc main.m -o main.cpp

打开main.cpp,直接来到最后

int main(int argc, const char * argv[]) {
    /* @autoreleasepool */ { __AtAutoreleasePool __autoreleasepool; 
        CJPerson *person = ((CJPerson *(*)(id, SEL))(void *)objc_msgSend)((id)objc_getClass("CJPerson"), sel_registerName("alloc"));
        ((void (*)(id, SEL))(void *)objc_msgSend)((id)person, sel_registerName("work"));

        play();
    }
    return 0;
}
复制代码

整理下,去掉强转

int main(int argc, const char * argv[]) {
    /* @autoreleasepool */ { __AtAutoreleasePool __autoreleasepool; 
        CJPerson *person = objc_msgSend(objc_getClass("CJPerson"), sel_registerName("alloc"));
        objc_msgSend(person, sel_registerName("work"));

        play();
    }
    return 0;
}
复制代码

可以看到,调用方法就是通过objc_msgSend来发送消息,可是调用play()函数却没有发送消息。

其实,发送消息就是在找函数实现imp的过程,paly()函数指针直接对标到了函数实现,也就不需要发送消息了

objc_msgSend这里有两个参数

  • id 消息接收者
  • sel 方法编号

假设存在缓存的情况,有了这两个参数就可以用id在对应的cls中的cache_t,把sel生成的key&mask得到哈希下标,通过了解过iOS底层-cache_t流程分析,这里会比较清晰。

消息发送的几种区别

根据开发经验,方法一般有四种调用情况:本类对象方法本类类方法父类对象方法父类类方法

依次验证下,在创建一个CJStudent类,继承于CJPerson,两者声明各自的对象方法和类方法。在CJStudent中调用,然后clang编译(不要在意这里递归死循环,只看编译结果,不运行)

- (void)study{
    [super work];//父类对象方法
    [self study];//本类对象方法
}

+ (void)play{
    [super buy];//父类类方法
    [CJStudent play];//本类类方法
}
复制代码

对应clang的结果部分(简化后):

static void _I_CJStudent_study(CJStudent * self, SEL _cmd) {
    //父类对象方法
    objc_msgSendSuper({self, class_getSuperclass(objc_getClass("CJStudent"))}, sel_registerName("work"));
    //本类对象方法
    objc_msgSend(self, sel_registerName("study"));
}

static void _C_CJStudent_play(Class self, SEL _cmd) {
    //父类类方法
    objc_msgSendSuper({self, class_getSuperclass(objc_getMetaClass("CJStudent"))}, sel_registerName("buy"));
    //本类类方法
    objc_msgSend(objc_getClass("CJStudent"), sel_registerName("play"));
}
复制代码

综合结果如下:

方法类型底层调用消息接收者传递父类
本类对象方法objc_msgSendself
本类类方法objc_msgSendself.class
父类对象方法objc_msgSendSuperself类的父类
父类类方法objc_msgSendSuperself元类的父类
```!
可以看出,这里最明显的区别在于,objc_msgSend和objc_msgSendSuper
```
基本可以确认,导致消息发送不一致的主要原因在于objc_msgSendSuper,我们经常说的都是objc_msgSend,那objc_msgSendSuper又是怎么回事,只能点击它看下:

可以看到,和objc_msgSend的主要区别在于第一个参数objc_super

struct objc_super {
    __unsafe_unretained _Nonnull id receiver;
#if !defined(__cplusplus)  &&  !__OBJC2__
    __unsafe_unretained _Nonnull Class class;
#else
    __unsafe_unretained _Nonnull Class super_class;
#endif
};
复制代码

objc_super是个结构体,需要两个参数,一个是id receiver,因为现在runtime__OBJC2__版本,所以第二个是Class super_class

明白了参数的意思,上面的结论也就好理解了:

  • 父类对象方法要去父类的方法列表查找
  • 父类类方法要去父类的元类的方法列表查找
  • 本类类方法对调用者主体是class
  • 需要注意,super 调用 objc_msgSendSuper 告诉系统

去父类方法列表里面去找,但是调用者主体还是 self

方法查找流程

1.寻找切入点

到了这里,一切的源头都指向objc_msgSend,老规矩,还是要去源码看一看。可是问题又来了,这源码这么多份,要去哪份里看呢?

提供一个思路:

根据目前已知条件,调用方法会执行objc_msgSend,那在工程下一个objc_msgSend符号断点,等跑到调用方法那一步时开启符号断点。断点来到:

居然发现,objc_msgSendobjc源码里面,总算定位到了一个小范围。

开开心心打开objc源码,还是尝试搜索下objc_msgSend 有600多个相关,直接奔溃,看来此路不通,还要再想想换个搜索关键字。

换个角度想,objc_msgSend是要被调用的方法,调用方法的一般格式为方法名(),那就可以搜索下objc_msgSend(, 搜索结果只有两个部分,.h部分汇编部分,首先,.h可以排除,源码实现和调用不可能在.h,那只剩下汇编了,难道objc_msgSend在底层是用汇编来实现的嘛。

回头想想,objc_msgSend是可变参数的,对于静态语言c来说,不能有效识别,确实很有可能使用汇编来实现。

经过各方面资料研究后,确认了objc_msgSend的快速查找由汇编实现,并得出两个原因:

  • 在c语言中不可能通过函数来保留未知参数并跳转到任意的函数指针
  • objc_msgSend在底层属于高频事件,对性能要求较高,必须足够快
  • 使用汇编可以有效防止系统函数被hook,更加安全

2.快速查找

既然知道了objc_msgSend是汇编实现,那只能硬着头皮去看看汇编了。 这里选择从常用的arm64着手,一般看汇编是从入口ENTRY开始,直接找到类似 ENTRY objc_msgSend 的地方就是要开始探索的地方

小知识点:x0 ~ x7存放参数,并且x0还存放返回值

1.对比第一个参数是否为空,也就是接收者self
2.判断self是否是TaggedPoint类型,此类型无需发送消息.
3.拿到第一个参数id的x0地址的值放在p13,也就是isa(参照对象和类的结构,首地址都是isa)
4.通过isa_mask得到class,这是p16等于class的原因,也就是获取方法所在的地方
5.查找isa完毕,先看看缓存里面有没有,也就是快速查找流程开始
复制代码

这里扩展下④和⑤

④:GetClassFromIsa_p16 内部通过isa_mask得到class

⑤:CacheLookup NORMAL

 * CacheLookup NORMAL|GETIMP|LOOKUP
 * 
 * Locate the implementation for a selector in a class method cache.
 *
 * Takes:
 *	 x1 = selector //第二个参数sel
 *	 x16 = class to be searched //通过isa得到
 *
复制代码

CacheLookup三种类型:正常(快速)查找|GETIMP|慢速查找

#define SUPERCLASS       __SIZEOF_POINTER__
#define CACHE            (2 * __SIZEOF_POINTER__)

1.x16平移CACHE(这里CACHE是定义为16字节的宏)得到cache_t,将cache_t的值取出来放在p10和p11。p10放buckets独占8位,p11放occupied和mask各占前后四位。
复制代码
struct cache_t {
    struct bucket_t *_buckets;//前8位
    mask_t _mask;//4位
    mask_t _occupied;//4位
}
复制代码
2.用w1的_cmd & w11的mask 得到w12,也就是找方法的哈希下标。这里用w,因为mask类型是32位就够了,并且因为小端模式取后四位即为mask。
复制代码
static inline mask_t cache_hash(cache_key_t key, mask_t mask) {
    return (mask_t)(key & mask);
}
复制代码
3.通过平移得到bucket的有效地址,然后从x12的bucket拿出imp放p17和sel放p9
复制代码
4.开始对比bucket内的sel和传进来的cmd,NoEqual时候走2fCheckMiss流程和循环查找buckets,否则CacheHit缓存命中。
复制代码
5,虽然调用了CheckMiss,但是CheckMiss有cbz判断sel是否为0,若不为0,则判断bucket ==buckets,Equal时候开始跳转到3f,否则开始循环递减查找buckets,循环递减查找buckets时,为了防止多线程更新缓存,有跳转1b重新查找流程;如果为0,也就是没有缓存,就开始__objc_msgSend_uncached慢速流程
复制代码
.macro CheckMiss
	// miss if bucket->sel == 0
.if $0 == GETIMP。
	cbz	p9, LGetImpMiss
.elseif $0 == NORMAL
	cbz	p9, __objc_msgSend_uncached
.elseif $0 == LOOKUP
	cbz	p9, __objc_msgLookup_uncached
复制代码
6.重复上面③④⑤的查找流程,如果都找完了还是没有找到缓存,则直接跳转到JumpMiss,再强制跳转__objc_msgSend_uncached。
复制代码
.macro JumpMiss
.if $0 == GETIMP
	b	LGetImpMiss
.elseif $0 == NORMAL
	b	__objc_msgSend_uncached
.elseif $0 == LOOKUP
	b	__objc_msgLookup_uncached
复制代码

__objc_msgLookup_uncached

目前已知,如果缓存未命中,会来到__objc_msgLookup_uncached,那看看其流程:

STATIC_ENTRY __objc_msgLookup_uncached
UNWIND __objc_msgLookup_uncached, FrameWithNoSaves

MethodTableLookup
ret
复制代码

__objc_msgLookup_uncached流程内,只有MethodTableLookup,看字面意思是方法列表查找,那么是直接就开始查找方法列表了嘛?也是用汇编进行查找嘛?只能继续往下看:

.macro MethodTableLookup

	// push frame
	SignLR
	stp	fp, lr, [sp, #-16]!
	mov	fp, sp

	// save parameter registers: x0..x8, q0..q7
	sub	sp, sp, #(10*8 + 8*16)
	stp	q0, q1, [sp, #(0*16)]
	stp	q2, q3, [sp, #(2*16)]
	stp	q4, q5, [sp, #(4*16)]
	stp	q6, q7, [sp, #(6*16)]
	stp	x0, x1, [sp, #(8*16+0*8)]
	stp	x2, x3, [sp, #(8*16+2*8)]
	stp	x4, x5, [sp, #(8*16+4*8)]
	stp	x6, x7, [sp, #(8*16+6*8)]
	str	x8,     [sp, #(8*16+8*8)]

	// receiver and selector already in x0 and x1
	mov	x2, x16
	bl	__class_lookupMethodAndLoadCache3

复制代码

挺长,前面一大段具体不太明白,但可以看出是地址操作,不影响我们阅读整体流程,准备完地址参数后,直接来到__class_lookupMethodAndLoadCache3

老规矩继续搜索__class_lookupMethodAndLoadCache3

会发现全部都是调用,尽然没有类似的实现过程,可能会觉得是苹果没有开源吧,如果是这样,到这里探索似乎就走到了尽头。

绝望的时候冷静下来想想,__class_lookupMethodAndLoadCache3是在__objc_msgLookup_uncached后,__objc_msgLookup_uncached又是在objc_msgSend后。继续走刚才的objc_msgSend符号断点,看看汇编调用是不是这样吧。

objc_msgSend之后确实会来到__objc_msgLookup_uncached,可是细看却是_objc_msgLookup_uncached前面少了个下划线。再去_objc_msgLookup_uncached里面看看是否有调用__class_lookupMethodAndLoadCache3

_objc_msgLookup_uncached内确实调用了__class_lookupMethodAndLoadCache3,可是细看却是_class_lookupMethodAndLoadCache3前面也少了个下划线。并且标注了是在objc-runtime-new.mm的4846行

看到这里,好像发现了新大陆,难道底层是调用_class_lookupMethodAndLoadCache3,直接搜索,

先找到标注所定位的地方,果然就找到了_class_lookupMethodAndLoadCache3的实现。

这里从汇编跳转到c,因为慢速查找流程将要开始,这里也反向解释了为什么在调用_class_lookupMethodAndLoadCache3前,有一段地址参数处理:

  • 因为慢速查找流程要开始去c,c++,静态语言需要确定确定参数列表,所以需要准备工作)

以上就是objc_msgSend快速查找流程,也就是缓存查找。总的来说,快速查找就是在cache_t中查找缓存,缓存命中则直接结束;查找完所有还未找到,就开始做慢速查找前的准备工作,并跳转到慢速查找流程

3.慢速查找

直接来到_class_lookupMethodAndLoadCache3,里面只调用了lookUpImpOrForward

IMP _class_lookupMethodAndLoadCache3(id obj, SEL sel, Class cls)
{
    return lookUpImpOrForward(cls, sel, obj, 
                              YES/*initialize*/, NO/*cache*/, YES/*resolver*/);
}
复制代码

lookUpImpOrForward方法较长,分为准备部分和查找部分给出,最后并给出验证和结论部分

a.准备部分

1.判断缓存是否存在,存在则直接通过cls和sel直接获取imp,并返回。
复制代码
2.相关类信息判断
  a.根据所有已知类的列表检查给定的类,有问题直接内部抛出异常。
  b.判断类是否已经被实现,未实现则去实现,这部分后面类的加载章节会详细分析,主要是按照superclass和isa走向去递归实现父类和元类,同时准备好对象方法和类方法的查找链。
  c.判断类是否被初始化,未初始化则去初始化。
复制代码

b.查找部分

查找部分代码还是比较长,一个屏幕都容不下,因此分为上下两张

1.类准备好后,再次判断是否存在缓存(因为OC动态语言,随时随地都能操作修改),存在则直接通过cls和sel直接获取imp,并返回。
复制代码
2.通过本类的方法列表查找(使用二分查找),如果找到meth,则先填充到缓存,然后返回。这里外层多用类一个{},形成局部作用域,防止meth重名。
复制代码
3.递归查找父类的缓存
  a.存在且不为消息转发类型的imp,则先填充到缓存,然后返回
  b.存在且为消息转发类型的imp,则停止搜索,不会缓存这个方法,但会调用
复制代码
4.找父类缓存结束且未找到时,查找父类的方法列表,如果找到meth,则先填充到缓存,然后返回。
复制代码
5.递归查找完所有父类依然找不到imp时,开始方法转发流程,且只有一次。方法转发在下一章进行详细分析,
复制代码

以上就是objc_msgSend慢速查找流程。总的来说,慢速查找就是从本类到父类最后到NSObject的方法查找链。先找本类的method_list,找到则填充缓存;找不到在找父类的cachemethod_list,找到则填充缓存;都找不到最后进行转发

c.验证和结论部分

这里根据开发经验直接给出结论,只验证一个面试坑点

对象方法:
1.对象方法 - 自己有 - 成功;
2.对象方法 - 自己没有 - 找老爸的 - 成功;
3.对象方法 - 自己没有 - 老爸没有 - 找老爸的老爸 - NSObject - 成功;
4.对象方法 - 自己没有 - 老爸没有 - 找老爸的老爸 -> NSObject 也没有 - 崩溃;
复制代码
类方法:
1.类方法 - 自己有 - 成功;
2.类方法 - 自己没有 - 老爸有 - 成功;
3.类方法 - 自己没有 - 老爸没有 - 找老爸的老爸 -> NSObject 也没有 - 没有有对象方法 - 奔溃
4.类方法 - 自己没有 - 老爸没有 - 找老爸的老爸 -> NSObject 也没有 - 有对象方法 - 成功
复制代码

以上都符合我们认知中的查找流程,只有类方法的34,居然最后会去调用对象方法?天啦,这不符合oc世界观,调用类方法,居然去实现了对象方法。

提供一个验证方法:

在NSObject的分类中定义一个对象方法并实现,然后用任意类的类名调用此定义的对象方法。
复制代码
分类声明
- (void)instanceMethod{
    NSLog(@"%s--我是对象方法",__func__);
}

类名调用
int main(int argc, const char * argv[]) {
    @autoreleasepool {
         [CJPerson instanceMethod];
    }
    return 0;
}
复制代码

执行后,果然成功了。这不符合常理的情况要怎么解释,其实从isasupclass的走位图,可以一探究竟。

回到开始的面试题:为什么子类可以调用类方法来实现NSObject的对象方法?

解释:通过类名调用,会依次走元类的方法列表,最后找到根元类的方法列表,但是都找不到对应的类方法;这时候,根元类的supclass指向了根类NSObject,所以去查找了NSObject的方法列表,因为NSObject的方法列表存放的是对象方法,因此找到了名为instanceMethod的对象方法。

写在最后

objc_msgSend是iOS开发绕不过去的坎,其流程与cache_t流程分析有紧密联系。下一章是发送消息的最后一个部分--消息转发流程分析。敬请关注。