深入理解計算機系統_第七章_鏈接

章前導讀

鏈接是將各種代碼和數據片段收集並組合成爲一個單一文件的過程,這個文件可被加載到內存並執行。鏈接可以執行於編譯時,也就是在源代碼被翻譯成機器代碼時;也可以執行於加載時,也就是程序被加載器加載到內存並執行時;甚至執行於運行時,也就是由應用程序來執行。現在系統中,鏈接是由叫做鏈接器的程序自動執行的。
鏈接器使得分離編譯成爲可能。這樣我們可以獨立修改和編譯不同的模塊,改變這些模塊中的一個時,只需要簡單地重新編譯它,並重新鏈接應用,而不必重新編譯其他文件。

編譯器驅動程序

大多數編譯系統提供編譯器驅動程序,它代表用戶在需要時調用語言預處理器、編譯器、彙編器和鏈接器。下圖展示了驅動程序將程序從ASCII碼源文件翻譯成可執行目標文件時的行爲。
在這裏插入圖片描述
在運行可執行文件prog時,shell調用操作系統中一個叫做加載器的函數,他將可執行文件prog中的代碼和數據複製到內存,然後將控制轉移到這個程序的開頭。

靜態鏈接

像Linux LD程序這樣的靜態鏈接器以一組可重定位目標文件和命令行參數作爲輸入,生成一個完全鏈接的、可以加載和運行的可執行目標作爲輸出。輸入的可重定位目標文件由各種不同的代碼和數據節組成,每一節都是一個連續的字節序列。指令在一節中,初始化了的全局變量在另一節中,而未初始化的變量又在另外一節中。
爲了構造可執行文件,鏈接器完成如下兩個主要任務:
1.符號解析。目標文件定義和引用符號,每個符號對應於一個函數、一個全局變量或一個靜態變量(C語言中任何以static屬性聲明的變量)。符號解析的目的是將每一個符號引用正好和一個符號定義關聯起來。
2.重定位。編譯器和彙編器生成從地址0開始的代碼和數據節。鏈接器通過把每個符號定義與一個內存爲止關聯,從而重定位這些節,然後修改所有這些符號的引用,使得它們紙箱這個內存位置。鏈接器使用匯編器產生的重定位條目的詳細指令,不加甄別地執行這樣的重定位。
目標文件純粹是字節塊的集合。這些塊中,有些包含程序代碼,有些包含程序數據,而其他的則包含引導鏈接器和加載器的數據結構。鏈接器將這些塊連接起來,確定被連接塊的運行時位置,並且修改代碼和數據塊中的各種位置。

目標文件

目標文件有三種形式:
1.可重定位目標文件。包含二進制代碼和數據,其形式可以在編譯時與其他可重定位目標文件合併起來,創建一個可執行目標文件。
2.可執行目標文件。包含二進制代碼和數據,其形式可以被直接複製到內存並執行
3.共享目標文件。一種特殊類型的可重定位目標文件,可以在加載或運行時被動態地加載進內存並鏈接。
編譯器和彙編器生成可重定位目標文件(包括共享目標文件)。鏈接器生成可執行目標文件。

可重定位目標文件

下圖是一個典型的ELF可重定位目標文件的格式。不同節的位置和大小是由節頭部表表述的,其中目標文件中每個節都有一個固定大小的條目。
在這裏插入圖片描述
.text:已編譯程序的機器代碼。
.rodata:制只讀數據,比如printf語句中格式串和開關語句的跳轉表。
.data:已初始化的全局和靜態C變量。局部C變量在運行時被保存在棧中,既不出現在.data節中,也不在.bss節中。
.bss:未初始化的全局和靜態C變量,以及所有被初始化爲0的全局或靜態變量。在目標文件中這個節不佔據實際的空間,它僅僅是一個佔位符。在目標文件中,未初始化變量不需要佔據任何實際的磁盤空間。運行時,在內存中分配這些變量,初始值爲0,這樣提高了空間效率。
.symtab:符號表,存放在程序中定義和引用的函數和全局變量的信息。
.rel.text:一個.text節中位置的列表,當鏈接器把這個目標文件和其他文件組合時,需要修改這些位置。一般而言,任何調用外部函數或引用全局變量的指令都需要修改。調用本地函數的指令則不需要修改。可執行目標文件中並不需要重定位信息,因此通常忽略。
.rel.data:被模塊引用或定義的全局變量的重定位信息。一般任何已初始化的全局變量,如果它的初始值是一個全局變量地址或外部定義函數的地址,都需要修改。
.debug:一個調試符號表,包含程序中定義的局部變量和類型定義,程序中定義和引用的全局變量,以及原始的C源文件。
.line:原始C源程序中行號和.text節中機器指令之間的映射。
.strtab:一個字符串表,包含.symtab和.debug節中的符號表,以及節頭部中的節名字。

