0's Space

【译】《A look inside blocks:Episode 2》

字数统计: 1.9k阅读时长: 8 min
2021/05/22 Share

原文作者:Matt Galloway

原文地址:https://www.galloway.me.uk/2012/10/a-look-inside-blocks-episode-2/


这篇文章是《A look inside blocks: Episode 1》的后续文章,在第一部分中,我研究了block的内部结构,以及编译器是如何编译block的。本篇文章中,我将探究一下非常量的block以及他们是如何存在于栈上的。


Block类型

第一篇文章中,我们接触到一个类叫做_NSConcreteGlobalBlock。block的结构和描述符在编译时都是被全完初始化的,因为所有变量都是已知的。不同类型的block都有他们独自关联的类。但是,为了简单起见,我们只关注一下3类:

1、_NSConcreteGlobalBlock是一种在编译时完成全局定义的block。这是一类没有捕获任何范围内变量的block,例如空block。

2、_NSConcreteStackBlock是一种位于栈上的block。在被最终复制到堆上之前都是的此类block。

3、_NSConcreteMallocBlock是一种位于堆上的block。在位于栈上的block被复制之后,就位于堆上。一旦在堆上,他们将被引用计数,并在引用计数降为零时被释放。


block的捕获范围

这次我们来看下下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#import <dispatch/dispatch.h>

typedef void(^BlockA)(void);
void foo(int);

__attribute__((noinline))
void runBlockA(BlockA block) {
block();
}

void doBlockA() {
int a = 128;
BlockA block = ^{
foo(a);
};
runBlockA(block);
}

方法调用了foo只是为了让block通过foo调用捕获的变量。让我们再一次只看armv7 指令集产生的相关汇编代码:

