裝載與動態(tài)鏈接
可執(zhí)行文件的裝載與進程
- 每個程序都擁有自己獨立的虛擬地址空間,這個空間大小由計算機硬件平臺決定(理論上的最大上限)。比如,32位硬件平臺的虛擬地址空間的地址為0到232-1,即0x000000000xFFFFFFFF,總共大概4G;而64位硬件平臺的虛擬地址空間地址為0到2<sup>64</sup>-1,即0x00000000000000000xFFFFFFFFFFFFFFFF,大概有17179869184G。在32位平臺上,Linux操作系統(tǒng)中4G的虛擬地址空間會被劃分為兩個部分,從0xC0000000到0xFFFFFFFF共1G的地址空間被分配給了操作系統(tǒng),剩下的從0x00000000到0xBFFFFFFF共3G的地址空間是留給進程的。從原則上講,我們進程最多能使用3G的虛擬地址空間。對于Windows操作系統(tǒng)來說,它的進程虛擬地址空間劃分是操作系統(tǒng)占用2G,進程只剩下2G。對于一些程序來說2G虛擬空間太小,所以Windows有個啟動參數(shù)可以將操作系統(tǒng)占用的虛擬地址空間減少到1G。方法如下:修改Windows系統(tǒng)盤根目錄下的boot.ini,加上“/3G”參數(shù)。
動態(tài)裝載的兩種典型方法是覆蓋裝入和頁映射,覆蓋裝入在沒有發(fā)明虛擬存儲之前使用比較廣泛,現(xiàn)在已經(jīng)幾乎被淘汰了。頁映射簡單的說就是操作系統(tǒng)將程序需要使用的頁按一定的算法動態(tài)映射到物理內(nèi)存中執(zhí)行。
-
從操作系統(tǒng)的角度來看,一個進程最關(guān)鍵的特征是它擁有獨立的虛擬地址空間,這使得它有別于其他進程。一個進程的建立有三步:
- 首先是創(chuàng)建虛擬地址空間。
- 讀取可執(zhí)行文件頭,并且建立虛擬空間與可執(zhí)行文件的映射關(guān)系。(可執(zhí)行文件裝載中最重要的一步,也是傳統(tǒng)意義上的“裝載”)
- 將CPU指令寄存器設(shè)置成可執(zhí)行文件入口,啟動運行。
我們知道,當程序執(zhí)行發(fā)生頁錯誤時,操作系統(tǒng)將從物理內(nèi)存中分配一個物理頁,然后將該“缺頁”從磁盤中讀取到內(nèi)存中,再設(shè)置缺頁的虛擬頁和物理頁的映射關(guān)系,這樣程序才得以正常運行。但是很明顯的一點是,當操作系統(tǒng)捕獲到缺頁錯誤時,它應(yīng)知道程序當前所需要的頁在可執(zhí)行文件中的哪一個位置。這就是虛擬空間與可執(zhí)行文件之間的映射關(guān)系。
ELF文件被映射時,是以系統(tǒng)的頁長度作為單位的。為避免內(nèi)存浪費,操作系統(tǒng)在裝載可執(zhí)行文件時主要關(guān)心的只是文件中段的權(quán)限(可讀、可寫、可執(zhí)行)。對于相同權(quán)限的段,把它們合并到一起當作一個段進行映射。Linux中將進程虛擬空間中的一個段叫做虛擬內(nèi)存區(qū)域(VMA),在Windows中將這個叫做虛擬段(Virtual Section)。很多情況下,一個進程中的堆和棧分別都有一個對應(yīng)的VMA。操作系統(tǒng)在進程啟動前會將系統(tǒng)的環(huán)境變量和進程的運行參數(shù)提前保存到進程的虛擬空間的棧中(也就是VMA中的stack VMA)。
PE文件的裝載和ELF有所不同,在PE文件中,所有段的起始地址都是頁的倍數(shù),段的長度如果不是頁的整數(shù)倍,那么在映射時向上補齊到頁的整數(shù)倍。由于這個特點,PE文件的映射過程比ELF簡單得多,因為它無需考慮如ELF里面諸多段地址對齊之類的問題,雖然這樣會浪費一些磁盤和內(nèi)存空間。
PE文件中,鏈接器在生產(chǎn)可執(zhí)行文件時,往往將所有的段盡可能地合并,所以一般只有代碼段、數(shù)據(jù)段、只讀數(shù)據(jù)段和BSS等為數(shù)不多的幾個段。
每個PE文件在裝載時都會有一個裝載目標地址,這個地址就是基地址,基地址不是固定的,每次裝載時都可能會變化。所以PE文件中有一個常見術(shù)語叫相對虛擬地址(RVA),它是相對于PE文件的裝載基地址的一個偏移地址。這樣無論基地址怎么變化,PE文件中的各個RVA都保持一致。
-
WIndows PE文件的裝載過程:
- 先讀取文件的第一個頁(包含DOS頭,PE文件頭和段表)。
- 檢查進程地址空間中,目標地址是否可用,如果不可用,則另外選一個裝載地址。(主要針對DLL裝載)
- 使用段表中提供的信息,將PE文件中所有的段一一映射到地址空間中相應(yīng)的位置。
- 如果裝載地址不是目標地址,則進行Rebasing。
- 裝載所有PE文件所需要的DLL文件。
- 對PE文件中的所有導(dǎo)入符號進行解析。
- 根據(jù)PE頭中指定的參數(shù),建立初始化堆和棧。
- 建立主線程并且啟動進程。
動態(tài)鏈接
-
為什么要動態(tài)鏈接?
- 靜態(tài)鏈接的方式對于計算機內(nèi)存和磁盤的空間浪費非常嚴重。
- 靜態(tài)鏈接對于程序的更新、部署和發(fā)布也會帶來很多麻煩。
在Linux系統(tǒng)中,ELF動態(tài)鏈接文件被成為動態(tài)共享對象(DSO),簡稱共享對象,它們一般都是以“.so”為擴展名的一些文件;而在Windows系統(tǒng)中,動態(tài)鏈接文件被成為動態(tài)鏈接庫(DLL),它們通常是以“.dll”為擴展名的文件。
靜態(tài)鏈接的重定位叫鏈接時重定位(Link Time Relocation),而動態(tài)鏈接的重定位為裝載時重定位(Load Time Relocation),在Windows中,這種裝載時重定位又被叫做基址重置(Rebasing)。在Linux和GCC中只要使用“-shared”參數(shù),輸出的共享對象就是使用的裝載時重定位。
把指令中那些需要修改的部分分離出來,跟數(shù)據(jù)部分放在一起,這樣指令部分就可以保持不變,而數(shù)據(jù)部分可以在每個進程中擁有一個副本,這種方案就是地址無關(guān)代碼(PIC)技術(shù)。在Linux共享對象中要生成地址無關(guān)代碼只用在編譯是帶上參數(shù)-fPIC。
上面的情況并沒有包括定義在共享模塊內(nèi)部的全局變量。ELF共享庫在編譯時,默認都把定義在模塊內(nèi)部的全局變量當做定義在其他模塊的全局變量,也就是說當做上圖中的類型(4),通過GOT來實現(xiàn)變量的訪問。當共享模塊被裝載時,如果某個全局變量在可執(zhí)行文件中擁有副本,那么動態(tài)鏈接器就會把GOT中的相應(yīng)地址指向該副本,這樣該變量在運行時實際上最終就只有一個實例。如果變量在共享模塊中被初始化,那么動態(tài)鏈接器還需要將該初始化值復(fù)制到主模塊中的變量副本;如果該全局變量在程序主模塊中沒有副本,那么GOT中的相應(yīng)地址就指向模塊內(nèi)部的該變量副本。
對于共享對象來說,如果數(shù)據(jù)段中有絕對地址引用,那么編譯器和鏈接器就會產(chǎn)生一個重定位表,這個重定位表里面包含了“R_386_RELATIVE”類型的重定位入口。當動態(tài)鏈接器裝載共享對象時,如果發(fā)現(xiàn)該共享對象有這樣的重定位入口,那么動態(tài)鏈接器就會對該共享對象進行重定位。
我們在編譯共享對象時如果使用“-fPIC”參數(shù),就表示要產(chǎn)生地址無關(guān)的代碼段。GCC編譯動態(tài)鏈接的可執(zhí)行文件會默認帶上該參數(shù)的。如果不使用該參數(shù)就會產(chǎn)生一個裝載時重定位的共享對象,它的代碼段就不是地址無關(guān)的,也就不能被多個進程之間共享,于是就失去了節(jié)省內(nèi)存的優(yōu)點。但是裝載時重定位的共享對象的運行速度要比使用地址無關(guān)代碼的共享對象快,因為它省去了地址無關(guān)代碼中每次訪問全局數(shù)據(jù)和函數(shù)時需要做一次計算當前地址以及間接地址尋址的過程。
動態(tài)鏈接比靜態(tài)鏈接慢的主要原因是動態(tài)鏈接下對于全局和靜態(tài)的數(shù)據(jù)訪問都要進行復(fù)雜的GOT定位,然后間接尋址;對于模塊間的調(diào)用也要先定位GOT,然后再進行間接跳轉(zhuǎn),這可能會導(dǎo)致程序啟動或者運行速度減慢,所以我們需要優(yōu)化動態(tài)鏈接性能。
ELF采用延遲綁定來優(yōu)化動態(tài)鏈接性能,基本思想是當函數(shù)第一次被用到時才進行綁定(符號查找、重定位等)。具體方法是使用了PLT(Procedure Linkage Table)。PLT為GOT間接跳轉(zhuǎn)又增加了一個中間層,在調(diào)用某個外部模塊的函數(shù)時,并不直接通過GOT跳轉(zhuǎn),而是通過一個叫作PLT項的結(jié)構(gòu)來進行跳轉(zhuǎn)。每個外部函數(shù)在PLT中都有一個相應(yīng)的項。(匯編指令實現(xiàn))
- 實際的PLT基本結(jié)構(gòu)代碼如下:
PLT0:
push *(GOT +4)
jump *(GOT+8)
...
bar@plt:
jmp *(bar@GOT)
push n
jump PLT0
在動態(tài)鏈接情況下,操作系統(tǒng)在裝載完可執(zhí)行文件之后會先啟動一個動態(tài)鏈接器,之后就將控制權(quán)交給動態(tài)鏈接器的入口地址。當動態(tài)鏈接器得到控制權(quán)之后,它開始執(zhí)行一系列自身的初始化操作,然后根據(jù)當前的環(huán)境參數(shù),開始對可執(zhí)行文件進行動態(tài)鏈接工作。當所有動態(tài)鏈接工作完成以后,動態(tài)鏈接器會將控制權(quán)交到可執(zhí)行文件的入口地址,程序開始正式執(zhí)行。
-
動態(tài)鏈接相關(guān)結(jié)構(gòu)
- “.interp”段:里面保存的就是一個字符串,這個字符串就是可執(zhí)行文件所需要的動態(tài)鏈接器的路徑。
- “.dynamic”段:ELF文件中最重要的結(jié)構(gòu),保存了依賴于哪些共享對象、動態(tài)鏈接符號表的位置、動態(tài)鏈接重定位表的位置、共享對象初始化代碼的地址等信息。
- “.dynsym”段:動態(tài)符號表,表示動態(tài)鏈接模塊之間的符號導(dǎo)入導(dǎo)出關(guān)系。
- “.rel.dyn”段:數(shù)據(jù)引用重定位,修正“.got”以及數(shù)據(jù)段。
- “.rel.plt”段:函數(shù)引用重定位,修正“.got.plt”。
動態(tài)鏈接基本上分為3步:先是啟動動態(tài)鏈接器本身(自舉,bootstrap),然后裝載所有需要的共享對象,最后是重定位和初始化。(跳轉(zhuǎn))
完成基本自舉以后,動態(tài)鏈接器將可執(zhí)行文件和鏈接器本身的符號表都合并到一個全局符號表中。在Linux中,當一個符號需要被加入全局符號表時,如果相同的符號名已經(jīng)存在,則后加入的符號被忽略(全局符號介入問題)。
當上面的步驟完成之后,鏈接器開始重新遍歷可執(zhí)行文件和每個共享對象的重定位表,將它們的GOT/PLT中的每個需要重定位的位置進行修正。
Windows下的動態(tài)鏈接
在ELF中,由于代碼段是地址無關(guān)的,所以它可以實現(xiàn)多個進程之間共享一份代碼,但是DLL的代碼卻并不是地址無關(guān)的,所以它只是在某些情況下可以被多個進程間共享。
PE文件里有兩個常用的概念就是基地址(Base Address)和相對地址(RVA,Relative Virtual Address)。基地址就是PE頭文件中的Image Base,是PE文件被裝載進進程地址空間中的起始地址,
對于EXE文件來說,其值一般是0x400000,對于DLL文件來說,其值一般是0x10000000。而相對地址就是一個地址相對于基地址的偏移。ELF默認導(dǎo)出所有的全局符號。但是在DLL中,我們需要顯式地“告訴”編譯器我們需要導(dǎo)出某個符號,否則編譯器默認所有符號都不導(dǎo)出。在VC++中,我們使用“__declspec(dllexport)”表示DLL導(dǎo)出符號,使用“__declspec(dllimport)”表示DLL導(dǎo)入符號。除了使用導(dǎo)出導(dǎo)入符號外,我們也可以使用“.def”文件中的IMPORT或者EXPORTS段來聲明導(dǎo)入導(dǎo)出符號。這個方法不僅對C/C++有效,對其他語言也有效。
使用.def文件來描述DLL文件導(dǎo)出屬性的優(yōu)點有兩個,一是可以控制導(dǎo)出符號的符號名,而是可以控制一些鏈接的過程。
Windows提供3個API來支持DLL的運行時鏈接,分別是LoadLibrary(LoadLibraryEx):裝載DLL,GetProcAddress:獲取某個符號的地址,F(xiàn)reeLibrary:卸載DLL。
在Windows PE中,所有導(dǎo)出的符號被集中存放在導(dǎo)出表(Export Table)的結(jié)構(gòu)中。從最簡單的結(jié)構(gòu)上來看,它提供了一個符號名與符號地址的映射關(guān)系。導(dǎo)出表是一個IMAGE_EXPORT_DIRECTORY結(jié)構(gòu)體,定義在“Winnt.h”中:
typedef struct _IMAGE_EXPORT_DIRECTORY {
DWORD Characteristics;
DWORD TimeDateStamp;
WORD MajorVersion;
WORD MinorVersion;
DWORD Name;
DWORD Base;
DWORD NumberOfFunctions;
DWORD NumberOfNames;
DWORD AddressOfFunctions; // RVA from base of image
DWORD AddressOfNames; // RVA from base of image
DWORD AddressOfNameOrdinals; // RVA from base of image
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;
導(dǎo)出表結(jié)構(gòu)中,最后3個成員執(zhí)行3個數(shù)組,分別是導(dǎo)出地址表(EAT,Export Address Table)、符號名表(Name Table)和名字序號對應(yīng)表(Name-Ordinal Table)。
導(dǎo)出地址表中存放的是各個導(dǎo)出函數(shù)的RVA,符號名表中存放的是導(dǎo)出函數(shù)的名字。序號表實際是早期16位windows為了應(yīng)對內(nèi)存小而使用的機制。使用序號導(dǎo)入導(dǎo)出的好處就是省去了函數(shù)名查找過程,函數(shù)名表也不需要保存到內(nèi)存中。但是它最大的問題就是一個函數(shù)的序號可能會變化。這就需要程序員手工指定每個導(dǎo)出函數(shù)的序號。由于目前硬件性能的提升,這種內(nèi)存空間的節(jié)省和查找速度的提升效果就不明顯了。所以現(xiàn)在這種方式基本就不采用了,但是為了保持向后兼容,它還是被保留了下來。
動態(tài)鏈接器如何查找函數(shù)RVA呢?假設(shè)模塊A導(dǎo)入了Math.dll中的Add函數(shù),那么A的導(dǎo)入表中就保存了“Add”這個函數(shù)名。當進行動態(tài)鏈接時,動態(tài)鏈接器在Math.dll的函數(shù)名表中進行二分查找,找到“Add”函數(shù),然后在名字序號對應(yīng)表中找到“Add”所對應(yīng)的序號,即1,減去Math.dll的Base值1,結(jié)果為0,然后在EAT中找到下標0的元素,即“Add”的RVA為0x1000。
在ELF中,“.rel.dyn”和“.rel.plt”兩個段中分別保存了該模塊所需要導(dǎo)入的變量和函數(shù)的符號以及所在的模塊等信息,而“.got”和“.got.plt”則保存著這些變量和函數(shù)的真正地址。Windows中也有類似機制,叫做導(dǎo)入表(Import Table)。當某個PE文件被加載時,Windows加載器的其中一個任務(wù)就是將所有需要導(dǎo)入的函數(shù)地址確定并且將導(dǎo)入表中的元素調(diào)整到正確的地址,以實現(xiàn)動態(tài)鏈接的過程。
導(dǎo)入表是一個IMAGE_IMPORT_DESCRIPTOR結(jié)構(gòu)體數(shù)組,每一個IMAGE_IMPORT_DESCRIPTOR結(jié)構(gòu)對應(yīng)一個被導(dǎo)入的DLL。它也被定義在“Winnt.h”中:
typedef struct _IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD Characteristics; // 0 for terminating null import descriptor
DWORD OriginalFirstThunk; // RVA to original unbound IAT (PIMAGE_THUNK_DATA)
} DUMMYUNIONNAME;
DWORD TimeDateStamp; // 0 if not bound,
// -1 if bound, and real date\time stamp
// in IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT (new BIND)
// O.W. date/time stamp of DLL bound to (Old BIND)
DWORD ForwarderChain; // -1 if no forwarders
DWORD Name;
DWORD FirstThunk; // RVA to IAT (if bound this IAT has actual addresses)
} IMAGE_IMPORT_DESCRIPTOR;
結(jié)構(gòu)體中的FirstThunk指向一個導(dǎo)入地址數(shù)組(IAT,Import Address Table),IAT中每個元素對應(yīng)一個被導(dǎo)入的符號,元素的值在不同的情況下有不同的含義。在動態(tài)鏈接器剛完成映射還沒有開始重定位和符號解析時,IAT中的元素值表示相對應(yīng)的導(dǎo)入符號的序號或者是符號名;當Windows的動態(tài)鏈接器在完成該模塊的鏈接時,元素值會被動態(tài)鏈接器改寫成該符號的真正地址,從這一點看,導(dǎo)入地址數(shù)組與ELF中的GOT非常類似。(INT)
為了使得編譯器能夠區(qū)分函數(shù)是從外部導(dǎo)入的還是模塊內(nèi)部定義的,MSVC引入了“__declspec(dllimport)”的擴展屬性,一旦一個函數(shù)被聲明為“__declspec(dllimport)”,那么編譯器就知道它是外部導(dǎo)入的,以便產(chǎn)生相應(yīng)的指令形式。比如:CALL DWORD PTR [0x0040D11C]。這里面的IAT表元素地址0x0040D11C也是絕對地址,這也是需要后面修正的。所以可以看到PE結(jié)構(gòu)中,DLL的代碼段并非地址無關(guān)的,所以Windows系統(tǒng)就是大氣,根本不像Linux那么在意代碼段指令的重復(fù)利用。
因為PE沒有類似ELF的全局符號介入問題,所以對于模塊內(nèi)部的全局函數(shù)調(diào)用,編譯器產(chǎn)生的都是直接調(diào)用指令CALL XXXXXXXX(不是相對地址偏移,是直接地址調(diào)用。這是因為Windows PE下,任何一個PE文件在編譯時都會給出自己的一個優(yōu)先裝載位置,然后根據(jù)此位置產(chǎn)生一系列的定位,當然這個絕對地址是需要在實際裝載運行時再重新修正的,采用了一種重定基地址的方法)。
DLL優(yōu)化
DLL的代碼段和數(shù)據(jù)段本身并不是地址無關(guān)的,也就是說它默認需要被裝載到由ImageBase指定的目標地址中。如果目標地址被占用,那么就需要裝載到其他地址,便會引起整個DLL的Rebase。這對于擁有大量DLL的程序來說,頻繁的Rebase也會造成程序啟動緩慢。這是影響DLL性能的一個原因。
動態(tài)鏈接過程中,導(dǎo)入函數(shù)的符號在運行時需要被逐個解析。在這個解析過程中,免不了涉及到符號字符串的比較和查找過程,這個查找過程中,動態(tài)鏈接器會在目標DLL的導(dǎo)出表中進行符號字符串的二分查找。即使是使用了二分查找法,對于擁有DLL數(shù)量很多,并且有大量導(dǎo)入導(dǎo)出符號的程序來說,這個過程仍然是非常耗時的。這是影響DLL性能的另一個原因。
Windows PE采用了裝載時重定位來解決共享對象的地址沖突問題。這個重定位過程有些特殊,因為所有這些需要重定位的地方只需要加上一個固定的差值,也就是說加上一個目標裝載地址與實際裝載地址的差值。這主要得益于DLL內(nèi)部的地址都是基于基地址的,或者似乎相對于基地址的RVA。所以這種重定位過程比一般的重定位要簡單,速度更快一些。PE里把這種特殊的重定位過程叫做重定基地址(Rebasing)。
MSVC的鏈接器提供了指定輸出文件的基地址的功能。可以在鏈接時使用link命令中的“/BASE”參數(shù)來指定基地址。比如:link /BASE:0x100100000, 0x10000 /DLL bar.obj。
Windows系統(tǒng)本身自帶很多系統(tǒng)的DLL,基本上Windows的應(yīng)用程序運行時都要用到。Windows系統(tǒng)就在進程空間中專門劃出一塊0x70000000~0x80000000區(qū)域,用于映射這些常用的系統(tǒng)DLL。Windows在安裝時就把這塊地址分配給這些DLL,調(diào)整這些DLL的基地址使得它們互相之間不沖突,從而在裝載時就不需要進行重定基址了。
每一次一個程序運行時,所有被依賴的DLL都會被裝載,并且一系列的導(dǎo)入導(dǎo)出符號依賴關(guān)系都會被重新解析。在大多數(shù)情況下,這些DLL都會以同樣的順序被裝載到相同的內(nèi)存地址,所以它們的導(dǎo)出符號的地址應(yīng)該都是不變的,既然這些符號的地址不變,那程序主模塊的導(dǎo)入表應(yīng)該還是和上次程序運行時相同,故而可以保留下來,這樣就可以省去每次啟動時符號解析的過程。這種方法稱為DLL綁定。
在PE的導(dǎo)入表中有一個和IAT一樣的數(shù)組叫做INT就是用來保存綁定符號的地址的。一旦檢測到INT里面有信息,則不需要再次進行符號重定位了,如果遇到問題(如依賴的DLL更新,DLL裝載順序打亂了和此前裝載位置不一致),導(dǎo)致INT中綁定符號信息失效,則也可以依靠IAT的信息再重來一次重定位。Windows系統(tǒng)中很多系統(tǒng)自帶程序便采用DLL綁定用以加速程序啟動。
參考文章
Windows下動態(tài)鏈接之二:DLL優(yōu)化加速
如何理解DLL不是地址無關(guān)的?DLL與ELF的對比分析