1. 程式人生 > >objc系列譯文(6.3):Mach-O 可執行檔案

objc系列譯文(6.3):Mach-O 可執行檔案

當我們在Xcode中構建一個程式的時候,其中有一部分就是把原始檔(.m和.h)檔案轉變成可執行檔案。這個可執行檔案包含了將會在CPU(iOS裝置上的arm處理器或者你mac上的Intel處理器)執行的位元組碼。

我們將會過一遍編譯器這個過程的做了些什麼,同時也看一下可執行檔案的內部到底是怎樣的。其實,裡面的東西比你看到的要多很多。

讓我們先把Xcode放一邊,踏入Commond-Lines的大陸。當我們在Xcode中構建一個App時,Xcode只是簡單的呼叫了一系列的工具而已。希望這將會讓你更好的明白一個可執行檔案(被稱之為Mach-O可執行檔案),是怎樣組裝起來的,並且是怎樣在iOS或者os x上執行的

XCrun

先從一些基礎性的東西開始:我們將會使用一個叫做Xcrun的命令列工具。他看起來很奇怪,但是的確相當出色。這個小工具是用來呼叫其他工具的。 原先的時候我們執行:

1 %clang-v

現在在終端中,我們可以執行:

1 %
xcrun clang-v

Xcrun定位Clang,並且使用相關的引數來執行Clang。

為什麼我們要做這個事情?這看起來毫無重點,胡扯八道。但是Xcrun允許我們使用多個版本的Xcode,或者使用特定Xcode版本里面的工具,或者針對特點的SDK使用不同的工具。如果你恰好有Xcode4.5和xcode5、使用xcode-select和xcrun你可以選擇選擇使用來自Xcode4.5裡面的SDK的工具,或者來自Xcode5裡面的SDK的工具。在大多數其他平臺上,這將是一個不可能的事情。如果你看一下幫助手冊上xcode-select和xcrun的一些細節。你就能在不安裝命令列工具的情況下,使用在終端中使用開發者工具。

一個不使用IDE的Hello World

回到終端,建立一個包含一個c檔案的目錄:

Shell
12345 %mkdir~/Desktop/objcio-command-line%cd!$%touchhelloworld.c

現在使用你喜歡的文字編輯器來編輯這個檔案,例如TextEdit.app:

Shell
1 %open-ehelloworld.c

錄入下面的程式碼:

Shell
123456 #include <stdio.h>intmain(intargc,char*argv[]){printf("Hello World!\n");return0;}

儲存,並且回到終端執行:

Shell
123 %xcrunclanghelloworld.c%./a.out

現在你能夠在終端上看到熟悉的Hello World!。你編譯了一個C程式並且執行了它。所有都是在不使用IDE的情況下做的。深呼吸一下,高興高興。

我們在這裡做了些什麼?我們將hellowrold.c編譯成了叫a.out的Mach-o二進位制檔案。a.out是編譯器的預設名字,除非你指定一個別的。

Hello World和編譯器

現在可選擇的編譯器是Clang(讀作:/’kl /)。Chris寫了一些更多關於Clang細節的介紹,可以參考: about the compiler

概括一下就是,編譯器將會讀入處理hellowrold.c,輸出可執行檔案a.out。這個過程包含了非常多的步驟。我們所要做的就是正確的執行它們。

預處理:

  • 序列化
  • 巨集定義展開
  • #include展開(引用檔案展開)

語法和語義分析:

  • 使用預處理後的單詞構建詞法樹
  • 執行語義分析生成語法樹
  • 輸出AST (Abstract Syntax Tree)

程式碼生成和優化

  • 將AST轉化成更低階的中間碼(LLVM IR)
  • 優化生成程式碼
  • 目的碼生成
  • 輸出彙編程式碼

彙編程式

  • 將彙編程式碼轉化成目標檔案

聯結器

  • 將多個目標檔案合併成可執行檔案(或者一個動態庫) 我們來看一個關於這些步驟的簡單的例子。

預處理

編譯器將做的第一件事情是處理檔案。使用Clang展示一下這個過程:

Shell
1 %xcrun clang-Ehelloworld.c

歐耶。輸出了413行內容。打開個編輯器看看到底發生了什麼:

Shell
1 %xcrun clang-Ehelloworld.c|open-f

在檔案頂部我們能看到很多以”#”開頭的行。這些被稱之為行標記語句的語句告訴我們它後面的內容來自哪裡。我們需要這個。如果我再看一下hellowrold.c,第一行是:

C
1 #include <stdio.h>

我們都用過#include和#import。它們做的就是告訴於處理器在#include語句的地方插入stdio.h的內容。在剛剛的檔案裡就是插入了一個以#開頭的行標記。跟在#後面的數字是在原始檔中的行號。每一行最後的數字是在新檔案中的行號。回到剛才開啟的檔案,接下來是系統標頭檔案,或者一些被看成包裹著extern “C”的檔案。

