前置知識(shí)
本文假定讀者已經(jīng)大概知道什么是靜態(tài)庫(kù)和動(dòng)態(tài)庫(kù),并且有一定的使用經(jīng)驗(yàn);編寫過簡(jiǎn)單的dll和lib模塊,并用于開發(fā)可執(zhí)行文件中。前置知識(shí)部分會(huì)介紹.lib和.dll文件的文件結(jié)構(gòu)(即PE/COFF)文件結(jié)構(gòu),解釋什么是符號(hào),什么是導(dǎo)入表和導(dǎo)出表,鏈接的簡(jiǎn)要過程等,強(qiáng)烈建議不要跳過。
PE/COFF文件結(jié)構(gòu)
.lib文件為COFF格式,示意圖如下。

可以看到,COFF文件中包含映像頭(Image Header),段表(Section Table),.text段,.data段,等部分組成。我知道你很急,但是你先別急,咱一個(gè)個(gè)來看。
映像頭(Image Header)用于描述COFF文件總體屬性,可以表示為一個(gè)“IMAGE_FILE_HEADER”的數(shù)據(jù)結(jié)構(gòu)。它的數(shù)據(jù)結(jié)構(gòu)定義可以從WinNT.h里找到。
typedef struct _IMAGE_FILE_HEADER {
WORD Machine; //目標(biāo)機(jī)器類型,包括86到MIPS R系列、ALPHA、ARM、PowerPC等
WORD NumberOfSections; //PE/COFF所包含的“段”的數(shù)量
DWORD TimeDateStamp; //PE/COFF文件的創(chuàng)建時(shí)間
DWORD PointerToSymbolTable; //符號(hào)表在PE/COFF文件中的位置
DWORD NumberofSymbols;
WORD SizeOfOptionalHeader; //Optional Header的大小。COFF文件中始終等于0
WORD Characteristics; //標(biāo)志位
}IMAGE_FILE_HEADER,*PIMAGE_FILE_HEADER;
映像頭后面緊跟著的就是COFF文件的段表(Section Table),它是一個(gè)類型為“IMAGE_SECTION_HEADER”結(jié)構(gòu)的數(shù)組,數(shù)組里面每個(gè)元素代表一個(gè)段的描述信息。定義同樣可以在WinNT.h中找到。
typedef struct _IMAGE_SECTION_HEADER{
BYTE Name[8]; //段名
union {
DWORD PhysicalAddress;
DWORD Virtualsize;
} Misc;
DWORD VirtualAddress;
DWORD SizeOfRawData; // 該段在文件中的大小
DWORD PointerToRawData; //段在文件中的位置
DWORD PointerToRelocations; //該段的重定位表在文件中的位置
DWORD PointerToLinenumbers; //段的行號(hào)表在文件中的位置
WORD NumberOfRelocations;
WORD NumberOfLinenumbers;
DWORD Characteristics; //標(biāo)志位
)IMAGE_SECTION_HEADER,*PIMAGE_SECTION_HEADER;
.textbss即BSS段(bss segment),通常是指用來存放程序中未初始化的全局變量的一塊內(nèi)存區(qū)域。BSS是英文Block Started by Symbol的簡(jiǎn)稱。BSS段屬于靜態(tài)內(nèi)存分配。(vc編譯器會(huì)將BSS段合并到.data數(shù)據(jù)段中,加快映射過程)。
.text代碼段(text segment)通常是指用來存放程序執(zhí)行代碼的一塊內(nèi)存區(qū)域。這部分區(qū)域的大小在程序運(yùn)行前就已經(jīng)確定,并且內(nèi)存區(qū)域?qū)儆谥蛔x。在代碼段中,也有可能包含一些只讀的常數(shù)變量,例如字符串常量等。
.data 數(shù)據(jù)段(data segment)通常是指用來存放程序中已初始化的全局變量的一塊內(nèi)存區(qū)域。數(shù)據(jù)段屬于靜態(tài)內(nèi)存分配。
.rdata 只讀數(shù)據(jù)段,包含導(dǎo)出表、導(dǎo)入表。導(dǎo)出表和導(dǎo)入表的概念,后文會(huì)講到。
.idata 導(dǎo)入段。包含程序需要的所有DLL文件信息。
.edata 導(dǎo)出段。包含所有提供給其他程序使用的函數(shù)和數(shù)據(jù)。(一般鏈接器不生成這個(gè)段,而是合并到.rdata 只讀數(shù)據(jù)段中)
.rsrc 資源數(shù)據(jù)段,程序用到什么資源數(shù)據(jù)都在這里。
.reloc 重定位段。用于在PE/COFF文件加載到內(nèi)存中時(shí),進(jìn)行內(nèi)存地址的修正。
.drectve 鏈接指示段。drectve實(shí)際上是“Directive”的縮寫,它的內(nèi)容是編譯器傳遞給鏈接器的指令,即編譯器希望告訴鏈接器應(yīng)該怎樣鏈接這個(gè)目標(biāo)文件。以一個(gè)原始數(shù)據(jù)”/DEFAULTLIB:‘LIBCMT’”的.drectve段為例,表示編譯器希望告訴鏈接器,該目標(biāo)文件須要LIBCMT這個(gè)默認(rèn)庫(kù)。
所有以“.debug”開始的段都包含著調(diào)試信息。比如“.debug$S”表示包含的是符號(hào)(Symbol)相關(guān)的調(diào)試信息段;“.debug$P”表示包含預(yù)編譯頭文件(PrecompiledHeader Files)相關(guān)的調(diào)試信息段;“.debug$T”表示包含類型(Type)相關(guān)的調(diào)試信息段。
符號(hào)表(Symbol table),包含符號(hào)名、符號(hào)的類型、所在的模塊。至于什么是符號(hào),后文會(huì)講到。
PE文件是基于COFF的擴(kuò)展,它比COFF文件多了幾個(gè)結(jié)構(gòu)。最主要的變化有兩個(gè):第一個(gè)是文件最開始的部分不是COFF文件頭,而是DOS MZ可執(zhí)行文件格式的文件頭和樁代碼(DOS MZ File Header and Stub);第二個(gè)變化是原來的COFF文件頭中的IMAGE_FILE_HEADER部分?jǐn)U展成了PE文件文件頭結(jié)構(gòu)IMAGE_NT_HEADERS,這個(gè)結(jié)構(gòu)包括了原來的“Image Header”及新增的PE擴(kuò)展頭部結(jié)構(gòu)(PE Optional Header)。PE文件的結(jié)構(gòu)如圖所示。.dll和.exe文件的文件結(jié)構(gòu)即為PE文件結(jié)構(gòu)。