符號解析

鏈接器解析符號引用的方法是將每個引用與它輸入的可重定位目標文件的符號表中的一個確定的符定義關聯起來。編譯器只允許每個模塊中每個局部符號有一個定義(這個模塊就是一個C源文件)。靜態局部變量也會有本地鏈接器符號,編譯器還要確保它們的名字是唯一的。
對於全局符號比較麻煩,因爲多個目標文件可能會定義相同名字的全局符號。如果遇到了一個不是在當前模塊中定義的符號時,會假設該符號是在其他某個模塊中定義的,生成一個鏈接器符號表條目,並把它交給鏈接器處理。如果鏈接器在它的任何輸入模塊中都找不到這個被引用符號的定義,就輸出一條錯誤信息並終止。

鏈接器如何解析多重定義的全局符號

在編譯時,編譯器向彙編器輸出每個全局符號,或者是或者是,而彙編器把這個信息隱含地編碼在可重定位目標文件的符號表裏。函數和已初始化的全局變量是強符號,未初始化的全局變量是弱符號。並且使用如下的規則來處理:
1.不允許有多個同名的強符號
2.如果一個強符號和多個弱符號同名,那麼選擇強符號
3.如果有多個弱符號同名,那麼從這些弱符號中任意選擇一個
如果重複的符號定義有不同的類型,經常導致錯誤,比如在a.c文件中定義了 int x = 15121;int y = 15322;(兩個初始化的符號,強符號)而在b.c文件中定義 double x;(未初始化的符號,弱符號),並且在b.c的函數中進行了賦值:x = -0.0;如果在一臺x86-64機器上,double類型是8個字節,int類型是4個字節,如果前面的x 和 y 放在了連續的地址上,後面的賦值操作就會覆蓋掉前面定義的y的值。

與靜態庫鏈接

到目前都是假設鏈接器讀取一組可重定位目標文件,並把它們鏈接起來,形成一個輸出的可執行文件。其實所有的編譯系統還可以將所有相關的目標模塊打包成一個單獨的文件,稱爲靜態庫,它可以用做鏈接器的輸入,當鏈接器構造一個輸出的可執行文件時,它只複製靜態庫裏被應用程序引用的目標模塊。靜態庫的概念提出後,相關的函數可以被編譯爲獨立的目標模塊,然後封裝成一個單獨的靜態庫文件。然後應用程序可以通過在命令行上指定單獨的文件名字來使用這些庫中定義的函數。鏈接時,鏈接器將只複製被程序引用的目標模塊,這就減少了可執行文件在磁盤和內存中的大小。
在linux系統中,靜態庫以一種稱爲存檔的特殊文件格式存放在磁盤中。存檔文件是一組連接起來的可重定位目標文件的集合,有一個頭部來描述每個成員目標文件的大小和位置。存放文件名由後綴.a標識。

鏈接器如何使用靜態庫來解析引用

