1. 程式人生 > >Mach-O 可執行檔案

Mach-O 可執行檔案

我們用 Xcode 構建一個程式的過程中,會把原始檔 (.m.h) 檔案轉換為一個可執行檔案。這個可執行檔案中包含的位元組碼會將被 CPU (iOS 裝置中的 ARM 處理器或 Mac 上的 Intel 處理器) 執行。

本文將介紹一下上面的過程中編譯器都做了些什麼,同時深入看看可執行檔案內部是怎樣的。實際上裡面的東西要比我們第一眼看到的多得多。

這裡我們把 Xcode 放一邊,將使用命令列工具 (command-line tools)。當我們用 Xcode 構建一個程式時,Xcode 只是簡單的呼叫了一系列的工具而已。Florian 對工具呼叫是如何工作的做了更詳細的討論。本文我們就直接呼叫這些工具,並看看它們都做了些什麼。

真心希望本文能幫助你更好的理解 iOS 或 OS X 中的一個可執行檔案 (也叫做 Mach-O executable) 是如何執行,以及怎樣組裝起來的。

xcrun

先來看一些基礎性的東西:這裡會大量使用一個名為 xcrun 的命令列工具。看起來可能會有點奇怪,不過它非常的出色。這個小工具用來呼叫別的一些工具。原先,我們在終端執行如下命令:

% clang -v

現在我們用下面的命令代替:

% xcrun clang -v

在這裡 xcrun 做的是定位到 clang,並執行它,附帶輸入 clang 後面的引數。

我們為什麼要這樣做呢?看起來沒有什麼意義。不過 xcode 允許我們: (1) 使用多個版本的 Xcode,以及使用某個特定 Xcode 版本中的工具。(2) 針對某個特定的 SDK (software development kit) 使用不同的工具。如果你有 Xcode 4.5 和 Xcode 5,通過 xcode-select

xcrun 可以選擇使用 Xcode 5 中 iOS SDK 的工具,或者 Xcode 4.5 中的 OS X 工具。在許多其它平臺中,這是不可能做到的。查閱 xcrunxcode-select 的主頁內容可以瞭解到詳細內容。不用安裝 Command Line Tools,就能使用命令列中的開發者工具。

不使用 IDE 的 Hello World

回到終端 (Terminal),建立一個包含一個 C 檔案的資料夾:

% mkdir ~/Desktop/objcio-command-line
% cd !$
% touch helloworld.c

接著使用你喜歡的文字編輯器來編輯這個檔案 -- 例如 TextEdit.app:

% open -e helloworld.c

輸入如下程式碼:

#include <stdio.h>
int main(int argc, char *argv[])
{
    printf("Hello World!\n");
    return 0;
}

儲存並返回到終端,然後執行如下命令:

% xcrun clang helloworld.c
% ./a.out

現在你能夠在終端上看到熟悉的 Hello World!。這裡我們編譯並執行 C 程式,全程沒有使用 IDE。深呼吸一下,高興高興。

上面我們到底做了些什麼呢?我們將 helloworld.c 編譯為一個名為 a.out 的 Mach-O 二進位制檔案。注意,如果我們沒有指定名字,那麼編譯器會預設的將其指定為 a.out。

這個二進位制檔案是如何生成的呢?實際上有許多內容需要觀察和理解。我們先看看編譯器吧。

Hello World 和編譯器

時下 Xcode 中編譯器預設選擇使用 clang(讀作 /klæŋ/)。關於編譯器,Chris 寫了更詳細的文章。

簡單的說,編譯器處理過程中,將 helloworld.c 當做輸入檔案,並生成一個可執行檔案 a.out。這個過程有多個步驟/階段。我們需要做的就是正確的執行它們。

預處理
  • 符號化 (Tokenization)
  • 巨集定義的展開
  • #include 的展開
語法和語義分析
  • 將符號化後的內容轉化為一棵解析樹 (parse tree)
  • 解析樹做語義分析
  • 輸出一棵抽象語法樹(Abstract Syntax Tree* (AST))
生成程式碼和優化
  • 將 AST 轉換為更低階的中間碼 (LLVM IR)
  • 對生成的中間碼做優化
  • 生成特定目的碼
  • 輸出彙編程式碼
彙編器
  • 將彙編程式碼轉換為目標物件檔案。
連結器
  • 將多個目標物件檔案合併為一個可執行檔案 (或者一個動態庫)

我們來看一個關於這些步驟的簡單的例子。

預處理

編譯過程中,編譯器首先要做的事情就是對檔案做處理。預處理結束之後,如果我們停止編譯過程,那麼我們可以讓編譯器顯示出預處理的一些內容:

% xcrun clang -E helloworld.c

喔喔。 上面的命令輸出的內容有 413 行。我們用編輯器開啟這些內容,看看到底發生了什麼:

% xcrun clang -E helloworld.c | open -f

在頂部可以看到的許多行語句都是以 # 開頭 (讀作 hash)。這些被稱為 行標記 的語句告訴我們後面跟著的內容來自哪裡。如果再回頭看看 helloworld.c 檔案,會發現第一行是:

#include <stdio.h>

我們都用過 #includeimport。它們所做的事情是告訴前處理器將檔案 stdio.h 中的內容插入到 #include 語句所在的位置。這是一個遞迴的過程:stdio.h 可能會包含其它的檔案。

由於這樣的遞迴插入過程很多,所以我們需要確保記住相關行號資訊。為了確保無誤,前處理器在發生變更的地方插入以 # 開頭的 行標記。跟在 # 後面的數字是在原始檔中的行號,而最後的數字是在新檔案中的行號。回到剛才開啟的檔案,緊跟著的是系統標頭檔案,或者是被看做為封裝了 extern "C" 程式碼塊的檔案。

如果滾動到檔案末尾,可以看到我們的 helloworld.c 程式碼:

# 2 "helloworld.c" 2
int main(int argc, char *argv[])
{
 printf("Hello World!\n");
 return 0;
}

在 Xcode 中,可以通過這樣的方式檢視任意檔案的預處理結果:Product -> Perform Action -> Preprocess。注意,編輯器載入預處理後的檔案需要花費一些時間 -- 接近 100,000 行程式碼。

編譯

下一步:分析和程式碼生成。我們可以用下面的命令讓 clang 輸出彙編程式碼:

% xcrun clang -S -o - helloworld.c | open -f

我們來看看輸出的結果。首先會看到有一些以點 . 開頭的行。這些就是彙編指令。其它的則是實際的 x86_64 彙編程式碼。最後是一些標記 (label),與 C 語言中的類似。

我們先看看前三行:

    .section    __TEXT,__text,regular,pure_instructions
    .globl  _main
    .align  4, 0x90

這三行是彙編指令,不是彙編程式碼。.section 指令指定接下來會執行哪一個段。

第二行的 .globl 指令說明 _main 是一個外部符號。這就是我們的 main() 函式。這個函式對於二進位制檔案外部來說是可見的,因為系統要呼叫它來執行可執行檔案。

.align 指令指出了後面程式碼的對齊方式。在我們的程式碼中,後面的程式碼會按照 16(2^4) 位元組對齊,如果需要的話,用 0x90 補齊。

接下來是 main 函式的頭部:

_main:                                  ## @main
    .cfi_startproc
## BB#0:
    pushq   %rbp
Ltmp2:
    .cfi_def_cfa_offset 16
Ltmp3:
    .cfi_offset %rbp, -16
    movq    %rsp, %rbp
Ltmp4:
    .cfi_def_cfa_register %rbp
    subq    $32, %rsp

上面的程式碼中有一些與 C 標記工作機制一樣的一些標記。它們是某些特定部分的彙編程式碼的符號連結。首先是 _main 函式真正開始的地址。這個符號會被 export。二進位制檔案會有這個位置的一個引用。

.cfi_startproc 指令通常用於函式的開始處。CFI 是呼叫幀資訊 (Call Frame Information) 的縮寫。這個呼叫 以鬆散的方式對應著一個函式。當開發者使用 debugger 和 step instep out 時,實際上是 stepping in/out 一個呼叫幀。在 C 程式碼中,函式有自己的呼叫幀,當然,別的一些東西也會有類似的呼叫幀。.cfi_startproc 指令給了函式一個 .eh_frame 入口,這個入口包含了一些呼叫棧的資訊(丟擲異常時也是用其來展開呼叫幀堆疊的)。這個指令也會發送一些和具體平臺相關的指令給 CFI。它與後面的 .cfi_endproc 相匹配,以此標記出 main() 函式結束的地方。

接著是另外一個 label ## BB#0:。然後,終於,看到第一句彙編程式碼:pushq %rbp。從這裡開始事情開始變得有趣。在 OS X上,我們會有 X86_64 的程式碼,對於這種架構,有一個東西叫做 ABI ( 應用二進位制介面 application binary interface),ABI 指定了函式呼叫是如何在彙編程式碼層面上工作的。在函式呼叫期間,ABI 會讓 rbp 暫存器 (基礎指標暫存器 base pointer register) 被保護起來。當函式呼叫返回時,確保 rbp 暫存器的值跟之前一樣,這是屬於 main 函式的職責。pushq %rbprbp 的值 push 到棧中,以便我們以後將其 pop 出來。