“Image DOS Header”和“DOS Stub”這兩個(gè)結(jié)構(gòu)就為了兼容DOS系統(tǒng)而設(shè)計(jì)的,可忽略。
“IMAGE_NT_HEADERS”是PE真正的文件頭,它包含了一個(gè)標(biāo)記(Signature)和兩個(gè)結(jié)構(gòu)體。標(biāo)記是一個(gè)常量,合法的PE文件的標(biāo)記是ASCII編碼的“PE/0/0”。文件頭包含的兩個(gè)結(jié)構(gòu),分別是映像頭(Image Header),和COFF文件的映像頭一致;另外一個(gè)是PE擴(kuò)展頭部結(jié)構(gòu)(Image Optional Header)。
typedef struct _IMAGE_NT_HEADERS {
DWORD Signature; //標(biāo)記
IMAGE_FILE_HEADER FileHeader; //映像頭
IMAGE_OPTIONAL_HEADER OptionalHeader; //PE擴(kuò)展頭部結(jié)構(gòu)
}IMAGE_NT_HEADERS,*PIMAGE_NT_HEADERS;
實(shí)際上對(duì)于PE可執(zhí)行文件(包括DLL)來說,PE擴(kuò)展頭部結(jié)構(gòu)(Image Optional Header)是必需的。在Windows系統(tǒng)裝載PE可執(zhí)行文件時(shí),往往須要很快地找到一些裝載所須要的數(shù)據(jù)結(jié)構(gòu),比如導(dǎo)入表、導(dǎo)出表、資源、重定位表等。這些常用的數(shù)據(jù)的位置和長(zhǎng)度都被保存在了一個(gè)叫數(shù)據(jù)目錄(Data Directory)的結(jié)構(gòu)里面,其實(shí)它就是前面IMAGE_OPTIONAL_HEADER結(jié)構(gòu)里面DataDirectory成員。這個(gè)成員是一個(gè)IMAGE_DATA_DIRECTORY的結(jié)構(gòu)數(shù)組。
//標(biāo)識(shí)導(dǎo)入表、導(dǎo)出表、資源、重定位表的地址和大小的結(jié)構(gòu)
typedef struct _IMAGE_DATA_DIRECTORY{
DWORD VirtualAddress;
DWORD Size;
}IMAGE_DATA_DIRECTORY,*PIMAGE_DATA_DIRECTORY;
#define IMAGE_NUMBEROF_DIRECTORY_ENTRIES 16 //數(shù)組長(zhǎng)度
.lib文件和.dll文件的主要區(qū)別在于?
其實(shí)一個(gè)靜態(tài)庫(kù)(.lib文件)可以簡(jiǎn)單地看成一組目標(biāo)文件的集合,即很多目標(biāo)文件經(jīng)過壓縮打包后形成的一個(gè)文件。生成.lib靜態(tài)庫(kù)文件的時(shí)候,若發(fā)生了符號(hào)重定義,即工程里有多個(gè)cpp文件定義了相同的全局符號(hào),是不會(huì)被警告的,原因是生成靜態(tài)庫(kù)的時(shí)候,根本不會(huì)進(jìn)行鏈接。那么什么時(shí)候會(huì)發(fā)生警告呢?exe可執(zhí)行文件或者dll動(dòng)態(tài)庫(kù)鏈接靜態(tài)庫(kù)時(shí),鏈接器會(huì)發(fā)現(xiàn)發(fā)生了符號(hào)沖突,并告知鏈接失敗。
而動(dòng)態(tài)庫(kù)(.dll文件)則不同,他是會(huì)在構(gòu)建的時(shí)候發(fā)生鏈接過程,若發(fā)生了符號(hào)重定義,則根本不會(huì)生成動(dòng)態(tài)庫(kù)文件。本質(zhì)上可執(zhí)行文件和動(dòng)態(tài)庫(kù)基本是一回事,它們都由PE文件結(jié)構(gòu)組成,只是其中一個(gè)能夠雙擊運(yùn)行,而另一個(gè)不行。
另外,動(dòng)態(tài)庫(kù)構(gòu)建的時(shí)候,一般也會(huì)生成一個(gè).lib文件。在動(dòng)態(tài)庫(kù)進(jìn)行靜態(tài)加載時(shí),會(huì)使用這個(gè).lib文件,這是怎么一回事呢?這樣的文件并不是靜態(tài)庫(kù),而是成為導(dǎo)入庫(kù)(Import Library),它包含了一段樁代碼,用于獲取導(dǎo)入符號(hào)的地址;另外,導(dǎo)入庫(kù)會(huì)描述dll中導(dǎo)出的符號(hào),用于鏈接時(shí)的符號(hào)決議。所以,要搞清楚.lib文件不一定等于靜態(tài)庫(kù)。(微軟也是搞事情,為什么要用相同的后綴?)
什么是符號(hào)?
鏈接失敗時(shí),比如VS編譯失敗并提示符號(hào)重定義,符號(hào)沒找到等情況時(shí),這里的符號(hào) (Symbol)是指一個(gè)函數(shù),或者變量的起始地址。在鏈接中,我們將函數(shù)和變量統(tǒng)稱為符號(hào)(Symbol),函數(shù)名或變量名,就是符號(hào)名(Symbol Name)。
每一個(gè)目標(biāo)文件都會(huì)有一個(gè)相應(yīng)的符號(hào)表 (Symbol Table),這個(gè)表里面記錄了目標(biāo)文件中所用到的所有符號(hào)。每個(gè)定義的符號(hào)有一 個(gè)對(duì)應(yīng)的值,叫做符號(hào)值(Symbol Value),對(duì)于變量和函數(shù)來說,符號(hào)值就是它們的地址。
一般意義上講,我們關(guān)注的符號(hào)有兩種,一種是定義在本目標(biāo)文件的全局符號(hào),可以被其他目標(biāo)文件引用。而另一種是在本目標(biāo)文件中引用的全局符號(hào),卻沒有定義在本目標(biāo)文件,稱為外部符號(hào)。
什么是導(dǎo)出表和導(dǎo)入表?
當(dāng)一個(gè)PE需要將一些函數(shù)或變量提供給其他PE文件使用時(shí),我們把這種行為叫做符號(hào)導(dǎo)出(Symbol Exporting),最典型的情況就是一個(gè)DLL將符號(hào)導(dǎo)出給EXE文件使用。Windows系統(tǒng)中,所有導(dǎo)出的符號(hào)被集中存放在了被稱作導(dǎo)出表(Export Table)的結(jié)構(gòu)中。事實(shí)上導(dǎo)出表從最簡(jiǎn)單的結(jié)構(gòu)上來看,它提供了一個(gè)符號(hào)名與符號(hào)地址的映射關(guān)系,即可以通過某個(gè)符號(hào)查找相應(yīng)的地址。
PE文件頭中有一個(gè)叫做DataDirectory的結(jié)構(gòu)數(shù)組,這個(gè)數(shù)組共有 16個(gè)元素,每個(gè)元素中保存的是一個(gè)地址和一個(gè)長(zhǎng)度。數(shù)據(jù)目錄(Data Directory)有16個(gè)_IMAGE_DATA_DIRECTORY結(jié)構(gòu)體元素,該結(jié)構(gòu)體數(shù)組是可選PE頭中最后一個(gè)成員。這十六個(gè)元素分別存儲(chǔ)了不同信息,分別是:導(dǎo)入表、導(dǎo)出表、資源、異常信息、安全證書、重定位表、調(diào)試信息、版權(quán)所有、全局指針、TLS、加載配置、綁定導(dǎo)入、IAT、延遲導(dǎo)入、COM信息、最后一個(gè)保留未使用。在這里我們關(guān)心的是導(dǎo)出表,其中第一個(gè)元素就是導(dǎo)出表的結(jié)構(gòu)的地址和長(zhǎng)度。導(dǎo)出表的位置位于.rdata 只讀數(shù)據(jù)段中。WinNT.h中同樣能找到導(dǎo)出表的數(shù)據(jù)結(jié)構(gòu)表示。
typedef struct _IMAGE_EXPORT_DIRECTORY{
DWORD Characteristics;
DWORD TimeDateStamp;
WORD MajorVersion;
WORD MinorVersion;
DWORD Name;
DWORD Base;
DWORD NumberOfFunctions;
DWORD NumberOfNames;
DWORD AddressOfFunctions;
DWORD AddressOfNames;
DWORD AddressOfOrdinals;
}IMAGE_EXPORT_DIRECTORY
導(dǎo)出表結(jié)構(gòu)中,最后的3個(gè)成員指向的是3個(gè)數(shù)組,這3個(gè)數(shù)組是導(dǎo)出表中最重要的結(jié)構(gòu),它們是導(dǎo)出地址表(EAT,ExportAddress Table)、符號(hào)名表(Name Table)和名字序號(hào)對(duì)應(yīng)表(Name-Ordinal Table)。早期Windows系統(tǒng),DLL的函數(shù)導(dǎo)出的主要方式是序號(hào)(Ordinals),使用序號(hào)進(jìn)行鏈接,能夠節(jié)省內(nèi)存空間,且鏈接時(shí)能夠省去符號(hào)名查找過程?,F(xiàn)在的符號(hào)的導(dǎo)出,為了保持兼容,仍使用序號(hào)進(jìn)行導(dǎo)出,因此有了名字序號(hào)對(duì)應(yīng)表。
如果我們?cè)谀硞€(gè)程序中使用到了來自DLL的函數(shù)或者變量,那么我們就把這種行為叫做符號(hào)導(dǎo)入(Symbol Importing)。當(dāng)某個(gè)PE文件被加載時(shí),Windows加載器的其中一個(gè)任務(wù)就是將所有需要導(dǎo)入的函數(shù)地址確定并且將導(dǎo)入表中的元素調(diào)整到正確的地址,以實(shí)現(xiàn)動(dòng)態(tài)鏈接的過程。在PE文件中,導(dǎo)入表是一個(gè)IMAGE_IMPORT_DESCRIPTOR的結(jié)構(gòu)體數(shù)組,每一個(gè)IMAGE_IMPORT_DESCRIPTOR結(jié)構(gòu)對(duì)應(yīng)一個(gè)被導(dǎo)入的DLL。
typedef struct {
DWORD OriginalFirstThunk;
DWORD TimeDateStamp;
DWORD ForwarderChain;
DWORD Nae;
DWORD FirstThunk;
}IMAGE_IMPORT_DESCRIPTOR;
FirstThunk指向一個(gè)導(dǎo)入地址表(Import Address Table),IAT是導(dǎo)入表中最重要的結(jié)構(gòu),IAT中每個(gè)元素對(duì)應(yīng)一個(gè)被導(dǎo)入的符號(hào),元素的值在不同的情況下有不同的含義。在動(dòng)態(tài)鏈接器剛完成映射還沒有開始重定位和符號(hào)解析時(shí),IAT中的元素值表示相對(duì)應(yīng)的導(dǎo)入符號(hào)的序號(hào)或者是符號(hào)名;當(dāng)Windows的動(dòng)態(tài)鏈接器在完成該模塊的鏈接時(shí),元素值會(huì)被動(dòng)態(tài)鏈接器改寫成該符號(hào)的真正地址。
指針OriginalFirstThrunk指向一個(gè)數(shù)組叫做導(dǎo)入名稱表(Import Name Table),簡(jiǎn)稱INT。這個(gè)數(shù)組跟IAT一摸一樣,里面的數(shù)值也一樣,其作用是用于DLL綁定中(一種DLL的性能優(yōu)化手段,后文會(huì)講到)。
鏈接的主要過程?
鏈接過程主要包括了地址和空間分配(Address and Storage Allocation)、符號(hào)決議(Symbol Resolution)和重定位(Relocation)等這些步驟。地址空間分配是指在進(jìn)程空間(內(nèi)存)中找到一塊合適的位置,放置模塊程序(一般是dll);符號(hào)決議,即是確定主模塊(一般是exe文件)引用的外部符號(hào)的在哪一個(gè)子模塊;重定位是指修正導(dǎo)入段(PE文件結(jié)構(gòu)中的一部分,稍后會(huì)提到)中的地址,即確定引用的外部符號(hào)的地址。
大部分鏈接器的鏈接方法叫兩步鏈接法(Two-pass Linking):
- 第一步——空間與地址分配:掃描所有的輸入目標(biāo)文件,并且獲得它們的各個(gè)段的長(zhǎng)度、屬性和位置,并且將輸入目標(biāo)文件中的符號(hào)表中所有的符號(hào)定義和符號(hào)引用收集起來,統(tǒng)一放到一個(gè)全局符號(hào)表。這一步中,鏈接器將能夠獲得所有輸入目標(biāo)文件的段長(zhǎng)度,并且將它們合并,計(jì)算出輸出文件中各個(gè)段合并后的長(zhǎng)度與位置,并建立映射關(guān)系。
- 第二步——符號(hào)解析與重定位:使用上面第一步中收集到的所有信息,讀取輸入文件中段的數(shù)據(jù)、重定位信息,并且進(jìn)行符號(hào)解析與重定位、調(diào)整代碼中的地址等。事實(shí)上第二步是鏈接過程的核心,特別是重定位過程。
實(shí)踐技巧
入口點(diǎn)函數(shù)
可以在DLL源代碼中實(shí)現(xiàn)一個(gè)入口點(diǎn)函數(shù),以獲得一些事件發(fā)生時(shí)的通知。如果不需要被通知,也可不實(shí)現(xiàn)。
BOOL WINAPI Dl1Main(HINSTANCE hInstD11, DWORD fdwReason, PVOID fImpLoad) {
switch (fdwReason) {
case DLL_PROCESS_ATTACH:
//當(dāng)DLL文件被映射到進(jìn)程空間,也就是DLL被加載時(shí)(不管是靜態(tài)加載還是動(dòng)態(tài)加載),
//DllMain被調(diào)用且fdwReason為DLL_PROCESS_ATTACH。
//注意,多次調(diào)用LoadLibrary(Ex)并不會(huì)讓程序調(diào)用入口點(diǎn)函數(shù)。
break;
case DLL_PROCESS_DETACH:
//DLL文件從進(jìn)程空間中撤銷映射時(shí),DllMain被調(diào)用且fdwReason為DLL_PROCESS_DETACH。
//注意若在處理DLL_PROCESS_ATTACH時(shí)返回的是FALSE,則撤銷映射時(shí)不會(huì)調(diào)用DllMain。
break;
case DLL_THREAD_ATTACH:
//當(dāng)進(jìn)程創(chuàng)建線程時(shí),所有DLL的DllMain會(huì)被調(diào)用且fdwReason為DLL_THREAD_ATTACH。
//新創(chuàng)建的線程負(fù)責(zé)調(diào)用所有DLL的DllMain函數(shù),只有全部完成后才開始執(zhí)行線程自身的程序。
//注意在創(chuàng)建新線程的時(shí)候DLL已經(jīng)被映射到進(jìn)程的地址空間中,DllMain才會(huì)被調(diào)用。
break;
case DLL_THREAD_DETACH:
//線程正常退出時(shí),所有DLL的DllMain會(huì)被調(diào)用且fdwReason為DLL_THREAD_DETACH。
//注意,存在場(chǎng)景fdwReason為DLL_THREAD_DETACH的DllMain被調(diào)用,但是
//fdwReason為DLL_THREAD_ATTACH的DllMain在之前并沒有被調(diào)用的場(chǎng)景。
break;
}
//DLL初始化場(chǎng)景下(fdwReason==DLL_PROCESS_ATTACH),若返回FALSE,
//則彈出消息框告知初始化失敗,并終止進(jìn)程。注意,其他場(chǎng)景下,返回值無意義。
return TRUE;
}
注意,如果進(jìn)程終止是因?yàn)橄到y(tǒng)中的某個(gè)線程調(diào)用了TerminateProcess,系統(tǒng)便不會(huì)用DLL_PROCESS_DETACH來調(diào)用DLL的DIIMain函數(shù)。這意味著在進(jìn)程終止之前,已映射到進(jìn)程的地址空間中的任何DLL將沒有機(jī)會(huì)執(zhí)行任何清理代碼。這可能會(huì)導(dǎo)致數(shù)據(jù)丟失。因此,除非萬不得已,我們應(yīng)該避免使用TerminateProcess函數(shù)。
同樣,如果線程終止是因?yàn)橄到y(tǒng)中的某個(gè)線程調(diào)用了TerminateThread,那么系統(tǒng)不會(huì)用DLL_THREAD_DETACH來調(diào)用所有DLL的DIMain函數(shù)。這意味著在線程終止之前,已映射到進(jìn)程的地址空間中的任何DLL將沒有機(jī)會(huì)執(zhí)行任何清理代碼。這可能會(huì)導(dǎo)致數(shù)據(jù)丟失。因此,與TerminateProcess一樣,除非萬不得已,我們應(yīng)該避免使用TerminateThread函數(shù)。
另外,處理fdwReason為DLL_THREAD_ATTACH和DLL_THREAD_DETACH的場(chǎng)景,需要留意是否可能發(fā)生死鎖問題。一般情況下,不建議在DLL的DIIMain函數(shù)中調(diào)用WaitForSingleObject。
函數(shù)轉(zhuǎn)發(fā)器
函數(shù)轉(zhuǎn)發(fā)器(function forwarder)是DLL輸出段中的一個(gè)條目,用來將一個(gè)函數(shù)調(diào)用轉(zhuǎn)發(fā)到另 一個(gè)DLL中的另一個(gè)函數(shù)。比如kernel32.dll中導(dǎo)出的CloseThreadpoollo,CloseThreadpoolTimer,CloseThreadpoolWait和CloseThreadpoolWork等函數(shù),實(shí)質(zhì)是調(diào)用的NTDLL.dll中的TpReleaseIoCompletion等函數(shù)。
使用函數(shù)轉(zhuǎn)發(fā)器的一種方法是使用pragma指示符。這個(gè)pragma告訴鏈接器,正在編譯的DLL應(yīng)該輸出一個(gè)名為SomeFunc的函數(shù),但實(shí)際實(shí)現(xiàn)SomeFunc的是另一個(gè)名為SomeOtherFunc的函數(shù),該函數(shù)被包含在另一個(gè)名為DIIWork.dIl的模塊中。
// Function forwarders to functions in Dllwork
#pragma comment(linker,"/export:SomeFunc=Dl1Work.SomeotherFunc")
DLL延遲載入
一個(gè)延遲載入的DLL是隱式鏈接的,系統(tǒng)一開始不會(huì)將該DLL載入,只有當(dāng)我們的代碼試圖去引用DLL中包含的一個(gè)符號(hào)時(shí),系統(tǒng)才會(huì)實(shí)際載入該DLL。這可以大大加快進(jìn)程的初始化時(shí)間,避免在應(yīng)用程序啟動(dòng)時(shí)加載不必馬上加載的DLL。使用延遲載入機(jī)制也有一些局限,包括:
- 延遲載入機(jī)制的底層實(shí)現(xiàn)要經(jīng)過LoadLibrary和GetProcAddress函數(shù),所以Kernel32.dll無法被延遲載入。
- 導(dǎo)出了全局變量的DLL無法被延遲載入。
- DllMain入口點(diǎn)函數(shù)調(diào)用一個(gè)延遲載入的DLL導(dǎo)出的函數(shù),可能會(huì)引發(fā)程序異常。
使用延遲載入機(jī)制的常見方法是在Visual Studio的工程設(shè)置中增加兩個(gè)鏈接器開關(guān),包括/Lib:xxx.lib和/DelayLoad:xxx.dIl,xxx代表需要延遲載入的DLL及其導(dǎo)入庫(kù)的名字。鏈接時(shí),鏈接器檢測(cè)到這兩個(gè)開關(guān),會(huì)將可執(zhí)行文件中導(dǎo)入段關(guān)于xxx.dll的信息去除,當(dāng)進(jìn)程初始化時(shí),操作系統(tǒng)便不會(huì)加載該DLL到進(jìn)程地址空間中。并且,鏈接器會(huì)往可執(zhí)行模塊中增加一個(gè)延遲載入段(.didata)來標(biāo)識(shí)要從xxx.dll中導(dǎo)入那些函數(shù)。最后,當(dāng)應(yīng)用程序啟動(dòng)時(shí),若對(duì)延遲載入的函數(shù)進(jìn)行調(diào)用,會(huì)調(diào)用延遲載入段導(dǎo)向的_delayLoadHelper2函數(shù),并調(diào)用LoadLibrary和GetProcAddress函數(shù),實(shí)現(xiàn)延遲載入。
此外,還可以將延遲載入的DLL動(dòng)態(tài)地卸載掉,需用增加新的鏈接器開關(guān)/Delay:unload,并調(diào)用_FUnloadDelayLoadedDLL2函數(shù)實(shí)現(xiàn)卸載。
重定基地址
將DLL加載到進(jìn)程空間時(shí),加載到哪個(gè)位置是一個(gè)問題。如果沒有為DLL模塊指定基地址,那么會(huì)先嘗試往默認(rèn)的首選基地址去加載該模塊(一般是0x10000000),若發(fā)生地址位置沖突(當(dāng)前位置已經(jīng)有其他模塊),則需要對(duì)DLL加載進(jìn)行運(yùn)行時(shí)的重定基地址,這會(huì)導(dǎo)致額外的性能開銷??梢栽赩S中的鏈接器設(shè)置中指定DLL模塊的首選基地址,但這么做靈活性比較低。使用Rebase工具,可以處理大量不同的DLL模塊載入同一個(gè)可執(zhí)行文件的場(chǎng)景(避免重定基地址之后依然有沖突)。通過調(diào)用ReBaseImage函數(shù),我們也可以實(shí)現(xiàn)自己的重定如果在執(zhí)行Rebase工具的時(shí)候傳給它一組映像文件名,那么它會(huì)執(zhí)行下列操作:
- 它會(huì)模擬創(chuàng)建一個(gè)進(jìn)程地址空間。
- 它會(huì)打開應(yīng)該被載入到這個(gè)地址空間中的所有模塊,并得到每個(gè)模塊的大小以及它們的首選基地址。
- 它會(huì)在模擬的地址空間中對(duì)模塊重定位的過程進(jìn)行模擬,使各模塊之間沒有交疊。
- 對(duì)每個(gè)重定位過的模塊,它會(huì)解析該模塊的重定位段,并修改模塊在磁盤文件中的代碼。
- 為了反映新的首選基地址,它會(huì)更新每個(gè)重定位過的模塊的文件頭。
需要注意的是,Windows操作系統(tǒng)提供的DLL模塊一般不需要重定基地址,它們已經(jīng)經(jīng)過特殊處理。
DLL綁定
對(duì)一個(gè)模塊進(jìn)行綁定,是用該模塊導(dǎo)入的所有符號(hào)的虛擬地址,來對(duì)該模塊的導(dǎo)入段進(jìn)行預(yù)處理。這樣處理過的DLL模塊,不需要花太長(zhǎng)的時(shí)間讀取導(dǎo)入段,去解析導(dǎo)入符號(hào)的地址,進(jìn)一步減少了啟動(dòng)應(yīng)用程序的開銷。Visual Studio提供了另一個(gè)名為Bind.exe的工具,幫助我們完成了DLL模塊綁定的工作。同樣,也可以使用BindImageEx函數(shù)來實(shí)現(xiàn)相同的特性。在實(shí)施DLL模塊綁定之前,需要先進(jìn)行首選基地址的重定,確保DLL載入過程沖不會(huì)發(fā)生地址沖突。
如果在執(zhí)行Bind工具的時(shí)候傳給它一個(gè)映像文件名,它會(huì)執(zhí)行下列操作:
- 它會(huì)打開指定的映像文件的導(dǎo)入段。
- 對(duì)導(dǎo)入段中列出的每個(gè)DLL,它會(huì)查看該DLL文件的文件頭,來確定該DLL的首選基地址。
- 它會(huì)在DLL的導(dǎo)出段中查看每個(gè)符號(hào)。
- 它會(huì)取得符號(hào)的RVA,并將它與模塊的首選基地址相加。它會(huì)將計(jì)算得到的地址,也就是導(dǎo)入符號(hào)預(yù)期的虛擬地址,寫入到映像文件的導(dǎo)入段中。
- 它會(huì)在映像文件的導(dǎo)入段中添加一些額外的信息。這些信息包括映像文件被綁定到的各DLL模塊的名稱,以及各模塊的時(shí)間戳。
如果Windwos系統(tǒng)加載程序發(fā)現(xiàn)模塊已經(jīng)綁定過了,所需的DLL也確實(shí)被載入到了它們的首選基地址,而且時(shí)間戳也吻合,那么它實(shí)際上就不需要再做任何事情了。它不必再對(duì)任何模塊進(jìn)行重新定位,它也不必再查看任何導(dǎo)入函數(shù)的虛擬地址。應(yīng)用程序的啟動(dòng)性能會(huì)有比較明顯的改善。另外,如果我們?cè)诠緝?nèi)部對(duì)模塊進(jìn)行綁定,那么會(huì)將它們綁定到我們安裝的系統(tǒng)DLL,而這些DLL很可能與用戶安裝的系統(tǒng)DLL不同(Windows不同版本的系統(tǒng)DLL有所差異)。因此我們應(yīng)該在應(yīng)用程序的安裝過程中來進(jìn)行綁定。
DLL注入
DLL注入是一種使應(yīng)用程序跨越進(jìn)程邊界來訪問另一個(gè)進(jìn)程的地址空間的機(jī)制。目標(biāo)應(yīng)用程序的源代碼可能無法修改,或者不太好直接修改,那么使用DLL注入的方式可以執(zhí)行自己編寫的代碼,實(shí)現(xiàn)一些特殊的功能,比如嵌入自己的子類窗口,進(jìn)行輔助調(diào)試,以及其他的業(yè)務(wù)邏輯和功能等。由于這里說到的“自己編寫的代碼”是以DLL作為載體注入到其他應(yīng)用程序中,因此成為DLL注入(DLL Injection)。DLL注入的實(shí)現(xiàn)方式包括:
- 使用注冊(cè)表來注入DLL。
HKEY_LOCAL_MACHINE\Software\Microsoft\Windows NT\CurrentVersion\Windows\注冊(cè)表路徑下AppInit_Dlls鍵的值寫入想要注入的DLL的文件名,LoadAppInit_DIls鍵的值設(shè)為1,即可在所有使用了User32.dIl的進(jìn)程啟動(dòng)時(shí),都加載上想要注入的DLL,執(zhí)行自己的代碼。此外,設(shè)置動(dòng)態(tài)的上下文菜單(即Windows桌面和應(yīng)用程序圖標(biāo)上鼠標(biāo)右鍵點(diǎn)擊時(shí)彈出的菜單,實(shí)現(xiàn)方式參考:https://learn.microsoft.com/zh-cn/windows/win32/shell/shortcut-menu-using-dynamic-verbs),一些應(yīng)用支持插件形式讓三方開發(fā)者編寫自己的業(yè)務(wù)界面和邏輯(比如開發(fā)office插件,實(shí)現(xiàn)方式參考https://learn.microsoft.com/zh-cn/office/dev/add-ins/develop/develop-overview),都需要在注冊(cè)表特定位置寫入需要注入的DLL路徑和名稱,也屬于使用注冊(cè)表進(jìn)行DLL注入的范疇。 - 使用Windows掛鉤(Windows Hook)來注入DLL??梢允褂?
SetWindowsHookEx向指定的應(yīng)用程序注入DLL。 - 注入DLL的第三種方法是使用遠(yuǎn)程線程(remote thread),它提供了最高的靈活性。Windows提供了
CreateRemoteThread函數(shù),令目標(biāo)進(jìn)程創(chuàng)建線程,并在此線程中動(dòng)態(tài)加載自己的DLL,即可實(shí)現(xiàn)此種方式的DLL注入。 - 直接替換目標(biāo)進(jìn)程會(huì)使用的DLL。使用函數(shù)轉(zhuǎn)發(fā)器機(jī)制,能夠保持被替換DLL導(dǎo)出的所有符號(hào),并增加自己的程序邏輯。由于不能適應(yīng)DLL版本變化,不建議使用。這種DLL也叫木馬DLL。
- 其他的方式包括把DLL作為調(diào)試器來注入,用CreateProcess來注入代碼等,需要針對(duì)具體CPU編寫代碼,比較復(fù)雜,不常用。
常見問題
鏈接過程中發(fā)現(xiàn)符號(hào)重定義問題,或者引用的符號(hào)不符合預(yù)期的原因。
對(duì)于C/C++語(yǔ)言來說,編譯器默認(rèn)函數(shù)和初始化了的全局變量為強(qiáng)符號(hào)(Strong Symbol),未初始化的全局變量為弱符號(hào)(Weak Symbol)。GCC編譯器可以使用_attribute_((weak))前綴來顯式地指定一個(gè)強(qiáng)符號(hào)為弱符號(hào),但Windows平臺(tái)下一般使用MSVC編譯器和工具鏈,不支持類似特性。
針對(duì)強(qiáng)弱符號(hào)的概念,鏈接器就會(huì)按如下規(guī)則處理與選擇被多次定義的全局符號(hào):
- 規(guī)則1:不允許強(qiáng)符號(hào)被多次定義(即不同的目標(biāo)文件中不能有同名的強(qiáng)符號(hào));如果有多個(gè)強(qiáng)符號(hào)定義,則鏈接器報(bào)符號(hào)重復(fù)定義錯(cuò)誤。
- 規(guī)則2:如果一個(gè)符號(hào)在某個(gè)目標(biāo)文件中是強(qiáng)符號(hào),在其他文件中都是弱符號(hào),那么選擇強(qiáng)符號(hào)。
- 規(guī)則3:如果一個(gè)符號(hào)在所有目標(biāo)文件中都是弱符號(hào),那么選擇其中占用空間最大的一個(gè)(坑爹吧)。比如目標(biāo)文件A定義全局變量global為int型,占4個(gè)字節(jié);目標(biāo)文件B定義global為double型,占8個(gè)字節(jié),那么目標(biāo)文件A和B鏈接后,符號(hào)global占8個(gè)字節(jié)。
盡量不要使用多個(gè)不同類型的弱符號(hào),否則容易導(dǎo)致很難發(fā)現(xiàn)的程序錯(cuò)誤。
更新DLL時(shí),發(fā)生難以理解和調(diào)試的崩潰的可能原因。
最可能的原因是二進(jìn)制兼容問題。二進(jìn)制兼容這個(gè)概念的含義比較廣,限定在Windows動(dòng)態(tài)庫(kù)開發(fā)的框下,其中一個(gè)場(chǎng)景是:dll文件的符號(hào)布局發(fā)生變化時(shí),直接替換dll文件,但沒有使用最新的導(dǎo)入庫(kù)(.lib)對(duì)exe可執(zhí)行文件進(jìn)行重新編譯和鏈接,從而導(dǎo)致exe文件獲取dll文件中的符號(hào)時(shí)發(fā)生地址訪問異常,引發(fā)堆棧或棧頂指針被破壞,內(nèi)存訪問違例異常的情況。
從另一個(gè)角度來看,這種場(chǎng)景的dll更新方式(exe使用dll靜態(tài)加載,但不重新使用最新的導(dǎo)入庫(kù)編譯和鏈接exe)的本質(zhì),是程序員將dll提供的接口視為ABI(Application Binary Interface),但dll提供的只是API(Application Programming Interface)。它們是內(nèi)涵上相當(dāng)不同的概念。API往往是指源代碼級(jí)別的接口,比如我們可以說POSIX是一個(gè)API標(biāo)準(zhǔn)、Windows所規(guī)定的應(yīng)用程序接口(Win32 API)是一個(gè)API;而ABI是指二進(jìn)制層面的接口,ABI的兼容程度比API要更為嚴(yán)格,比如我們可以說C++的對(duì)象內(nèi)存分布(Object Memory Layout)是C++ABI的一部分。ABI的概念其實(shí)從開始至今一直存在,因?yàn)槿藗兛偸窍M绦蚰軌蛟诓唤?jīng)任何修改的情況下得到重用(在上文提到的場(chǎng)景下,即只替換dll文件而不重新編譯鏈接exe)。人們始終在朝這個(gè)方向努力,但是由于現(xiàn)實(shí)的因素,二進(jìn)制級(jí)別的重用還是很難實(shí)現(xiàn)。最大的問題之一就是各種硬件平臺(tái)、編程語(yǔ)言、編譯器、鏈接器和操作系統(tǒng)之間的ABI相互不兼容,由于ABI的不兼容,各個(gè)目標(biāo)文件之間無法相互鏈接,二進(jìn)制兼容性更加無從談起。相對(duì)于Linux而言,Windows的動(dòng)態(tài)庫(kù)使用得更加頻繁,但沒有一種非常好的機(jī)制去保證其二進(jìn)制兼容問題,極容易發(fā)生問題,這個(gè)現(xiàn)象被人戲稱為DLL噩夢(mèng)(DLL hell)。
解決DLL hell的有效方法,包括使用.NET框架提供的清單文件機(jī)制,COM接口機(jī)制等。但這些機(jī)制的引入會(huì)增加開發(fā)的復(fù)雜度,需要程序員掌握更多的繁雜的知識(shí),因此大部分軟件公司不會(huì)采取這些技術(shù)去解決DLL hell問題(以本人呆過的公司來說)。所以解決DLL hell問題,更多的是可以模仿COM組件的思想,去規(guī)范編寫動(dòng)態(tài)庫(kù)程序,進(jìn)而減少二進(jìn)制兼容問題的發(fā)生。
- 接口類的函數(shù)都應(yīng)使用純虛函數(shù),向外暴露的是接口,dll內(nèi)部繼承這個(gè)接口類去實(shí)現(xiàn)方法邏輯,對(duì)外提供一個(gè)類似CreateInstance接口,通過操作接口類的指針,去使用dll提供的能力(啰里八嗦的,其實(shí)從設(shè)計(jì)模式的角度講,就是依賴倒轉(zhuǎn)原則+工廠方法)?;蛘邔㈩惖姆椒暶鳛閕nline。
- 所有的全局函數(shù)都應(yīng)該使用extern“C”來防止名字修飾的不兼容。并且導(dǎo)出函數(shù)的都應(yīng)該是_stdcall調(diào)用規(guī)范的(COM的DLL都使用這樣的規(guī)范)。這樣即使用戶本身的程序是默認(rèn)以_cdecl方式編譯的,對(duì)于DLL的調(diào)用也能夠正確。
- 函數(shù)接口的參數(shù)和返回值,不要使用STL,避免STL和編譯器版本不同導(dǎo)致的兼容問題。
- 不同編譯器的異常處理機(jī)制可能不同,因此不要使用異常(主要是拋異常,必要時(shí)還是需要catch一些三方庫(kù)拋出的異常)。
- 不同編譯器對(duì)虛函數(shù)和虛函數(shù)指針機(jī)制實(shí)現(xiàn)有差異,因此不要使用虛析構(gòu)函數(shù)。可以創(chuàng)建一個(gè)destroy()方法,并且重載delete操作符在dll內(nèi)部調(diào)用destroy()。
- 不要在DLL里面中請(qǐng)內(nèi)存,而且在DLL外釋放(或者相反)。不同的DLL和可執(zhí)行文件可能使用不同的CRT堆(C運(yùn)行時(shí)庫(kù)維護(hù)的一塊內(nèi)存區(qū)域),在一個(gè)堆里面申請(qǐng)內(nèi)存而在另外一個(gè)堆里面釋放會(huì)導(dǎo)致錯(cuò)誤。
- 對(duì)于內(nèi)存分配相關(guān)的函數(shù)不應(yīng)該是inline的,以防止它在編譯時(shí)被展開到不同的DLL和可執(zhí)行文件。
- 不要在使用重載的接口函數(shù),否則在dll動(dòng)態(tài)加載的場(chǎng)景下,GetProcAddress獲取的函數(shù)位置,可能不符合預(yù)期。
- 如無必要,盡量不要導(dǎo)出類,而是導(dǎo)出普通函數(shù)。另外,增加接口時(shí)應(yīng)在末尾增加,避免影響原有函數(shù)接口的二進(jìn)制地址位置。
代碼層面的實(shí)踐參考可以閱讀這篇文章Dll導(dǎo)出C++類的3種方式(多干貨)。總之,實(shí)現(xiàn)二進(jìn)制兼容性良好的動(dòng)態(tài)庫(kù),需要有一套嚴(yán)格的編碼規(guī)范。
參考&推薦閱讀:
Peering Inside the PE: A Tour of the Win32 Portable Executable File Format
《程序員的自我修養(yǎng) :鏈接、裝載與庫(kù)》 ——俞甲子 石凡 潘愛民
《Windows核心編程》——Jeffrey Richter
Dll導(dǎo)出C++類的3種方式(多干貨)