在符號解析階段,鏈接器從左到右按照它們在編譯器驅動程序命令行上出現的順序來掃描可重定位目標文件和存檔文件。(驅動程序自動將命令行中所有的.c文件翻譯爲.o文件)。在這次掃描中,鏈接器維護一個可重定位目標文件的集合E(這個集合中的文件被合併起來形成可執行文件),一個未解析的符號集合U(即引用了但尚未定義的符號),一個在前面輸入文件中已定義的符號集合D。初始時,E、U和D都是空的。
1.對於命令行上每個輸入文件f,鏈接器會判斷f時一個目標文件還是一個存檔文件。如果f是一個目標文件,那麼鏈接器把f添加到E,修改U和D來反饋f中的符號定義和引用,並繼續下一個輸入文件。
2.如果f是一個存檔文件,那麼鏈接器就嘗試匹配U中未解析的符號和存檔文件成員定義的符號。如果某個存檔文件成員m,定義了一個符號來解析U中的一個引用。對存檔文件中所有的成員目標文件都依次進行這個過程,直到U和D都不在發生變換。此時,任何不包含在E中的成員目標文件都被丟棄,鏈接器繼續處理下一個輸入文件。
3.如果當鏈接器完成對命令行上輸入文件的掃描後,U是非空的,那麼鏈接器就會輸出錯誤並終止,否則,它會合並和重定位E中的目標文件,構建輸出的可執行文件。
通過這個過程可以看出,我們在命令行中輸入各個靜態庫時,一定要按照依賴關係,從前到後的排放,爲了滿足特殊的依賴需求,也可以在命令行上重複排放靜態庫。

重定位

一旦鏈接器完成了符號解析,就把代碼中每個符號引用和正好一個符號定義關聯起來。此時,鏈接器就知道它的輸入目標模塊中的代碼節和數據節的確切大小。現在就可以開始重定位步驟了,在這個步驟中,將合併輸入模塊,併爲每個符號分配運行時地址。
1.重定位節和符號定義。在這一步,鏈接器將所有相同類型的節合併爲同一類型的新的聚合節。例如,來自所有輸入模塊的,data節全被合併成一個節,這個節成爲輸出的可執行目標文件的.data節。然後,鏈接器將運行時內存地址賦給新的聚合節,賦給輸入模塊定義的每個節,以及賦給輸入模塊定義的每個符號。當這一步完成,程序中的每條指令和全局變量都有唯一的運行時內存地址了。
2.重定位節中的符號引用。在這一步中,鏈接器修改代碼節和數據節中對每個符號的引用,使得它們指向正確的運行時地址。要執行這一步,依賴於下面要介紹的可重定位目標模塊中稱爲重定位條目的數據結構。

重定位條目

當彙編器生成一個目標模塊時,它並不知道數據和代碼最終將放在內存中的什麼位置。它也不知道這個模塊引用的任何外部定義的函數或者全局變量的位置,所以當彙編器遇到對最終位置未知的目標引用,就會生成一個重定位條目,告訴鏈接器在將目標文件合併成可執行文件時如何修改這個引用。
在這裏插入圖片描述
上圖展示了ELF重定位條目的格式。type:32 代表了由32種不同的重定位類型,只介紹2種最基本的:
1.重定位使用32位PC相對地址的引用。回想一下,一個PC相對地址就是距程序計數器(PC)的當前運行時值的偏移量。當CPU執行一條使用PC相對尋址的指令時,它就將在指令中編碼的32位值加上PC的當前運行時值,得到有效地址,PC值通常是下一條指令在內存中的地址。
2.重定位使用32位絕對地址的引用。通過絕對尋址,CPU直接使用在指令中編碼的32位值作爲有效地址,不需要再修改。

可執行目標文件

上面已經能夠解釋清楚鏈接器如何將多個目標文件合併成一個可執行目標文件。下圖是一個典型的ELF可執行目標文件。
在這裏插入圖片描述
可執行目標文件和格式類似於可重定位目標文件的格式。它被設計得很容易加載到內存,可執行文件的連續的片被映射到連續的內存段,這種映射關係在程序頭部表中被描述。

加載可執行目標文件

當我們運行一個可執行文件時,加載器將可執行目標文件的代碼和數據從磁盤複製到內存中,然後通過跳轉到程序的第一條指令或者入口點來運行該程序。這個程序複製到內存並運行的過程叫做加載
在這裏插入圖片描述
每個Linux程序都有一個運行時內存映像,如上圖。在Linux x86-64系統中,代碼段總是從地址0x400000處開始,後面是數據段。運行時在數據段之後,通過調用malloc庫往上增長。堆後面的區域是爲共享模塊保留的。用戶棧總是從最大的合法用戶地址(2
的48 - 1)開始,向較小內存地址增長。棧上的區域,從地址2
的48開始,是爲內核中的代碼和數據保留的。
爲了簡介,把堆、數據和代碼段畫的彼此相鄰,並且把棧頂放在了最大的合法用戶地址處。實際上,由於.data段有對齊要求,所以代碼段和數據段之間是有間隙的。並且在分配棧、共享庫和堆段運行時地址的時候,鏈接器還會使用地址空間佈局隨機化。雖然每次程序運行時,這些區域的地址都會改變,但它們的相對位置是不變的。
當加載器運行時,它創建類似上圖的內存映像。在程序頭部表的引導下,加載器將可執行文件的片複製到代碼段和數據段。接下來,加載器跳轉到程序的入口點,初始化執行環境,調用用戶層的main函數,處理main函數的返回值,並且在需要的時候把控制返回給內核。

動態鏈接共享庫

上面介紹的靜態庫已經解決了許多關於如何讓大量相關函數對應用程序可用的問題。然而,靜態庫仍然有一些明顯的缺點。靜態庫和所有的軟件一樣,需要定期維護和更新。而且幾乎每個C程序都是用標準I/O函數。在運行時,這些函數的代碼會被複制到每個運行進行的本文段中。在一個運行上百個進程的系統上,這就是對內存資源的極大浪費。
共享庫是致力於解決靜態庫缺陷的一個現代創新產物。動態庫是一個目標模塊,在運行或加載時,可以加載到任意的內存地址,並和一個在內存中的程序鏈接起來。這個過程稱爲動態鏈接,是由一個叫做動態鏈接器的程序來執行的。共享庫也稱爲共享目標,在Linux系統中通常用.so後綴來標識。微軟的操作系統大量地使用了共享庫,它們稱爲DLL。
共享庫是以兩種不同的方式來“共享”的。首先,在任何給定的文件系統中,對於一個庫只有一個.so文件。所有引用該庫的可執行目標文件共享這個.so文件中的代碼和數據,而不是像靜態庫的內容那樣被複制和嵌入到引用它們的可執行的文件中。其次,在內存中,一個共享庫的.text節的一個副本可以被不同的正在運行的進程共享。
在這裏插入圖片描述
上圖展示了動態鏈接過程。在鏈接器中,沒有任何libvector.so的代碼和數據節真的被複制到可執行文件prog21中。而是複製了一些重定位和符號表信息,它們使得運行時可以解析對libvector.so中代碼和數據的引用。在加載部分鏈接的可執行文件prog21時,會發現prog21中包含一個.interp節,這個節包含動態鏈接器的路徑名,動態鏈接器本身就是一個共享目標。加載器不會像通常那樣將控制傳遞給應用。而是加載和運行這個動態鏈接器。然後,動態鏈接器通過執行下面的重定位完成鏈接任務:
1.重定位libc.so文件和數據到某個內存段
2.重定位libvector.so的文本和數據到另一個內存段
3.重定位prog21中所有對由libc.so和libvector.so定義的符號的引用
最後,動態鏈接器將控制傳遞給應用程序。

位置無關代碼

共享庫的一個主要目的就是允許多個正在運行的進行共享內存中相同的庫代碼,從而節約內存資源。爲了完美地讓多個進程共享共享庫中程序的一個副本,現代系統使用一種方法,使得無限多個進程可以共享一個共享模塊的代碼段的單一副本(每個進程仍然有它自己的讀/
寫數據塊)。可以加載而無需重定位的代碼稱爲位置無關代碼

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章