(译)窥探Blocks(1)

本文翻译自Matt Galloway的博客,借此机会学习一下Block的内部原理。

今天我们从编译器的视角来研究一下Block的内部是怎么工作的。这里说的Blocks指的是Apple为C语言添加的闭包,而且现在从clang/LLVM角度来说已经成为了语言的一部分。我一直很好奇Block到底是什么以及怎样被视为一个 Objective-C对象的(你可以对它们执行copyretainrelease操作。)这篇博客来稍微研究一下Block。

基础

下面代码是一个Block:

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

它创建了一个叫做block的变量,而且用一个简单的代码块赋值给它。这很简单。这就完成了?不,我想了解编译器为这一小段代码干了什么事。

此外,你也可以给block传递一个参数:

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

甚至还可以反悔一个值:

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

作为一个闭包,它们捕获了它们的上下文:

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

那么编译器是怎样组织这所有部分的呢?这正是我感兴趣的。

深究一个简单的示例

我的第一个想法是看看编译器怎样编译一个非常简单的block的,比如下例代码:

#import <dispatch/dispatch.h>

typedef void(^BlockA)(void);

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

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

搞两个方法是因为我想看看一个block是如何被创建以及如何被调用的。如果两者都放在一个方法里面,编译优化器可能比较聪明,那我们就看不到有趣的现象了。我必须声明runBlocknoinline的,否则优化器会把它内联到doBlock方法中,这会导致上述同样的问题。

上述代码编译出来的汇编代码如下(编译器是armv7,03):

.globl  _runBlockA
    .align  2
    .code   16                      @ @runBlockA
    .thumb_func     _runBlockA
_runBlockA:
@ BB#0:
    ldr     r1, [r0, #12]
    bx      r1

这是runBlockA部分,非常的简单。回顾一下源码,这个方法仅仅调用了一个block。寄存器r0ARM EABI中被设置为这个方法的第一个参数。因此第一条指令意味着r1是从r0 + 12的地址处加载的。可以认为这是一个指针的间接引用,读入12个字节进去。然后我们跳转到哪个地址。注意使用的是r1,意味着r0仍然是参数block自身。所以这看起来就像是正在调用的方法把这个block作为第一个参数。

从这里我可以确定block很可能是一些结构体组成,实际执行的方法是存储在相应结构体里面的12个字节。当传递一个block时,实际上传递的是指向某一个结构体的指针。

现在来看看doBlock方法:

    .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_global的地址加载到r0。然后调用了_runBlockA方法。我们已经知道r0当作block对象被传递给_runBlockA了,那___block_literal_global一定就是那个block对象。

现在我们已经取得一些进展了!但是___block_literal_global是个什么东西?通过汇编代码我们发现:

    .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字节大小。这肯定就是runBlockA操作的block对象。再看,结构体的第12个字节叫做___doBlockA_block_invoke_0的东西疑似一个函数指针。如果你还记得,那就是上述runBlockA所跳转的地方。

然而,什么又是__NSConcreteGlobalBlock?这个我们后面再说。我们更感兴趣的是___doBlockA_block_invoke_0___block_descriptor_tmp

    .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里的如下定义:

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。而且,我猜对了descriptor的第二个值就是size。Block_descriptor的第三个和第四个值有点奇怪。它们看起来应该是函数指针,但是我们编译阶段看到的是两个字符串。暂时先忽略它们。

Block_layoutisa很有趣,它一定就是_NSConcreteGlobalBlock,而且一定是block视作一个一个Objective-C对象的原因。如果_NSConcreteGlobalBlock是一个类,那么OC的消息分发机制一定乐于把block当作一个普通的对象。这类似于toll-free bridging的工作原理。

总结起来,编译器好像用如下的逻辑来处理代码:

#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底层是如何工作的。

comments powered by Disqus