(譯)窺探Blocks(2)

foolish-boy發表於2019-01-30

本文翻譯自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_:                     @ @" 1L_OBJC_CLASS_NAME_"
    .asciz   " 01P"

    .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的程式碼,可以去compiler-rt.llvm.org下載原始碼,重點看看runtime.c

下一篇我們將研究一下Block_Copy的原理。

相關文章