1
2
3
4
5
6
7
.globl  _runBlockA
.align 2
.code 16 @ @runBlockA
.thumb_func _runBlockA
_runBlockA:
ldr r1, [r0, #12]
bx r1

首先,runBlockA方法和前面的一样。他调用block的invoke方法。下面,我们看一下doBlockA:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
.globl  _doBlockA
.align 2
.code 16 @ @doBlockA
.thumb_func _doBlockA
_doBlockA:
push {r7, lr}
mov r7, sp
sub sp, #24
movw r2, :lower16:(L__NSConcreteStackBlock$non_lazy_ptr-(LPC1_0+4))
movt r2, :upper16:(L__NSConcreteStackBlock$non_lazy_ptr-(LPC1_0+4))
movw r1, :lower16:(___doBlockA_block_invoke_0-(LPC1_1+4))
LPC1_0:
add r2, pc
movt r1, :upper16:(___doBlockA_block_invoke_0-(LPC1_1+4))
movw r0, :lower16:(___block_descriptor_tmp-(LPC1_2+4))
LPC1_1:
add r1, pc
ldr r2, [r2]
movt r0, :upper16:(___block_descriptor_tmp-(LPC1_2+4))
str r2, [sp]
mov.w r2, #1073741824
str r2, [sp, #4]
movs r2, #0
LPC1_2:
add r0, pc
str r2, [sp, #8]
str r1, [sp, #12]
str r0, [sp, #16]
movs r0, #128
str r0, [sp, #20]
mov r0, sp
bl _runBlockA
add sp, #24
pop {r7, pc}

这和之前的很不一样。与block直接从global符号开始加载不同,在这之前貌似多做一些工作。这些看起来很复杂,但是很容易看出来编译器做了什么。可以考虑下重新对函数进行排列,在功能上不会发生什么变化。编译器按照指令的顺序发出指令的原因是为了优化以减少流水线的停顿等。所以我们可以像下面一样对方法进行重新排列:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
_doBlockA:
// 1
push {r7, lr}
mov r7, sp

// 2
sub sp, #24

// 3
movw r2, :lower16:(L__NSConcreteStackBlock$non_lazy_ptr-(LPC1_0+4))
movt r2, :upper16:(L__NSConcreteStackBlock$non_lazy_ptr-(LPC1_0+4))
LPC1_0:
add r2, pc
ldr r2, [r2]
str r2, [sp]

// 4
mov.w r2, #1073741824
str r2, [sp, #4]

// 5
movs r2, #0
str r2, [sp, #8]

// 6
movw r1, :lower16:(___doBlockA_block_invoke_0-(LPC1_1+4))
movt r1, :upper16:(___doBlockA_block_invoke_0-(LPC1_1+4))
LPC1_1:
add r1, pc
str r1, [sp, #12]

// 7
movw r0, :lower16:(___block_descriptor_tmp-(LPC1_2+4))
movt r0, :upper16:(___block_descriptor_tmp-(LPC1_2+4))
LPC1_2:
add r0, pc
str r0, [sp, #16]

// 8
movs r0, #128
str r0, [sp, #20]

// 9
mov r0, sp
bl _runBlockA

// 10
add sp, #24
pop {r7, pc}

上面的指令做了如下的事情:

1、函数的开始。因为r7要被覆盖并且r7是一个必须要在函数被调用之前被保存的寄存器,所以r7被压入栈。lr是一个链接寄存器,包含函数返回时要执行的下一条指令的地址。了解这方面的更多信息,关注函数的尾部。另外栈指针保存在r7中。

2、栈指针减去24,这是为了在栈空间留出空间来存储24字节的数据。

3、这一小块代码查找L__NSConcreteStackBlock$non_lazy_ptr符号,相对于程序计数器,这种做法当最终链接到二进制代码时,它可以工作在任何代码可能结束的地方。然后该值被存储到栈指针的地址。

4、值1073741824被存储在栈指针+4的地址。

5、值0被存储在栈指针+8的地址。现在对于如何运作的就十分清晰了。一个Block_layout结构在栈上被创建。到目前为止,已经设置了isa指针、标识值flags和保留值reserved

6、___doBlockA_block_invoke_0的地址被存储在栈指针+12的地址上。这是block结构的invoke参数。

7、___block_descriptor_tmp的地址被存储在栈指针+16的地址上。这是block结构的descriptor参数。

8、值128存储在栈指针+20的位置。如果你回顾下Block_layout结构体,你会发现有5个值在其中。所以,在结构体的末尾存储了什么?好,你会注意到被block捕获的变量的值值128。所以,在

Block_layout末尾一定是block存储block需要使用的值的地方。

9、现在指向完全初始化的block结构体的栈指针被放到r0中,并且runBlockA被调用了。(记住,在ARM EABI中r0包含了传递给方法的第一个参数)。

10、最后,栈指针又加上了24,以抵消函数开始时减去的值。然后2个值被弹出站栈,分别放入r7pc中。r7平衡了开始处的压入,pc会得到函数开始时在lr中的值。这会有效的执行函数的返回,因为lr链接寄存器中设置了函数返回后CPU从哪里继续执行(程序技术器)的地址。

哇!还能跟上我的思路,真实太棒了!

这部分的最后的一块是查明invoke函数是什么以及descriptor是什么样子的。

我们希望这不要和第一部分中的global block相差甚远。来看一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
.align  2
.code 16 @ @__doBlockA_block_invoke_0
.thumb_func ___doBlockA_block_invoke_0
___doBlockA_block_invoke_0:
ldr r0, [r0, #20]
b.w _foo

.section __TEXT,__cstring,cstring_literals
L_.str: @ @.str
.asciz "v4@?0"

.section __TEXT,__objc_classname,cstring_literals
L_OBJC_CLASS_NAME_: @ @"\\01L_OBJC_CLASS_NAME_"
.asciz "\\001P"

.section __DATA,__const
.align 2 @ @__block_descriptor_tmp
___block_descriptor_tmp:
.long 0 @ 0x0
.long 24 @ 0x18
.long L_.str
.long L_OBJC_CLASS_NAME_

是的,真的没有多少不一样的地方。唯一的不同是block的descriptor中的size参数。现在这个参数的值是24而不是20。这是因为block捕获了一个整形值,所以block的结构大小是24而不是标准的20。我们看到了当他被创建时一些额外的字节被加入到结构的尾部。

实际上,同样的情况发生在block方法,如__doBlockA_block_invoke_0,你可以看到在block结构结束位置的值如r0+20被读出。这是被block捕获的变量。


如果捕获的是对象类型呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.align  2
.code 16 @ @__copy_helper_block_
.thumb_func ___copy_helper_block_
___copy_helper_block_:
ldr r1, [r1, #20]
adds r0, #20
movs r2, #3
b.w __Block_object_assign

.align 2
.code 16 @ @__destroy_helper_block_
.thumb_func ___destroy_helper_block_
___destroy_helper_block_:
ldr r0, [r0, #20]
movs r1, #3
b.w __Block_object_dispose

我假设这两个函数是block被复制和销毁时执行的函数。他们用来持有和释放block捕获来的对象。copy函数看起来接收2个参数因为当r0r1都被处理成包含有效数据。destroy函数看起来只接受1个参数。所有的工作看起来都是_Block_object_assign_Block_object_dispose完成的。他们的代码在block的运行时代码中,是LLVM的compiler-rt项目的一部分。

如果你想了解更多并阅读以下block的运行时代码,可以在http://compiler-rt.llvm.org下载相关的源码进行学习。具体地,可以看runtime.c这个文件。


下一步

在下一部分,我将通过Block_copy的代码来探究block的运行时,看看它是如何工作的。这会使我们深入了解我们刚才看到的为了block捕获对象创建的copy和destroy helper该函数。

CATALOG
  1. 1. Block类型
  2. 2. block的捕获范围
  3. 3. 如果捕获的是对象类型呢?
  4. 4. 下一步