Windows DLL基本原理
Windows系統(tǒng)平臺上,你可以將獨立的程序模塊創(chuàng)建為較小的DLL(Dynamic Linkable Library)文件,并可對它們單獨編譯和測試。在運行時,只有當EXE程序確實要調用這些DLL模塊的情況下,系統(tǒng)才會將它們裝載到內存空間中。這種方式不僅減少了EXE文件的大小和對內存空間的需求,而且使這些DLL模塊可以同時被多個應用程序使用。Microsoft Windows自己就將一些主要的系統(tǒng)功能以DLL模塊的形式實現。例如IE中的一些基本功能就是由DLL文件實現的,它可以被其它應用程序調用和集成。一般來說,DLL是一種磁盤文件(通常帶有DLL擴展名,是標準win32可執(zhí)行文件-“PE”格式),它由全局數據、服務函數和資源組成,在運行時被系統(tǒng)加載到進程的虛擬空間中,成為調用進程的一部分,進程中所有線程都可以調用其中的函數。如果與其它DLL之間沒有沖突,該文件通常映射到進程虛擬空間的同一地址上。DLL模塊中包含各種導出函數,用于向外界提供服務。Windows在加載DLL模塊時將進程函數調用與DLL文件的導出函數相匹配。
在Win32環(huán)境中,每個進程都復制了自己的讀/寫全局變量。如果想要與其它進程共享內存,必須使用內存映射文件或者聲明一個共享數據段。DLL模塊需要的堆棧內存都是從運行進程的堆棧中分配出來的。
DLL文件中包含一個導出函數表(存在于PE的.edata節(jié)中)。這些導出函數由它們的符號名和稱為標識號的整數與外界聯系起來。函數表中還包含了DLL中函數的地址。當應用程序加載DLL模塊時時,它并不知道調用函數的實際地址,但它知道函數的符號名和標識號。動態(tài)鏈接過程在加載的DLL模塊時動態(tài)建立一個函數調用與函數地址的對應表。如果重新編譯和重建DLL文件,并不需要修改應用程序,除非你改變了導出函數的符號名和參數序列。
簡單的DLL文件只為應用程序提供導出函數,比較復雜的DLL文件除了提供導出函數以外,還調用其它DLL文件中的函數。
每個DLL都有一個入口函數(DLLMain),系統(tǒng)在特定環(huán)境下會調用DLLMain。在下面的事件發(fā)生時會調用dll入口函數:1.進程裝載DLL。2.進程卸載DLL。3.DLL在被裝載之后創(chuàng)建了新線程。4. DLL在被裝載之后一個線程被終止了。
應用程序導入函數與DLL文件中的導出函數進行鏈接有兩種方式:隱式鏈接和顯式鏈接。
隱式鏈接(load-time dynamic linking)是指在應用程序中不需指明DLL文件的實際存儲路徑,程序員不需關心DLL文件的實際裝載(由編譯器自動完成地址分配)。采用隱式鏈接方式,程序員在建立一個DLL文件時,鏈接程序會自動生成一個與之對應的LIB導入文件。該文件包含了每一個DLL導出函數的符號名和可選的標識號,但是并不含有實際的代碼。LIB文件作為DLL的替代文件被編譯到應用程序項目中。當程序員通過靜態(tài)鏈接方式編譯生成應用程序時,應用程序中的調用函數與LIB文件中導出符號相匹配,這些符號或標識號進入到生成的EXE文件中。LIB文件中也包含了對應的DLL文件名(但不是完全的路徑名),鏈接程序將其存儲在EXE文件內部。當應用程序運行過程中需要加載DLL文件時,Windows根據這些信息發(fā)現并加載DLL,然后通過符號名或標識號實現對DLL函數的動態(tài)鏈接。我們使用的大部分系統(tǒng)Dll就是通過這樣的方式鏈接的。若找不到需要的Dll則會給出一個Dll缺少的錯誤消息。
顯式鏈接(run-time dynamic linking)與此相反。用戶程序在編譯的時候并沒有指明需要哪些Dll,而是在運行起來之后調用Win32 的LoadLibary()函數,去裝載Dll。若沒有找到Dll則這個函數就會返回一個錯誤。在用LoadLibary()函數裝載Dll之后,應用程序還需要用GetProcAdress()函數去獲得Dll輸出函數的地址。顯式鏈接方式對于集成化的開發(fā)語言比較適合。有了顯式鏈接,程序員就不必再使用導入文件,而是直接調用Win32 的LoadLibary()函數,并指定DLL的路徑作為參數。還要說明一點的就是Known Dlls就是保證在通過LoadLibary()去裝載系統(tǒng)Dll的時候,只從特定的系統(tǒng)目錄去裝載,防止裝載錯。裝載的時候會去看注冊表下是否有一樣的注冊表鍵名。如果是裝載windowssystem32目錄下的對應的Dll。
Dll的搜索順序,在Windows上有個注冊表鍵值決定了Dll的搜索順序:HKLMSystemCurrentControlSetSessionManagerSafeDllSearchMode。在vista,server2003,xp sp2中這個值為1,在xp,2000 sp4中為0。1值時的搜素順序為:1.可執(zhí)行文件所在目錄,2.系統(tǒng)目錄windowssystem32,3. 16位系統(tǒng)目錄,4.windows目錄,5.當前進程目錄。6.環(huán)境變量PATH中的目錄。0值時的搜素順序為:1.可執(zhí)行文件所在目錄,2. 當前進程目錄。3.系統(tǒng)目錄windowssystem32,4. 16位系統(tǒng)目錄,5.windows目錄,6.環(huán)境變量PATH中的目錄。
DLL的加載與連接
Windows DLL裝入(除ntdll.dll外)和連接是通過ntdll.dll中一個函數LdrInitializeThunk實現的。先對LdrInitializeThunk()這個函數名作些解釋“Ldr顯然是“Loader”的縮寫。而“Thunk”意為“翻譯”、“轉換”、或者某種起著“橋梁”作用的東西。這個詞在一般的字典中是查不到的,但卻是個常見于微軟的資料、文檔中術語。這個術語起源于編譯技術,表示一小片旨在獲取某個地址的代碼,最初用于函數調用時“形參”和“實參”結合。后來這個術語有了不少新的特殊含義和使用,但是DLL的動態(tài)連接與函數調用時“形實結合”確實有著本質的相似。
由于Windows沒有公開這個函數的代碼,所以學習起來比較困難,只能通過查閱一些資料來大概猜測這個函數的實現。這個過程中也參看了很多ReactOS(ReactOS是一個免費而且完全兼容 Microsoft Windows XP 的操作系統(tǒng)。ReactOS 旨在通過使用類似構架和提供完整公共接口實現與 NT 操作系統(tǒng)二進制下的應用程序和驅動設備的完全兼容。)的LdrInitializeThunk()函數實現源代碼。
在進入這個函數之前,目標 EXE映像已經被映射到當前進程的用戶空間,系統(tǒng)DLL ntdll.dll的映像也已經被映射,但是并沒有在EXE映像與ntdll.dll映像之間建立連接 (實際上 EXE映像未必就直接調用ntdll.dll中的函數)。LdrInitializeThunk()是ntdll.dll中不經連接就可進入的函數,實質上就是ntdll.dll的入口。除ntdll.dll以外,別的 DLL都還沒有被裝入(映射)。此外,當前進程(除內核中的“進程控制塊”EPROCESS等數據結構外)在用戶空間已經有了一個“進程環(huán)境塊”PEB,以及該進程的第一個“線程環(huán)境塊”TEB。這就是進入 LdrInitializeThunk()前的“當前形勢”。
PEB中有一個字段Ldr是個PEB_LDR_DATA結構指針,所指向的數據結構用來為本進程維持三個“模塊”隊列、即InLoadOrderModuleList、InMemoryOrderModuleList、和InInitializationOrderModuleList。這里所謂“模塊”就是PE格式的可執(zhí)行映像,包括EXE映像和DLL映像。前兩個隊列都是模塊隊列,第三個是初始化隊列。兩個模塊隊列的不同之處在于排列的次序,一個是按裝入的先后,一個是按裝入的位置。每當為本進程裝入一個模塊、即.exe映像或DLL映像時,就要為其分配,創(chuàng)建一個LDR_DATA_TABLE_ENTRY數據結構,并將其掛入InLoadOrderModuleList。然后,完成對這個模塊的動態(tài)連接以后,就把它掛入InInitializationOrderModuleList隊列,以便依次調用它們的初始化函數。相應地,LDR_DATA_TABLE_ENTRY數據結構中有三個隊列頭,因而可以同時掛在三個隊列中。在我做的小實驗當中就是通過查找這三個隊列,來將當前進程的Dll加載信息顯示出來的。具體的實例請見我的實驗說明文檔。
在LdrInitializeThunk()中,最開始為做的事情就是將加載的模塊信息存放在PEB中的ldr字段,如上面一段文字中所述。之后,LdrInitializeThunk()函數又調用了一個叫LdrPEStartup()的函數。LdrPEStartup()函數首先判斷了“期望地址”是否可用,PE映像的NtHeader(peb中有個ImageBaseAddress的地址,代表exe映像在用戶空間的位置,在這個地址指向的數據結構中就有NtHeader的結構)中有個指針,指向一個OptionalHeader。在OptionalHeader中有個字段ImageBase,是具體映像建議、或者說希望被裝入的地址,我們稱之為“愿望地址”。在裝入一個映像時,只要相應的區(qū)間(取決于它的期望地址和大小)空閑,就總正常裝入。但是如果與已經被占用的區(qū)間相沖突,就只好利用LdrPerformRelocations()換個地方。
那么映像的愿望地址有著什么物理的或者邏輯的意義呢?我們知道,軟件在編譯以后有個連接的過程,即為函數的調用者落實被調用函數的入口地址、為全局變量(按絕對地址)的使用者落實變量地址的過程。連接有靜態(tài)和動態(tài)兩種,靜態(tài)連接是在“制造”軟件時進行的,而動態(tài)連接則是在使用軟件時進行的。盡管EXE模塊和DLL模塊之間的連接是動態(tài)連接,但是EXE或DLL模塊內部的連接卻是靜態(tài)連接。既是靜態(tài)連接,就必須為模塊的映像提供一個假定的起點地址。如果以此假定地址為基礎進行連接以后就不可變更,使用時必須裝入到這個地址上,那么這個地址就是固定的“指定地址”了。早期的靜態(tài)連接往往都是使用指定地址的。但是,如果允許按假定地址連接的映像在實際使用時進行“重定位”,那么這假定地址就是可浮動的“愿望地址”了??伞爸囟ㄎ弧钡撵o態(tài)連接當然比固定的靜態(tài)連接靈活。事實上,要是沒有可“重定位”的靜態(tài)連接技術,DLL的使用就無法實現,因為根本就不可能事先為所有可能的DLL劃定它們的裝入位置和大小。至于可“重定位”靜態(tài)連接的實現,則一般都是采用間接尋址,通過指針來實現。
所謂重定位,就是計算出實際裝入地址與建議裝入地址間的位移a,然后調整每個重定位塊中的每一個重定位項、即指針,具體就是在指針上加a。而映像中使用的所有絕對地址(包括函數入口、全局量數據的位置)實際上用的都是間接尋址,每個這樣的地址都有個指針存在于某個重定位塊中。
完成了可能需要的EXE映像重定位以后,下一個主要的操作就是LdrFixupImports()了。實際上這才是關鍵所在,它所處理的就是當前模塊所需DLL模塊的裝入和連接。各DLL的程序入口記錄在它們的LDR_DATA_TABLE_ENTRY數據結構中借助InInitializationOrderModuleList隊列就可依次調用所有DLL的初始化函數。
NtHeader的OptionalHeader中有個數組DataDirectory[],其中之一是重定位目錄。除此之外,數組中還有“(普通)引入(import)”、“綁定引入(bound import)”以及其它多種目錄,但是我們在這里只關心“引入”和“綁定引入”。這兩個目錄都是用于庫函數的引入,但是作用不同,目錄項的數據結構也不同。每個引入目錄項都代表著一個被引入模塊,其模塊名、即文件名在dwRVAModuleName(ReactOS中的名字,下同)所指的地方。需要從同一個被引入模塊引入的函數通常有很多個,dwRVAFunctionNameList指向一個字符串數組,數組中的每一個字符串都是一個函數名;與此相對應,dwRVAFunctionAddressList則指向一個指針數組。這兩個數組是平行的,同一個函數在兩個數組中具有相同的下標。從一個被引入模塊中引入一個函數的過程大體上就是:根據函數名在被引入模塊的引出目錄中搜索,找到目標函數以后就把它實際裝入后的入口地址填寫到指針數組中的相應位置上。但是,這個過程可能是個開銷相當大、速度比較慢的過程。為此,又發(fā)展起一種稱為“綁定”的優(yōu)化。
所謂綁定,就是在軟件的編譯,連接過程中先對使用時的動態(tài)連接來一次預演,預演時假定所有的DLL都被裝入到它們的愿望地址上,然后把預演中得到的被引入函數的地址直接記錄在引入者模塊中相應引入目錄下的指針數組中。這樣,使用軟件時的動態(tài)連接就變得很簡單快捷,因為實際上已經事先連接好了。其實“綁定引入”和靜態(tài)連接并無實質的不同。但是,各模塊的版本配套就成為一個問題,因為萬一使用的某個DLL不是當初綁定時的版本,而且其引出目錄又發(fā)生了變化,就有可能引起混亂。為此,PE格式增加了一種“綁定引入”目錄,相關的機制會進行判斷。但是,“綁定引入”畢竟不是很可靠的,萬一發(fā)現版本不符就不能使用原先的綁定了。所以“綁定引入”不能單獨存在,而必須有普通引入作為后備。如果不符就不能按“綁定引入”目錄處理引入,而只好退而求其次,改成按普通“引入”目錄處理引入。另一方面,所謂“綁定”是指當被引入模塊裝入在預定位置上時的地址綁定,如果被引入模塊的裝入位置變了,就得對原先所綁定的地址作相應的調整、即“重定位”。
LdrFixupImports()函數首先從映像頭部獲取指向“引入”目錄和“綁定引入”目錄的指針。若存在“綁定引入”目錄,則先通過LdrpGetOrLoadModule()找到或裝入(映射)被引入模塊的映像。首先當然是在模塊隊列中尋找,找不到就從被引入模塊的磁盤文件裝入。之后檢查綁定版本是否一致,如果不一致就退而求其次,通過LdrpProcessImportDirectory()處理引入。當然,那樣一來效率就要降低了。如果一致,則返回(因為在“預演”中已經連接好,效率當然高了)。而LdrpProcessImportDirectory()才是真正意義上的動態(tài)連接?。。ㄕf了這么多原來才開始……)。
LdrpProcessImportDirectory()首先根據目錄項中的兩個位移量取得分別指向函數名字符串數組和函數指針數組的指針。這兩個數組是平行的(前面有介紹),然后對字符串數組中的元素計數,得到該數組的大小IATSize。顯然,函數指針數組的大小也是IATSize。這里IAT是“引入地址表(Imported Address Table)”的縮寫,其實就是函數指針數組。這個數組在映像內部,其所在的頁面在裝入映像時已被加上寫保護,而下面要做的事正是要改變這些指針的值,所以先要通過NtProtectVirtualMemory()把這些頁面的訪問模式改成可讀可寫。做完這些準備之后,下面就是連接的過程了,那就是根據需要把被引入模塊所引出的函數入口(地址)填寫到引入者模塊的IAT中。與當前模塊中的兩個數組相對應,在被引入模塊的“引出”目錄中也有兩個數組,說明本模塊引出函數的名稱和入口地址(在映像中的位移)。當然,這兩個數組也是平行的。要獲取被引入模塊中的函數入口有兩種方法,即按序號(Ordinal)引入和按函數名引入。從而分別調用LdrGetExportByOrdinal()和LdrGetExportByName()。這兩個函數都返回目標函數在本進程用戶空間中的入口地址,把它填寫入當前模塊引入目錄函數指針數組中的相應元素,就完成了一個函數的連接。當然,同樣的操作要循環(huán)實施于當前模塊需要從給定模塊引入的所有函數,并且(在上一層)循環(huán)實施于所有的被引入模塊。完成了對一個被引入模塊的連接之后,又調用NtProtectVirtualMemory()恢復當前模塊中給定目錄項內函數指針數組所在頁面的保護。
到此,我們大概的清楚Windows Dll的加載與連接過程。
一,首先編寫DLL (建win32空DLL工程)
頭文件.h
?
extern "C" _declspec(dllexport) int Max(int a, int b);???????? //extern "C"解決函數名由于不同編譯器造成的名字匹配問題,通常c++編譯器編譯時會對函數進行改名,而c編譯器不會
extern "C" _declspec(dllexport) int Min(int a, int b);???????? //_declspec(dllexport)說明該函數為導出函數
?
實現文件.cpp
#include"TestDLL.h"
int Max(int a, int b)
{
?return (a > b?a:b);
}
int Min(int a, int b)
{
?return (a > b?b:a);
}
?
二,編程測試DLL的工程(建win32 空工程)
1,靜態(tài)加載dll
a。將dll工程下的dll和lib文件拷到測試工程下
b。同時編寫頭文件.h
extern "C" _declspec(dllimport) int Max(int a, int b);?????????? //_declspec(dllimport)說明函數為導入函數
extern "C" _declspec(dllimport) int Min(int a, int b);
c。編寫實現文件.cpp
#include "test.h"
#include
?
2.動態(tài)加載dll(僅用包含dll文件,同時不需要.h文件和lib文件)
a。將dll工程下的dll文件拷貝到測試工程下
b。編寫實現文件.cpp
#include
int main()
{
?HINSTANCE his =?LoadLibraryA("TestDLL001.dll");??????????????????? //用于加載dll
?typedef int(*pmin)(int a, int b);??????????
?pmin mmi=(pmin)GetProcAddress(his, "Min");????????????????? //GetProcAddress()用于獲得函數地址
?int aa=mmi(3,4);
?cout<<aa<<endl;
?FreeLibrary(his);?????????????????????????????????????????????????? //釋放dll
?system("pause");
?return 0;
}