DLL导出类和函数

最近研究在DLL中导出类,探寻最佳的DLL导出类的方法和技术。把整个过程记录一下,防止遗忘。

基础知识

动态链接库

动态链接库(英语:Dynamic-link library,缩写为DLL)是微软公司在微软视窗操作系统中实现共享函数库概念的一种实现方式。这些库函数的扩展名是.DLL、.OCX(包含ActiveX控制的库)或者.DRV(旧式的系统驱动程序)。

所谓动态链接,就是把一些经常会共用的代码(静态链接的OBJ程序库)制作成DLL档,当可执行文件调用到DLL档内的函数时,Windows操作系统才会把DLL档加载存储器内,DLL档本身的结构就是可执行档,当程序有需求时函数才进行链接。通过动态链接方式,存储器浪费的情形将可大幅降低。静态链接库则是直接链接到可执行文件。

编写方法

使用DLL导出C函数或全局变量很简单,具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 下列 ifdef 块是创建使从 DLL 导出更简单的
// 宏的标准方法。此 DLL 中的所有文件都是用命令行上定义的 DLLDEMO_EXPORTS
// 符号编译的。在使用此 DLL 的
// 任何其他项目上不应定义此符号。这样,源文件中包含此文件的任何其他项目都会将
// DLLDEMO_API 函数视为是从 DLL 导入的,而此 DLL 则将用此宏定义的
// 符号视为是被导出的。

#ifdef DLLDEMO_EXPORTS
#define DLLDEMO_API __declspec(dllexport)
#else
#define DLLDEMO_API __declspec(dllimport)
#endif

extern "C" extern DLLDEMO_API int nDllDemo;

//不使用extern "C"将导致函数名字改编
DLLDEMO_API int fnDllDemo(int);

extern "C" DLLDEMO_API int fnExternCDllDemo(int);

运行时通知DLL进程/线程加载

进程/线程加载时,可以通过DllMain函数通知DLL相关信息,提供对应处理的机会。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
BOOL WINAPI DLLMain(HINSTANCE hinstDLL,DWORD fdwReason,LPVOID fImpLoad)
{
switch(fdwReason)
{
case DLL_PROCESS_ATTACH:
//当这个DLL第一次被映射到了这个进程的地址空间时。DLLMain函数的返回值为FALSE,说明DLL的初始化没有成功,系统就会终结整个进程,去掉所有文件映象,之后显示一个对话框告诉用户进程不能启动。
break;
case DLL_THREAD_ATTACH:
//一个线程被创建,新创建的线程负责执行这次的DllMain函数。系统不会让进程已经存在的线程以DLL_THREAD_ATTACH的值来调用DllMain函数。主线程永远不会以DLL_THREAD_ATTACH的值来调用DllMain函数。系统是顺序调用DllMain函数的,一个线程执行完DllMain函数才会让另外一个线程执行DllMain函数。
break;
case DLL_THREAD_DETACH:
//如果线程调用了ExitThread来结束线程(线程函数返回时,系统也会自动调用ExitThread)。线程调用了TerminateThread,系统就不会用值DLL_THREAD_DETACH来调用所有DLL的DllMain函数。
break;
case DLL_PROCESS_DETACH:
//这个DLL从进程的地址空间中解除映射。如果进程的终结是因为调用了TerminateProcess,系统就不会用DLL_PROCESS_DETACH来调用DLL的DllMain函数。这就意味着DLL在进程结束前没有机会执行任何清理工作。
break;
}
return(TRUE);
}

DLL的静态调用方法

采用静态调用方法,DLL最终将打包到生成的EXE中。静态调用方法步骤如下[2]

  1. 把你的youApp.DLL拷到你目标工程(需调用youApp.DLL的工程)的Debug目录下;
  2. 把你的youApp.lib拷到你目标工程(需调用youApp.DLL的工程)目录下;
  3. 把你的youApp.h(包含输出函数的定义)拷到你目标工程(需调用youApp.DLL的工程)目
    录下;
  4. 打开你的目标工程选中工程,选择Visual C++的Project主菜单的Settings菜单;
  5. 执行第4步后,VC将会弹出一个对话框,在对话框的多页显示控件中选择Link页。然
    后在Object/library modules输入框中输入:youApp.lib
  6. 选择你的目标工程Head Files加入:youApp.h文件;
  7. 最后在你目标工程(*.cpp,需要调用DLL中的函数)中包含你的:#include “youApp.h”

