动态链接库是Windows的基石。所有的Win32 API函数都包含在DLL中。3个最重要的DLL是KERNEL32.DLL,它由管理内存、进程和线程的函数组成;USER32.DLL,它由执行用户界面的任务(如创建窗口和发送消息)的函数组成;GDI32.DLL,它由绘图和显示文本的函数组成。在此,我们主要用实际的操作过程,简要的说明如何创建自己的 Win32 DLL。
这里,我们为了简要说明DLL的原理,我们决定使用最简单的编译环境VC6.0,如下图,我们先建立一个新的Win32 Dynamic-Link Library工程,名称为“MyDLL”,在Visual Studio中,你也可以通过建立Win32控制台程序,然后在“应用程序类型”中选择“DLL”选项,
点击确定,选择“一个空的DLL工程”,确定,完成即可。
在第一步我们建立的工程中建立一个源码文件”dllmain.cpp“,在“dllmain.cpp”中,键入如下代码
#include <Windows.h> #include <stdio.h> BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) { switch (ul_reason_for_call) { case DLL_PROCESS_ATTACH: printf("DLL_PROCESS_ATTACH\n"); break; case DLL_THREAD_ATTACH: printf("DLL_THREAD_ATTACH\n"); break; case DLL_THREAD_DETACH: printf("DLL_THREAD_DETACH\n"); break; case DLL_PROCESS_DETACH: printf("DLL_PROCESS_DETACH\n"); break; } return TRUE; }
之后,我们直接编译,即可以在Debug文件夹下,找到我们生成的dll文件,“MyDLL.dll”,注意,代码里面的printf语句,并不是必须的,只是我们用于测试程序时使用。而DllMain函数,是dll的进入/退出函数。
实际上,让线程调用DLL的方式有两种,分别是隐式链接和显式链接,其目的均是将DLL的文件映像映射进线程的进程的地址空间。我们这里只大概提一下,不做深入研究,如果感兴趣,可以去看《Window高级编程指南》的第12章内容。
隐士地链接是将DLL的文件影响映射到进程的地址空间中最常用的方法。当链接一个应用程序时,必须制定要链接的一组LIB文件。每个LIB文件中包含了DLL文件允许应用程序(或另一个DLL)调用的函数的列表。当链接器看到应用程序调用了某个DLL的LIB文件中给出的函数时,它就在生成的EXE文件映像中加入了信息,指出了包含函数的DLL文件的名称。当操作系统加载EXE文件时,系统查看EXE文件映像的内容来看要装入哪些DLL,而后试图将需要的DLL文件映像映射到进程的地址空间中。当寻找DLL时,系统在系列位置查找文件映像。
这种方法,一般都是在程序链接时控制,反映在链接器的配置上,网上大多数讲的各种库的配置,比如OPENGL或者OPENCV等,都是用的这种方法
这里我们只提到两种函数,一种是加载函数
HINSTANCE LoadLibrary(LPCTSTR lpszLibFile); HINSTANCE LoadLibraryEx(LPCSTR lpszLibFile,HANDLE hFile,DWORD dwFlags);
返回值HINSTANCE值指出了文件映像映射的虚拟内存地址。如果DLL不能被映进程的地址空间,函数就返回NULL。你可以使用类似于
LoadLibrary("MyDLL")或者
LoadLibrary("MyDLL.dll")的方式进行调用,不带后缀和带后缀在搜索策略上有区别,这里不再详解。
BOOL FreeLibrary(HINSTANCE hinstDll);
这里,在同一个进程中调用同一个DLL时,实际上还牵涉到一个计数的问题。这里也不在详解。
线程可以调用GetModuleHandle函数:
GetModuleHandle(LPCTSTR lpszModuleName);来判断一个DLL是否被映射进进程的地址空间。例如,下面的代码判断MyDLL.dll是否已被映射到进程的地址空间,如果没有,则装入它:
HINSTANCE hinstDll; hinstDll = GetModuleHandle("MyDLL"); if (hinstDll == NULL){ hinstDll = LoadLibrary("MyDLL"); }实际上,还有一些函数,比如 GetModuleFileName用来获取DLL的全路径名称,FreeLibraryAndExitThread来减少DLL的使用计数并退出线程。具体内容还是参见《Window高级编程指南》的第12章内容,此文中不适合讲太多的内容以至于读者不能一下子接受。
说到这里,实际上只是讲了几个常用的函数,这一个小节才是重点。
在上面,我们看到的MyDLL的例子中,有一个DllMain函数,这就是所谓的进入/退出函数。系统在不同的时候调用此函数。这些调用主要提供信息,常常被DLL用来执行进程级或线程级的初始化和清理工作。如果你的DLL不需要这些通知,就不必再你的DLL源代码中实现此函数,例如,如果你创建的DLL只含有资源,就不必实现该函数。但如果有,则必须像我们上面的格式。
DllMain函数中的ul_reason_for_call参数指出了为什么调用该函数。该参数有4个可能值: DLL_PROCESS_ATTACH、DLL_THREAD_ATTACH、DLL_THREAD_DETACH、DLL_PROCESS_DETACH。
其中,DLL_PROCESS_ATTACH是在一个DLL首次被映射到进程的地址空间时,系统调用它的DllMain函数,传递的ul_reason_for_call参数为DLL_PROCESS_ATTACH。这只有在首次映射时发生。如果一个线程后来为已经映射进来的DLL调用LoadLibrary或LoadLibraryEx,操作系统只会增加DLL的计数,它不会再用DLL_PROCESS_ATTACH调用DLL的DllMain函数。
而DLL_PROCESS_DETACH是在DLL被从进程的地址空间解除映射时,系统调用它的DllMain函数,传递的ul_reason_for_call值为DLL_PROCESS_DETACH。我们需要注意的是,当用DLL_PROCESS_ATTACH调用DLL的DllMain函数时,如果返回FALSE,说明初始化不成功,系统仍会用DLL_PROCESS_DETACH调用DLL的DllMain。因此,必须确保没有清理那些没有成功初始化的东西。
DLL_THREAD_ATTACH:当进程中创建一个线程时,系统察看当前映射到进程的地址空间中的所有DLL文件映像,并用值DLL_THREAD_ATTACH调用所有的这些DLL的DllMain函数。该通知告诉所有的DLL去执行线程级的初始化。注意,当映射一个新的DLL时,进程中已有的几个线程在运行,系统不会为已经运行的线程用值DLL_THREAD_ATTACH调用DLL的DllMain函数。
而DLL_THREAD_DETACH,如果线程调用ExitThread来终结(如果让线程函数返回而不是调用ExitThread,系统会自动调用ExitThread),系统察看当前映射到进程空间的所有DLL文件映像,并用值DLL_THREAD_DETACH来调用所有的DLL的DllMain函数。该通知告诉所有的DLL去执行线程级的清理工作。
这里,我们需要注意的是,如果线程的终结是因为系统中的一个线程调用了TerminateThread,系统就不会再使用DLL_THREAD_DETACH来调用DLL和DllMain函数。这与TerminateProcess一样,不再万不得已时,不要使用。
下面,我们贴出《Window高级编程指南》中的两个图来说明上述四种参数的调用情况。
好的,介绍了以上的情况,下面,我们来继续实践,这次,建立一个新的空的win32控制台工程TestDLL,不再多说,代码如下:
#include <iostream> #include <Windows.h> using namespace std; DWORD WINAPI someFunction(LPVOID lpParam) { cout << "enter someFunction!" << endl; Sleep(1000); cout << "This is someFunction!" << endl; Sleep(1000); cout << "exit someFunction!" << endl; return 0; } int main() { HINSTANCE hinstance = LoadLibrary("MyDLL"); if(hinstance!=NULL) { cout << "Load successfully!" << endl; }else { cout << "Load failed" << endl; } HANDLE hThread; DWORD dwThreadId; cout << "createThread before " << endl; hThread = CreateThread(NULL,0,someFunction,NULL,0,&dwThreadId); cout << "createThread after " << endl; cout << endl; Sleep(3000); cout << "waitForSingleObject before " << endl; WaitForSingleObject(hThread,INFINITE); cout << "WaitForSingleObject after " << endl; cout << endl; FreeLibrary(hinstance); return 0; }
代码很好理解,但是前提是,你必须对线程有一定的概念。另外,注意,我们上面编译的获得的“MyDLL.dll"必须拷贝到能够让我们这个工程找到的地方,也就是上面我们提到的搜索路径中的一个地方。
这里,我们先贴结果,当然,这只是在我机器上其中某次运行结果。
有了上面我们介绍的知识,这个就不是很难理解,主进程在调用LoadLibrary时,用DLL_PROCESS_ATTACH调用了DllMain函数,而线程创建时,用DLL_THREAD_ATTACH调用了DllMain函数,而由于主线程和子线程并行的原因,可能输出的时候会有打断。但是,这样反而能让我们更清楚的理解程序。
”在前面对DllMain函数的讨论中,我假设读者使用Microsoft的Visual C++编译器来建立自己的动态链接库。当编写DLL时,可能会需要一些C运行库的启动帮助。比方说,你建立的DLL中包含一个全局变量,它是一个C++类的实例。在DLL能使用该全局变量之前,必须调用了它的构造函数——这就是C运行时库的DLL启动代码的工作。“
上面一段话也就是告诉我们,实际上,当DLL文件被映射到进程的地址空间中时,系统实际上调用的并不直接是DllMain函数,而是另外一个函数,需要先完成一些初始化工作,实际上,这个函数便是_DllMainCRTStartup函数。该函数初始化了C运行时库,并确保当它接收到DLL_PROCESS_ATTACH通知时,所有的全局或静态C++对象都被创建了。为了解释这点,我们准备对以上的MyDLL.dll代码进行一些修改,如下,其中增加了一个类A,以及定义了一个全局变量a。
#include <Windows.h> #include <stdio.h> class A{ public: A(){ printf("A construct..."); } ~A(){ printf("A deconstruct..."); } }; A a; BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) { switch (ul_reason_for_call) { case DLL_PROCESS_ATTACH: printf("DLL_PROCESS_ATTACH\n"); break; case DLL_THREAD_ATTACH: printf("DLL_THREAD_ATTACH\n"); break; case DLL_THREAD_DETACH: printf("DLL_THREAD_DETACH\n"); break; case DLL_PROCESS_DETACH: printf("DLL_PROCESS_DETACH\n"); break; } return TRUE; }编译为DLL后,替换掉原来的的MyDLL.dll,可以直接运行TestDLL.exe,可以看到,在DLL_PROCESS_ATTACH调用了类A的构造函数,而在DLL_PROCESS_DETACH之后,调用了类A 的析构函数。
在经过以上的证实之后,实际上,我们也可以理解为什么我们之前说不必在DLL的源代码中实现一个DllMain函数,因为如果你没有DllMain函数,C运行库有它自己的一个DllMain函数。链接器链接DLL时,如果它在DLL的OBJ文件中找不到一个DllMain函数,就会链接C运行时的DllMain函数的实现。
当创建一个DLL时,实际上创建了一组能让EXE或其他DLL调用的一组函数。当一个DLL函数能被EXE或另一个DLL文件使用时,它被称为输出了(exported)。
这里,我们只说一种方法,即用_declspec(dllexport) 的方法。当然,也可以用def文件,但是,我们最常用的还是 _declspec(dllexport)的方法。什么是输出函数与输出变量。简单的来说,你开发一个dll之后,一般都是想让别的程序员开发的应用程序或dll调用,而输出变量就是为了完成这件事情的。想一想你在开发windows应用程序时调用的各种api,实际上,大部分也都是在dll中封起来的函数。废话不多少,上代码,我们还用以上的两个工程,MyDLL工程和TestDLL工程,但是这次,大幅度修改了代码,我们删去了DllMain函数,增加了一个函数,和一个全局整形变量,同时也修改了类A,这次,我们准备先来点正常。
其中MyDLL工程中的代码(注意,这里代码没有按照头文件与源代码的分离,仅仅为了更好理解知识,在工程项目中请勿模仿)
#include <Windows.h> #include <stdio.h> extern "C"{ class _declspec(dllexport) A{ public: A(){ printf("A construct...\n"); } const char * whoIsMe() { return "My name is A"; } ~A(){ printf("A deconstruct...\n"); } }; _declspec(dllexport) A a; _declspec(dllexport) int Add(int x,int y) { return x+y; } _declspec(dllexport) int g_nUsageCount = 3195; }
这里需要注意的是 _declspec(dllexport) ,代表了dll导出的意思,编译组建一下,你会发现,这次,我们得到的不在单单是一个dll文件,还有MyDLL.lib和MyDLL.exp文件,其中,这些文件的意思,请参见此文 .dll,.lib,.def 和 .exp文件
之后,我们这里决定先用上面提到的隐式链接的方法进行调用。我们需要先配置一下我们的TestDLL工程,配置方法如下,选择“工程”->“设置”弹出一下窗口,选择“链接”标签页,然后按照我们下面圈红的部分,添加上“MyDLL.lib”文件以及相应的附加库路径(及lib所在的位置),这里我们为了方便起见,把MyDLL工程的Debug文件夹下生成的dll与lib均拷贝到了TestDLL的Debug文件夹下。
之后,修改TestDLL工程的源代码如下,(这里,我们再次声明,我们没有用头文件的方式,非常不建议这样用。)
#include <Windows.h> #include <stdio.h> extern "C"{ _declspec(dllimport) int Add(int x,int y); _declspec(dllimport) int g_nUsageCount; class _declspec(dllimport) A{ public: A(); const char * whoIsMe(); ~A(); }; _declspec(dllimport) A a; } int main() { printf("%d\n",Add(5,3) ); printf("%d\n",g_nUsageCount); printf("%s\n",a.whoIsMe()); printf("-----------------------------\n"); A b; printf("%s\n",b.whoIsMe()); return 0; }这里,注意的是,在导出的位置,我们用的是 _declspec(dllexport) ,而在这里导入的时候,我们声明的时候,用的是 _declspec(dllimport) ,这个例子当中,我们分别导出了变量,函数,类。读者仅仅需要注意的是导入和导出关键字的使用。运行结果如下:
另外,大家可能对为什么要用 extern "C"括起来表示好奇,这里可以先推后考虑,我们在说到如何显式加载该文件时会提到。
这里,建议大家一下,如果将类A声明部分的构造函数删除,即改为
class _declspec(dllimport) A{ public: const char * whoIsMe(); };想想会发生什么,不妨动手试一下,这又是为什么?如果还理解,说明你可能对动态链接库的lib文件理解不够透彻,可以再读一读我们上面说的那篇文章 .dll,.lib,.def 和 .exp文件。
到这里,实际上我们已经大致说完动态链接库的相关内容,但是,既然我们上面提到了显式调用,那么,想过没有如果才能显式调用我们现在的这个dll文件,还有,那个extern “C”到底是什么,这里,我们还是先推荐一篇文章, extern "C"的用法解析
看了这么多,快疯了吧? 有点儿接受不了,告诉你,笔者也写的快疯了,come on ! 动手干活,先找一个工具,dumpbin,一般在你VC或VS的安装目录的某个bin文件夹下,搜一下就出来了(笔者的VC下的dumpbin不能用,所以用VS2013下的dumpbin了,但是,应该变化不大,如果不同,还请见谅),然后再cmd中运行,如笔者一样以下的截图一样,加上 -EXPORTS 参数,如下,
之后,去掉extern “C”,如下,
#include <Windows.h> #include <stdio.h> //extern "C"{ class _declspec(dllexport) A{ public: A(){ printf("A construct...\n"); } const char * whoIsMe() { return "My name is A"; } ~A(){ printf("A deconstruct...\n"); } }; _declspec(dllexport) A a; _declspec(dllexport) int Add(int x,int y) { return x+y; } _declspec(dllexport) int g_nUsageCount = 3195; //}再次编译成DLL,同样使用dumpbin工具,再次运行,如下,
两幅图对比着看,主要看我们用红框圈出来的部分,这样,特别是一会儿我们准备调用的Add方法,很容易发现,有extern "C"的直接为“Add”,而去掉extern "C"之后,变成了“?Add@@YAHHH@Z”,后面那一场串东西,实际上就是编译器为了解决重载问题加入的东西。
这里,我们为了调用方便,我们使用带有 extern "C"的版本,TestDLL代码如下:
#include <Windows.h> #include <stdio.h> int main() { HINSTANCE h = LoadLibrary("MyDLL"); int(*pAdd)(int,int); pAdd = (int(__cdecl *)(int,int))(GetProcAddress(h,"Add")); int sum = pAdd(239,23); printf("sum is %d\n",sum); FreeLibrary(h); return 0; }之后,最后一幅图,运行效果,
代码中主要用到的就是 GetProcAddress函数,来获取函数指针,之后通过函数指针调用Add函数,如果感兴趣的话,可以将pAdd的值输出出来,看一下,是否和我们用dumpbin看到的相互一致。而我们用extern "C"的原因在于,如果不使用的话,我们在调用GetProcAddress函数时,填第二个参数,将会令人头疼。
好了,结束。顺便一提,此文实际上还说的比较简略,如果想深入研究,还是找本书,细细研究几遍的好。