接下來是兩個 CFI 指令:.cfi_def_cfa_offset 16.cfi_offset %rbp, -16。這將會輸出一些關於生成呼叫堆疊展開和除錯的資訊。我們改變了堆疊和基礎指標,而這兩個指令可以告訴編譯器它們都在哪兒,或者更確切的,它們可以確保之後偵錯程式要使用這些資訊時,能找到對應的東西。

接下來,movq %rsp, %rbp 將把區域性變數放置到棧上。subq $32, %rsp 將棧指標移動 32 個位元組,也就是函式會呼叫的位置。我們先將老的棧指標儲存到 rbp 中,然後將此作為我們區域性變數的基址,接著我們更新堆疊指標到我們將會使用的位置。

之後,我們呼叫了 printf()

leaq    L_.str(%rip), %rax
movl    $0, -4(%rbp)
movl    %edi, -8(%rbp)
movq    %rsi, -16(%rbp)
movq    %rax, %rdi
movb    $0, %al
callq   _printf

首先,leaq 會將 L_.str 的指標載入到 rax 暫存器中。留意 L_.str 標記在後面的彙編程式碼中是如何定義的。它就是 C 字串"Hello World!\n"edirsi 暫存器儲存了函式的第一個和第二個引數。由於我們會呼叫別的函式,所以首先需要將它們的當前值儲存起來。這就是為什麼我們使用剛剛儲存的 rbp 偏移32個位元組的原因。第一個 32 位元組的值是 0,之後的 32 位元組的值是 edi 暫存器的值 (儲存了 argc)。然後是 64 位元組 的值:rsi 暫存器的值 (儲存了 argv)。我們在後面並沒有使用這些值,但是編譯器在沒有經過優化處理的時候,它們還是會被存下來。

現在我們把第一個函式 printf() 的引數 rax 設定給第一個函式引數暫存器 edi 中。printf() 是一個可變引數的函式。ABI 呼叫約定指定,將會把使用來儲存引數的暫存器數量儲存在暫存器 al 中。在這裡是 0。最後 callq 呼叫了 printf() 函式。

    movl    $0, %ecx
    movl    %eax, -20(%rbp)         ## 4-byte Spill
    movl    %ecx, %eax

上面的程式碼將 ecx 暫存器設定為 0,並把 eax 暫存器的值儲存至棧中,然後將 ect 中的 0 拷貝至 eax 中。ABI 規定 eax 將用來儲存一個函式的返回值,或者此處 main() 函式的返回值 0:

    addq    $32, %rsp
    popq    %rbp
    ret
    .cfi_endproc

函式執行完成後,將恢復堆疊指標 —— 利用上面的指令 subq $32, %rsp 把堆疊指標 rsp 上移 32 位元組。最後,把之前儲存至 rbp 中的值從棧中彈出來,然後呼叫 ret 返回呼叫者, ret 會讀取出棧的返回地址。 .cfi_endproc 平衡了 .cfi_startproc 指令。

接下來是輸出字串 "Hello World!\n":

    .section    __TEXT,__cstring,cstring_literals
L_.str:                                 ## @.str
    .asciz   "Hello World!\n"

同樣,.section 指令指出下面將要進入的段。L_.str 標記執行在實際的程式碼中獲取到字串的一個指標。.asciz 指令告訴編譯器輸出一個以 ‘\0’ (null) 結尾的字串。

__TEXT __cstring 開啟了一個新的段。這個段中包含了 C 字串:

L_.str:                                 ## @.str
    .asciz     "Hello World!\n"

上面兩行程式碼建立了一個 null 結尾的字串。注意 L_.str 是如何命名,之後會通過它來訪問字串。

最後的 .subsections_via_symbols 指令是靜態連結編輯器使用的。

重申一下,通過下面的選擇操作,我們可以用 Xcode 檢視任意檔案的彙編輸出結果:Product -> Perform Action -> Assemble.

彙編器

彙編器將可讀的彙編程式碼轉換為機器程式碼。它會建立一個目標物件檔案,一般簡稱為 物件檔案。這些檔案以 .o 結尾。如果用 Xcode 構建應用程式,可以在工程的 derived data 目錄中,Objects-normal 資料夾下找到這些檔案。

連結器

稍後我們會對連結器做更詳細的介紹。這裡簡單介紹一下:連結器解決了目標檔案和庫之間的連結。什麼意思呢?還記得下面的語句嗎:

callq   _printf

