1. 程式人生 > >動態連結庫 —— Dll 基礎

動態連結庫 —— Dll 基礎

1. DLL 的初識

  在 windows 中,動態連結庫是不可缺少的一部分,windows 應用程式程式介面提供的所有函式都包含在 DLL 中,其中有三個非常重要的系統 DLL 檔案,分別為 Kernel32.dllUser32.dllGDI32.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 檔案映像中儲存一些與可執行檔案略微不同的資訊,這樣 windows 載入器在載入它們時容易將它們區分開(PE 檔案頭結構中的檔案屬性欄位會指出)。

  如果一個應用程式或者是另外的 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.dllMyDll.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 檔案當然是必需的。