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

一水流年發表於2013-11-21

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

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

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

 

XCrun

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

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

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檔案的目錄:

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

錄入下面的程式碼:

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

現在你能夠在終端上看到熟悉的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展示一下這個過程:

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

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

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

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

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

 

編譯

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

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

我們從前三行開始:

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

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

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

下面是main函式的頭部:

這一部分有一些和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():

首先,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函式。

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

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

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

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

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

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

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

更多關於彙編指令的資訊可以從蘋果的Apple’s assemebler reference獲取。AMD64網站有關於ABI for x86的文件。同時也有Gentle Introduction to x86-64 Assemble。 再一次,Xcode允許你檢視任何檔案的彙編程式碼通過 Product->Perform Action -> Assemble.

 

彙編編譯器:

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

 

聯結器:

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

 

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

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

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片段包含了被動態連結器使用的佔位符片段。

蘋果的文件OSX Assembler Reference有更多關於片段定義的內容。

 

段內容:

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

這就是我們app的程式碼。從-s __TEXT __text非常普通,otool有一個對此的縮寫,使用-t.我們甚至可以看反彙編的程式碼通過在後面加上-v:

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

同樣的方法,我們可以查案一下其他片段:

或者:

關於效能的腳註

從側面來講,_DATA和_TEXT段會影響效能。如果你有一個非常大的二進位制檔案,你可能回想檢視蘋果的程式碼大小優化指南。將資料移到__TEXT段是個不錯的選擇,因為這些頁從來不會變髒。

 

任意的片段

你可以以片段的方式向你的二進位制檔案新增任何的資料,通過-sectcreate連結引數。這就是你怎樣新增info.plist到一個獨立的二進位制檔案。Info.plist的資料需要被放在_TEXT段的_info_plist片段。你可以使用聯結器的命令-sectcreate segname sectname file來實現:

同樣的,-sectalign也致命了對齊方式。如果你新增一個全新的段,通過-segprot來制定資料的保護方式。這些都是在聯結器中的幫助手冊中的。

你能夠到達在/usr/include/mach-o/getsect.h中定義的函式在二進位制檔案中的那些片段,通過使用getsectdata(),它將會返回片段資料的指標和大小。

 

Mch-o

在OS X和iOS中可執行檔案是Mach-o格式的:

對於GUI的程式來說也是這樣:

你可以從這裡找到關於mach-o檔案格式的詳細資料。

我們可以使用otool來看一看mach-o檔案的頭部。這說明了這個檔案是什麼,和怎樣被載入的。我們將會使用-h引數來列印頭部資訊。

cputype和cpusubtype指明瞭可執行檔案的目標架構。ncmds和sizeofcmds將會載入一些命令,這些命令我們可以通過-l引數來檢視:

載入命令指明瞭檔案的邏輯結構和檔案在虛擬記憶體中的佈局。絕大多數otool列印的資訊都是從這些載入命令中來的。看一下Load comand 1部分,我們看到了initprot r-x,這指明瞭我們上面提到的資料保護模式:只讀並且可執行。

對於每一個段和每一個段中的片段,載入命令說明了它們會在記憶體中的位置和它們的保護模式,例如,這是關於__TEXT __text片段的輸出:

我們的程式碼將截止在0x100000f30.它在檔案中的偏移量通常是3888。如果你看一下a.out的範彙編輸出。你能夠在0x100000f30處看到我們的程式碼。

我們同樣可以看一下在可執行檔案中,動態連結庫是怎樣使用的:

這是你能夠在二進位制檔案中的__printf符號連結將要用到的庫。

 

一個更復雜的例子

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

編譯多個檔案 非常明顯,我們現在有多個檔案。所以我們需要對每一個檔案呼叫clang來生成目標檔案:

我們從來不編譯標頭檔案。標頭檔案的目的是在實現檔案中貢獻程式碼,並通過這種方式來唄編譯。通過#import語句Foo.m和helloworld.m中都被插入了foo.h的內容。 我們得到了兩個檔案:

為了生成可執行檔案,我們需要連結這兩個目標檔案和Foundation系統庫:

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

 

符號表和連結

我們這個簡單的app是通過兩個目標檔案合併到一起得到的。Foo.o包含了Foo類的實現,同事helloworld.o包含了呼叫Foo類方法run的main函式。 進一步,兩個檔案都使用了Foundation庫。在helloworld.o中autorelease pool使用了這個庫,以簡潔的方式使用了libobjc.dylib中的Objctive-c執行時。它需要使用執行時的函式來傳送訊息呼叫。foo.o也是一樣的。

這些被形象的稱之為符號。我們可以把符號看成一些在執行時將會變成指標的東西。雖然實際上並不是這樣能夠。 每一個函式,全域性變數,類等等都是通過符號的方式來使用的。當我們為可執行檔案連線一個目標檔案,聯結器將會按需要決定目標檔案和動態庫之間的所有符號。 可執行檔案和目標檔案都有一個符號表來儲存這些符號。如果你使用nm工具來檢視一下helloworld.o你會發現:

這就是檔案中所有的符號連結。__OBJC_CLASS_$_Foo是類Foo的符號連結。它還沒有被決定成Foo類的外部連結。外部表示它對不是私有的。與此相反non-external表明符號連結對於特定的檔案是私有的。 我們的helloworld.o檔案引用了Foo類,但是並沒有實現它。於是符號最後以未確定結尾。

下面,main函式同樣是外部連結,因為它需要能夠被外部看到並被呼叫。無論怎樣,main函式是在helloworld中實現的。並且放在了地址0,和放在__TEXT __text片段中。然後是四個objc執行時的函式。它們同樣是未定義的,需要聯結器來決定。

我們再來看看Foo.o檔案:

末五行指出_OBJC_CLASS_$_Foo是一個已定義的並且是個外部符號,同時包含Foo的實現。 Foo.o也有未定義的符號。最前面的是它使用過的NSFullUserName(),NSLog()和NSObject。 當我們連線著兩個檔案還有Foundation庫的時候,將會確定這些在動態連結庫中的符號。臨界期記錄了輸出檔案以來特定的動態連結庫和它們的位置。這就是NSFullName()等將會發生的事情。

我們可以看一下最後的執行檔案a.out的符號表,就能夠發現聯結器是怎樣確定這些符號的:

我們發現Foundation和Objctive-C執行時的一些符號依然是未確定的。但是符號表中,記錄了怎樣去確定它們。例如那些它們可以去查詢的動態連結庫。

可執行檔案一樣也知道去哪找這些庫:

這些未定義的符號將會在執行時被dyld(1)確定。當我們執行程式的時候,dyld將會在Foundation中確定指向_NSFullUserName等的實現的指標,等等等等

我們可以再次使用nm來檢視你這些符號在Foundation中的情況,實際上,如下:

動態連結編輯器

這裡有一些環境變數能幫助我們看一下dyld到底做了些什麼。首先是DYLD_PRINT_LIBRARIES.如果設定了,dyld將會輸出已經載入的東戴連結庫:

這顯示了七十多個在載入Foundation的時候載入的動態連結庫。這是因為Foundation庫也依賴於其他很多動態連結庫, 你可以執行:

來檢視五十多個Foundation依賴的庫。

 

dyld的共享快取

當你構建一個真正的程式的時候,你將會連結各種各樣的庫。它們又會依賴其他的一些框架和動態連結庫。於是要載入的動態連結庫會非常多。同樣非獨立的符號也非常多。這裡就會有成千上萬的符號要確定。這個工作將會話費很多時間——幾秒鐘。 為了優化這個過程,OS X和iOS上動態連結器使用了一個共享快取,在/var/db/dyld/。對於每一種架構,作業系統有一個單獨的檔案包含了絕大多數的動態連結庫,這些庫已經互相連線並且符號都已經確定。當一個Mach-o檔案被載入的時候,動態連結器會首先檢查共享快取,如果存在相應的庫就是用。每一個程式都把這個共享快取對映到了自己的地址空間中。這個方法戲劇性的優化了OS X和iOS上程式的載入時間。

相關文章