库是写好的现有的,并且可以复用的代码。现实开发中每个程序都要依赖很多基础的底层库,不可能每个功能都从零开始开发,因此库是必须存在的。库的本质是一种可执行的二进制文件,可以被操作系统加载到内存中执行,库有两种:静态链接库(简称为静态库)和动态链接库(简称为动态库),所谓静态、动态是指链接,一个程序编译成可执行文件步骤如下图所示:
静态链接库在内存中会存在多份拷贝导致空间浪费,比如一个静态库占用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隐式加载和显式加载进行详细说明。
采用隐式加载的方法,DLL最终将打包到生成的EXE中。DLL隐式加载步骤如下:
youApp.dll
拷到你目标工程(需调用youApp.dll
的工程)的Debug
目录下;youApp.lib
拷到你目标工程(需调用youApp.dll
的工程)目录下;youApp.h
(包含输出函数的定义)拷到你目标工程(需调用youApp.DLL
的工程)目录下;以上三步:隐式加载需要.dll/.lib/.h
三个文件,.lib
文件包含DLL导出的函数和变量的符号名,只是用来为链接程序提供必要的信息,以便在链接时找到函数或变量的入口地址,.dll
文件包含了实际的函数和数据。
youApp.lib
youApp.h
文件;*.cpp
,需要调用DLL中的函数)中包含你的:#include “youApp.h”
youApp
是你DLL的工程名。.lib是一种文件名后缀,代表的是静态数据连接库,在windows操作系统中起到链接程序和函数(或子过程)的作用,相当于Linux中的.a或.o、.so文件,LIB文件中存放的是函数调用的信息。
无论是动态链接库还是静态链接库,都有lib文件,但是两者不是同一个东西。 对于静态链接库对应的lib文件叫静态库,其包含了实际执行代码、符号表等;动态库对应的lib文件叫导入库,其实际的代码位于动态库中,而导入库只包含了地址符号表,用来确保程序找到对应函数的一些基本地址信息。这就是为啥静态库的lib比动态库的lib要大很多的原因。
在Windows下动态调用DLL的步骤:
LoadLibrary()
显式的调用DLL,此函数返回DLL的实例句柄。GetProcAddress()
获取要调用的DLL的函数地址, 把结果赋给自定义函数的指针类型。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()
。
DLL导出函数的声明有两种方式:一种是在函数声明中加上__declspec(dllexport)
,另一种是采用模块定义(.def)文件声明,(.def)文件为链接器提供了有关被链接程序的导出、属性及其他方面的信息。
这两种方式的主要区别是导出函数的名字上,其次还有一些操作的灵活性以及功能的强弱。
__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;
}
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的方式进行编译),它只能用于导出全局函数这种情况,而不能导出一个类的成员函数。
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)
这里举一个例子,源代码由两个项目组成
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
这种方式类似与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的方式导出兼容性更强
缺点:
// 在其他处定义了 void* GetSomeOtherObject(void) 函数
XYZHANDLE h = GetSomeOtherObject();
// 编译器将无法捕获错误
XyzFoo(h, 42);
几乎所有的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++类的用法几乎相同。
缺点:
后期维护会很麻烦,导出的东西太多、使用者对类的实现依赖太大
必须保证使用同一种编译器,导出类的本质是导出类里的函数,因为语法上直接导出了类,没有对函数的调用方式、重命名进行设置,导致了产生的dll并不通用
dll地狱问题,参考: DLL导出类避免地狱问题的完美解决方案, DLL地狱概念
使用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
#include
...
typedef std::shared_ptr<IXyz> IXyzPtr;
IXyzPtr ptrXyz(::GetXyz(), std::mem_fn(&IXyz::Release));
if(ptrXyz)
{
ptrXyz->Foo(42);
}
优点:
缺点: