(譯)窺探Blocks (1)

foolish-boy發表於2017-11-09

本文翻譯自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底層是如何工作的。

相關文章