動態連結庫 —— Dll 基礎
1. DLL 的初識
在 windows 中,動態連結庫是不可缺少的一部分,windows 應用程式程式介面提供的所有函式都包含在 DLL 中,其中有三個非常重要的系統 DLL 檔案,分別為 Kernel32.dll
、User32.dll
和 GDI32.dll
,下面說下這三個重要的 DLL 的用途:
Kernel32.dll
:包含的函式用來管理記憶體、程序以及執行緒。User32.dll
:包含的函式用來執行與使用者介面相關的任務,如建立視窗和傳送訊息。GDI32.dll
:包含的函式用來繪製圖像和顯示文字。
當然,windows 還有其它一些 DLL,用來執行更加專門的任務。比如下面一些 DLL:
AdvAPI32.dll
:包含的函式與物件的安全性、登錄檔的操控以及事件日誌有關。ComDlg32.dll
:包含了一些常用的對話方塊(如開啟檔案和儲存檔案)。ComCtl32.dll
:支援所有常用的視窗控制元件。
2. 為何使用 DLL
下面簡要說下使用 DLL 的一些理由:
- 它們擴充套件了應用程式的特性。
- 它們簡化了專案管理。
- 它們有助了節省記憶體。
- 它們促進了資源的共享。
- 它們促進了本地化。
- 它們有助於解決平臺間的差異。
- 它們可以用於特殊目的(比如 HOOK 安裝某些掛鉤函式)。
3. DLL 和程序的地址空間
建立 DLL 比建立應用程式簡單,DLL 中通常沒有用來處理訊息迴圈或建立視窗的程式碼,DLL 只不過是一組原始碼模組,生成 DLL 檔案時,需給連結器指定 \DLL
如果一個應用程式或者是另外的 DLL 想去呼叫 DLL 裡的函式,則必須將該 DLL 對映到呼叫程序的地址空間去,可以通過兩種方式來呼叫,分別是隱式呼叫和顯示呼叫,這兩種呼叫方式以後會說到。
一旦系統將一個 DLL 的檔案映像對映到呼叫程序的地址空間之後,程序中的所有執行緒就可以呼叫該 DLL 中的函數了。記住,當執行緒呼叫 DLL 中的一個函式的時候,該函式會線上程棧中取得傳給它的引數,並使用執行緒棧來存放它需要的區域性變數。此外,該 DLL 中的函式建立的任何物件都為呼叫執行緒或呼叫程序所擁有 —— DLL 絕對不會擁有任何物件。
4. 縱觀全域性
以上為 DLL 建立過程及應用程式隱式連結到 DLL 的過程,概括了各元件是如何結合到一起的。構建一個 DLL 步驟:
- 必須先建立一個頭檔案,在其包含我們想要在 DLL 中匯出的函式原型、結構以及符號。
- 建立 C/C++ 原始檔來實現想要在 DLL 模組匯出的函式和變數。
- 在構建該 DLL 模組的時候,編譯器會對每個原始檔進行處理併產生一個
.obj
模組(每一個原始檔對應一個.obj
模組)。 - 當所有
.obj
模組都建立完畢後,連結器會將所有.obj
模組的內容合併起來,產生一個單獨的 DLL 映像檔案。 - 如果連結器檢測到 DLL 的原始檔輸出了至少一個函式或變數,那麼連結器還會生一個
.lib
檔案,這個.lib
檔案非常小,這是因為它不包含任何函式或變數。它只是列出了所有被匯出的函式和變數的符號名。
一旦 DLL 構建完成後,那麼我們就可以去構建一個可執行模組來呼叫 DLL 中的函式和變量了,具體呼叫過程如下:
載入程式先為新的程序建立一個虛擬地址空間,並將可執行模組對映到新程序的地址空間中。載入程式接著解析可執行檔案的匯入段,也就是 PE 中的匯入表,對匯入表列出的每個 DLL,載入程式會在使用者的系統中對該 DLL 模組進行定位,並將該 DLL 對映到程序的地址空間中。還要注意的一點就是,由於 DLL 模組可以從其它 DLL 模組中匯入函式和變數,因此 DLL 模組可能有自已的匯入表並需要將它所需的 DLL 模組對映到程序的地址空間中,這一過程可能會耗費更長的時間。一旦載入程式將可執行模組和所有的 DLL 模組對映到程序的地址空間之後,程序的主執行緒可以開始執行,這樣應用程式就能夠運行了。
4.1 構建 DLL 模組
開啟 VS,我這裡用的是 VS2015,新建專案,在 Visual C++ 選項卡下選擇 Win32,右側選擇 Win32 控制檯應用程式,然後給一個名稱,如下:
點選確定後,選擇 DLL,附加選擇空專案,如下:
建立好之後,再建立一個頭檔案和一個原始檔,如下:
然後以 MyDll.h
檔案中輸入如下程式碼:
#pragma once
// extern "C" 修飾符只有在編寫 C++ 程式碼的時候,才會用到此修飾符
// 在編寫 C 程式碼時不應該使用該修飾符,C++ 編譯器通常會對函式名和變數名進行改編
// 如果一個 DLL 是用 C++ 編寫的,而可執行檔案是用 C 編寫的,在構建 DLL 時
// 編譯器會對函式名進行改編,但是在構建可執行檔案時,編譯器不會對函式名進行改編
// 當連結器試圖連結可執行檔案時,會發現可執行檔案引用了一個不存在的符號並報錯
// extern "C" 用來告訴編譯器不要對變數名或函式名進行改編
// 那麼這樣用 C、C++ 或任何程式語言編寫的可執行模組都可以訪問該變數或函式
// 換句話說,是為了防止名稱被粉碎
extern "C" __declspec(dllimport) int g_nResult;
extern "C" __declspec(dllimport) int Add(int nLeft, int nRight);
在 MyDll.cpp
檔案中輸入如下程式碼:
#include <windows.h>
#include "MyDll.h"
int g_nResult;
int Add(int nLeft, int nRight)
{
g_nResult = nLeft + nRight;
return g_nResult;
}
在程式碼完成後,點生成解決方案,這樣它就會生成 Dll 檔案,如下:
其中在標頭檔案中還做了部分註釋,還有部分說明後面再說,我們先在解決方案下再建立一個新的工程來呼叫這個 Dll,這個呼叫是隱式呼叫,需要用到上圖中的 MyDll.dll
、MyDll.lib
這兩個檔案,建立好後,再建立一個 cpp 原始檔,如下:
在 MyDllTest.cpp
檔案中輸入如下程式碼:
#include <iostream>
#include "../MyDll/MyDll.h"
#pragma comment(lib, "../Debug/MyDll.lib")
int main()
{
int nLeft = 10;
int nRight = 25;
std::cout << Add(nLeft, nRight) << std::endl;
return 0;
}
然後我們去編譯連結它,輸出如下:
程式執行後得出了正確的答案,說明呼叫 Dll 中的 Add 函式成功,接下來要說明下程式碼中的意思。extern "C"
這個修飾符已在程式碼註釋中說明,但這裡還需要補充一下額外知識,C 編譯器在對函式編譯後,函式名不會發生改變,而 C++ 編譯器不同,它在對函式編譯後會在原函式名的基礎上加上一個下劃線,在最後面加上 @
符號,其後跟上一個該函式形參所佔用的總共位元組數,比如:
__declspec(dllexport) LONG __stdcall MyFunc(int a, int b);
經過 C++ 編譯器編譯後,該函式名會發生改變,變為 [email protected]
,那 C++ 編譯器為什麼要這麼做呢?原因是在 C++ 中,存在函式過載,而在 C 中不存在函式過載,所以在 C 中無需對函式名稱進行粉碎,為了讓 C++ 編譯器不對函式名改編,需加下 extern "C"
,其實方法也不止這一種,還可以在你專案下建立一個 .def
檔案,寫下如下程式碼:
EXPORTS
MyFunc
接下來要說的是 __declspec(dllimport)
修飾符,當編譯器看到用這個修飾符修飾的變數、函式原型或 C++ 類的時候,會在生成的 .obj
檔案中嵌入一些額外的資訊。當連結器在連結 Dll 所有的 .obj
檔案時,會解析這些資訊。
另外,在連結 Dll 的時候,連結器會檢測到這些與匯出的變數、函式或類有關的嵌入資訊,並生成一個 .lib
檔案。這個 .lib
檔案列出了該Dll 匯出的符號。在連結任何可執行模組的時候,只要可執行模組引用了該 Dll 匯出的符號,這個 .lib
檔案當然是必需的。