printf()libc 庫中的一個函式。無論怎樣,最後的可執行檔案需要能需要知道 printf() 在記憶體中的具體位置:例如,_printf 的地址符號是什麼。連結器會讀取所有的目標檔案 (此處只有一個) 和庫 (此處是 libc),並解決所有未知符號 (此處是 _printf) 的問題。然後將它們編碼進最後的可執行檔案中 (可以在 libc 中找到符號 _printf),接著連結器會輸出可以執行的執行檔案:a.out

Section

就像我們上面提到的一樣,這裡有些東西叫做 section。一個可執行檔案包含多個段,也就是多個 section。可執行檔案不同的部分將載入進不同的 section,並且每個 section 會轉換進某個 segment 裡。這個概念對於所有的可執行檔案都是成立的。

我們來看看 a.out 二進位制中的 section。我們可以使用 size 工具來觀察:

% xcrun size -x -l -m a.out 
Segment __PAGEZERO: 0x100000000 (vmaddr 0x0 fileoff 0)
Segment __TEXT: 0x1000 (vmaddr 0x100000000 fileoff 0)
    Section __text: 0x37 (addr 0x100000f30 offset 3888)
    Section __stubs: 0x6 (addr 0x100000f68 offset 3944)
    Section __stub_helper: 0x1a (addr 0x100000f70 offset 3952)
    Section __cstring: 0xe (addr 0x100000f8a offset 3978)
    Section __unwind_info: 0x48 (addr 0x100000f98 offset 3992)
    Section __eh_frame: 0x18 (addr 0x100000fe0 offset 4064)
    total 0xc5
Segment __DATA: 0x1000 (vmaddr 0x100001000 fileoff 4096)
    Section __nl_symbol_ptr: 0x10 (addr 0x100001000 offset 4096)
    Section __la_symbol_ptr: 0x8 (addr 0x100001010 offset 4112)
    total 0x18
Segment __LINKEDIT: 0x1000 (vmaddr 0x100002000 fileoff 8192)
total 0x100003000

如上程式碼所示,我們的 a.out 檔案有 4 個 segment。有些 segment 中有多個 section。

當執行一個可執行檔案時,虛擬記憶體 (VM - virtual memory) 系統將 segment 對映到程序的地址空間上。對映完全不同於我們一般的認識,如果你對虛擬記憶體系統不熟悉,可以簡單的想象虛擬記憶體系統將整個可執行檔案載入進記憶體 -- 雖然在實際上不是這樣的。VM 使用了一些技巧來避免全部載入。

當虛擬記憶體系統進行對映時,segment 和 section 會以不同的引數和許可權被對映。

上面的程式碼中,__TEXT segment 包含了被執行的程式碼。它被以只讀和可執行的方式對映。程序被允許執行這些程式碼,但是不能修改。這些程式碼也不能對自己做出修改,因此這些被對映的頁從來不會被改變。

__DATA segment 以可讀寫和不可執行的方式對映。它包含了將會被更改的資料。

第一個 segment 是 __PAGEZERO。它的大小為 4GB。這 4GB 並不是檔案的真實大小,但是規定了程序地址空間的前 4GB 被對映為 不可執行、不可寫和不可讀。這就是為什麼當讀寫一個 NULL 指標或更小的值時會得到一個 EXC_BAD_ACCESS 錯誤。這是作業系統在嘗試防止引起系統崩潰

在 segment中,一般都會有多個 section。它們包含了可執行檔案的不同部分。在 __TEXT segment 中,__text section 包含了編譯所得到的機器碼。__stubs__stub_helper 是給動態連結器 (dyld) 使用的。通過這兩個 section,在動態連結程式碼中,可以允許延遲連結。__const (在我們的程式碼中沒有) 是常量,不可變的,就像 __cstring (包含了可執行檔案中的字串常量 -- 在原始碼中被雙引號包含的字串) 常量一樣。

__DATA segment 中包含了可讀寫資料。在我們的程式中只有 __nl_symbol_ptr__la_symbol_ptr,它們分別是 non-lazylazy 符號指標。延遲符號指標用於可執行檔案中呼叫未定義的函式,例如不包含在可執行檔案中的函式,它們將會延遲載入。而針對非延遲符號指標,當可執行檔案被載入同時,也會被載入。

_DATA segment 中的其它常見 section 包括 __const,在這裡面會包含一些需要重定向的常量資料。例如 char * const p = "foo"; -- p 指標指向的資料是可變的。__bss section 沒有被初始化的靜態變數,例如 static int a; -- ANSI C 標準規定靜態變數必須設定為 0。並且在執行時靜態變數的值是可以修改的。__common section 包含未初始化的外部全域性變數,跟 static 變數類似。例如在函式外面定義的 int a;。最後,__dyld 是一個 section 佔位符,被用於動態連結器。

Section 中的內容

下面,我們用 otool(1) 來觀察一個 section 中的內容:

% xcrun otool -s __TEXT __text a.out 
a.out:
(__TEXT,__text) section
0000000100000f30 55 48 89 e5 48 83 ec 20 48 8d 05 4b 00 00 00 c7 
0000000100000f40 45 fc 00 00 00 00 89 7d f8 48 89 75 f0 48 89 c7 
0000000100000f50 b0 00 e8 11 00 00 00 b9 00 00 00 00 89 45 ec 89 
0000000100000f60 c8 48 83 c4 20 5d c3 

上面是我們 app 中的程式碼。由於 -s __TEXT __text 很常見,otool 對其設定了一個縮寫 -t 。我們還可以通過新增 -v 來檢視反彙編程式碼:

% xcrun otool -v -t a.out
a.out:
(__TEXT,__text) section
_main:
0000000100000f30    pushq   %rbp
0000000100000f31    movq    %rsp, %rbp
0000000100000f34    subq    $0x20, %rsp
0000000100000f38    leaq    0x4b(%rip), %rax
0000000100000f3f    movl    $0x0, 0xfffffffffffffffc(%rbp)
0000000100000f46    movl    %edi, 0xfffffffffffffff8(%rbp)
0000000100000f49    movq    %rsi, 0xfffffffffffffff0(%rbp)
0000000100000f4d    movq    %rax, %rdi
0000000100000f50    movb    $0x0, %al
0000000100000f52    callq   0x100000f68
0000000100000f57    movl    $0x0, %ecx
0000000100000f5c    movl    %eax, 0xffffffffffffffec(%rbp)
0000000100000f5f    movl    %ecx, %eax
0000000100000f61    addq    $0x20, %rsp
0000000100000f65    popq    %rbp
0000000100000f66    ret

上面的內容是一樣的,只不過以反彙編形式顯示出來。你應該感覺很熟悉,這就是我們在前面編譯時候的程式碼。唯一的不同就是,在這裡我們沒有任何的彙編指令在裡面。這是純粹的二進位制執行檔案。

同樣的方法,我們可以檢視別的 section:

% xcrun otool -v -s __TEXT __cstring a.out
a.out:
Contents of (__TEXT,__cstring) section
0x0000000100000f8a  Hello World!\n

或:

% xcrun otool -v -s __TEXT __eh_frame a.out 
a.out:
Contents of (__TEXT,__eh_frame) section
0000000100000fe0    14 00 00 00 00 00 00 00 01 7a 52 00 01 78 10 01 
0000000100000ff0    10 0c 07 08 90 01 00 00 

效能上需要注意的事項

從側面來講,__DATA__TEXT segment對效能會有所影響。如果你有一個很大的二進位制檔案,你可能得去看看蘋果的文件:關於程式碼大小效能指南。將資料移至 __TEXT 是個不錯的選擇,因為這些頁從來不會被改變。

任意的片段

使用連結符號 -sectcreate 我們可以給可執行檔案以 section 的方式新增任意的資料。這就是如何將一個 Info.plist 檔案新增到一個獨立的可執行檔案中的方法。Info.plist 檔案中的資料需要放入到 __TEXT segment 裡面的一個 __info_plist section 中。可以將 -sectcreate segname sectname file 傳遞給連結器(通過將下面的內容傳遞給 clang):

-Wl,-sectcreate,__TEXT,__info_plist,path/to/Info.plist

同樣,-sectalign 規定了對其方式。如果你新增的是一個全新的 segment,那麼需要通過 -segprot 來規定 segment 的保護方式 (讀/寫/可執行)。這些所有內容在連結器的幫助文件中都有,例如 ld(1)

我們可以利用定義在 /usr/include/mach-o/getsect.h 中的函式 getsectdata() 得到 section,例如 getsectdata() 可以得到指向 section 資料的一個指標,並返回相關 section 的長度。

Mach-O

在 OS X 和 iOS 中可執行檔案的格式為 Mach-O

% file a.out 
a.out: Mach-O 64-bit executable x86_64

對於 GUI 程式也是一樣的:

% file /Applications/Preview.app/Contents/MacOS/Preview 
/Applications/Preview.app/Contents/MacOS/Preview: Mach-O 64-bit executable x86_64

我們可以使用 otool(1) 來觀察可執行檔案的頭部 -- 規定了這個檔案是什麼,以及檔案是如何被載入的。通過 -h 可以打印出頭資訊:

% otool -v -h a.out           a.out:
Mach header
      magic cputype cpusubtype  caps    filetype ncmds sizeofcmds      flags
MH_MAGIC_64  X86_64        ALL LIB64     EXECUTE    16       1296   NOUNDEFS DYLDLINK TWOLEVEL PIE

cputypecpusubtype 規定了這個可執行檔案能夠執行在哪些目標架構上。ncmdssizeofcmds 是載入命令,可以通過 -l 來檢視這兩個載入命令:

% otool -v -l a.out | open -f
a.out:
Load command 0
      cmd LC_SEGMENT_64
  cmdsize 72
  segname __PAGEZERO
   vmaddr 0x0000000000000000
   vmsize 0x0000000100000000
...

載入命令規定了檔案的邏輯結構和檔案在虛擬記憶體中的佈局。otool 打印出的大多數資訊都是源自這裡的載入命令。看一下 Load command 1 部分,可以找到 initprot r-x,它規定了之前提到的保護方式:只讀和可執行。

對於每一個 segment,以及segment 中的每個 section,載入命令規定了它們在記憶體中結束的位置,以及保護模式等。例如,下面是 __TEXT __text section 的輸出內容:

Section
  sectname __text
   segname __TEXT
      addr 0x0000000100000f30
      size 0x0000000000000037
    offset 3888
     align 2^4 (16)
    reloff 0
    nreloc 0
      type S_REGULAR
attributes PURE_INSTRUCTIONS SOME_INSTRUCTIONS
 reserved1 0
 reserved2 0

上面的程式碼將在 0x100000f30 處結束。它在檔案中的偏移量為 3888。如果看一下之前 xcrun otool -v -t a.out 輸出的反彙編程式碼,可以發現程式碼實際位置在 0x100000f30。

我們同樣看看在可執行檔案中,動態連結庫是如何使用的:

% otool -v -L a.out
a.out:
    /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 169.3.0)
    time stamp 2 Thu Jan  1 01:00:02 1970

上面就是我們可執行檔案將要找到 _printf 符號的地方。

一個更復雜的例子

我們來看看有三個檔案的複雜例子:

Foo.h:

#import <Foundation/Foundation.h>

@interface Foo : NSObject

- (void)run;

@end

Foo.m:

#import "Foo.h"

@implementation Foo

- (void)run
{
    NSLog(@"%@", NSFullUserName());
}

@end

helloworld.m:

#import "Foo.h"

int main(int argc, char *argv[])
{
    @autoreleasepool {
        Foo *foo = [[Foo alloc] init];
        [foo run];
        return 0;
    }
}

編譯多個檔案

在上面的示例中,有多個原始檔。所以我們需要讓 clang 對輸入每個檔案生成對應的目標檔案:

% xcrun clang -c Foo.m
% xcrun clang -c helloworld.m

我們從來不編譯標頭檔案。標頭檔案的作用就是在被編譯的實現檔案中對程式碼做簡單的共享。Foo.mhelloworld.m 都是通過 #import 語句將 Foo.h 檔案中的內容新增到實現檔案中的。

最終得到了兩個目標檔案:

% file helloworld.o Foo.o
helloworld.o: Mach-O 64-bit object x86_64
Foo.o:        Mach-O 64-bit object x86_64

為了生成一個可執行檔案,我們需要將這兩個目標檔案和 Foundation framework 連結起來:

xcrun clang helloworld.o Foo.o -Wl,`xcrun --show-sdk-path`/System/Library/Frameworks/Foundation.framework/Foundation

現在可以執行我們的程式了:

% ./a.out 
2013-11-03 18:03:03.386 a.out[8302:303] Daniel Eggert

符號表和連結

我們這個簡單的程式是將兩個目標檔案合併到一起的。Foo.o 目標檔案包含了 Foo 類的實現,而 helloworld.o 目標檔案包含了 main() 函式,以及呼叫/使用 Foo 類。

另外,這兩個目標物件都使用了 Foundation framework。helloworld.o 目標檔案使用了它的 autorelease pool,並間接的使用了 libobjc.dylib 中的 Objective-C 執行時。它需要執行時函式來進行訊息的呼叫。Foo.o 目標檔案也有類似的原理。

所有的這些東西都被形象的稱之為符號。我們可以把符號看成是一些在執行時將會變成指標的東西。雖然實際上並不是這樣的。

每個函式、全域性變數和類等都是通過符號的形式來定義和使用的。當我們將目標檔案連結為一個可執行檔案時,連結器 (ld(1)) 在目標檔案盒動態庫之間對符號做了解析處理。

可執行檔案和目標檔案有一個符號表,這個符號表規定了它們的符號。如果我們用 nm(1) 工具觀察一下 helloworld.0 目標檔案,可以看到如下內容:

% xcrun nm -nm helloworld.o
                 (undefined) external _OBJC_CLASS_$_Foo
0000000000000000 (__TEXT,__text) external _main
                 (undefined) external _objc_autoreleasePoolPop
                 (undefined) external _objc_autoreleasePoolPush
                 (undefined) external _objc_msgSend
                 (undefined) external _objc_msgSend_fixup
0000000000000088 (__TEXT,__objc_methname) non-external L_OBJC_METH_VAR_NAME_
000000000000008e (__TEXT,__objc_methname) non-external L_OBJC_METH_VAR_NAME_1
0000000000000093 (__TEXT,__objc_methname) non-external L_OBJC_METH_VAR_NAME_2
00000000000000a0 (__DATA,__objc_msgrefs) weak private external l_objc_msgSend_fixup_alloc
00000000000000e8 (__TEXT,__eh_frame) non-external EH_frame0
0000000000000100 (__TEXT,__eh_frame) external _main.eh

上面就是那個目標檔案的所有符號。_OBJC_CLASS_$_FooFoo Objective-C 類的符號。該符號是 undefined, externalExternal 的意思是指對於這個目標檔案該類並不是私有的,相反,non-external 的符號則表示對於目標檔案是私有的。我們的 helloworld.o 目標檔案引用了類 Foo,不過這並沒有實現它。因此符號表中將其標示為 undefined。

接下來是 _main 符號,它是表示 main() 函式,同樣為 external,這是因為該函式需要被呼叫,所以應該為可見的。由於在 helloworld.o 檔案中實現了 這個 main 函式。這個函式地址位於 0處,並且需要轉入到 __TEXT,__text section。接著是 4 個 Objective-C 執行時函式。它們同樣是 undefined的,需要連結器進行符號解析。

如果我們轉而觀察 Foo.o 目標檔案,可以看到如下輸出:

% xcrun nm -nm Foo.o
0000000000000000 (__TEXT,__text) non-external -[Foo run]
                 (undefined) external _NSFullUserName
                 (undefined) external _NSLog
                 (undefined) external _OBJC_CLASS_$_NSObject
                 (undefined) external _OBJC_METACLASS_$_NSObject
                 (undefined) external ___CFConstantStringClassReference
                 (undefined) external __objc_empty_cache
                 (undefined) external __objc_empty_vtable
000000000000002f (__TEXT,__cstring) non-external l_.str
0000000000000060 (__TEXT,__objc_classname) non-external L_OBJC_CLASS_NAME_
0000000000000068 (__DATA,__objc_const) non-external l_OBJC_METACLASS_RO_$_Foo
00000000000000b0 (__DATA,__objc_const) non-external l_OBJC_$_INSTANCE_METHODS_Foo
00000000000000d0 (__DATA,__objc_const) non-external l_OBJC_CLASS_RO_$_Foo
0000000000000118 (__DATA,__objc_data) external _OBJC_METACLASS_$_Foo
0000000000000140 (__DATA,__objc_data) external _OBJC_CLASS_$_Foo
0000000000000168 (__TEXT,__objc_methname) non-external L_OBJC_METH_VAR_NAME_
000000000000016c (__TEXT,__objc_methtype) non-external L_OBJC_METH_VAR_TYPE_
00000000000001a8 (__TEXT,__eh_frame) non-external EH_frame0
00000000000001c0 (__TEXT,__eh_frame) non-external -[Foo run].eh

第五行至最後一行顯示了 _OBJC_CLASS_$_Foo 已經定義了,並且對於 Foo.o 是一個外部符號 -- ·Foo.o· 包含了這個類的實現。

Foo.o 同樣有 undefined 的符號。首先是使用了符號 NSFullUserName()NSLog()NSObject

當我們將這兩個目標檔案和 Foundation framework (是一個動態庫) 進行連結處理時,連結器會嘗試解析所有的 undefined 符號。它可以解析 _OBJC_CLASS_$_Foo。另外,它將使用 Foundation framework。

當連結器通過動態庫 (此處是 Foundation framework) 解析成功一個符號時,它會在最終的連結圖中記錄這個符號是通過動態庫進行解析的。連結器會記錄輸出檔案是依賴於哪個動態連結庫,並連同其路徑一起進行記錄。在我們的例子中,_NSFullUserName_NSLog_OBJC_CLASS_$_NSObject_objc_autoreleasePoolPop 等符號都是遵循這個過程。

我們可以看一下最終可執行檔案 a.out 的符號表,並注意觀察連結器是如何解析所有符號的:

% xcrun nm -nm a.out 
                 (undefined) external _NSFullUserName (from Foundation)
                 (undefined) external _NSLog (from Foundation)
                 (undefined) external _OBJC_CLASS_$_NSObject (from CoreFoundation)
                 (undefined) external _OBJC_METACLASS_$_NSObject (from CoreFoundation)
                 (undefined) external ___CFConstantStringClassReference (from CoreFoundation)
                 (undefined) external __objc_empty_cache (from libobjc)
                 (undefined) external __objc_empty_vtable (from libobjc)
                 (undefined) external _objc_autoreleasePoolPop (from libobjc)
                 (undefined) external _objc_autoreleasePoolPush (from libobjc)
                 (undefined) external _objc_msgSend (from libobjc)
                 (undefined) external _objc_msgSend_fixup (from libobjc)
                 (undefined) external dyld_stub_binder (from libSystem)
0000000100000000 (__TEXT,__text) [referenced dynamically] external __mh_execute_header
0000000100000e50 (__TEXT,__text) external _main
0000000100000ed0 (__TEXT,__text) non-external -[Foo run]
0000000100001128 (__DATA,__objc_data) external _OBJC_METACLASS_$_Foo
0000000100001150 (__DATA,__objc_data) external _OBJC_CLASS_$_Foo

可以看到所有的 Foundation 和 Objective-C 執行時符號依舊是 undefined,不過現在的符號表中已經多瞭如何解析它們的資訊,例如在哪個動態庫中可以找到對應的符號。

可執行檔案同樣知道去哪裡找到所需庫:

% xcrun otool -L a.out
a.out:
    /System/Library/Frameworks/Foundation.framework/Versions/C/Foundation (compatibility version 300.0.0, current version 1056.0.0)
    /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1197.1.1)
    /System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation (compatibility version 150.0.0, current version 855.11.0)
    /usr/lib/libobjc.A.dylib (compatibility version 1.0.0, current version 228.0.0)

在執行時,動態連結器 dyld(1) 可以解析這些 undefined 符號,dyld 將會確定好 _NSFullUserName 等符號,並指向它們在 Foundation 中的實現等。

我們可以針對 Foundation 執行 nm(1),並檢查這些符號的定義情況:

% xcrun nm -nm `xcrun --show-sdk-path`/System/Library/Frameworks/Foundation.framework/Foundation | grep NSFullUserName
0000000000007f3e (__TEXT,__text) external _NSFullUserName 

動態連結編輯器

有一些環境變數對於 dyld 的輸出資訊非常有用。首先,如果設定了 DYLD_PRINT_LIBRARIES,那麼 dyld 將會打印出什麼庫被載入了:

% (export DYLD_PRINT_LIBRARIES=; ./a.out )
dyld: loaded: /Users/deggert/Desktop/command_line/./a.out
dyld: loaded: /System/Library/Frameworks/Foundation.framework/Versions/C/Foundation
dyld: loaded: /usr/lib/libSystem.B.dylib
dyld: loaded: /System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation
dyld: loaded: /usr/lib/libobjc.A.dylib
dyld: loaded: /usr/lib/libauto.dylib
[...]

上面將會顯示出在載入 Foundation 時,同時會載入的 70 個動態庫。這是由於 Foundation 依賴於另外一些動態庫。執行下面的命令:

% xcrun otool -L `xcrun --show-sdk-path`/System/Library/Frameworks/Foundation.framework/Foundation

可以看到 Foundation 使用了 15 個動態庫。

dyld 的共享快取

當你構建一個真正的程式時,將會連結各種各樣的庫。它們又會依賴其他一些 framework 和 動態庫。需要載入的動態庫會非常多。而對於相互依賴的符號就更多了。可能將會有上千個符號需要解析處理,這將花費很長的時間:一般是好幾秒鐘。

為了縮短這個處理過程所花費時間,在 OS X 和 iOS 上的動態連結器使用了共享快取,共享快取存於 /var/db/dyld/。對於每一種架構,作業系統都有一個單獨的檔案,檔案中包含了絕大多數的動態庫,這些庫都已經連結為一個檔案,並且已經處理好了它們之間的符號關係。當載入一個 Mach-O 檔案 (一個可執行檔案或者一個庫) 時,動態連結器首先會檢查 共享快取 看看是否存在其中,如果存在,那麼就直接從共享快取中拿出來使用。每一個程序都把這個共享快取對映到了自己的地址空間中。這個方法大大優化了 OS X 和 iOS 上程式的啟動時間。