1. 程式人生 > >程式的編譯,連結和裝載

程式的編譯,連結和裝載

 《程式設計師的自我修養-連結裝載與庫》是一本值得推薦的書,主要介紹系統軟體的執行機制和原理,涉及在Windows和Linux兩個系統平臺上,一個應用程式在編譯、連結和執行時刻所發生的各種事項,包括:程式碼指令是如何儲存的,庫檔案如何與應用程式程式碼靜態連結,應用程式如何被裝載到記憶體中並開始執行,動態連結如何實現,C/C++執行庫的工作原理,以及作業系統提供的系統服務是如何被呼叫的。
本文主要對書中涉及Linux中程式的變異、連結、裝載等核心部分內容進行整理,方便檢視。

編譯過程

許多IDE和編譯器將編譯和連結的過程合併在一起,稱為構建(Build),使用起來非常方便。但只有深入理解其中的機制,才能看清許多問題的本質,正確解決問題。
一般的編譯過程可以分解為4個步驟,預處理,編譯,彙編和連結:

  • 預編譯:處理原始碼中的以”#”開始的預編譯指令,如”#include”、”#define”等。
  • 編譯:把預處理完的檔案進行一系列的詞法分析、語法分析、語義分析及優化後產生相應的彙編程式碼檔案,是程式構建的核心部分,也是最複雜的部分之一。
  • 彙編:將彙編程式碼根據指令對照表轉變成機器可以執行的指令,一個彙編語句一般對應一條機器指令。
  • 連結:將多個目標檔案綜合起來形成一個可執行檔案。

而對於第2步,編譯由編譯器完成器,編譯器是將高階語言翻譯成機器語言的一個工具,其具體步驟包括:

  • 詞法分析:將原始碼程式輸入掃描器,將原始碼字元序列分割成一系列記號(Token)。
  • 語法分析:對產生的記號使用上下文無關語法進行語法分析,產生語法樹。
  • 語義分析:進行靜態語義分析,通常包括宣告和型別的匹配,型別的轉換。
  • 中間語言生成:使用原始碼優化器將語法樹轉換成中間程式碼並進行原始碼級的優化。
  • 目的碼生成:使用程式碼生成器將中間程式碼轉成依賴於具體機器的目標機器程式碼。
  • 目的碼優化:使用目的碼優化器對目的碼進行優化,比如選擇合適的定址方式、使用位移替代乘法、刪除多餘指令等。

如果一個原始碼檔案中有變數或函式等符號定義在其他模組,那麼編譯後得到的目的碼中,該符號的地址並沒有確定下來,因為編譯器不知道到哪裡去找這些符號,事實上這些變數和函式的最終地址要在連結的時候才能確定。現代的編譯器只是將一個原始碼編譯成一個未連結的目標檔案,最終由連結器將這些目標檔案連結起來形成可執行檔案。

目標檔案格式

編譯器編譯原始碼後生成的檔案稱為目標檔案,事實上,目標檔案是按照可執行檔案的格式儲存的,二者結構只是稍有不同。Linux下的目標檔案和可執行檔案可以看成一種型別的檔案,統稱為ELF檔案,一般有以下幾類:

  • 可重定位檔案:如.o檔案,包含程式碼和資料,可以被連結成可執行檔案或共享目標檔案,靜態連結庫屬於這一類。
  • 可執行檔案:如/bin/bash檔案,包含了可以直接執行的程式,一般沒有副檔名。
  • 共享目標檔案:如.so檔案,包含程式碼和資料,可以跟其他可重定位檔案和共享目標檔案連結產生新的目標檔案,也可以跟可執行檔案結合作為程序映像的一部分。

目標檔案由許多段組成,其中主要的段包括:

  • 程式碼段(.text):儲存編譯後得到的指令資料。
  • 資料段(.data):儲存已經初始化的全域性靜態變數和區域性靜態變數。
  • 只讀資料段(.rodata):儲存只讀變數和字串常量,有些編譯器會把字串常量放到”.data”段。
  • BSS段(.bss):儲存未初始化的全域性變數和區域性靜態變數。

