DLL导出函数和类

1 基本概念

1.1 动态链接库

库是写好的现有的,并且可以复用的代码。现实开发中每个程序都要依赖很多基础的底层库,不可能每个功能都从零开始开发,因此库是必须存在的。库的本质是一种可执行的二进制文件,可以被操作系统加载到内存中执行,库有两种:静态链接库(简称为静态库)和动态链接库(简称为动态库),所谓静态、动态是指链接,一个程序编译成可执行文件步骤如下图所示:
在这里插入图片描述

静态链接库在内存中会存在多份拷贝导致空间浪费,比如一个静态库占用1M内存,如果有2000个程序调用这个静态库,将会占用接近2G的空间;其次,静态库不易维护和扩展,比如某静态库更新了,那么使用它的应用程序都需要重新编译并发布给用户。

基于静态链接库这些特点,动态链接库就诞生了,首先动态库在编译时不会链接到目标代码中,而是在程序运行时才会被载入,因此当不同的应用程序调用同一个动态库时,那么内存只会存在一份拷贝,避免了静态库的空间浪费问题,同时也容易维护和扩展,比如动态库更新了,使用它的应用程序不需要重新编译就能使用。

使用中所需文件优点缺点
动态链接库隐式加载: .h /.lib/.dll
显式加载:.dll
1.可执行文件的体积小
2.内存占用小
3.易维护和扩展
加载速度慢
静态链接库.h /.lib加载速度快1.可执行文件的体积大
2.空间浪费
3.不易维护和扩展

因此,动态链接库(即DLL,Dynamic Link Library)是一个包含由多个程序同时使用的代码和数据的库,DLL文件中存放的是各类程序的函数实现过程,当程序需要调用函数时需要载入DLL,当DLL被载入到内存中后,并不是直接被拷贝到可执行文件的进程地址空间中,而是通过内存映射将DLL映射到进程地址空间,在物理内存中只存在一份DLL,同时也复制该DLL的全局数据的一份拷贝到该进程空间,即每个进程都拥有相同的DLL全局数据,其名称相同但是值不一定相同,而且互不干涉,然后获得函数的地址,最后进行调用。

动态链接库有两种加载方式:隐式加载和显式加载。

①隐式加载又称为载入时加载,指主程序载入内存时搜索DLL,并将DLL载入内存。如果程序需要访问十几个DLL,那么在程序启动时,这些DLL都需要加载到内存中,并映射到调用进程的地址空间,加载时间就会过长,用户就会难以接受,比如双击打开一个软件,要很久才能看到界面。

②显式加载又称为运行时加载,指主程序在运行过程中需要DLL中的函数时再加载。显式加载是将较大的程序分开加载的。

下面对DLL隐式加载和显式加载进行详细说明。

1.2 DLL隐式加载方法

采用隐式加载的方法,DLL最终将打包到生成的EXE中。DLL隐式加载步骤如下:

  1. 把你的youApp.dll拷到你目标工程(需调用youApp.dll的工程)的Debug目录下;
  2. 把你的youApp.lib拷到你目标工程(需调用youApp.dll的工程)目录下;
  3. 把你的youApp.h(包含输出函数的定义)拷到你目标工程(需调用youApp.DLL的工程)目录下;

以上三步:隐式加载需要.dll/.lib/.h三个文件,.lib文件包含DLL导出的函数和变量的符号名,只是用来为链接程序提供必要的信息,以便在链接时找到函数或变量的入口地址,.dll文件包含了实际的函数和数据。

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

.lib是一种文件名后缀,代表的是静态数据连接库,在windows操作系统中起到链接程序和函数(或子过程)的作用,相当于Linux中的.a或.o、.so文件,LIB文件中存放的是函数调用的信息。

无论是动态链接库还是静态链接库,都有lib文件,但是两者不是同一个东西。 对于静态链接库对应的lib文件叫静态库,其包含了实际执行代码、符号表等;动态库对应的lib文件叫导入库,其实际的代码位于动态库中,而导入库只包含了地址符号表,用来确保程序找到对应函数的一些基本地址信息。这就是为啥静态库的lib比动态库的lib要大很多的原因。

1.3 DLL显式加载方法

在Windows下动态调用DLL的步骤:

  1. 创建一个函数指针,其指针数据类型要与调用的DLL引出函数相吻合。
  2. 通过Win32 API函数LoadLibrary()显式的调用DLL,此函数返回DLL的实例句柄。
  3. 通过Win32 API函数GetProcAddress()获取要调用的DLL的函数地址, 把结果赋给自定义函数的指针类型。
  4. 使用函数指针来调用DLL函数。
  5. 最后调用完成后,通过Win32 API函数FreeLibrary()释放DLL函数。

