釋出
2014年1月25日(最後更新:2014年4月10日)

製作外掛系統

評分:4.1/5(485票)
*****
現在,假設您正在編寫一個程式,可能是一款遊戲,並且您希望在不干預的情況下使其具有可修改性。當然,您會思考這如何實現,並且不用強迫使用者直接將程式碼注入您的可執行檔案,或者直接修改原始碼。您將如何做到這一點?

嗯,答案當然是一個外掛系統。我將簡要解釋它是如何工作的:外掛系統只是搜尋一個指定的資料夾以查詢 DLL(或其他類似檔案),如果找到任何檔案,則將內容新增到程式中。當然,由於程式實際上不知道 DLL 中包含什麼,所以通常的做法是讓 DLL 定義一個由程式本身定義的入口點和呼叫函式,然後程式可以使用這些 DLL 中暴露的功能。如何做到這一點取決於您,無論是定義要實現的函式,還是讓 DLL 提供一個基類的例項,然後使用該例項的功能。在本文中,我將簡要演示這兩種選項。不過,首先,讓我們看看如何實際載入庫。



載入庫


好了,讓我們從基礎開始。要在執行時載入 DLL,只需呼叫 LoadLibrary,引數是要載入的 DLL 的檔案路徑。但是,當您想到這一點時,這並沒有多大幫助,對吧?我們想載入可變數量的 DLL,它們的名稱在編譯時無法得知。所以,這意味著我們需要找到所有是外掛的 DLL,然後載入它們。

現在,最簡單的方法是使用 WinAPI 的 FindFile 函式,使用檔案掩碼來收集所有 .dll 檔案。不過,這可能會遇到一個問題,那就是您可能會嘗試載入您的程式需要執行的 DLL!這就是程式通常有一個“plugins”資料夾的原因:如果您嘗試從程式的目錄載入所有 DLL,您可能會開始嘗試載入非外掛 DLL。將它們分離到一個指定的外掛資料夾有助於防止這種情況發生。

好了,說得夠多了,這裡有一些示例程式碼,演示如何遍歷目錄中的所有檔案並載入每個檔案的值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// A list to store our DLL handles
std::vector<HINSTANCE> modules;
// The data for each file we find.
WIN32_FIND_DATA fileData;

// Find the first DLL file in out plugins folder,
// and store the data in out fileData structure.
HANDLE fileHandle = FindFirstFile(R"(.\plugins\*.dll)", &fileData);

if (fileHandle == (void*)ERROR_INVALID_HANDLE ||
    fileHandle == (void*)ERROR_FILE_NOT_FOUND) {
    // We couldn't find any plugins, lets just
    // return for now (imagine this is in main)
    return 0;
}

// Loop over every plugin in the folder, and store
// the handle in our modules list
do {
    // Load the plugin. We need to condense the plugin
    // name and the path together to correctly load the
    // file (There are other ways, I won't get into it here)
    HINSTANCE temp = LoadLibrary((R"(.\plugins\)" + 
                         std::string(fileData.cFileName)) .c_str());

    if (!temp) {
        // Couldn't load the library, continue on
        cerr << "Couldn't load library " << fileData.cFileName << "!\n";
        continue;
    }

    // Add the loaded module to our list of modules
    modules.push_back(temp);
// Continue while there are more files to find
} while (FindNextFile(fileHandle, &fileData));


好了,這相當複雜。我只是想現在提一下,您需要一個 C++11 編譯器才能編譯這些示例,否則像原始字串字面量之類的一些東西將無法編譯。另外,如果您使用 Unicode 編譯器,您將需要指定它正在使用寬字串。

現在,我們已經載入了所有外掛,但是如果我們完成工作後不釋放它們,我們將導致記憶體洩漏,這在大型專案中可能成為一個真正的問題。但是,因為我們將所有控制代碼都儲存在 vector 中,所以釋放它們實際上並不難。
1
2
for (HINSTANCE hInst : modules)
    FreeLibrary(hInst);




實際使用我們的庫


