[DLL] Dynamic link library (dll) 的编写和使用教程
前一阵子,项目里需要导出一个DLL,但是导出之后输出一直不怎么对,改了半天才算改对。。。读了一些DLL教程,感觉之后要把现在的代码导出,应该还要花不少功夫。。。下面教程参照我读的3个教程写成,所以内容比较多:
http://www.tutorialspoint.com/dll/index.htm
http://www.tuicool.com/articles/ZVBnE3b
http://www.cnblogs.com/cswuyg/archive/2011/10/06/DLL2.html
第三个链接,包含类导入和纯虚接口导入的例子,如果教程读完还觉得比较困惑,可以下载来看看。另外文章中代码以c++为主,之后可能会陆续将非c++代码用其他的sample code替换掉。
介绍
DLL是微软开发的共享库。DLL文件包括多个程序可同时共用的代码和数据,使代码具有重复利用性、模块化等特性。DLL在运行时链接应用与资源库,资源库不会被复制到可执行程序中。DLL也可以连接到其他DLL。
DLL的优点
- 占用资源少:DLL文件不会和主程序一起被载入到RAM中,如果未被调用时,不会占用内存资源
- 让程序模块化:大型程序需要多语言或者多模块时可采用DLL
- 容易修改、卸载、安装:当DLL模块更新后,不需要重新连接
重要的DLL文件
重要的DLL文件包括:
- COMDLG32.DLL − 对话框控制
- GDI32.DLL − 绘图,显示文字、图像,字体控制
- KERNEL32.DLL − 内存管理,进程管理
- USER32.DLL − 用户界面控制,包括程序窗的创建、用户交互函数
DLL文件编写
DLL的种类
两种链接DLL的方法为:
- Load-time dynamic linking
- 这种连接方式中,应用程序如同内部函数一样显示调用DLL函数
- 需要向编译器、连接器提供:头文件(.h)、库文件(.lib)
- 当载入DLL时,连接器向系统提供需要的信息和DLL函数入口位置
- Runtime dynamic linking
- 应用程序需要在运行时调用LoadLibrary或LoadLibraryEx,来载入DLL
- 载入后,需要调用GetProcAddress得到函数入口位置地址
- 不需要提供.lib
- 两种链接方式的选择:
- 启动性能:如果启动性能很重要,应该选择run-time dynamic linking.
- 易用性:load-time dynamic linking函数如同内部函数一样调用,比较方便使用
- 程序的逻辑性:通过runtime dynamic linking,应用程序可以按照需求载入不同的模块。再多语言环境开发中,尤其重要。
DLL入口
创建DLL时,可以选择性的声明进入点函数(The entry point function)。进入点函数在进程链接(attach)DLL或者离开(detach)DLL时自动调用。这个函数可以用于为DLL初始化或者释放空间。
如果应用是多线程的,可以用thread local storage (TLS)来分配每个线程的私有空间。以下代码是进入点函数的示例:
BOOL APIENTRY DllMain( HANDLE hModule, // Handle to DLL module DWORD ul_reason_for_call, LPVOID lpReserved ) // Reserved { switch ( ul_reason_for_call ) { case DLL_PROCESS_ATTACHED: // A process is loading the DLL. break; case DLL_THREAD_ATTACHED: // A process is creating a new thread. break; case DLL_THREAD_DETACH: // A thread exits normally. break; case DLL_PROCESS_DETACH: // A process unloads the DLL. break; } return TRUE; }
如果使用load-time dynamic linking时,进入点函数返回FALSE,应用程序不会开始。如果使用runtime dynamic linking,只有当前的DLL不会载入。
进入点函数只能进行简单的初始化,不能调用其他DLL的载入或结束函数。例如,在进入点函数中,不能直接或间接的调用LoadLibrary或LoadLibraryEx。另外,在进程结束时也不能调用FreeLibrary。
WARNING − 再多线程应用中,需要确保使用DLL全局数据时是同步的(进程安全)进而避免可能的数据冲突。为此,可以用TLS来为每个线程提供单独的空间。
导出DLL函数
可以用一下几种方法导出DLL函数:
关键字导出
- C语言方法
- C++内在方法:导出类
- C++成熟方法:利用纯虚接口
def导出
关键字导出
C语言可以实现应用二进制接口(ABI),这样使调用者和被调用着可以遵从统一的标准,但是C++语言没有这个特性,导致从一个编译器生成的binary不能被另一个编译器所识别。这样使得直接导出C++类就成了冒险。
我们假设有一个类DllClass,类里面有一个成员函数Foo。类DllClass的实现在DLL里,此DLL可以被不同的用户以下列方式所调用:
- 纯C
- 一般C++
- c++抽象接口
源代码包含两个工程:
- DllClassLibrary – DLL库
- DllClassExecutable – Window 32 应用程序
DllClassLibrary以下列方式导出函数:
#ifdef DLLCLASSLIBRARY_EXPORT // inside DLL #define DLLCLASSAPI __declspec(dllexport) #else // outside DLL #define DLLCLASSAPI __declspec(dllimport) #endif // DLLCLASSLIBRARY_EXPORT
DLLCLASSLIBRARY_EXPORT标签仅在DllClassLibrary工程中被定义,所以DLLCLASSAPI在工程DllClassLibrary代表__declspec(dllexport),而在客户工程(DllClassExecutable)代表
__declspec(dllimport)
C语言方法
传统C语言可以利用指针或者句柄实现面向对象语言的一些特性,比如调用一个函数来创建一个类对象,并用次对象作为参数来调用对象的不同函数操作。比如,DllClass对象可以用c接口导出:
typedef tagDLLCLASSHANDLE {} * DLLCLASSHANDLE; // Factory function that creates instances of the DllClass object. DLLCLASSAPI DLLCLASSHANDLE APIENTRY GetDllClass(VOID); // Calls DllClass.Foo method. DLLCLASSAPI INT APIENTRY DllClassFoo(DLLCLASSHANDLE handle, INT n); // Releases DllClass instance and frees resources. DLLCLASSAPI VOID APIENTRY DllClassRelease(DLLCLASSHANDLE handle); // APIENTRY is defined as __stdcall in WinDef.h header.
客户端代码如下:
#include "DllClassLibrary.h" ... /* Create DllClass instance. */ DLLCLASSHANDLE hDllClass = GetDllClass(); if(hDllClass) { /* Call DllClass.Foo method. */ DllClassFoo(hDllClass, 42); /* Destroy DllClass instance and release acquired resources. */ DllClassRelease(hDllClass); /* Be defensive. */ hDllClass = NULL; }
此方法中,DLL必须提供创建对象和清理对象的函数接口。
- 调用规则
- 对于所有的输出函数必须定义调用规则,如果用户调用规则与DLL相一致,则一切OK,反之则会在运行时出错。 DllClassLibrary 工程利用 APIENTRY 宏,这个宏在文件" WinDef.h "被定义为 __stdcall 。
- __stdcall是一种函数的调用方式。默认情况下VC使用的是__cdecl的函数调用方式,如果产生的dll只会给C/C++程序使用,那么就没必要定义为__stdcall调用方式。有一些要求必须使用__stdcall,例如com相关的东西、系统的回调函数,Win32汇编使用的程序。具体看有没有需要。
- 可以自己在调用函数的时候设置函数调用的规则。像VC就可以设置函数的调用方式,所以可以方便的使用win32汇编产生的dll。
- __stdcall的调用方式,无论是C的Name Mangling,还是C++的Name Mangling都会对函数名进行修饰。如果既要__stdcall调用约定,又要函数名不给修饰,那可以使用*.def文件,或者在代码里#pragma的方式给函数提供别名(这种方式需要知道修饰后的函数名是什么),不然就不能使用GetProcAddress()通过函数名获取函数指针。
- 异常和安全
- C++异常不能从DLL中抛出,C语言不知道也不能处理C++异常。如果对象函数需要报告错误,只能通过返回错误码实现。
- DLL可以被广泛的调用。几乎所有的现代语言都支持与纯C函数的交互。
- DLL和用户的C实时库互相彼此独立。因为资源申请和释放完全在DLL模块,用户不受DLL 所选择的CRT的影响。
- 缺点
- 因为对象和函数分离,使得调用对象和函数完全依赖于用户。以下面的代码为例,编译器不能发现错误。
/* void* GetSomeOtherObject(void) is declared elsewhere. */ DllClassHANDLE h = GetSomeOtherObject(); /* Oops! Error: Calling DllClass.Foo on wrong object intance. */ DllClassFoo(h, 42);
- 用户必须显式地调用函数创建和删除对象实例。用户必须在所有的退出函数中调用 DllClassRelease 。如何忘记调用 DllClassRelease 则会引起内存泄漏。
C++内在方法:导出类
Windows平台上几乎所有的C++编译器都支持从DLL导出类。导出类与导出函数一样:如果导出整个类的话,只需要在类前面加上标志 __declspec(dllexport/dllimport) ,如果导出类里面特定的成员函数,就在成员函数前面加上标志 __declspec(dllexport/dllimport) 。下面是实例代码:
// The whole CDllClass class is exported with all its methods and members. class DLLCLASSAPI CDllClass { public: int Foo(int n); }; // Only CDllClass::Foo method is exported. class CDllClass { public: DLLCLASSAPI int Foo(int n); };
没有必要显式的为类或者成员函数定义调用规则。C++编译器默认用 __thiscall
作为成员函数的调用规则。但是,不同的编译器对于函数的名称修饰方式不同,因此DLL和用户最好用相同版本的编译器。下图是一个visual c++编译器中的函数修饰名称。
可以看出编译器生成出来的名称修饰与源代码中的成员函数名称差别很大。下图是用dependency walker工具所描述的同一个DLL模块中的函数名称
再次强调,只有MS C++能用这个DLL。为了使调用者和被调用者之间的名称修饰相同,DLL和用户还必须 用同一个版本的MS C++。下面是用户使用DllClass对象的代码。
#include "DllClassLibrary.h" ... // Client uses DllClass object as a regular C++ class. CDllClass dllclass; dllclass.Foo(42);
NOTE:利用导出类的DLL和静态库没有什么区别。所有适用于静态库的规则也适用于动态库(DLL)。
- 所见非所得
- 细心的读者应该注意到,用dependency walker会解析出另外一个赋值函数 CDllClass& CDllClass::operator =(const CDllClass&) 。根据C++标准,每个类都有特殊的4个函数:
- 默认构造函数
- 拷贝构造函数
- 析构函数
- 赋值函数
- 如果开发着不定义这些函数,则编译器会自动为我们生成这些函数。对于类CDllClass,编译器判断默认构造函数,拷贝构造函数和析构函数无价值,所以把他们优化掉了。而对于赋值函数则导出。
NOTE:导出一个类,则意味着导出与类相关的一切:类成员变量,类成员函数(无论是显式声明还是隐式生成的),基类。
class Base { ... }; class Data { ... }; // MS Visual C++ compiler emits C4275 warning about not exported base class. class __declspec(dllexport) Derived : public Base { ... private: Data m_data; // C4251 warning about not exported data member. };
在上面的代码中,编译器警告没有导出基类和类成员变量。所以如果要成功的导出一个类,我们必须导出所有的基类和定义成员变量的类。这个滚雪球式的要求是一个明显的缺点。这也是为什么导出一个继承自STL模版或者含有STL模版成员函数的类是多么痛苦的一件事。导出一个STL map实例至少需要导出相关的10个以上的类。必须按照如下方法导出:
class __declspec(dllexport) Base { ... }; class __declspec(dllexport) Data { ... }; // MS Visual C++ compiler emits C4275 warning about not exported base class. class __declspec(dllexport) Derived : public Base { ... private: Data m_data; // C4251 warning about not exported data member. };
- 异常和安全
- 导出的C++类可以抛出异常。因为DLL和用户都用相同版本的编译器,C++异常可以被抛出或者捕获。
- 导出的C++类可以像其他C++类一样使用
- 在DLL模块内部抛出的异常可以在用户端捕获
- DLL内部代码的小改动,不需要重新编译其他模块。这在大的工程里面特别有用。
- 把一个大工程的不同逻辑模块生成不同的DLL被当作是通向模块化的第一步
- 缺点
- 从DLL导出类并不能阻止对象和用户之间的耦合。在函数依赖方面,DLL被看作静态库。
- 用户代码和DLL必须用同一个CRT动态链接。如果用户代码和DLL链接的CRT版本不一样,或者静态链接到CRT,那么在一个CRT中申请的资源将在不同的CRT实例中释放。这将破坏CRT的内部状态,试图操作外部资源很可能导致程序崩溃。
- 用户代码和DLL必须遵从相同的异常处理模型,相同编译器异常设置。
- 导出C++类需要导出与此类相关的一切:基类,定义成员函数的类等。
C++成熟方法:利用纯虚接口
C++纯虚接口是只只包含纯虚函数没有成员的类。它试图获得最佳的两个方面:独立于编译器的对象接口和方便的面向对象的函数调用。所有的一切只需要声明一个包含接口声明的头文件和实现一个返回一个对象的工厂函数。这个工厂函数需要一个标识符 __declspec(dllexport/dllimport)
。实例如下:
// The abstract interface for DllClass object. // No extra specifiers required. struct IDllClass { virtual int Foo(int n) = 0; virtual void Release() = 0; }; // Factory function that creates instances of the DllClass object. extern "C" DLLCLASSAPI IDllClass* APIENTRY GetDllClass();
工厂函数 GetDllClass 被声明为 extern “C",是为了防止函数名字捆绑(name mangling),这样可以被任何C编译器所识别。据说,C++标准并没有规定Name-Mangling的方案,所以不同编译器使用的是不同的,而且不同版本的编译器他们的Name-Mangling规则也是不同的。不同编译器编译出来的目标文件.obj 是不通用的,因为同一个函数,使用不同的Name-Mangling在obj文件中就会有不同的名字。如果DLL里的函数重命名规则跟DLL的使用者采用的重命名规则不一致,那就会找不到这个函数。
C标准规定了C语言Name-Mangling的规范(林锐的书有这样说过)。这样就使得,任何一个支持C语言的编译器,它编译出来的obj文件可以共享,链接成可执行文件。这是一种标准,如果DLL跟其使用者都采用这种约定,那么就可以解决函数重命名规则不一致导致的错误。
影响符号名的除了C++和C的区别、编译器的区别之外,还要考虑调用约定导致的Name Mangling。如extern “c” __stdcall的调用方式就会在原来函数名上加上写表示参数的符号,而extern “c” __cdecl则不会附加额外的符号。
dll中的函数在被调用时是以函数名或函数编号的方式被索引的。这就意味着采用某编译器的C++的Name-Mangling方式产生的dll文件可能不通用。因为它们的函数名重命名方式不同。为了使得dll可以通用些,很多时候都要使用C的Name-Mangling方式,即是对每一个导出函数声明为extern “C”,而且采用_stdcall调用约定,接着还需要对导出函数进行重命名,以便导出不加修饰的函数名。
注意到extern “C”的作用是为了解决函数符号名的问题,这对于动态链接库的制造者和动态链接库的使用者都需要遵守的规则。
动态链接库的显式装入就是通过GetProcAddress函数,依据动态链接库句柄和函数名,获取函数地址。因为GetProcAddress仅是操作系统相关,可能会操作各种各样的编译器产生的dll,它的参数里的函数名是原原本本的函数名,没有任何修饰,所以一般情况下需要确保dll’里的函数名是原始的函数名。分两步:
- 如果导出函数使用了extern”C” _cdecl,那么就不需要再重命名了,这个时候dll里的名字就是原始名字;如果使用了extern”C” _stdcall,这时候dll中的函数名被修饰了,就需要重命名。
- 重命名的方式有两种,要么使用*.def文件,在文件外修正,要么使用#pragma,在代码里给函数别名。
下面的是客户段代码,说明如何使用这个接口。
#include "DllClassLibrary.h" ... IDllClass* pDllClass = ::GetDllClass(); if(pDllClass) { pDllClass->Foo(42); pDllClass->Release(); pDllClass = NULL; }
定义一个不包含任何成员变量纯虚类,然后定义子类并实现接口成员函数。这样用户无需知道这个函数是如何实现的,只需知道它做了什么。
- 如何工作
- 其实思想很简单:纯虚类只包含了一个虚函数表(包含了多个函数指针的数组)。下图显示了DLL和用户调用的内部实现(此图中Xyz==DllClass):
- 上图显示了纯虚类IDllClass作为接口被DLL和用户EXE所使用。在DLL模块内部,类 DllClassImpl 继承自接口IDllClass并实现了方法,EXE模块函数的调用通过虚函数表可以触发DLL模块具体的函数实现。
- 使用标准C++智能指针调用
- 使用智能指针实现了RAII(资源申请在初始化),这样在不需要的时候会自动释放资源。而不用像在C语言里实现的那样,程序员必须记得在哪里释放资源。
#include "DllClassLibrary.h" #include <memory> #include <functional> ... typedef std::shared_ptr<IDllClass> IDllClassPtr; IDllClassPtr ptrDllClass(::GetDllClass(), std::mem_fn(&IDllClass::Release)); if(ptrDllClass) { ptrDllClass->Foo(42); } // No need to call ptrDllClass->Release(). std::shared_ptr class // will call this method automatically in its destructor.
- 异常和安全
- 纯C++接口不能让DLL内部的异常抛出DLL外。类成员函数需要用错误码返回错误。不同的编译器对C++异常处理的实现也会不同,所以不能共享。从这个角度上讲,纯虚C++接口与纯C函数表现的相同。
- 优点
- 一个C++类能通过虚接口被不同的C++编译器使用
- DLL和用户的CRT(C运行库)互相独立。因为资源的申请和释放完全在DLL模块内部实现,用户不受DLL的CRT所影响。
- 真正的模块分离。DLL模块可以被重新设计和重构而不影响项目的其他模块。
- 缺点
- 创造和删除对象虚显式函数调用来实现,虽然后者可以通过智能指针而避免。
- 虚接口不同返回或者接受一个普通的C++对象作为参数。它必须是内建类型(int,double,char*等)或者其他的虚接口
NOTE: 关于STL模版类
标准C++容器(vector,list,map)不是用来设计DLL的。C++标准对DLL保持沉默,因为DLL是跟平台相关的技术,在其他平台上并不是必须存在的。MS Visual C++可以导出或者导入STL类,只要我们在前面加上 __declspec(dllexport/dllimport) 标志。可以工作但是编译器会给出警告信息。我们应该知道,导出STL类与导出一般C++类一样,都会有前面(C++内在方法:导出类)所提及的缺点。
.def文件
def文件指定导出函数,并告知编译器不要以修饰后的函数名作为导出函数名,而以指定的函数名导出函数(比如有函数func,让编译器处理后函数名仍为func)。这样,就可以避免由于microsoft VC++编译器的独特处理方式而引起的链接错误。
也就是说,使用了def文件,那就不需要extern “C”了,也可以不需要__declspec(dllexport)了(不过,dll的制造者除了提供dll之外,还要提供头文件,需要在头文件里加上这extern”C”和调用约定,因为使用者需要跟制造者遵守同样的规则,除非使用者和制造者使用的是同样的编译器并对调用约定无特殊要求)。
在模块定义文件中,使用LIBRARY语句和EXPORTS语句来定义DLL。举例def文件格式:
LIBRARY dll_name // dll name is not necessary, but must be the same as generated dll file EXPORTS function_name @ function_index
例如
// SampleDLL.def LIBRARY "sampleDLL" EXPORTS HelloWorld
编写好之后加入到VC的项目中,就可以了。
另外,要注意的是,如果要使用__stdcall,那么就必须在代码里使用上__stdcall,因为*.def文件只负责修改函数名称,不负责调用约定。也就是说,def文件只管函数名,不管函数平衡堆栈的方式。
如果把*.def文件加入到工程之后,链接的时候并没有自动把它加进去。那么可以这样做:
手动的在link添加:
- 工程的properties—>Configuration Properties—>Linker—>Command Line—>在“Additional options”里加上:/def:[完整文件名].def
- 工程的properties—>Configuration Properties—>Linker—>Input—>Module Definition File里加上[完整文件名].def
注意到:即便是使用C的名称修饰方式,最终产生的函数名称也可能是会被修饰的。例如,在VC下,_stdcall的调用方式,就会对函数名称进行修饰,前面加‘_’,后面加上参数相关的其他东西。所以使用*.def文件对函数进行命名很有用,很重要。
也可以使用模块定义文件来声明需要导出的DLL函数。这种情况下,不需要加导出关键字
示例DLL
可以选择Win32 Dynamic-Link Library project或MFC AppWizard (dll) project来创建DLL。以下是一个用Win32 Dynamic-Link Library project创建DLL的例子
// SampleDLL.cpp #include "stdafx.h" #define EXPORTING_DLL #include "sampleDLL.h" BOOL APIENTRY DllMain( HANDLE hModule, DWORD ul_reason_for_call, LPVOID lpReserved ) { return TRUE; } void HelloWorld() { MessageBox( NULL, TEXT("Hello World"), TEXT("In a DLL"), MB_OK); } // File: SampleDLL.h #ifndef INDLL_H #define INDLL_H #ifdef EXPORTING_DLL extern __declspec(dllexport) void HelloWorld() ; #else extern __declspec(dllimport) void HelloWorld() ; #endif #endif
调用DLL示例
// SampleApp.cpp #include "stdafx.h" #include "sampleDLL.h" int APIENTRY WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow) { HelloWorld(); return 0; }
NOTE:
- 使用load-time dynamic linking时,必须链接SampleDLL.lib
- 用runtime dynamic linking方式时,用与以下代码相似的代码调用SampleDLL.dll来导出DLL
... typedef VOID (*DLLPROC) (LPTSTR); ... HINSTANCE hinstDLL; DLLPROC HelloWorld; BOOL fFreeDLL; hinstDLL = LoadLibrary("sampleDLL.dll"); if (hinstDLL != NULL) { HelloWorld = (DLLPROC) GetProcAddress(hinstDLL, "HelloWorld"); if (HelloWorld != NULL) (HelloWorld); fFreeDLL = FreeLibrary(hinstDLL); } ...
当你编译链接SampleDLL应用时,Windows操作系统按以下顺序搜索SampleDLL DLL:
- 应用程序文件夹
- 当前文件夹
- 系统文件夹 (GetSystemDirectory函数返回Windows system文件夹路径)
- Windows文件夹 (GetWindowsDirectory函数返回Windows文件夹路径)
注册DLL
为了使用DLL作为系统服务,必须注册DLL。有时引用发生冲突,DLL就不能继续使用。在“开始—>运行”中输入下列命令,可以从新注册DLL:
regsvr32 somefile.dll
这个命令假设somefile.dll在PATH指向的文件夹中。否则,必须用DLL的绝对地址。如下列命令所示,使用"/u”,可以取消注册DLL文件。
regsvr32 /u somefile.dll
这些命令可以用于打开和关闭某服务。
DLL工具
Visual Studio提供了一些工具可以帮助解答DLL问题
Dependency Walker
Dependency Walker工具(depends.exe)可以扫描某一程序依赖的所有DLL。当你用Dependency Walker打开某个程序时,Dependency Walker进行如下检测:
- 检测丢失的DLLs.
- 检测失效的程序文件和DLLs
- 检测导入函数和导出函数是否匹配
- 检测循环依赖错误
- 检测模块是否是为其他操作系统设计的
通过Dependency Walker可以记录一个程序使用的所用DLLs。它可以帮助预防和改正未来可能发生的DLL问题。Dependency Walker的路径如下:
drive\Program Files\Microsoft Visual Studio\Common\Tools
DLL Universal Problem Solver
DLL Universal Problem Solver (DUPS) 工具是用来记录、比较和显示DLL信息的。下面列举了DUPS包含的一些工具
- Dlister.exe − 枚举计算机中所有DLLs并把信息计入txt或数据库文件
- Dcomp.exe − 比较两个文本文件中列出的DLLs,并创建第三个文本文件记录区别
- Dtxt2DB.exe − 将Dlister.exe和Dcomp.exe产生的文本文件载入到dllHell数据库
- DlgDtxt2DB.exe − 提供Dtxt2DB.exe的图形界面(GUI)版本
编写建议
- 使用正确的调用 (C or stdcall).
- 注意参数传递顺序
- 绝不改变传入参数中数组大小或链接传入参数中的string。
- 如果准备进行数组或字符串操作,一定要传入一个足够大的buffer
- 使用C++编译器时,在头文件中用extern .C.{} 语句声明函数以避免名字捆绑
- 编写自己的DLL时,不能在另外一个程序将DLL载入进内存时从新编译。再重新编译DLL之前,确保所有使用该DLL的函数都没有载入到内存中。如果没有这样做,可能会重新编译失败,但编译器可能不会提示
- 必须用程序测试DLLs是否正确运行。