以上:显式加载只需要.dll文件,因为显式加载是用过指针来调用DLL函数,编译器不生成外部引用,因此不需要.lib文件。

代码示例如下:

int main()
{
	HMODULE hModule = LoadLibrary(_T("DllDemo.dll"));  //动态调用dll
	typedef int(*TYPE_fnDllDemo) (int);//定义函数指针  
	typedef int(*TYPE_fnExternCDllDemo) (int);//定义函数指针  
    
	//创建类对象
	CDllDemo* pCDllDemo = (CDllDemo*)malloc(sizeof(CDllDemo));
	//获取要引入的"?fnDllDemo@@YAHH@Z"函数
	TYPE_fnDllDemo fnDllDemo = (TYPE_fnDllDemo)GetProcAddress(hModule, "?fnDllDemo@@YAHH@Z");  
    //获取要引入的"nDllDemo"函数
	int *nDllDemo = (int *)GetProcAddress(hModule, "nDllDemo"); 
    //获取要引入的"fnExternCDllDemo"函数
	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;
}

动态调用动态链接库时:

① 调用LoadLibrary()以加载DLL和获取模块句柄,该函数作用是将指定的可执行模块映射到调用进程的地址空间,LoadLibrary()函数的原型声明如下所示:

HMODULE  LoadLibrary(LPCTSTR 1pFileName); 
//加载成功则返回其加载模块的句柄,返回类型是HMODULE

② 调用 GetProcAddress() 以获取指向应用程序要调用的每个导出函数的函数指针,由于应用程序是通过指针调用DLL的函数,编译器不生成外部引用,故无需与导入库链接,GetProcAddress()函数的原型声明如下所示:

FARPROC  GetProcAddress(HMODULE hModule, LPCSTR 1pProcName);
//hModule:指定动态链接库模块的句柄,即 LoadLibrary() 函数的返回值。
//1pProcName:字符串指针,表示DLL中函数的名字。

③使用完DLL后调用 FreeLibrary()

2 DLL导出函数

DLL导出函数的声明有两种方式:一种是在函数声明中加上__declspec(dllexport),另一种是采用模块定义(.def)文件声明,(.def)文件为链接器提供了有关被链接程序的导出、属性及其他方面的信息。

这两种方式的主要区别是导出函数的名字上,其次还有一些操作的灵活性以及功能的强弱。

2.1 在函数声明中加上__declspec(dllexport)关键字

DLL可以通过在函数声明中加上__declspec(dllexport)导出函数,代码如下:

// 下面 ifdef 块是创建宏的标准方法,这样更容易的从DLL中导出函数
// 此DLL中的所有函数都是用命令行上定义的DLLDEMO_EXPORTS符号编译的。
// 因此,源文件中包含此文件的任何其他项目都会将
// DLLDEMO_API 函数视为是从该DLL导入的

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

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

extern "C" DLLDEMO_API int fnExternCDllDemo(int);

使用extern "C"和不使用extern "C"会导致DLL导出的函数名字有时会不相同,原因是和编译DLL时指定DLL导出函数的界定符有关,影响编译后导出的函数输出的名称不仅与名字修饰约定(extern "C"extern "C++"等)有关,还和函数调用约定(__stdcall__cdecl等)有关。

导出的函数名和实际函数名存在差异,如果在exe中显式加载(LoadLibrary()GetProcAddress()Func_cdecl()或者Func_stdcall()函数肯定会失败;如果是隐式加载的话,因为编译器自动处理转换函数名,则没有问题。

因此,名字修饰约定和函数调用约定只会对显式加载有影响。

我们可以用代码进行测试一下:

首先用c的方式导出两个函数:

我们用VS2019新建一个叫TestDLL的DLL工程,把默认的源文件后缀.cpp改为.c(c文件)

输入的测试代码如下:

_declspec(dllexport) int __cdecl Func_cdecl(int a,int b)
{
	return 1;
}
 
_declspec(dllexport) int __stdcall Func_stdcall(int a,int b)
{
	return 1;
}

通过Dependency查看DLL导出函数的名字

在这里插入图片描述

_stdcall会使导出的函数名字前面加一个下划线,名字后面加一个@再加上参数的字节数。

再以C++方式导出两个函数:

同样的,我们VS2019新建一个叫TestDLL的DLL工程,把默认的源文件后缀.cpp(c++文件)。

输入相同的测试代码如下:

_declspec(dllexport) int __cdecl Func_cdecl(int a,int b)
{
	return 1;
}
 
_declspec(dllexport) int __stdcall Func_stdcall(int a,int b)
{
	return 1;
}

用Dependency查看导出的函数:

在这里插入图片描述

加入extern "C"extern "C++"后,再以C++方式导出以下函数:

extern "C" _declspec(dllexport) int __stdcall Func_C_stdcall(int a,int b)
{
	return 1;
}
 
extern "C++" _declspec(dllexport) int __stdcall Func_CPP_stdcall(int a,int b)
{
	return 1;
}
 
extern "C" _declspec(dllexport) int __cdecl Func_C_cdecl(int a,int b)
{
	return 1;
}
 
extern "C++" _declspec(dllexport) int __cdecl Func_CPP_cdecl(int a,int b)
{
	return 1;
}

用Dependency查看导出的函数:
在这里插入图片描述

summary:

①c方式编译(extern "C"

_stdcall调用约定:输出名称在原名称前加一下划线,后面再加上一个“@”和其参数的总字节数

__cdecl调用约定:与原名称相同

②C++方式编译(extern "C++"

__stdcall调用约定:输出名称以“?”开始,后跟原名称,原名称后再跟“@@YG”,后面再跟返回值代号和参数表代号

_ _ cdecl调用约定:与_stdcall调用约定基本一致,只是参数表的开始标识由上面的“@@YG”变为“@@YA”。

以上,不同的编译器使用的改变规则不一样,因此改变后的函数名也是不同的(一般涉及到C++的重载等),编译c文件默认的是extern "C",不需要额外的加extern "C",编译c++文件时默认的是extern "C++",不需要额外加extern "C++"

如果希望编译c++后的名字不发生改变,可以在定义导出函数时加上限定符extern "C",且采用__cdecl调用约定,这样显式加载dll就不会出问题。

extern "C"只是解决了C和C++语言之间调用的问题(即extern "C"告诉编译器,让它按照C的方式进行编译),它只能用于导出全局函数这种情况,而不能导出一个类的成员函数。

2.2 使用传统的模块定义def文件方法

def文件方法相对于上面的__declspec(dllexport)关键字使用上和理解上都更简单。

首先我们需要在创建dll头文件中声明函数:

extern "C"{
    int __stdcall Add_stdcall(int a,int b);
    int __cdecl Add_cdecl(int a,int b); 
}

我们需要新建一个def文件并添加到项目工程中,在该文件中添加”EXPORTS“关键字,表示dll导出函数的位置,然后在 “EXPORTS” 字段下面添加要导出函数的名称即可。如下:

LIBRARY "DLLTestDef"  //说明def文件对应哪一个dll
EXPORTS         //后面列出了要导出函数的名称         
Add_stdcall @1          //函数名@n,其中n表示要导出函数的序号
Add_cdecl @2

同样的,我们对__stdcall__cdecl进行测试,看导出函数名是否改变,测试结果表示均为改变。

其实def文件的功能相当于extern “C” __declspec(dllexport)

3 DLL导出类

这里举一个例子,源代码由两个项目组成

XyzLibrary  //一个 DLL 库项目
XyzExecutable  //一个使用“ XyzLibrary.dll ”的 Win32 控制台程序

XyzLibrary的对象是Xyz,只有一种方法int Foo(int)

XyzLibrary项目通过以下宏方式导出代码:

#if defined(XYZLIBRARY_EXPORT) // inside DLL
#   define XYZAPI   __declspec(dllexport)
#else // outside DLL
#   define XYZAPI   __declspec(dllimport)
#endif  // XYZLIBRARY_EXPORT

3.1 纯C的方式导出

这种方式类似与Win32的窗口句柄,用户将句柄作为参数传递给函数,并对对象执行各种操作。创建一个Xyz对象通过c接口导出,如下所示:

typedef tagXYZHANDLE {} * XYZHANDLE;

// 返回的是XYZHANDLE类的Xyz对象
XYZAPI XYZHANDLE APIENTRY GetXyz(VOID);

// 调用 Xyz.Foo 方法
XYZAPI INT APIENTRY XyzFoo(XYZHANDLE handle, INT n);
// 释放对象和相关资源
XYZAPI VOID APIENTRY XyzRelease(XYZHANDLE handle);

// 调用约定:使用了APIENTRY宏,该宏在__stdcall  “WinDef.h”头文件中定义.

XyzExecutable.cpp代码示例如下:

#include "XyzLibrary.h"

...

/* Create Xyz instance. */
XYZHANDLE hXyz = GetXyz();

if(hXyz)
{
    /* Call Xyz.Foo method. */
    XyzFoo(hXyz, 42);

    /* Destroy Xyz instance and release acquired resources. */
    XyzRelease(hXyz);

    /* Be defensive. */
    hXyz = NULL;
}

使用这种方法,dll必须为对象创建和释放提供显式函数。

优点:因为c可以和任何语言进行互操作,因此纯c的方式导出兼容性更强

缺点:

  1. 调用创建对象函数的时候编译器无法判断类型是否匹配
// 在其他处定义了 void* GetSomeOtherObject(void) 函数
XYZHANDLE h = GetSomeOtherObject();

// 编译器将无法捕获错误
XyzFoo(h, 42);
  1. 需要手动调用Release函数,一旦忘记则会造成内存泄露
  2. 如果导出的函数的参数支持除基本数据类型以外的其他类型的参数(例如:class),则也得为这些类型提供接口。

3.2 C++直接导出类

几乎所有的Windows平台的C++编译器都支持从DLL中导出C++类,导出C++类与导出C函数非常相似。如果需要导出整个类,就是在类名前面使用说明符__declspec(dllexport/dllimport),如果只需要在导出特定类方法,则只需在方法声明之前使用说明符,如下:

// 导出整个CXyz类 
class XYZAPI CXyz
{
public:
    int Foo(int n);
};

// 仅仅导出CXyz::Foo方法
class CXyz
{
public:
    XYZAPI int Foo(int n);
};

默认情况下,C++编译器使用__thiscall的调用约定,由于不同的编译器使用的名字修饰约定不同,因此导出的C++类只能被相同的编译器和相同版本的编译器使用。

XyzExecutable.cpp代码示例如下:

#include "XyzLibrary.h"

...
// Client uses Xyz object as a regular C++ class.
CXyz xyz;
xyz.Foo(42);

该种导出类的方法与任何其他C++类的用法几乎相同。

但是在以下情况下,编译器会警告你未导出基类和数据成员,如果要成功导出C++类,开发人员必须导出所有相关的基类和所有用于定义数据成员的类,

class Base
{
    ...
};

class Data
{
    ...
};

// 警告:未导出基类
class __declspec(dllexport) Derived : public Base
{
    ...

private:
    Data m_data;    // 警告:未导出数据成员
};

优点:该导出C++类的使用方式与任何其他C++类的用法几乎相同。

缺点:

  1. 后期维护会很麻烦,导出的东西太多、使用者对类的实现依赖太大

  2. 必须保证使用同一种编译器,导出类的本质是导出类里的函数,因为语法上直接导出了类,没有对函数的调用方式、重命名进行设置,导致了产生的dll并不通用

  3. dll地狱问题,参考: DLL导出类避免地狱问题的完美解决方案, DLL地狱概念

3.3 使用抽象接口方式(推荐这种)

使用C++抽象接口方式同时兼顾以下两个方面:与对象无关的纯净接口,以及方便的面向对象的调用方式。

// The abstract interface for Xyz object.
struct IXyz
{
    virtual int Foo(int n) = 0;
    virtual void Release() = 0;
};

// Factory function that creates instances of the Xyz object.
extern "C" XYZAPI IXyz* APIENTRY GetXyz();

GetXyz被声明为extern "C"是为了防止导出的函数名被修改。因此此函数作为常规 C 函数公开,并且可以与任何 C 兼容的编译器兼容。

XyzExecutable.cpp代码示例如下:

#include "XyzLibrary.h"

...
IXyz* pXyz = ::GetXyz();

if(pXyz)
{
    pXyz->Foo(42);

    pXyz->Release();
    pXyz = NULL;
}

c++中定义接口的常用方法是在基类中只给出纯虚函数的声明,然后在派生类中根据纯虚函数的具体定义方式实现接口,使用者只能拿到基类的声明,对于派生类中的实现即不知道也不关心。

下图中,在DLL模块内部,XyzImpl类从IXyz接口继承,并实现其方法,EXE 模块中的方法调用通过虚拟表调用 DLL 模块中的实际实现。

在这里插入图片描述

使用智能指针

在纯c的方式导出类中,开发人员必须通过显式函数调用来释放资源,为了确保能够将资源释放,这里使用标准 C++ 库提供的智能指针。

#include "XyzLibrary.h"

#include <memory>
#include <functional>

...

typedef std::shared_ptr<IXyz> IXyzPtr;

IXyzPtr ptrXyz(::GetXyz(), std::mem_fn(&IXyz::Release));

if(ptrXyz)
{
    ptrXyz->Foo(42);
}

优点:

  1. 导出的C++类可以通过抽象接口与任何C++编译器一起使用,这是因为抽象接口作为模块之间的接口采用的是COM技术,该技术可以与其他编译器一起使用。
  2. 实现了真正的模块分离,可以重新设计和重新生成DLL模块,而不会影响其他部分。
  3. 这种导出方法和智能指针一起使用与任何其他C++类的用法几乎相同,推荐使用这种方法。

缺点:

  1. 创建对象的时候将其删除必须显示函数调用,但是可以用智能指针来解决。
  2. 抽象接口方法不能将C++对象作为入参或者返回值。
  • 8
    点赞
  • 83
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值