发布
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]