除了這幾個常用的段之外,ELF可能包含其他的段,儲存與程式相關的資訊,如:

  • .comment 編譯器版本資訊
  • .debug 除錯資訊
  • .dynamic 動態連結資訊
  • .hash 符號雜湊表
  • .line 除錯時的行號表,原始碼行號與編譯後指令的對應表
  • .note 額外的比編譯器資訊
  • .strtab String Table,字串表,儲存用到的各種字串
  • .symtab Symbol Table,符號表
  • .shstrtab Section String Table,段名錶
  • .plt 動態連結跳轉表
  • .got 動態連結全域性入口表
  • .init 程式初始化程式碼段
  • .fini 程式終結程式碼段

ELF目標檔案的總體結構如下圖所示,其中省去了一些繁瑣的結果,把最終的提出出來。

ELF Header
.text
.data
.rodata
.comment
.shstrtab
Section Table
.symtab
.rel.text

以下選取較為重要的進行介紹。
ELF檔案頭(ELF Header):儲存描述整個檔案的基本屬性,如ELF魔數、檔案機器位元組長度、資料儲存格式等。

段表(Section Header Table):儲存各個段的基本屬性,是除了檔案頭之最重要的結構。節選樣例內容如下:

[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[1] .text PROGBITS 00000000 000034 00005b 00 AX 0 0 4

其表示的意義為,下標為1的段是.text段,型別是程式段(PROGBITS包括程式碼段和資料段),載入地址為0,在檔案中的偏移量是0×34,長度為0x5b,項的長度為0(表示該段不包含固定大小的項),標誌AX表示該段要分配空間及可以被執行,連結資訊的兩個0沒有意義(不是與連結相關的段),最後的4表示段地址對齊為2^4=16位元組。

重定位表:連結器在處理目標檔案的時候,需要對目標檔案中某些部位進行重定位,即程式碼段和資料段中那些絕對地址的引用位置,這些重定位資訊記錄在重定位表裡。每個需要重定位的程式碼段或資料段都會有一個相應的重定位表,如.rel.text是針對”.text”段的重定位表,”.rel.data”是針對”.data”段的重定位表。

字串表:ELF檔案中用到很多字串,如段名、變數名,因為字串的長度不固定,用固定的結構來表示它比較困難,一般把字串集中起來存放到一個表,然後使用字串在表中的偏移來引用字串。一般字串表在ELF中以段的形式儲存,常見的有.strtab(字串表,String Table)和.shstrtab(段表字符串表,Section Header String Table),前者儲存如符號名字等普通字串,後者儲存如段名等段表中用到的字串。

符號表:函式和變數統稱為符號,其名稱稱為符號名。連結過程中關鍵的部分就是符號的管理,每一個目標檔案都會有一個相應的符號表,記錄了目標檔案用到的所有符號,每個符號有一個對應的符號值,一般為符號的地址。一個樣例如下:

Num Value Size Type Bind Vis Ndx Name
13 0000001b 64 FUNC GLOBAL DEFAULT 1 main

其意義如下:下標為13的符號的符號值為0x1b,大小為64位元組,型別為函式,繫結資訊為全域性符號,VIS可以忽略,Ndx表示其所在段的下標為1(通過上一個樣例可知,該段為.text段),符號名稱為main。如果Ndx下標一項為UND(undefine),則表示該符號在其他模組定義,以後需要重定位。

除錯資訊:目標檔案裡可能儲存有除錯資訊,如在GCC編譯時加上”-g”引數,會生成許多以”.debug”開頭的段。

靜態連結

幾個目標檔案進行連結時,每個目標檔案都有其自身的程式碼段、資料段等,連結器需要將它們各個段的合併到輸出檔案中,具體有兩種合併方法:

  • 按序疊加:將輸入的目標檔案按照次序疊加起來。
  • 相似段合併:將相同性質的段合併到一起,比如將所有輸入檔案的”.text”合併到輸出檔案的”.text”段,接著是”.data”段、”.bss”段等。

第一種方法會產生很多零散的段,而且每個段有一定的地址和空間對齊要求,會造成記憶體空間大量的內部碎片。所以現在的連結器空間分配基本採用第二種方法,而且一般採用一種稱為兩部連結的方法:

  1. 空間與地址分配。掃描所有輸入的目標檔案,獲得他們各個段的長度、屬性和位置,收集它們符號表中所有的符號定義和符號引用,統一放到一個全域性符號表中。此時,連結器可以獲得所有輸入目標檔案的段長度,將他們合併,計算出輸出檔案中各個段合併後的長度與位置並建立對映關係。
  2. 符號解析與重定位。使用上面收集到的資訊,讀取輸入檔案中段的資料、重定位資訊,並且進行符號解析與重定位、調整程式碼中的地址等。

經過第一步後,輸入檔案中的各個段在連結後的虛擬地址已經確定了,連結器開始計算各個符號的虛擬地址。各個符號在段內的相對地址是固定的,連結器只需要給他們加上一個偏移量,調整到正確的虛擬地址即可。

ELF中每個需要重定位的段都有一個對應的重定位表,也稱為重定位段。重定位表中每個需要重定位的地方叫一個重定位入口,包含:

  • 重定位入口的偏移:對於可重定位檔案來說,偏移指該重定位入口所要修正的位置的第一個位元組相對於該段的起始偏移。
  • 重定位入口的型別和符號:低8位表示重定位入口的型別,高24位表示重定位入口的符號在符號表的下標。

不同的處理器指令對於地址的格式和方式都不一樣,對於每一個重定位入口,根據其重定位型別使用對應的指令修正方式修改其指令地址,完成重定位過程。

可執行檔案的裝載

32位硬體平臺上程序的虛擬地址空間的地址為0到2^32-1:0×00000000~0xFFFFFFFF,即通常說的4GB虛擬空間大小。在Linux作業系統下,4GB被劃分成兩部分,作業系統本身佔用了0xC00000000到0xFFFFFFFF共1GB的空間,剩下的從0×00000000到0xBFFFFFFFF共3GB的空間留給程序使用。
可執行檔案只有被裝載到記憶體以後才能執行,最簡單的辦法是把所有的指令和資料全部裝入記憶體,但這可能需要大量的記憶體,為了更有效地利用記憶體,根據程式執行的區域性性原理,我們可以把程式中最常用的部分駐留記憶體,將不太常用的資料放在磁碟中,即動態裝入。

現在大部分作業系統採用的是頁對映的方法進行程式裝載。頁對映並不是一下把程式的所有資料和指令都裝入記憶體,而是將記憶體和所有磁碟中的資料和指令按照”頁(Page)”為單位劃分成若干個頁,以後所有的裝載和操作的單位就是頁。目前一般的頁大小為4K=4096位元組。裝載管理器負責控制程式的裝載問題,當執行到的某條指令不在記憶體的時候,會將該指令所在的頁裝載到記憶體中的一個地方,然後繼續程式的執行。如果記憶體中已經沒有位置,裝載管理器會根據一定的演算法放棄某個正在使用的頁,並用新的頁來替代,然後程式可以繼續執行。

可執行檔案中包含程式碼段、資料段、BSS段等一系列的段,其中很多段都要對映進程序的虛擬地址空間。當段的數量增加時,會產生空間浪費問題。因為ELF檔案被對映時是以系統的頁長度為單位進行的,一個段對映的長度應為頁長度的整數倍,如果不是,那麼多餘部分也將佔用一個頁,從而產生記憶體浪費。
實際上作業系統並不關心可執行檔案各個段所包含的實際內容,它只關心一些跟裝載有關的問題,最主要的是段的許可權(可讀、可寫、可執行)。ELF中,段的許可權組合可以分成三類:

  • 以程式碼段為代表的許可權為可讀可執行的段。
  • 以資料段和BSS段為代表的許可權為可讀可寫的段。
  • 以只讀資料段為代表的許可權為只讀的段。

於是,對於相同許可權的段,可以把它們合併到一起當做一個段進行對映,這樣可以把原先的多個段當做一個整體進行對映,明顯地減少頁面內部碎片,節省記憶體空間。這個稱為”Segment”,表示一個或多個屬性類似的”Section”,可以認為”Section”是連結時的概念,”Segment”是裝載時的概念。連結器會把屬性相似的”Section”放在一起,然後系統會按照這些”Section”組成的”Segment”來對映並裝載可執行檔案。

程序的虛擬地址空間中除了被用來對映可執行檔案的各個”Segment”之外,還有包括棧(Stack)和堆(Heap)的空間,一個程序中的棧和堆在也是以虛擬記憶體區域(VMA, Virtual Memrory Area)的形式存在。作業系統通過給程序空間劃分出一個個的VMA來管理程序的虛擬空間,基本原則是將相同許可權屬性的、有相同映像檔案的對映成一個VMA,一個程序基本可以分為如下幾種VMA區域:

  • 程式碼VMA,許可權只讀,可執行,有映像檔案。
  • 資料VMA,許可權可讀寫,可執行,有映像檔案。
  • 堆VMA,許可權可讀寫,可執行,無映像檔案,匿名,可向上擴充套件。
  • 棧VMA,許可權可讀寫,不可執行,無映像檔案,匿名,可向下擴充套件。

其常見的分佈情況如下圖所示:

OS
STACK VMA
HEAP VMA
DATA VMA
CODE VMA

動態連結

靜態連結允許不同程式開發者相對獨立地開發和測試自己的程式模組,促程序序開發的效率,但其也有相應的缺點:

  • 浪費記憶體和磁碟空間。在多程序作業系統下,每個程式內部都保留了公用的庫函式及其他數量可觀的庫函式及輔助資料結構,浪費大量空間。
  • 程式開發和釋出困難。一個程式如果使用了很多第三方的靜態庫,那麼程式中一旦有任何庫的更新,整個程式就要重新連結並重新發布給客戶,非常不方便。

動態連結可以解決空間浪費和更新困難的問題,其不對那些組成程式的目標檔案進行連結,而是等到程式執行時才進行連結。使用了動態連結之後,當我們執行一個程式時,系統會首先載入該程式依賴的其他的目標檔案,如果其他目標檔案還有依賴,系統會按照同樣方法將它們全部載入到記憶體。當所需要的所有目標檔案載入完畢之後,如果依賴關係滿足,系統開始進行連結工作,包括符號解析及地址重定位等。完成之後,系統把控制權交回給原程式,程式開始執行。此時如果執行第二個程式,它依賴於一個已經載入過的目標檔案,則系統不需要重新載入目標檔案,而只要將它們連線起來即可。

動態連結可以解決共享的目標檔案存在多個副本浪費磁碟和記憶體空間的問題,因為同一個目標檔案在記憶體中只儲存一份。另外,當一個程式所依賴的庫升級之後,只需要將簡單地用新的庫將舊的覆蓋掉,無需將所有的程式再重新連結一遍,當程式下次執行時,新版本的庫會被自動載入到記憶體並連結起來,程式仍然可以正常執行,並且完成了升級過程。

對於靜態連結的可執行檔案來說,整個程序只有一個檔案要被對映,那就是可執行檔案本身。但是對於動態連結來說,除了可執行檔案本身,還有它所依賴的共享目標檔案,此時,它們都是被作業系統用同樣的方法對映進程序的虛擬地址空間,只是它們佔用的虛擬地址和長度不同。另外,動態連結器也和普通共享物件一樣被對映到程序的地址空間。系統開始執行程式之前,會把控制權交給動態連結器,由它完成所有的動態連結工作,然後再把控制權交回給程式,程式就開始執行。

裝載時重定位

動態連結的共享物件在被裝載時,其在程序虛擬地址空間的位置是不確定的,為了使共享物件能夠在任意地址裝載,可以參考靜態連結時的重定位(Link Time Relocation)思想,在連結時對所有的絕對地址的引用不做重定位,把這一步推遲到裝載時再完成。一旦模組裝載完畢,其地址就確定了,即目標地址確定,系統就對程式中所有的絕對地址引用進行重定位。這種裝載時重定位(Load Time Relocation)又稱為基址重置(Rebasing)。

但是動態連結模組被裝載對映至虛擬空間後,指令部分是在多個程序之間共享的,由於裝載時重定位的方法需要修改指令,所以沒有辦法做到同一份指令被多個程序共享,因為指令被重定位之後對於每個程序來講是不同的。當然,動態連結庫中的可修改的資料部分對於不同的程序來說有多個副本,所以它們可以採用裝載時重定位的方法來解決。

地址無關程式碼

裝載時重定位導致指令部分無法在多個程序之間共享,失去了動態連結節省記憶體的一大優勢。為了程式模組中共享的指令部分在裝載時不需要因為裝載地址的改變而改變,可以把指令中那些需要改變的部分分離出來,跟資料部分放在一起,這樣指令部分就可以保持不變了,而資料部分可以在每個程序中擁有一個副本。這種方案稱為地址無關程式碼(PIC, Position-independent Code)技術。
我們把共享物件模組中的地址引用按照是否擴模組分成模組內部引用和模組外部引用,按照不用的引用方式分成指令引用和資料引用,然後把得到的4種情況分別進行處理:

  • 模組內部呼叫或跳轉。因為被呼叫的函式和呼叫者處於同一個模組,相對位置固定,而現代的系統對於模組內部的跳轉、函式呼叫可以採用相對地址呼叫或者給予暫存器的相對呼叫,所以這種指令不需要重定位,其是地址無關的。
  • 模組內部資料訪問。顯然指令不能包含資料的絕對地址,那麼只有進行相對定址。因為一個模組前面一半是若干個頁的程式碼,然後是若干個也的資料,這些頁之間的相對位置是固定的,即任何一條指令與它所需要訪問的模組顳部資料之間的相對位置是固定的,那麼只需要相對當前指令加上固定的偏移量就可以訪問模組內部資料了。現代的體系結構中,資料的相對定址往往沒有相對當前指令地址(PC)的定址方式,ELF中使用了巧妙的辦法獲取當前的PC值,然後再加上一個偏移量達到訪問相應變數的目的。
  • 模組間資料訪問。模組間的資料訪問目標地址要等到裝載時才能確定,這些變數的地址跟模組的裝載地址相關。ELF在資料段裡建立一個指向這些變數的指標陣列,稱為全域性偏移表(GOT, Global Offset Table),當代碼需要引用該全域性變數時,可以通過GOT中相對應的項間接引用。當指令需要一個其他模組的變數時,程式會先找到GOT,然後根據GOT中變數對應的項找到該變數的目標地址。每個變數對應一個4位元組的地址,連結器在裝載模組的時候會查詢每個變數所在的地址,然後填充GOT的各個項,以確保每個指標所指向的地址都正確。由於GOT本身放在資料段,它可以在被模組裝載時修改,並且每個程序都可以有獨立的副本,相互不受影響。
  • 模組間呼叫、跳轉。採用上述類似的方法,不同的是,GOT中相應儲存的是目標函式的地址,當模組需要呼叫目標函式時,可以通過GOT中的項進行間接跳轉。呼叫一個函式時,先得到當前指令地址PC,然後加上一個偏移得到函式地址在GOT中的偏移,然後進行間接呼叫。

於是,四種地址引用方式在理論上都實現了地址無關性。

資料段地址無關性

以上的方法能夠保證共享物件中程式碼部分地址無關,但資料部分並不是地址無關的,比如:

static int a;
static int* p = &a;

指標p的地址是絕對地址,指向變數a,但a的地址會隨著共享物件的裝載地址改變而變。
資料段在每個程序都有一份獨立的副本,並不擔心被程序改變,於是可以選擇裝載時重定位的方法來解決資料段中絕對地址引用的問題。對於共享物件來說,如果資料段中有絕對地址的引用,那麼編譯器和連結器會產生一個重定位表,這個表中包含了”R_386_RELATIVE”型別的重定位入口來解決上述問題。當動態連結器裝載共享物件時,如果發現共享物件上有這樣的重定位入口,就會對該共享物件進行重定位。
其實對程式碼段也可以使用裝載時重定位而不是地址無關程式碼的方法,它有以下特點:
程式碼段不是地址無關,不能被多個程序共享,失去了節省記憶體的有點。
執行速度比地址無關程式碼的共享物件塊,因為它省去了地址無關程式碼中每次訪問全域性資料和函式時都要做一次計算當前地址以及間接地址定址的過程。

動態連結相關結構

動態連結下可執行檔案的裝載與靜態連結下基本一樣,首先作業系統會讀取可執行檔案的頭部,檢查檔案的合法性,然後從頭部中的”Program Header”中讀取每個”Segment”的虛擬地址、檔案地址和屬性,並將它們對映到程序虛擬空間的相應位置,這些步驟跟前面的靜態連結情況下的裝載基本無異。在靜態連結情況下,作業系統接著就可以把控制權交給可執行檔案的入口地址,然後程式開始執行。但在動態連結情況下,作業系統會先啟動一個動態連結器,動態連結器得到控制權後,開始執行一系列自身的初始化操作,然後根據當前的環境引數,開始對可執行檔案進行動態連結工作。當所有動態連結工作完成以後,動態連結器會將控制權轉交到可執行檔案的入口地址,程式開始正式執行。

動態連結涉及到的段主要如下:

  • “.interp”段。在Linux中,作業系統在對可執行檔案進行載入時,會尋找裝載該可執行檔案需要的相應的動態連結器,即”.interp”段指定的路徑的共享物件。
  • “.dynamic”段。動態連結ELF中最重要的結構,儲存了動態連結器需要的基本資訊,比如依賴於哪些共享物件、動態連結符號表的位置、動態連結重定位表的位置、共享物件初始化程式碼的地址等。”.dynamic”段儲存的資訊類似於ELF檔案頭,只是ELF檔案頭儲存的是靜態連結相關的內容,這裡換成動態連結所使用的相應資訊。
  • 動態符號表。ELF中專門儲存符號資訊的段為”.dynsym”。類似於”.symtab”,但”.dynsym”只儲存與動態連結相關的符號,而”.symtab”則儲存了所有的符號,包括”.synsyms”中的符號。同樣地,動態符號表也需要一些輔助的表,如儲存符號名的字串表,靜態連結時叫符號字串表”.strtab”,在這裡就是動態符號字串表”.dynstr”(Dynamic String Table)。為了加快動態連結下程式符號查詢的過程,往往還有扶著的符號雜湊表”.hash”。動態連結符號表的結構與靜態連結的符號表幾乎一樣,可以簡單地將匯入函式看做是對其他目標檔案函式的引用,把匯出函式看做是在本目標檔案定義的函式即可。
  • 動態連結重定位表。動態連結下,可執行檔案一旦依賴於其他共享物件,它的程式碼或資料中就會有對於匯入符號的引用,這些匯入符號的地址在執行時才確定,所以需要在執行時將這些匯入符號的引用修正,即需要重定位。如果共享物件不是以PIC編譯的,那麼它需要在裝載是被重定位;如果它是PIC編譯的,雖然程式碼段不需要重定位,但是資料段還包含了絕對地址的引用,其絕對地址被分離出來成了GOT,而GOT是資料段的一部分,需要重定位。
    裝載時重定位跟靜態連結中的目標檔案重定位十分相似。靜態連結中,目標檔案裡包含專門用於重定位資訊的重定位表,如”.rel.txt”表示程式碼段的重定位表,”.rel.data”表示資料段的重定位表。類似地,動態連結中,重定位表分別為”.rel.dyn”和”.rel.plt”,前者是對資料引用的修正,修正的位置位於”.got”以及資料段,後者是對於函式引用的修正,修正的位置位於”.got.plt”。

動態連結的步驟

動態連結的步驟基本上分為3步:啟動動態連結器本身,然後是裝載所有需要的共享物件,最後是重定位和初始化。

  1. 動態連結器自舉。普通共享物件檔案的重定位工作由動態連結器完成,動態連結器本身本身不可以依賴於其他共享物件,其重定位工作由其自身完成,這需要動態連結器在啟動時有一段非常精巧的程式碼可以完成這項艱鉅的工作而同時不能用到全域性和靜態變數,甚至不能呼叫函式,這種具有一定限制的啟動程式碼稱為自舉(Bootstrap)。
    動態連結器獲得控制權後,自舉程式碼開始執行。自舉程式碼首先找到自己的GOT,而GOT的第一個入口即是”.dynamic”段的偏移地址,由此找到了動態連結器本身的”.dynamic”段。通過”.dynamic”的資訊,自舉程式碼可以獲得動態連結器本身的重定位表和符號表,從而得到動態連結器本身的重定位入口,先將他們全部重定位,然後動態連結器程式碼可以使用自己的全域性變數和靜態變數。
  2. 裝載共享物件。自舉完成後,動態連結器將可執行檔案盒連結器本身的符號表合併到一個全域性符號表中,然後開始尋找可執行檔案依賴的共享物件。通過”.dynamic”段中型別的入口是DT_NEEDED的項,連結器可以列出可執行檔案所依賴的所有共享物件,將他們的名字放入一個裝載集合中。然後從集合中取出一個共享物件的名字,找到相應的檔案後開啟,讀取相應的ELF檔案頭”.dynamic”段,然後將它相應的程式碼段和資料段對映到程序空間。如果這個ELF共享物件還依賴其他共享物件,則將所依賴的共享物件的名字放入裝載集合中。如此迴圈把所有依賴物件都裝載進記憶體為止。如果把依賴關係看做一個圖的話,裝載過程就是圖的遍歷過程,可以使用廣度優先或深度優先搜尋的順序進行編譯。
  3. 重定位和初始化。上述步驟完成後,連結器開始重新遍歷可執行檔案和每個共享物件的重定位表,將他們的GOT/PLT中的每個需要重定位的位置進行修正。因為此時動態連結器已經擁有了程序的全域性符號表,所以這個修正過程比較容易,和前面的地址重定位原理基本相同。
    重定位完成後,如果共享物件有”.init”段,那麼動態連結器會執行”.init”段的程式碼,用來實現共享物件特有的初始化過程,比如共享物件中C++的全域性/靜態物件的構造。相應地,如果有”.finit”段,當程序退出時會執行”.finit”段中的程式碼,比如類似的C++全域性物件的析構。而程序的可執行檔案本身的的”.init”和”.finit”段不是由動態連結器執行,而是有執行庫的初始化部分程式碼負責執行。

重定位和初始化後,準備工作宣告完成,所需要的共享物件也都已經裝載並且連結完成,這是動態連結器就如釋重負,將程序的控制權交給程式的入口並開始執行。

顯式執行時連結

動態連結還有一種更加靈活的模組載入方式,稱為顯式執行時連結(Explicit Run-time Linking),也叫執行時載入。就是讓程式自己在執行時控制載入指定的模組,並且可以在不需要該模組時將其解除安裝。一般的共享物件不需要進行任何修改就可以進行執行時載入,稱為動態裝載庫(Dynamic Loading Library)。動態庫的裝載通過以下一系列的動態連結器API完成:

  • dlopen:開啟一個動態庫,載入到程序的地址空間,完成初始化過程。
  • dysm:通過指定的動態庫控制代碼找到制定的符號的地址。
  • dlerror:每次呼叫dlopen()、dlsym()或dlclose()以後,可以呼叫dlerror()來判斷上一次呼叫是否成功。
  • dlclose:將一個已經載入的模組解除安裝。系統會維持一個載入引用計數器,每次使用dlopen()載入時,計數器加一;每次使用dlclose()解除安裝時,計數器減一。當計數器減到0時,模組才真正地解除安裝。