(译)窥探Blocks(2)
本文翻译自Matt Galloway的博客
之前的文章(译)窥探Blocks(1)我们已经了解了block的内部原理,以及编译器如何处理它。本文我将讨论一下非常量的blocks以及它们在栈上的组织方式。
Block 类型
在第一篇文章中,我们看到block有__NSConcreteGlobalBlock
类。block结构体和descriptor都在编译阶段基于已知的变量完全初始化了。block还有一些不同的类型,每一个类型都对应一个相关的类。为了简单起见,我们只考虑其中的三个:
-
_NSConcreteGlobalBlock
是一个全局定义的block,在编译阶段就完成创建工作。这些block没有捕获任何域,比如一个空block。 -
_NSConcreteStackBlock
是一个在栈上的block,这是所有blocks在最终拷贝到堆上之前所开始的地方。 -
_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}
这就是它所做的事:
-
方法开始。
r7
被压入栈,因为它即将被重写,而且作为一个寄存器必须在方法调用时候保存值。lr
是一个链接寄存器,也被压入栈,保存了下一个指令的地址,好让方法返回时继续执行下一个指令。可以在方法结尾看到。 栈指针(sp)也被保存在r7
中。 -
栈指针(sp)减去24,留出24字节的栈空间存储数据。
-
这一小块代码正在相对于程序计数器查找
L__NSConcreteStackBlock$non_lazy_ptr
符号,这样最后链接成功的二进制文件,不管代码结束于任何地方,它都可以正常工作(这句话有点绕,翻译的不好,需要好好理解一下)。这个值最后存储在栈指针指向的位置。 -
1073741824
存储在sp + 4 的位置上。 -
0
存储在sp + 8的位置上。现在可能情况比较清晰了。回顾上一篇文章中提到的Block_layout
结构体,可以看出一个Block_layout
结构体在栈上创建了!目前为止已经有了isa
指针,flags
和reserved
值被设置了。 -
___doBlockA_block_invoke_0
的地址存储在sp + 12位置。这就是block结构体的invoke
参数。 -
___block_descriptor_tmp
的地址存储在sp + 16位置。这就是block结构体的descriptor
参数。 -
128
存储在sp + 20的位置。啊!如果你回看Block_layout
结构体你会发现里面只有5个值。那么存在这个结构体末尾的是什么呢?哈哈,别忘记了,这个128
就是在这个block前定义的、被block捕获的值。所以这一定是存储它们使用变量的地方——在Block_layout
最后。 -
sp现在指向一个完全初始化的block结构体,它被放入
r0
寄存器,然后runBlockA
被调用。(记住在ARM EABI中r0包含了方法的第一个参数) -
最后sp + 24 已抵消最开始减去的24。然后分别从栈弹出两个值到
r7
和pc
中。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捕获的对象。看起来拷贝函数用了两个参数,因为r0
和r1
被寻址,它们两可能有有效的数据。销毁函数好像就一个参数。所有复杂的操作貌似都是_Block_object_assign
和_Block_object_dispose
干的。这部分代码在block runtime里。
如果你想了解更多关于block runtime的代码,可以去http://compiler-rt.llvm.org下载源码,重点看看runtime.c
。
下一篇我们将研究一下Block_Copy
的原理。