如果你滾動到檔案末尾,你將會發現我們的helloworld.c的程式碼:

C
123456 # 2 "helloworld.c" 2intmain(intargc,char*argv[]){printf("Hello World!\n");return0;}

在Xcode中,你可以通過使用Product->Perform Action-> Preprocess來檢視任何一個檔案的預處理輸出。一定要注意這將會花費一些時間來載入預處理輸出檔案(接近100,000行)。

編譯

下一個步驟:文字處理和程式碼生成。我們可以呼叫clang輸出彙編程式碼就像這樣:

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

看一看輸出。我們首先注意到的是一些以點開頭的行。這些是彙編指令。其他的是真正的x86_64彙編程式碼。最後是些標記,就像C中的那些標記一樣。

我們從前三行開始:

123 .section    __TEXT,__text,regular,pure_instructions.globl  _main.align4,0x90

這三行是彙編指令,不是彙編程式碼。”.section”指令指出了哪一個段接下來將會被執行。比用二進位制表示好看多了。

下一個,.global指令說明_main是一個外部符號。這就是我們的main()函式。它能夠從我們的二進位制檔案之外看到,因為系統要呼叫它來執行可執行檔案。

.align指令指出了下面程式碼的對齊方式。從我們的角度看,接下來的程式碼將會按照16位元對齊並且如果需要的時候用0x90補齊。

下面是main函式的頭部:

123456789101112 _main:## @main.cfi_startproc## BB#0:pushq%rbpLtmp2:.cfi_def_cfa_offset16Ltmp3:.cfi_offset%rbp,-16movq%rsp,%rbpLtmp4:.cfi_def_cfa_register%rbpsubq$32,%rsp

這一部分有一些和C標記工作機制一樣的一些標記。它們是某些特定部分的彙編程式碼的符號連結。首先是_main函式真正的開始地址。這個也是被丟擲的符號。二進位制檔案將會在這個地方產生一個引用。

.cfi_startproc指令一半會在函式開始的地方使用。CFI是Call Frame Information的縮寫。幀鬆散的與一個函式互動。當你使用偵錯程式,並且單步執行的時候,你實際上是在呼叫幀中跳轉。在C程式碼中,函式有自己的呼叫幀,除了函式之外的一些結構也會有呼叫站。.cfi_startproc指令給了函式一個.en_frame的入口,這個入口包含了堆疊展開資訊(表示異常如何展開呼叫幀堆疊)。這個指令也會發送一些和具體平臺相關的指令給CFI。檔案後面的.cfi_endproc與.cfi_startproc相匹配,來表示結束main函式。

下一步,這裡有另外一個Label ## BB#0.然後,終於來了第一句彙編程式碼:pushq %rbp。從這裡開始事情開始變得有趣。在OS X上,我們將會有x84_64的程式碼。對於這種架構,有一個東西叫做ABI(application binary interface),ABI表示函式呼叫是怎樣在彙編程式碼層面上工作的。ABI指出在函式呼叫時,rbp暫存器必須被保護起來。這是main函式的責任,來確保返回時,rbp暫存器中有資料。pushq %rbp將它的資料推進堆疊,以便我們以後使用。

下面是,兩個CFI指令: .cfi_def_cfa_offset 16 和 .cfi_offset %rbp, -16. 這將會輸出一些資訊,這些資訊是關於生成呼叫堆疊展開資訊和除錯資訊的。我們改變了堆疊,並且這兩個指令告訴編譯器指標指向哪裡,或者它們說出了之後偵錯程式將會使用的資訊。

現在movq %rsp, %rbp將會把區域性變數載入進堆疊。subq $32,%rsp將堆疊指標移動32位元,也就是函式將會呼叫的位置。我們先在rbp中儲存了老的堆疊指標,然後將此作為我們區域性變數的基址,然後我們更新堆疊指標到我們將會使用的位置。

之後,我們呼叫了printf():

1234567 leaq    L_.str(%rip),%raxmovl$0,-4(%rbp)movl%edi,-8(%rbp)movq%rsi,-16(%rbp)movq%rax,%rdimovb$0,%alcallq   _printf

首先,leaq載入到L_.str的指標到暫存器rax。注意L_.str標記是怎樣在下面的程式碼中定義的。它就是C字串“hello world!\n”。暫存器edi和rsi儲存了函式的第一個和第二個引數。直到我們呼叫其他函式,我們第一步需要儲存它們當前值。這就是為什麼我們使用剛剛儲存的rbp偏移32位元的原因。第一個32位元是零,之後32個位元是edi的值(儲存了argc),然後是64bit的rsi暫存器的值。我們在後面不會使用這些資料。但是如果編譯器沒有使用優化的時候,它們還是會被存下來。

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

123 movl$0,%ecxmovl%eax,-20(%rbp)## 4-byte Spillmovl%ecx,%eax

