當我們點擊Xcode的運行按鈕時,你會注意到在界面頂端的提示欄上會出現“Building”的字樣,緊接着會出現“Linking”的字樣,我們知道Building是編譯過程,那這個Linking(鍊接)是什麼過程呢?本文将對鍊接過程做一個講解,了解鍊接的過程,可以幫助你理解計算機系統的底層原理,并解答你平時關于計算機怎樣識别并執行程序的一些疑惑。另外,本文也是後續篇章的基礎,我們會由鍊接的知識延展出Mach-O文件、fishhook原理以及hook objc_msgSend的知識講解。
鍊接的基本概念鍊接(linking)是将各種代碼和數據片段收集并組合成為一個單一文件的過程,這個文件可被加載(複制)到内存并執行。
鍊接可以執行與編譯時(complie time),也就是源代碼被翻譯成機器代碼時;也可以執行于加載時(load time),也就是在程序被加載器(load-er)加載到内存并執行時;甚至可以執行在運行時(run time),也就是由應用程序來執行。在早期的計算機系統中,鍊接是手動執行的。在現代系統中,鍊接是由叫做連接器(linker)的程序自動執行的。
鍊接的作用鍊接器使分離編譯成為可能,我們不用将一個大型的應用程序組織為一個巨大的源文件,而是可以把它分解為更小、更好管理的模塊,可以獨立地修改和編譯這些模塊。當我們改變這些模塊中的一個時,隻需簡單地重新編譯它,并重新鍊接應用,而不必重新編譯其它文件。
下面的讨論基于這樣的環境:一個運行Linux的x86-64系統,使用标準的ELF-64目标文件格式。
編譯器驅動程序下面的C語言示例程序,由兩個源文件組成,main.c和sum.c。main函數初始化一個整數數組,然後調用sum函數來對數組元素求和。
// sum.c
int sum(int *a, int n) {
int s = 0;
for (int i = 0; i < n; i ) {
s = a[i];
}
return s;
}
// main.c
int array[2] = {1, 2};
int main() {
int val = sum(array, 2);
return val;
}
大多數的編譯系統會提供編譯器驅動程序(compile driver),包含語言預處理器、編譯器、彙編器和鍊接器。首先編譯器驅動程序會對main.c與sum.c文件的源代碼進行翻譯,翻譯過程如下:
image
其中,main.o稱為可重定位目标文件。
之後,編譯系統會運行鍊接器ld,将main.o和sum.o以及一些必要的系統目标文件組合起來,創建一個可以執行目标文件,這個過程是靜态鍊接,過程如下:
image
再之後,操作系統會調用加載器(loader),将可執行文件prog中的代碼和數據複制到内存中,然後執行。
靜态鍊接靜态鍊接器(static linker)以一組可重定位目标文件作為輸入,生成一個完全鍊接的、可以加載和運行的可執行目标文件。輸入的可重定位目标文件由各種不同的代碼和數據節(section)組成,每一節都是一個連續的字節序列。指令在一節中,初始化了的全局變量在另一個節中,而未初始化的變量又在另外一節中。
為了構造可執行文件,鍊接器必須完成兩個重要的任務:
目标文件純粹是字節塊的集合,這些塊中,有些包含程序代碼,有些包含數據,而有些則是引導鍊接器和加載器的數據結構。鍊接器将這些塊連接起來,确定被連接塊的運行時位置,并且修改代碼和數據塊中的各種位置。
目标文件目标文件有三種形式:
目标文件的生成方式:
目标文件的格式:
image.png
下上展示了一個典型的ELF可重定位目标文件的格式。ELF頭包含很多信息,包括生成該文件的系統的字節大小,字節順序,ELF頭的大小,目标文件的類型,機器類型等等。節頭部表描述了不同節的位置和大小。
加載ELF頭和節頭部表的是節:
每個可重定位目标模塊(目标文件)m都有一個符号表,它包含m定義和引用的符号的信息。在鍊接器的上下文中,有三種不同的符号:
.symtab中的符号表不包含非靜态程序變量的任何符号,這些程序變量符号在棧中被管理,鍊接器對此類符号不感興趣。
如何解析多重定義的全局符号鍊接器的輸入是一組可重定位目标模塊。每個模塊定義一組符号,有些是局部的(隻對定義該符号的模塊可見),有些是全局的(對其他模塊也可見)。如果多個模塊定義同名的全局符号,會發生什麼呢?下面是 Linux編譯系統采用的方法。
在編譯時,編譯器向彙編器輸出每個全局符号,或者是強( strong)或者是弱(weak),而彙編器把這個信息隐含地編碼在可重定位目标文件的符号表裡。函數和已初始化的全局變量是強符号,未初始化的全局變量是弱符号。
根據強弱符号的定義,Linux鍊接器使用下面的規則來處理多重定義的符号名
迄今為止,我們都是假設鍊接器讀取一組可重定位目标文件,并把它們鍊接起來,輸出一個可執行目标文件。實際上,所有的編譯系統都提供一種機制,将所有相關的目标模塊打包成一個單獨的文件,稱為靜态庫。靜态庫可以用做鍊接器的輸入,當鍊接器構造一個輸出的可執行目标文件時,它隻複制靜态庫裡被應用程序引用的目标模塊,這就減少了可執行文件在磁盤和内存中的大小。在Linux系統中,靜态庫由後綴.a标識。
重定位一旦鍊接器完成了符号解析這一步,就把代碼中的每個符号引用和正好一個符号定義(即它的一個輸入目标模塊中的一個符号表條目)關聯起來。此時,鍊接器就知道它的輸入目标模塊中的代碼節和數據節的确切大小。現在就可以開始重定位步驟了,在這個步驟中,将合并輸入模塊,并為每個符号分配運行時地址。重定位由兩步組成:
當彙編器生成一個目标模塊時,它并不知道數據和代碼最終将放在内存中的什麼位置,它也并不知道這個模塊引用的任何外部定義的函數或者全局變量的位置。所以,無論何時彙編器遇到對最終位置的目标引用,它就會生成一個重定位條目,告訴鍊接器在将目标文件合并成可執行目标文件時如何修改這個引用。
可執行目标文件 與 加載可執行目标文件見《深入理解計算機系統》
動态鍊接共享庫靜态庫由一些缺點:靜态庫需要定期維護和更新;每個程序都會使用一些通用的标準函數,在運行時,這些函數的代碼會被複制到每個運行進程的文本段中,在一個運行上百個進行的典型系統上,這是對内存資源的浪費。
共享庫(shared library)是緻力于解決靜态庫缺陷的一個現代創新産物。共享庫是一個目标模塊,在運行或加載時,可以加載到任意内存地址,并和一個在内存中的程序鍊接起來。這個過程稱為動态鍊接,是由一個叫做動态鍊接器(dynamic linker)的程序來執行的。在Linux系統中,共享庫通常由.so後綴标識。
共享庫以兩種不同的方式來共享的。首先,在任何給定的文件系統中,對于一個庫隻有一個.so文件。所有引用該哭的可執行目标文件共享這個.so文件中的代碼和數據,而不是像靜态庫的内容那樣被複制和嵌入到引用它們的可執行文件中。其次,在内存中,一個共享庫的.text節的一個副本可以被不同的正在運行的進程共享。
image.png
,更多精彩资讯请关注tft每日頭條,我们将持续为您更新最新资讯!