(译)窥探Blocks(2)

本文翻译自Matt Galloway的博客

之前的文章(译)窥探Blocks(1)我们已经了解了block的内部原理,以及编译器如何处理它。本文我将讨论一下非常量的blocks以及它们在栈上的组织方式。

Block 类型

第一篇文章中,我们看到block有__NSConcreteGlobalBlock类。block结构体和descriptor都在编译阶段基于已知的变量完全初始化了。block还有一些不同的类型,每一个类型都对应一个相关的类。为了简单起见,我们只考虑其中的三个:

  1. _NSConcreteGlobalBlock是一个全局定义的block,在编译阶段就完成创建工作。这些block没有捕获任何域,比如一个空block。
  2. _NSConcreteStackBlock是一个在栈上的block,这是所有blocks在最终拷贝到堆上之前所开始的地方。
  3. _NSConcreteMallocBlock是一个在堆上的block,这是拷贝一个block后最终的位置。它们在这里被引用计数并且在引用计数变为0时被释放。

捕获域的block

现在我们来看看下面一段代码:

#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捕获了一些东西,用一个捕获到的变量来调用方法。我又看了一下armv7所产生的一小段相关代码:

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

首先,runBlockA方法与之前的结果一样,它调用block的invoke方法。然后看看doBlockA

.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不同,这看起来做了许多工作。看起来可能有点麻烦,但其实也非常简单。我们最好考虑重新整理这些方法,但请相信我这样做不会没有改变任何功能。编译器之所以这样安排它的指令顺序,是为了优化编译性能,减少流水线气泡。重新整理后的方法如下:

_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被压入栈,因为它即将被重写,而且作为一个寄存器必须在方法调用时候保存值。lr是一个链接寄存器,也被压入栈,保存了下一个指令的地址,好让方法返回时继续执行下一个指令。可以在方法结尾看到。 栈指针(sp)也被保存在r7中。

  2. 栈指针(sp)减去24,留出24字节的栈空间存储数据。

  3. 这一小块代码正在相对于程序计数器查找L__NSConcreteStackBlock$non_lazy_ptr符号,这样最后链接成功的二进制文件,不管代码结束于任何地方,它都可以正常工作(这句话有点绕,翻译的不好,需要好好理解一下)。这个值最后存储在栈指针指向的位置。

  4. 1073741824存储在sp + 4 的位置上。

  5. 0存储在sp + 8的位置上。现在可能情况比较清晰了。回顾上一篇文章中提到的Block_layout结构体,可以看出一个Block_layout结构体在栈上创建了!目前为止已经有了isa指针,flagsreserved值被设置了。

  6. ___doBlockA_block_invoke_0的地址存储在sp + 12位置。这就是block结构体的invoke参数。

  7. ___block_descriptor_tmp的地址存储在sp + 16位置。这就是block结构体的descriptor参数。

  8. 128存储在sp + 20的位置。啊!如果你回看Block_layout结构体你会发现里面只有5个值。那么存在这个结构体末尾的是什么呢?哈哈,别忘记了,这个128就是在这个block前定义的、被block捕获的值。所以这一定是存储它们使用变量的地方——在Block_layout最后。

  9. sp现在指向一个完全初始化的block结构体,它被放入r0寄存器,然后runBlockA被调用。(记住在ARM EABI中r0包含了方法的第一个参数)

  10. 最后sp + 24 已抵消最开始减去的24。然后分别从栈弹出两个值到r7pc中。r7抵消一开始压栈的操作,pc将获得方法开始时lr里面的值。这样有效地完成了方法返回的操作,让CPU继续(程序计数器pc)从方法返回的地方(链接寄存器lr)执行。

哇哦!你还在跟着我学?太牛逼啦!

这一小段的最后一部分是来看看invoke方法和descriptor长什么样。我们希望它们不要与第一篇文章中的全局block差太多。

.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结构体时,这额外的4字节被放在了最后。

同样地,你在实际执行的方法__doBlockA_block_invoke_0中也会发现参数值从结构体末尾处(r0 + 20)读取出来,这就是block捕获的值。

捕获对象类型的值会怎样?

下面要考虑的是捕获的不再是一个整形,而是一个对象,比如NSString。欲知详情,请看下面代码:

#import <dispatch/dispatch.h>

typedef void(^BlockA)(void);
void foo(NSString*);

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

void doBlockA() {
    NSString *a = @"A";
    BlockA block = ^{
        foo(a);
    };
    runBlockA(block);
}

我不再研究doBlockA的细节,因为变化不大。比较有意思的是它创建的block descriptor结构体。

 .section        __DATA,__const
    .align  4                       @ @__block_descriptor_tmp
___block_descriptor_tmp:
    .long   0                       @ 0x0
    .long   24                      @ 0x18
    .long   ___copy_helper_block_
    .long   ___destroy_helper_block_
    .long   L_.str1
    .long   L_OBJC_CLASS_NAME_

注意现在有了名为___copy_helper_block____destroy_helper_block_的函数指针。这里是这些函数的定义:

.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捕获的对象。看起来拷贝函数用了两个参数,因为r0r1被寻址,它们两可能有有效的数据。销毁函数好像就一个参数。所有复杂的操作貌似都是_Block_object_assign_Block_object_dispose干的。这部分代码在block runtime里。

如果你想了解更多关于block runtime的代码,可以去http://compiler-rt.llvm.org下载源码,重点看看runtime.c

下一篇我们将研究一下Block_Copy的原理。

comments powered by Disqus