《Runtime面试题与栈区参数》的一点小错误与另一种解题思路
1. 背景
最近看到了一篇文章,探究了一道疯人院中的很老的面试题。我认为单用这道题去作为面试题并不是很好,但是题目中包含的知识点却是一点也不少。
请大家先阅读原文,部分原文中包含的知识点也许在本篇文章中不会提及。
原文链接:juejin.im/post/685041…
题目如下:
- 下面代码运行后会编译报错、崩溃还是正常运行?
- 如果能正常运行,会输出什么?
@interface Speaker : NSObject
@property (nonatomic, copy) NSString *name;
- (void)speak;
@end
@implementation Speaker
- (void)speak {
NSLog(@"Speaker's name: %@", self.name);
}
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
id cls = [Speaker class]; // 1
void *obj = &cls; // 2
[(__bridge id)obj speak]; // 3
}
@end
复制代码
这里直接把结果放出来:Speaker's name: <ViewController: 0x7fcc84e09e90>
欢迎大家动手尝试,这是一个很有趣的问题。
2. 解题
2.1 原文中的错误
-
clang -rewrite-objc 生成的 c++ 代码与最终二进制文件执行的代码并非完全相同,而且在 release 与 debug 环境下,也许会得到完全不同的结果,这跟编译器优化有关。因此比较严谨的解题思路应当是从汇编代码入手。
-
关于参数入栈的问题,函数参数是否入栈,临时变量是否入栈,在不开启编译器优化的情况下是默认入栈的。而开启编译器优化,是极有可能不入栈的。
-
最后遗留的问题,直接调用 [super viewDidLoad],栈区第三个变量ViewController,并非是 Xcode 的bug,而是因为错误 1 导致的,实际上调用的方法根本不是 objc_msgSendSuper
接下来我们就开始从头开始剖析这道题
2.2 为何能编译通过?
给 id 类型发送消息都是能通过编译的,读者可以自己尝试如下代码是否能编译通过:
id temp = [[NSObject alloc] init];
[temp speak];
复制代码
2.3 为何能调用speak方法成功
这一部分在原文中已经解释的比较清楚了,我简单地再提一下。
我们知道对一个结构体取地址和对结构体中第一个字段取地址是一样的。
objc_object、objc_class 的首个字段为 isa,因此我们对 cls 取地址实际上是构造了一个指向 Class 的指针,这其实就是一个实例对象了。
不难看出,最核心的代码是:
void *obj = &cls;
复制代码
对类对象取地址,相当于构造了一个对象,其 isa 指向 Class。
但是要明确的是,这个对象是一个伪造的。因为我们 在 [[NSObject alloc] init]时,会在堆中进行分配内存,而本次 &cls,则是构造了一个指针,指向了栈上的地址。这里的区别非常重要。
当你伪造好了这个对象,至于剩下的工作,就交由 runtime 发消息的机制去做了。
2.4 为什么会输出 ViewController
这里我借助了反编译工具 hooper 的帮助,这是个非常好用的工具,免费版每次可以使用 30 分钟。
这里我们要知道一些基础知识:
- 栈的生长方向是高地址到低地址
- 根据 arm64 调用规约,8个以下入参存放在 x0 - x7 寄存器中
- 返回值存放在 x0 寄存器中
- 局部变量存放在栈上
2.4.1 [ViewController viewDidLoad]
然后让我们通过 hooper 来查看 viewDidLoad 中的汇编代码吧,为了方便说明,我给代码加了行号,代码如下:
-[ViewController viewDidLoad]:
1\. 0000000100005ce4 sub sp, sp, #0x40 ; Objective C Implementation defined at 0x1000082a0 (instance method), DATA XREF=0x1000082a0
2\. 0000000100005ce8 stp x29, x30, [sp, #0x30]
3\. 0000000100005cec add x29, sp, #0x30
4\. 0000000100005cf0 adrp x8, #0x100009000
5\. 0000000100005cf4 add x8, x8, #0x708 ; @selector(viewDidLoad)
6\. 0000000100005cf8 adrp x9, #0x100009000
7\. 0000000100005cfc add x9, x9, #0x748 ; _OBJC_CLASSLIST_SUP_REFS_$_
8\. 0000000100005d00 stur x0, [x29, #-0x8]
9\. 0000000100005d04 stur x1, [x29, #-0x10]
10\. 0000000100005d08 ldur x10, [x29, #-0x8]
11\. 0000000100005d0c str x10, [sp, #0x10]
12\. 0000000100005d10 ldr x9, [x9] ; _OBJC_CLASSLIST_SUP_REFS_$_,_OBJC_CLASS_$_ViewController
13\. 0000000100005d14 str x9, [sp, #0x18]
14\. 0000000100005d18 ldr x1, [x8] ; "viewDidLoad",@selector(viewDidLoad)
15\. 0000000100005d1c add x0, sp, #0x10
16\. 0000000100005d20 bl imp___stubs__objc_msgSendSuper2
17\. 0000000100005d24 adrp x8, #0x100009000
18\. 0000000100005d28 add x8, x8, #0x710 ; @selector(class)
19\. 0000000100005d2c adrp x9, #0x100009000
20\. 0000000100005d30 add x9, x9, #0x730 ; objc_cls_ref_Speaker
21\. 0000000100005d34 ldr x9, [x9] ; objc_cls_ref_Speaker,_OBJC_CLASS_$_Speaker
22\. 0000000100005d38 ldr x1, [x8] ; "class",@selector(class)
23\. 0000000100005d3c mov x0, x9
24\. 0000000100005d40 bl imp___stubs__objc_msgSend
25\. 0000000100005d44 mov x29, x29
26\. 0000000100005d48 bl imp___stubs__objc_retainAutoreleasedReturnValue
27\. 0000000100005d4c adrp x8, #0x100009000
28\. 0000000100005d50 add x8, x8, #0x718 ; @selector(speak)
29\. 0000000100005d54 str x0, [sp, #0x8]
30\. 0000000100005d58 add x9, sp, #0x8
31\. 0000000100005d5c str x9, [sp]
32\. 0000000100005d60 ldr x0, [sp]
33\. 0000000100005d64 ldr x1, [x8] ; "speak",@selector(speak)
34\. 0000000100005d68 bl imp___stubs__objc_msgSend
35\. 0000000100005d6c add x0, sp, #0x8
36\. 0000000100005d70 movz x8, #0x0
37\. 0000000100005d74 mov x1, x8
38\. 0000000100005d78 bl imp___stubs__objc_storeStrong
39\. 0000000100005d7c ldp x29, x30, [sp, #0x30]
40\. 0000000100005d80 add sp, sp, #0x40
41\. 0000000100005d84 ret
; endp
前三行的作用是分配栈空间和寄存器保护,将上一个栈帧的信息保存在头部。可以看出,这里分配了0x30的空间,其中0x10用来寄存器保护。
我们这里做个假设,假设这一段栈空间为 0x60 - 0x20,其中 0x60 - 0x50 为头部信息,x29 寄存器指向 0x50,sp 寄存器指向 0x20
第 8 行,将 x0 寄存器的值写到栈上,位置是 x29 - 0x8,即0x48,需要注意,此时 x0 中存放的是 self
第 10、11 行,将 x29 - 0x8 中的值读取到 x10 寄存器,并将 x10 寄存器写到 sp + 0x10 的位置,即 0x30,存放的也是self
第 24 行,这里调用的是 objc_msgSend,方法为 class,该行执行完后,x0 中存放的即 cls
第 29 行,x0 入栈,sp + 0x8的位置,即 0x28 的位置存放了 cls
关键的部分来了
第 30 行,add x9, sp, #0x8,这一句代码的意思是:将 sp + 0x8 的结果写入 x9 寄存器。也就是说:x9 寄存器目前存放的是 0x28 这个数字,而并非是 0x28 这个地址上存放的内容。
第 31 行,x9 寄存器入栈,写入 sp 的位置,即 0x20
*上面两步的对应 oc 代码为:void obj = &cls; 此时,0x20 存放指针,指向了 0x28,即 cls 的位置
第 32 行,将 sp 存放的值写入 x0 寄存器,这里就是在准备函数调用了,这里 x0 存放的实际上是个指针,指向了 0x28,即 cls 的位置
第 34 行,调用 speak 方法。
这里给一张栈区的分布图:
2.4.2 [Speaker name]
上一部分是 viewDidLoad 中的代码,我们把栈上存放的内容理清了,如果你还不清楚,可以画一张栈的图,将内容写在对应的地址上,就一目了然了。
接下来的重点自然就是看 [Speaker name] 的汇编代码,如下:
-[Speaker name]:
1\. 0000000100005c54 sub sp, sp, #0x10 ; Objective C Implementation defined at 0x100008180 (instance method), DATA XREF=0x100008180
2\. 0000000100005c58 str x0, [sp, #0x8]
3\. 0000000100005c5c str x1, [sp]
4\. 0000000100005c60 ldr x8, [sp, #0x8]
5\. 0000000100005c64 ldr x0, [x8, #0x8]
6\. 0000000100005c68 add sp, sp, #0x10
7\. 0000000100005c6c ret
; endp
这里的代码就非常明显了,我们来看关键性代码
第 2 行,x0 入栈,存放在 sp + 0x8 的位置,当前 存入的是 0x28
第 4 行,将 sp + 0x8 的内容取出,读入 x8 寄存器,x8 当前存放 0x28 第 5 行,写入返回值,即 x0 寄存器,将 x8 + 0x8 的内容写入 x0 寄存器。
x8 当前是 0x28,x8 + 0x8 即 0x30,而 0x30 中存放的内容,是 self(ViewController)!
2.4.3 结案陈词
至此,我们通过汇编代码,已经可以完全明白为什么最终输出了 ViewController。
尽管这道题有一些老学究的味道,但是其中的内容与涵盖的知识点都是非常不错的。
你以为事情到这里就结束了吗?我们刚刚看的是 debug 下的汇编代码,要知道 release 和 debug 下的汇编代码会有非常大的差距。
我想经过上面的文章,如果你每个点都已经搞懂了,那么 release 下的代码对你应该不是问题。这里我贴上两张图,仅做一点简单的说明.
2.4.4 release 下的汇编代码
相信大家已经能很简单的看懂了
2.5 为何栈区的第三个变量是 ViewController
这个问题的答案,就在刚刚的汇编代码中。原文将 [super viewDidLoad] 替换成了如下代码:
((void (*)(struct objc_super *, SEL))(void *)objc_msgSendSuper)(&((struct objc_super){(id)self, (id)class_getSuperclass(objc_getClass("ViewController"))}), sel_registerName("viewDidLoad"));
发现栈区的第三个变量是 UIViewController
如果你细心的话,已经发现了,我们的汇编代码中,并不是调用的 objc_msgSendSuper 方法,而是调用的 objc_msgSendSuper2 方法,这是两种写法栈区第三个变量不同的原因。
需要更多iOS面试文集资料,加iOS开发交流群:789143298,群文件直接获取
——点击加入:iOS开发交流群
作者:leeluanxin
链接:https://juejin.im/post/6875116521417834509