好的,現在我們可以載入庫了。問題是,它實際上還沒有做任何事情。讓我們改變一下。首先,我們應該為 DLL 定義一個頭檔案,以便它們可以包含:這定義了我們希望它們匯出的函式和類。我決定在這裡展示兩件事:如何匯出多型類以及如何匯出函式。一旦您掌握了想法,大多數事情就很容易了。總之,讓我們定義我們的標頭檔案。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#ifndef __MAIN_HPP_INCLUDED__
#define __MAIN_HPP_INCLUDED__

// includes we need
#include <string>
#include <memory>

// Test to see if we are building a DLL.
// If we are, specify that we are exporting
// to the DLL, otherwise don't worry (we
// will manually import the functions).
#ifdef BUILD_DLL
    #define DLLAPI __declspec(dllexport)
#else
    #define DLLAPI
#endif // BUILD_DLL

// This is the base class for the class
// retrieved from the DLL. This is used simply
// so that I can show how various types should
// be retrieved from a DLL. This class is to
// show how derived classes can be taken from
// a DLL.
class Base {
public:
    // Make sure we call the derived classes destructors
    virtual ~Base() = default;

    // Pure virtual print function, effect specific to DLL
    virtual void print(void) = 0;

    // Pure virtual function to calculate something, 
    // according to an unknown set of rules.
    virtual double calc(double val) = 0;
};


// Get an instance of the derived class
// contained in the DLL.
DLLAPI std::unique_ptr<Base> getObj(void);

// Get the name of the plugin. This can
// be used in various associated messages.
DLLAPI std::string getName(void);

#endif // __MAIN_HPP_INCLUDED__ 


現在,到了複雜的部分。我們需要從我們之前載入的 DLL 中載入這些函式。用於此目的的函式稱為 GetProcAddress(),它返回一個指向 DLL 中具有您指定的名稱的函式的指標。但是,由於它不知道它獲取的函式型別,我們需要將返回的指標顯式轉換為適當型別的函式指標。將此程式碼新增到之前的示例中。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
do {
    ...

    modules.push_back(temp);
    
    // Typedefs for the functions. Don't worry about the
    // __cdecl, that is for the name mangling (look up
    // that if you are interested).
    typedef std::unique_ptr<Base> (__cdecl *ObjProc)(void);
    typedef std::string (__cdecl *NameProc)(void);

    // Load the functions. This may or may not work, based on
    // your compiler. If your compiler created a '.def' file
    // with your DLL, copy the function names from that to
    // these functions. Look up 'name mangling' if you want
    // to know why this happens.
    ObjProc objFunc = (ObjProc)GetProcAddress(temp, "_Z6getObjv");
    NameProc nameFunc = (NameProc)GetProcAddress(temp, "_Z7getNamev");

    // use them!
    std::cout << "Plugin " << nameFunc() << " loaded!\n";
    std::unique_ptr<Base> obj = objFunc();
    obj->print();
    std::cout << "\t" << obj->calc() << std::endl;
} while (...);


載入和使用外掛就是這樣!您可能希望將物件/名稱儲存在自己的列表中,但這並不重要,這只是一個示例。



構建外掛


現在,還有最後一件事:實際構建外掛。相比之下,這非常簡單。您需要 #include "main.hpp" 以獲取類,然後簡單地實現函式。您需要注意的唯一一件事是 main() 函式:首先,它實際上不再稱為 main 了!這裡只是一個基本的 main 函式(您通常不需要比這更多)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
extern "C" DLLAPI BOOL APIENTRY DllMain(HINSTANCE hInst, 
                                        DWORD reason, 
                                        LPVOID reserved)
{
    switch (reason) {
        case DLL_PROCESS_ATTACH:
            // attach to process, return FALSE to fail
        break;

        case DLL_PROCESS_DETACH:
            // detaching from process
        break;

        case DLL_THREAD_ATTACH:
            // attach to thread within process
        break;

        case DLL_THREAD_DETACH:
            // detach from thread within process
        break;
    }

    // return success
    return TRUE;
}




獲取原始碼

在這裡,我提供了一個原始碼連結以方便您使用。這是使用 MinGW-w64 工具鏈編譯的,但它應該適用於大多數 Windows 編譯器。這些示例需要 C++11 支援(主要是原始字串字面量和 std::unique_ptr),並且考慮到 ANSI 編碼(而不是 Unicode)而開發。這應該不會有太大區別,只需將字串字面量更改為長字串字面量,然後使用寬字串(std::wstring)即可。

