0's Space

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

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

原文作者:Matt Galloway

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


今天,我从编译器的角度研究了一下blocks是如何工作的。我所说的blocks是苹果公司在C语言中添加的闭包,如今从clang/LLVM的角度来看,blocks确实是语言的一部分。我一直都很好奇”block”是如何运作的,”block”是如何是如何神奇的作为Objective-C对象出现的(例如:你可以对block对象执行copyretainrelease操作)。


基础

block就是下面这样的:

1
2
3
void(^block)(void) = ^{
NSLog(@"I'm a block!");
};

上面的代码创建了一个叫名为block的变量,这个变量被赋值为一个简单的block。这很简单,但这就完了吗?不!我想知道编译器对上面的代码编译的所有细节。

此外,你可以传递一个变量给block:

1
2
3
void(^block)(int a) = ^{
NSLog(@"I'm a block! a = %i", a);
};

或者从block返回一个值:

1
2
3
4
int(^block)(void) = ^{
NSLog(@"I'm a block!");
return 1;
};

作为一个闭包,block捕获它所在位置的上下文:

1
2
3
4
int a = 1;
void(^block)(void) = ^{
NSLog(@"I'm a block! a = %i", a);
};

我所感兴趣的是编译器是如何处理这些代码的。


探究一个简单的例子

我最初的想法是看一下编译器是如何编译一个简单的block的。思考一下下面的代码:

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

typedef void(^BlockA)(void);

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

void doBlockA() {
BlockA block = ^{
// Empty block
};
runBlockA(block);
}

这里写了两个方法的原因是我想看一下block是如何被设置和调用的。如何设置和调用的代码写在一个方法中,编译器很聪明,以至于会把我们想看到的细节给优化掉。因为我写了一个noinline的方法runBlockA,所以编译器在doBlockA中就不会内联这个方法,把两个方法优化为一个方法。

该代码的相关位被编译成如下(armv7, 03):

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

这就是编译后的runBlockA方法的指令集。所以,这很简单。回顾一下这个方法的源代码,这个方法只是调用了一下block。在ARM的EABI中,r0(寄存器r0)被设置为方法的第一个参数。因此,第一个指令意味着存在r0+12这块地址中的值被加载到r1中。可以把这个看做对指针的解引用,向其读入12字节。接着我们看下r1的地址。注意,r1被使用了,这也意味着r0仍然是block本身。所以很可能这个调用的函数将block作为它的第一个参数。

我可以在这里断定,block是一种结构体,block所要调用的函数被存储在这个12字节的结构体中。当一个block被传递时,指向这些结构的一个指针被传递。

现在,看下doBlockA方法:

1
2
3
4
5
6
7
8
9
10
.globl  _doBlockA
.align 2
.code 16 @ @doBlockA
.thumb_func _doBlockA
_doBlockA:
movw r0, :lower16:(___block_literal_global-(LPC1_0+4))
movt r0, :upper16:(___block_literal_global-(LPC1_0+4))
LPC1_0:
add r0, pc
b.w _runBlockA

好吧,这也很简单。这是一个程序计数器的相关加载。你可以把这当做是把__block_literal_gobal的变量的地址加载进r0。然后runBlockA方法就被调用了。我们可以看出,被传递到runBlockA方法中的block对象就是以上汇编指令集中的__block_literal_gobal

现在我们有些进展了。但是__block_literal_gobal到底是什么呢?我们通过汇编指令集发现如下:

1
2
3
4
5
6
7
.align  2                       @ @__block_literal_global
___block_literal_global:
.long __NSConcreteGlobalBlock
.long 1342177280 @ 0x50000000
.long 0 @ 0x0
.long ___doBlockA_block_invoke_0
.long ___block_descriptor_tmp

啊哈,这里看起来像一个结构体。在这个结构体里有5个值,每一个值占用4字节(long)。这个结构体一定是runBlockA所操作的block对象。看,这个结构体中12字节处被叫做___doBlockA_block_invoke_0的值多像一个指针。记住,这是runBlockA方法跳转的位置。

但是,什么是__NSConcreteGlobalBlock?我们一会看这个问题。___doBlockA_block_invoke_0___block_descriptor_tmp很值得关注,因为他们也出现在如下的汇编程序集中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
    .align  2
.code 16 @ @__doBlockA_block_invoke_0
.thumb_func ___doBlockA_block_invoke_0
___doBlockA_block_invoke_0:
bx lr

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

.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 "\\001"

这个___doBlockA_block_invoke_0看起来更像是实际的block对他自己的实现,尽管我们使用的是一个空block。这个函数直接返回,这正是我们所期望的空函数被编译的方式。

现在来看下___block_descriptor_tmp。这似乎是另一个结构体,这个结构体中有4个值。第二个的值是20,这正是___block_literal_global结构体的大小。猜测这可能是一个size的值?这里还有一个C字符串叫做.str,值是v4@?0。这看起来像是某种类型编码的标识。这可能是block类型的标识(返回空且没有参数的类型)。其他的值,我没有什么头绪。


源码不就推理出来了?

是的,源码就可以推理出来了。这是LLVM中一个叫做compiler-rt项目的一部分。通过阅读这个项目的源码,在Block_private.h文件中,找到如下定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct Block_descriptor {
unsigned long int reserved;
unsigned long int size;
void (*copy)(void *dst, void *src);
void (*dispose)(void *);
};

struct Block_layout {
void *isa;
int flags;
int reserved;
void (*invoke)(void *, ...);
struct Block_descriptor *descriptor;
/* Imported variables. */
};

惊人的相似!这个Block_layout结构体就是我们分析的___block_literal_globalBlock_descriptor结构体就是我们分析的___block_descriptor_tmp。我猜测的描述符中的第二个值是size的观点是对的。奇怪的是Block_descriptor中的第三和第四个值。这两个值看起来应该是函数的指针,但是在我们编译后的指令集中这两个值是两个字符串。这两个值我们暂且按下不表。

Block_layout中的isa很值得关注,因为他可能就是_NSConcreteGlobalBlock。而且也可能是一个block如何可以具有一个Objective-C对象行为的关键。如果_NSConcreteGlobalBlock是一个,那么Objective-C的消息传递机制系统很乐意将一个block对象当做一个普通对象来处理。这与无缝桥接(toll-free bridging)工作机制很相似。关于这方面(toll-free bridging)的更多信息,请阅读Mike Ash’s的优秀博文

将上面的零碎的点合在一起,编译器好像是这样处理代码的:

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
#import <dispatch/dispatch.h>

__attribute__((noinline))
void runBlockA(struct Block_layout *block) {
block->invoke();
}

void block_invoke(struct Block_layout *block) {
// Empty block function
}

void doBlockA() {
struct Block_descriptor descriptor;
descriptor->reserved = 0;
descriptor->size = 20;
descriptor->copy = NULL;
descriptor->dispose = NULL;

struct Block_layout block;
block->isa = _NSConcreteGlobalBlock;
block->flags = 1342177280;
block->reserved = 0;
block->invoke = block_invoke;
block->descriptor = descriptor;

runBlockA(&block);
}

现在,block下运作的细节就很好理解了。


下一步

接下来,我会继续去探究带有参数的block是如何从作用域捕获变量的。这肯定会有所不同,请持续关注!

CATALOG
  1. 1. 基础
  2. 2. 探究一个简单的例子
  3. 3. 源码不就推理出来了?
  4. 4. 下一步