DLL的动态调用方法

动态调用DLL的步骤:

  1. 创建一个函数指针,其指针数据类型要与调用的DLL引出函数相吻合。
  2. 通过Win32 API函数LoadLibrary()显式的调用DLL,此函数返回DLL的实例句柄。
  3. 通过Win32 API函数GetProcAddress()获取要调用的DLL的函数地址, 把结果赋给自定义函数的指针类型。
  4. 使用函数指针来调用DLL函数。
  5. 最后调用完成后,通过Win32 API函数FreeLibrary()释放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
int main()
{
HMODULE hModule = LoadLibrary(_T("DllDemo.dll"));
typedef int(*TYPE_fnDllDemo) (int);//定义函数指针
typedef int(*TYPE_fnExternCDllDemo) (int);//定义函数指针
//创建类对象
CDllDemo* pCDllDemo = (CDllDemo*)malloc(sizeof(CDllDemo));

TYPE_fnDllDemo fnDllDemo = (TYPE_fnDllDemo)GetProcAddress(hModule, "?fnDllDemo@@YAHH@Z");
int *nDllDemo = (int *)GetProcAddress(hModule, "nDllDemo");
TYPE_fnExternCDllDemo fnExternCDllDemo = (TYPE_fnExternCDllDemo)GetProcAddress(hModule, "fnExternCDllDemo");

if (pCDllDemo != NULL)
// printf("pCDllDemo->Max(32,42) = %d\n", pCDllDemo->Max(32, 42));//Dll导出类的调用太麻烦,因为DLL本来就是为C函数服务设计的。
if (fnDllDemo != NULL)
printf("fnDllDemo(32) = %d\n", fnDllDemo(32));
if (nDllDemo != NULL)
printf("*nDllDemo = %d\n", *nDllDemo);
if (fnExternCDllDemo != NULL)
printf("fnExternCDllDemo(22) = %d\n", fnExternCDllDemo(22));
_tsystem(_T("pause"));
FreeLibrary(hModule);
return 0;
}

COM技术

COM主要是一套给C/C++用的接口,当然为了微软的野心,它也被推广到了VB、Delphi以及其他一大堆奇奇怪怪的平台上。它主要为了使用dll发布基于interface的接口。我们知道dll的接口是为了C设计的,它导出的基本都是C的函数,从原理上来说,将dll加载到内存之后,会告诉你一组函数的地址,你自己call进去就可以调用相应的函数[3]

但是对于C++来说这个事情就头疼了,现在假设你有一个类,我们知道使用一个类的第一步是创建这个类:new MyClass()。这里直接就出问题了,new方法通过编译器计算MyClass的大小来分配相应的内存空间,但是如果库升级了,相应的类可能会增加新的成员,大小就变了,那么使用旧的定义分配出来的空间就不能在新的库当中使用。

要解决这问题,我们必须在dll当中导出一个CreateObject的方法,用来代替构造函数,然后返回一个接口。然而,接口的定义在不同版本当中也是有可能会变化的,为了兼容以前的版本同时也提供新功能,还需要让这个对象可以返回不同版本的接口。接口其实是一个只有纯虚函数的C++类,不过对它进行了一些改造来兼容C和其他一些编程语言。

在这样改造之后,出问题的还有析构过程~MyClass()或者说delete myClass,因为同一个对象可能返回了很多个接口,有些接口还在被使用,如果其中一个被人delete了,其他接口都会出错,所以又引入了引用计数,来让许多人可以共享同一个对象。

其实到此为止也并不算是很奇怪的技术,我们用C++有的时候也会使用Factory方法来代替构造函数实现某些特殊的多态,也会用引用计数等等。COM技术的奇怪地方在于微软实在是脑洞太大了,它们构造了一个操作系统级别的Factory,规定所有人的Interface都统一用UUID来标识,以后想要哪个Interface只要报出UUID来就行了。这样甚至连链接到特定的dll都省了。

这就好比一个COM程序员,只要他在Windows平台上,调用别的库就只要首先翻一下魔导书,查到了一个用奇怪文字写的“Excel = {xxx-xxx-xxxx…}”的记号,然后它只要对着空中喊一声:“召唤,Excel!CoCreateInstance, {xxx-xxx-xxxx…}”然后呼的从魔法阵里面窜出来了一个怪物,它长什么样我们完全看不清,因为这时候它的类型是IUnknow,这是脑洞奇大无比的微软为所有接口设计的一个基类。

我们需要进一步要求它变成我们能控制的接口形态,于是我们再喊下一条指令:“变身,Excel 2003形态!QueryInterface, {xxx-xxx-xxxx…}”QueryInterface使用的是另一个UUID,用来表示不同版本的接口。于是怪物就变成了我们需要的Excel 2003接口,虽然我们不知道它实际上是2003还是2007还是更高版本。等我们使唤完这只召唤兽,我们就会对它说“回去吧,召唤兽!Release!”但是它不一定听话,因为之前给它的命令也许还没有执行完,它会忠诚地等到执行完再回去,当然我们并不关心这些细节。

微软大概会觉得自己设计出了软件史上最完美的二进制接口,从今以后所有的第三方库都可以涵盖在这套接口之下。然而历史的车轮是无情的,它碾过那些自以为是的人的速度总是会比想象的更快。Java的直接基于类的接口被广泛应用,开发使用起来远远来的简单,即便偶尔出点问题大家也都想办法解决了,事实证明程序员并不愿意花10倍的编写代码的时间来解决二进制库的版本兼容问题,他们更愿意假装没看见。很快微软也抄了一个.NET托管dll的方案出来,于是纯的二进制接口COM就慢慢被抛弃了。

COM,OLE,ActiveX,OCX,VBScript,历史不会忘记你们的,如果历史忘了,我替历史记住你们。安息吧。

DLL导出类

借鉴COM技术,这里直接给出DLL到处类的成熟方法,可有效避免DLL地狱问题。具体结构为:

导出类是一个派生类,派生自一个抽象类——都是纯虚函数。使用者需要知道这个抽象类的结构。DLL最少只需要提供一个用于获取类对象指针的接口。使用者跟DLL提供者共用一个抽象类的头文件,使用者依赖于DLL的东西很少,只需要知道抽象类的接口,以及获取对象指针的导出函数,对象内存空间的申请是在DLL模块中做的,释放也在DLL模块中完成,最后记得要调用释放对象的函数。

这种方式比较好,通用,产生的DLL没有特定环境限制。借助了C++类的虚函数。一般都是采用这种方式。除了对DLL导出类有好处外,采用接口跟实现分离,可以使得工程的结构更清晰,使用者只需要知道接口,而不需要知道实现。

具体代码如下:

  1. 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
    47
    48
    49
    50
    51
    //DLL导出类头文件MatureApproach.h,与DLL使用者共享
    #ifdef MATUREAPPROACH_EXPORTS
    #define MATUREAPPROACH_API __declspec(dllexport)
    #else
    #define MATUREAPPROACH_API __declspec(dllimport)
    #endif

    class IExport
    {
    public:
    virtual void Hi() = 0;
    virtual void Test() = 0;
    virtual void Release() = 0;
    };


    extern "C" MATUREAPPROACH_API IExport* _stdcall CreateExportObj();
    extern "C" MATUREAPPROACH_API void _stdcall DestroyExportObj(IExport* pExport);

    //DLL导出接口函数的实现MatureApproach.cpp
    #include "stdafx.h"
    #include "MatureApproach.h"
    #include "ExportClassImpl.h"

    BOOL APIENTRY DllMain( HMODULE hModule,
    DWORD ul_reason_for_call,
    LPVOID lpReserved
    )
    {
    switch (ul_reason_for_call)
    {
    case DLL_PROCESS_ATTACH:
    case DLL_THREAD_ATTACH:
    case DLL_THREAD_DETACH:
    case DLL_PROCESS_DETACH:
    break;
    }
    return TRUE;
    }

    MATUREAPPROACH_API IExport* APIENTRY CreateExportObj()
    {
    return new ExportImpl;
    }


    //这里不能直接delete pExport,因为没有把IExport的析构函数定义为虚函数
    MATUREAPPROACH_API void APIENTRY DestroyExportObj(IExport* pExport)
    {
    pExport->Release();
    }
  2. 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
//DLL导出类头文件ExportClassImpl.h
#include "MatureApproach.h"

class ExportImpl : public IExport
{
public:
virtual void Hi();
virtual void Test();
virtual void Release();
~ExportImpl();
private:
};

//DLL导出类的实现ExportClassImpl.cpp
#include "stdafx.h"
#include "ExportClassImpl.h"

void ExportImpl::Hi()
{
wcout << L"Hello World" << endl;
}

void ExportImpl::Test()
{
wcout << L"Hi cswuyg" << endl;
}

void ExportImpl::Release()
{
delete this;
}

ExportImpl::~ExportImpl()
{
cout << "Release OK" << endl;
}

Dll导出C++类的3种方式

Using pure C (纯C语言方式)

这种方式类似与Win32的窗口句柄,用户将句柄作为参数传递给函数,并对对象执行各种操作。存在以下缺点:

  1. 调用创建对象函数的时候编译器无法判断类型是否匹配
  2. 需要手动调用Release函数,一旦忘记则会造成内存泄露
  3. 如果导出的函数的参数支持除基本数据类型以外的其他类型的参数(例如:class),则也得为这些类型提供接口。

Using a regular C++ class (C++直接导出类)

缺点:

  1. 这种方式虽然简单易用,但是局限性很大,而且后期维护会很麻烦,除了导出的东西太多、使用者对类的实现依赖太多之外,还有其它问题:必须保证使用同一种编译器。导出类的本质是导出类里的函数,因为语法上直接导出了类,没有对函数的调用方式、重命名进行设置,导致了产生的dll并不通用。
  2. Dll地狱问题:

Using an abstract C++ interface (使用抽象接口方式)

C++抽象接口(仅包含纯虚函数且不包含数据成员的C++类)同时兼顾以下两个方面:与对象无关的纯净接口,以及方便的的面向对象的调用方式。

推荐使用该种方式导出类库。

优点:

  • 导出的C++类可以通过抽象接口与任何C++编译器一起使用。
  • DLL的C运行时库和客户端彼此独立。因为资源获取和释放完全发生在DLL模块内部,客户端不受DLL内部改变的影响。
  • 实现了真正的模块分离。可以重新设计和重建生成的DLL模块,而不会影响项目的其余部分。
  • 如果需要,可以将DLL模块轻松转换为成熟的COM模块。

缺点:

  • 创建新对象实例并将其删除需要显式函数调用。但是,智能指针可以解决。

  • 抽象接口方法不能返回或接受常规C++对象作为参数。它是A内置类型(如int,double,char*等)或另一抽象接口。它与C接口的限制相同。

参考链接

  1. 动态链接库, by wikipedia
  2. C++调用DLL有两种方法——静态调用和动态调用,by 特洛伊-Micro
  3. 怎么通俗的解释COM组件?, by 灵剑.
  4. 编写DLL所学所思(2)——导出类,by 烛秋.
  5. Visibility,by Niall Douglas.
  6. Linux 編譯 shared library 的方法和注意事項,by fcamel.
  7. GCC制作Library–shared部分相当不错,by kk.
  8. [原创]Dll导出C++类的3种方式(多干货) ,by Jmsrwt.
  9. C++类库开发详解,by 奔跑的小河.
  10. C++ DLL导出类 知识大全,by 三小.
  11. std::vector needs to have dll-interface to be used by clients of class ‘X warning,by stackoverflow.