關於專案檔案,不幸的是我無法提供全部,只有一個 GCC makefile。要了解如何使用您的編譯器編譯專案,請檢視該編譯器的文件。可能您最不知道的資訊是如何生成 DLL,以及在編譯時如何定義符號。

下載原始碼


最後的 Remarks

如果您不理解本文中的任何內容,請首先檢視 MSDN(Microsoft 開發者網路)。事實上,我將引用 MSDN 作為本文的主要資訊來源。這裡有一些您可能感興趣的相關頁面的連結。

DLL 載入函式
檔案相關函式
如果本文中有任何不清楚的地方、您發現的錯誤或您對本文更新有任何建議,請告訴我!



更新 - 在不同編譯器中使用外掛系統

由於遇到了問題,我決定稍微更新本文。以上內容保持不變,但是。基本上,我將介紹如何解決使用其他編譯器構建的 DLL 的問題。

問題

如果您使用不同的編譯器、編譯器的不同版本,甚至同一編譯器的不同設定,DLL 的生成方式都會不同,並可能導致與其連結的應用程式崩潰。這是因為 C++不是二進位制標準化的 - 即,不同編譯器上的相同原始碼沒有要求以相同的方式執行。特別是 C++ 標準庫,不同的編譯器可能有不同的實現,這可能導致程式出現問題。

在編譯器之間可能更改的另一件事(通常,它更改)是函式的名稱修飾。在上面的示例中,函式 getObj 被替換為以下名稱:_Z6getObjv。但是,這個特定的名稱取決於生成它的編譯器:這個名稱來自 MinGW 編譯器,MSVS 編譯器會生成不同的名稱,Intel 編譯器會生成另一個名稱。這也可能導致問題。

一些解決方案

對於上述問題,有幾種解決方案。第一個(非解決方案)是始終使用相同的編譯器。如果您或您的公司是此應用程式的唯一外掛提供者,那麼使用相同的編譯器設定很有用,這樣您就可以確保與匯出主應用程式時使用的編譯器設定相同。

另一個解決方案是避免使用標準庫。標準庫非常有用,但由於實現不同,它可能在使用物件時導致問題:我的編譯器std::string和另一個編譯器的std::string可能看起來行為相同,但實際上內部可能非常不同,所以使用一個而不是另一個可能會導致問題。可能的解決方法是傳遞與物件關聯的原始資料,而不是物件本身。

例如,您仍然可以使用std::stringstd::vector<int>在您的程式中,但對於匯出的介面,您將傳遞一個 const char* 或一個 int*,並在過程中進行轉換。

這就引出了最後一個問題:名稱修飾。C++ 編譯器如果設定了不同的選項(例如最佳化級別或除錯/釋出版本),通常會以不同的方式修飾函式和變數的名稱。但是,C 編譯器不進行名稱修飾,這意味著函式名稱不會根據編譯器選項而改變。以下是宣告我們正在匯出具有“C”連結的函式的方法。
1
2
3
4
5
6
7
8
9
10
#ifdef __cplusplus  // if we are compiling C++
extern "C" {        // export the functions with C linkage
#endif

// ... your DLL exported functions, e.g.
const char* getName(void);

#ifdef __cplusplus
}
#endif 

然後,在實現函式時,您需要指定您也為它們使用了 C 連結。
1
2
3
4
5
6
7
8
9
10
11
// ...

const std::string pluginName = "TestPlugin";

extern "C"
const char* getName(void) {
    // just extrapolating on information given above: we can still
    // use the C++ Standard Library within functions, just you can't
    // pass them as the return value or function arguments
    return pluginName.c_str();
}


這是告訴編譯器使用 C 連結,這通常可以確保所有編譯器以相同的方式看到函式,並且還有一個附帶的好處是擺脫了許多可能出現的奇怪符號。當然,這意味著您只能匯出 C 風格的函式和結構,但這卻是為了獲得相容性而付出的代價。

附件:[plugin-src.zip]