這將設定ecx寄存的值為0,並且把eax的值壓棧。然後從ecx複製0到eax。ABI指定eax將會儲存函式的返回值,我們man函式的返回值是0:

1234 addq$32,%rsppopq%rbpret.cfi_endproc

函式執行完成後,將恢復堆疊指標,通過上移32bit在rsp中的堆疊指標。我們將會出棧我們早先儲存的rbp的值,然後呼叫ret來返回,ret將會讀取離開堆疊的地址。.cfi_endproc平衡了.cfi_startproc指令。

下一步是一個字一個字的輸出我們的字串:“hello world!\n”:

之後.section指令指出下面將要跳入的段。L_.str標記允許獲取一個字元轉的指標。.asciz指定告訴彙編器輸出一個0的字串結尾。

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

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

這兩行建立了一個沒有結束符的字元創。注意L_.str是怎樣命名,和來獲取字串的。

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

彙編編譯器:

彙編編譯器,只是簡單的將彙編程式碼轉換成機器碼。它建立了一個目標檔案。這些檔案以.o結尾。如果你使用Xcode構建一個app,你將會在Derived Data目錄下面的你的工程目錄中的objects-normal目錄下面發現這些檔案。

聯結器:

我們將會多談一點關於連結的東西。但是簡單的說,聯結器確定了目標檔案和庫之間的連結。這是什麼意思? 重新呼叫 callq _printf. printf是在libc庫中的一個函式。無論怎樣,最後的可執行檔案需要能知道printf()在記憶體中的什麼位置。例如符號_printf的地址。聯結器將會讀取所有的目標檔案,所有的庫和結束任何未定義的符號。然後將它們編碼進最後的可執行檔案,然後輸出最後的可執行檔案:a.out。

就像我們上面提到的一樣,這裡有些東西叫做段。一個可執行檔案包含多個段。可執行檔案不同的部分將會載入進不同的段。並且每個段將會轉化進一個“Segment”中。這對我們隨便寫的app如此,對我們用心寫的app也一樣。

我們來看看在a.out中的段。我們可以使用size:

1234567891011121314151617 %xcrunsize-x-l-ma.outSegment __PAGEZERO:0x100000000(vmaddr0x0fileoff0)Segment __TEXT:0x1000(vmaddr0x100000000fileoff0)Section __text:0x37(addr0x100000f30offset3888)Section __stubs:0x6(addr0x100000f68offset3944)Section __stub_helper:0x1a(addr0x100000f70offset3952)Section __cstring:0xe(addr0x100000f8aoffset3978)Section __unwind_info:0x48(addr0x100000f98offset3992)Section __eh_frame:0x18(addr0x100000fe0offset4064)total0xc5Segment __DATA:0x1000(vmaddr0x100001000fileoff4096)Section __nl_symbol_ptr:0x10(addr0x100001000offset4096)Section __la_symbol_ptr:0x8(addr0x100001010offset4112)total0x18Segment __LINKEDIT:0x1000(vmaddr0x100002000fileoff8192)total0x100003000

a.out檔案有四個段。其中一些有section。

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

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

__TEXT段包含了可執行的程式碼。它們被以只讀和可執行的方式對映。程序被允許執行這些程式碼,但是不能修改。這些程式碼也不能改變它們自己,並且這些頁從來不會被汙染。

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

第一個段是__PAGEZERO。這個有4GB大小。這4GB並不是檔案的真實大小,但是說明了程序的前4GB地址空間將會被對映為,不能執行,不能讀,不能寫。這就是為什麼在去寫NULL指標或者一些低位的指標的時候,你會得到一個EXC_BAD_ACCESS錯誤。這是作業系統在嘗試防止你引起系統崩潰。

在每一個段內有一些片段。它們包含了可執行檔案的不同的部分。在_TEXT段,_text片段包含了編譯得到的機器碼。_stubs和_stub_helper是給動態連結器用的。著允許動態連結的程式碼延遲連結。_const是不可變的部分,就像_cstring包含了可執行檔案的字串一樣。

_DATA段包含了可讀寫資料。從我們的角度,我們只有_nl_sysmol_ptr 和__la_symble_ptr,它們是延遲連結的指標。延遲連結的指標被用來執行未定義的函式。例如,那些沒有包含在可執行檔案本身內部的函式。它們將會延遲載入。那些非延遲連結的指標將會在可執行檔案被夾在的時候確定。

其他在_DATA中共同的段是_const。她包含了那些需要重定位的不可變資料。一個例子是chat* const p = “foo”; p指標指向的資料不是靜態的。_bss片段包含了沒有被初始化的靜態變數例如static int a; ANSI C標準指出這些靜態變數將會被設定為零。但是在執行時可以被改變。_common片段包含了被動態連結器使用的佔位符片段。

段內容:

我們能檢查每一個片段的內容,使用otool像這樣: