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隐式加载步骤如下:
- 把你的
youApp.dll 拷到你目标工程(需调用youApp.dll 的工程)的Debug 目录下; - 把你的
youApp.lib 拷到你目标工程(需调用youApp.dll 的工程)目录下; - 把你的
youApp.h (包含输出函数的定义)拷到你目标工程(需调用youApp.DLL 的工程)目录下;
以上三步:隐式加载需要.dll/.lib/.h 三个文件,.lib 文件包含DLL导出的函数和变量的符号名,只是用来为链接程序提供必要的信息,以便在链接时找到函数或变量的入口地址,.dll 文件包含了实际的函数和数据。
- 打开你的目标工程选中工程,选择Visual C++的Project主菜单的Settings菜单;
- 执行第4步后,VC将会弹出一个对话框,在对话框的多页显示控件中选择Link页。然后在Object/library modules输入框输入:
youApp.lib - 选择你的目标工程Head Files加入:
youApp.h 文件; - 最后在你目标工程(
*.cpp ,需要调用DLL中的函数)中包含你的:#include “youApp.h” 注:youApp 是你DLL的工程名。
.lib是一种文件名后缀,代表的是静态数据连接库,在windows操作系统中起到链接程序和函数(或子过程)的作用,相当于Linux中的.a或.o、.so文件,LIB文件中存放的是函数调用的信息。
无论是动态链接库还是静态链接库,都有lib文件,但是两者不是同一个东西。 对于静态链接库对应的lib文件叫静态库,其包含了实际执行代码、符号表等;动态库对应的lib文件叫导入库,其实际的代码位于动态库中,而导入库只包含了地址符号表,用来确保程序找到对应函数的一些基本地址信息。这就是为啥静态库的lib比动态库的lib要大很多的原因。
1.3 DLL显式加载方法
在Windows下动态调用DLL的步骤:
- 创建一个函数指针,其指针数据类型要与调用的DLL引出函数相吻合。
- 通过Win32 API函数
LoadLibrary() 显式的调用DLL,此函数返回DLL的实例句柄。 - 通过Win32 API函数
GetProcAddress() 获取要调用的DLL的函数地址, 把结果赋给自定义函数的指针类型。 - 使用函数指针来调用DLL函数。
- 最后调用完成后,通过Win32 API函数
FreeLibrary() 释放DLL函数。
以上:显式加载只需要.dll 文件,因为显式加载是用过指针来调用DLL函数,编译器不生成外部引用,因此不需要.lib 文件。
代码示例如下:
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)
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);
② 调用 GetProcAddress() 以获取指向应用程序要调用的每个导出函数的函数指针,由于应用程序是通过指针调用DLL的函数,编译器不生成外部引用,故无需与导入库链接,GetProcAddress() 函数的原型声明如下所示:
FARPROC GetProcAddress(HMODULE hModule, LPCSTR 1pProcName);
③使用完DLL后调用 FreeLibrary() 。
2 DLL导出函数
DLL导出函数的声明有两种方式:一种是在函数声明中加上__declspec(dllexport) ,另一种是采用模块定义(.def)文件声明,(.def)文件为链接器提供了有关被链接程序的导出、属性及其他方面的信息。
这两种方式的主要区别是导出函数的名字上,其次还有一些操作的灵活性以及功能的强弱。
2.1 在函数声明中加上__declspec(dllexport) 关键字
DLL可以通过在函数声明中加上__declspec(dllexport) 导出函数,代码如下:
#ifdef DLLDEMO_EXPORTS
#define DLLDEMO_API __declspec(dllexport)
#else
#define DLLDEMO_API __declspec(dllimport)
#endif
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"
EXPORTS
Add_stdcall @1
Add_cdecl @2
同样的,我们对__stdcall 和__cdecl 进行测试,看导出函数名是否改变,测试结果表示均为改变。
其实def文件的功能相当于extern “C” __declspec(dllexport)
3 DLL导出类
这里举一个例子,源代码由两个项目组成
XyzLibrary
XyzExecutable
XyzLibrary 的对象是Xyz ,只有一种方法int Foo(int) 。
XyzLibrary 项目通过以下宏方式导出代码:
#if defined(XYZLIBRARY_EXPORT)
# define XYZAPI __declspec(dllexport)
#else
# define XYZAPI __declspec(dllimport)
#endif
3.1 纯C的方式导出
这种方式类似与Win32的窗口句柄,用户将句柄作为参数传递给函数,并对对象执行各种操作。创建一个Xyz 对象通过c接口导出,如下所示:
typedef tagXYZHANDLE {} * XYZHANDLE;
XYZAPI XYZHANDLE APIENTRY GetXyz(VOID);
XYZAPI INT APIENTRY XyzFoo(XYZHANDLE handle, INT n);
XYZAPI VOID APIENTRY XyzRelease(XYZHANDLE handle);
XyzExecutable.cpp 代码示例如下:
#include "XyzLibrary.h"
...
XYZHANDLE hXyz = GetXyz();
if(hXyz)
{
XyzFoo(hXyz, 42);
XyzRelease(hXyz);
hXyz = NULL;
}
使用这种方法,dll必须为对象创建和释放提供显式函数。
优点:因为c可以和任何语言进行互操作,因此纯c的方式导出兼容性更强
缺点:
- 调用创建对象函数的时候编译器无法判断类型是否匹配
XYZHANDLE h = GetSomeOtherObject();
XyzFoo(h, 42);
- 需要手动调用Release函数,一旦忘记则会造成内存泄露
- 如果导出的函数的参数支持除基本数据类型以外的其他类型的参数(例如:class),则也得为这些类型提供接口。
3.2 C++直接导出类
几乎所有的Windows平台的C++编译器都支持从DLL中导出C++类,导出C++类与导出C函数非常相似。如果需要导出整个类,就是在类名前面使用说明符__declspec(dllexport/dllimport) ,如果只需要在导出特定类方法,则只需在方法声明之前使用说明符,如下:
class XYZAPI CXyz
{
public:
int Foo(int n);
};
class CXyz
{
public:
XYZAPI int Foo(int n);
};
默认情况下,C++编译器使用__thiscall 的调用约定,由于不同的编译器使用的名字修饰约定不同,因此导出的C++类只能被相同的编译器和相同版本的编译器使用。
XyzExecutable.cpp 代码示例如下:
#include "XyzLibrary.h"
...
CXyz xyz;
xyz.Foo(42);
该种导出类的方法与任何其他C++类的用法几乎相同。
但是在以下情况下,编译器会警告你未导出基类和数据成员,如果要成功导出C++类,开发人员必须导出所有相关的基类和所有用于定义数据成员的类,
class Base
{
...
};
class Data
{
...
};
class __declspec(dllexport) Derived : public Base
{
...
private:
Data m_data;
};
优点:该导出C++类的使用方式与任何其他C++类的用法几乎相同。
缺点:
-
后期维护会很麻烦,导出的东西太多、使用者对类的实现依赖太大 -
必须保证使用同一种编译器,导出类的本质是导出类里的函数,因为语法上直接导出了类,没有对函数的调用方式、重命名进行设置,导致了产生的dll并不通用 -
dll地狱问题,参考: DLL导出类避免地狱问题的完美解决方案, DLL地狱概念
3.3 使用抽象接口方式(推荐这种)
使用C++抽象接口方式同时兼顾以下两个方面:与对象无关的纯净接口,以及方便的面向对象的调用方式。
struct IXyz
{
virtual int Foo(int n) = 0;
virtual void Release() = 0;
};
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);
}
优点:
- 导出的C++类可以通过抽象接口与任何C++编译器一起使用,这是因为抽象接口作为模块之间的接口采用的是COM技术,该技术可以与其他编译器一起使用。
- 实现了真正的模块分离,可以重新设计和重新生成DLL模块,而不会影响其他部分。
- 这种导出方法和智能指针一起使用与任何其他C++类的用法几乎相同,推荐使用这种方法。
缺点:
- 创建对象的时候将其删除必须显示函数调用,但是可以用智能指针来解决。
- 抽象接口方法不能将C++对象作为入参或者返回值。
|