链接库分为静态链接库和动态链接库,而动态链接库在使用时,又进一步分为装载时链接和运行时链接。装载时链接是指该动态链接库是在程序装入时进行加载链接的,而运行时链接是指该动态链接库是在程序运行时执行LoadLibrary(或LoadLibraryEx,下同)函数动态加载的。因此,由于动态链接库有这两种链接方式,所以在编写使用DLL的程序时,就有了两种可选方案。
可能有人会问“为什么需要装载时链接?直接静态链接不就行了吗?”,这是模块化程序设计的需要。试想,如果你开发一个很大的程序,并且经常需要更新。如果你选择静态链接,那么每次更新就必须更新整个exe文件,而如果你把需要经常更新的模块做成dll,那么只需要更新这个文件即可,每次程序运行时加载这个更新的文件即可。
在进入编写DLL程序之前,先介绍一些相关知识。
VC支持三种DLL,它们分别是Non-MFC DLL、MFC Regular DLL、MFC Extension DLL。由于本文只讲解API编程,所以这里只对第一种DLL进行介绍,后面两种DLL将在另外的文章中介绍。
动态链接库的标准后缀是.DLL,当然也可以使用其它任意后缀名。但使用.DLL后缀的好处是:一是,很直观表明该文件的性质;二是,只有后缀为.DLL的动态链接库才能被Windows自动地加载,而其它后缀的动态链接库只能通过LoadLibrary显示式加载。
动态链接库的用途:一是作为动态函数库使用,另一个常用的方式是作为动态资源库。当然,没有绝对的划分,比如你的动态函数库时也可能有资源,但动态资源库一般不会有函数。
另两个重要的、需要区分的概念是:对象库(Object Library)和导入库(Import Library)。对象库是指普通的库文件,比如C运行时库libc.lib;而导入库是一种比较特殊的对象库文件,与一个动态链接库相对应。它们都有后缀.lib,并且都仅在程序编译链接时使用,被链接器用来解析函数调用。然而,导入库不包含代码,它只为链接器提供动态链接库的信息,以便于链接器对动态链接库中的对象作恰当地链接。
动态链接库的查找规则。如果在使用时没有指定动态链接库的路径,则Windows系统按如下顺序搜索该动态链接库:使用该动态链接库的.exe文件所在目录、当前目录、Windows系统目录、Windows目录、环境变量%PATH%中的路径下的目录。
DLL内的函数划分为两种类型:(1)导出函数,可供应用程序调用;(2) 内部函数(普通函数),只能在DLL程序内使用,应用程序无法调用它们。同样的划分适用于数据对象。
在DLL中,要导出某个对象(函数或者数据),声明方式有两种:一种是利用关键字__declspec(dllexport);另一种方式是采用模块定义文件(.def)。另外,还可以通过链接选项/EXPORT指定导出。应该优先选用第一种方式,但.def文件方式在某些情况下是必须的。
下面,我们分别介绍动态链接库的的制作、发布、使用及相关技术,重点介绍装载时链接和运行时链接的使用方法。在介绍运行时链接时,引入了模块定义文件(.def),详细介绍了其在DLL制作过程中的作用及使用方法。另外,还介绍了DLL中全局变量的导出、DLL中的数据共享和资源DLL的制作及使用。
动态链接库的制作及装载时链接
首先,打开VC6.0,创建一个名为DLLTest的空工作区。然后,创建一个名为DLL_Lib的Win32 Dynamic-Link Library工程,注意将该工程添加到刚创建的工作区DLLTest中,并且将该工程保存在工作区的目录下(不建子目录)。然后,在该工程中,加入这下面两个文件:
/* * dll_lib.h */ #ifndef DLL_LIB_H #define DLL_LIB_H #ifdef __cplusplus #define EXPORT extern "C" __declspec (dllexport) #else #define EXPORT __declspec (dllexport) #endif EXPORT int WINAPI GetMax(int a, int b); #endif |
/* * dll_lib.c */ #include <windows.h> #include <stdio.h> #include "dll_lib.h" int WINAPI DllMain (HINSTANCE hInstance, DWORD fdwReason, PVOID pvReserved) { switch (fdwReason) { case DLL_PROCESS_ATTACH: printf("> process attach of dll\n"); break; case DLL_THREAD_ATTACH: printf("> thread attach of dll\n"); break; case DLL_THREAD_DETACH: printf("> thread detach of dll\n"); break; case DLL_PROCESS_DETACH: printf("> process detach of dll\n"); break; } return TRUE; } int GetMax(int a, int b) { return a > b ? a : b; } |
接着,再创建一个Win32 Console Application工程DLL_Test,同样将该工程加入先前的DLLTest工作区中,并直接保存在该工作区目录下。然后向工程DLL_Test加入下面的文件:
/* * testMain.c */ #include <windows.h> #include <stdio.h> #include "dll_lib.h" int main() { int a = 2; int b = 3; printf(" max(2, 3) = %d\n", GetMax(2, 3)); return 0; } |
此时,工作差不多做完了,但还需进行一下设置。在Project|Settings里,把两个工程里的General标签里的Intermediate files和Output files都设置为Debug。这样确保两个工程的输出文件在一个目录中,以便后面动态库链接时的查找。另外,设置DLL_Test为活动工程(Project|Set Active Project),设置DLL_Test依赖于DLL_Lib(Project|Dependencies)。此时,就可以编译运行了。运行结果为:
> process attach of dll
max(2, 3) = 3
> process detach of dll
Press any key to continue
下面对上面的代码和结果进行分析。
在dll_lib.h中,EXPORT宏实质上就是一个导出函数所需要的关键字。__declspec (dllexport)是Windows扩展关键字的组合,表示DLL里的对象的存储类型关键字。extern "C"用于C++程序使用该函数时的函数声明的链接属性。WINAPI是宏定义,等价于__stdcall。下面列出Windows编程中常见的几种有关调用约定的宏,它们都是与__stdcall和__cdecl有关的(from windef.h):
#define CALLBACK __stdcall // 用于回调函数
#define WINAPI __stdcall // 用于API函数
#define WINAPIV __cdecl
#define APIENTRY WINAPI
#define APIPRIVATE __stdcall
#define PASCAL __stdcall
另外,关于__stdcall:如果通过VC++编写的DLL欲被其他语言编写的程序调用,应将函数的调用约定声明为__stdcall方式,WINAPI、CALLBACK都采用这种方式,而C/C++缺省的调用方式却为__cdecl。__stdcall方式与__cdecl对函数名最终生成符号的方式不同。若采用C编译方式(在C++中需将函数声明为extern "C"),__stdcall调用约定在输出函数名前面加下划线,后面加“@”符号和参数的字节数,形如_functionName@number ,而__cdecl调用约定仅在输出函数名前面加下划线,形如_functionName。(小技巧:如何查看这些符号?写一个程序,只提供函数的声明而不给定义,就可以看到链接器给出的符号了)
因此,在前面例子中,该DLL声明了一个导出函数GetMax,其连接属性采用CALLBACK(即__stdcall)。另外,请注意,例子中的宏EXPORT会根据是在C程序还是在C++程序中被调用选择相应的连接方式。在定义导出函数时,不需要EXPORT宏,只需要在函数声明时使用即可。
DllMain函数在DLL载入和卸载时被调用。它的第一个参数是DLL句柄,第三个参数保留。第二个参数用来区分该DLLMain函数是在什么情况下被调用的,如程序所示。如果初始化成功,则DllMain应该返回一个非零值。如果返回零值将导致程序停止运行(你可以修改上面例子中的DllMain的返回值为0,将看到相应的出错结果)。如果在你的DLL程序中没有编写DllMain函数,那么在执行该DLL时,系统将引入一个不做任何操作的缺省DllMain函数版本。
在前面的例子中,给出了DLL的制作及使用。注意,我们在使用DLL时,直接关联了两个工程。如果你想把自己制作的DLL提供给别人使用,而又不想提供源代码,那应该怎么做呢?
由文章最开始的分析知,要达到这个目的,只需要提供给DLL用户三个文件即可:.h文件,.lib文件和.dll文件。当然,对于dll_lib库,我们只需要提供dll_lib.h, dll_lib.lib, dll_lib.dll三个文件即可。
用户应该怎么使用些文件呢?我们利用前面的工程进行介绍。首先将前面两个工程的依赖关系去掉,并设置DLL_Test工程为当前活动工程。先编译下下试试,你会发现,编译器在链接时会发生错误,提示不能完成GetMax函数的链接。然后,找到Project|Settings|Link|Object/Library Modules,往里加入库文件debug\dll_lib.lib。再次链接,OK!运行,结果跟最先的结果一模一样。小结:(1)库用户在调用DLL的导出函数的文件中包含库头文件;(2)将与.dll对应的.lib库文件加入工程的链接库中;(3)在.exe文件所在目录中放入一份.dll文件的拷贝。当然,如果是已经发布的.exe程序使用的.dll需要更新,此时只需要将.dll替换原来的.dll即可。
运行时链接
前面介绍了DLL的制作及相关技术和它的装载时链接,下面介绍运行时链接的方法。还是接着利用前面的例子,需要做一点小小的修改:把DLL_Lib工程里的GetMax函数的WINAPI调用约定暂时先去掉(后面将说明为什么这样做),然后编译该工程。然后,将testMain函数作如下修改:
/* * testMain.c */ #include <windows.h> #include <stdio.h> typedef int (* PGetMax)(int, int); int main() { int a = 2; int b = 3; HINSTANCE hDll; // DLL句柄 PGetMax pGetMax; // 函数指针 hDll = LoadLibrary(".\\Debug\\DLL_lib.dll"); if (hDll == NULL) { printf("Can't find library file \"dll_lib.dll\"\n"); exit(1); } pGetMax = (PGetMax)GetProcAddress(hDll, "GetMax"); if (pGetMax == NULL) { printf("Can't find function \"GetMax\"\n"); exit(1); } printf(" max(2, 3) = %d\n", pGetMax(2, 3)); FreeLibrary(hDll); return 0; } |
此时,不再需要动态的.h文件和.lib文件,只需要提供.dll文件即可。在具体使用时,先用LoadLibrary加载Dll文件,然后用GetProcAddress寻找函数的地址,此时必须提供该函数的在Dll中的名字(不一定与函数名相同)。
然后编译链接、运行,结果与前面的运行结果相同。
下面将解释,为什么前面要去掉WINAPI调用约定(即采用默认的__cdecl方式)。我们可以先看看DLL_Lib.dll里面的链接符号。在cmd中运行命令:
dumpbin /exports DLL_Lib.dll
得到如下结果:
Dump of file f:\code\DLLTest\Debug\Dll_lib.dll File Type: DLL Section contains the following exports for DLL_Lib.dll 0 characteristics 4652C3B1 time date stamp Tue May 22 18:19:29 2007 0.00 version 1 ordinal base 1 number of functions 1 number of names ordinal hint RVA name 1 0 0000100A GetMax Summary 4000 .data 1000 .idata 3000 .rdata 2000 .reloc 28000 .text |
可以看到GetMax函数在编译后在Dll中的名字仍为GetMax,所以在前面的程序中使用的是:
pGetMax = (PGetMax)GetProcAddress(hDll, "GetMax");
然后,我们把WINAPI添加回去,重新编译DLL_Lib工程。运行刚才的DLL_Test程序,运行出错,结果如下:
> process attach of dll
Can't find function "GetMax"
> process detach of dll
Press any key to continue
显然,运行失败原因是因为没有找到GetMax函数。再次运行命令:dumpbin /exports DLL_Lib.dll,结果如下(部分结果):
1 ordinal base 1 number of functions 1 number of names ordinal hint RVA name 1 0 0000100A _GetMax@8 |
从上面dumpbin的输出看,GetMax函数在WINAPI调用约定方式下在DLL里的名字与源码中的函数定义时的名字不再相同,其导出名是"_GetMax@8"。此时,你把testMain.c中的函数指针类型声明和函数查找语句作如下修改:
typedef int (WINAPI* PGetMax)(int, int);
pGetMax = (PGetMax)GetProcAddress(hDll, "_GetMax@8");
再次编译链接,然后运行,发现结果又正确了。
现在找到了问题所在。很显然,这种修改方式并不适用,而默认生成的名字又不是我们所想要的。那么该怎么解决这个问题呢?这就需要用到.def文件来解决。
模块定义文件(.def)
模块定义文件(.def文件)是一个描述DLL的各种属性的文件,可以包含一个或多个模块定义语句。如果你不使用关键字__declspec(dllexport)关键字导出DLL中的函数,那么DLL就需要一个.def文件。
一个最小的.def文件必须包含下面的模块定义语句:
(1)文件中第一个语句必须是LIBRARY语句。该语句标记该.def文件属于哪个DLL。语法形式为:LIBRARY 。
(2)EXPORTS语句列表。第一个导出语句的形式为:entryname[=internalname] [@ordinal],列出DLL中要导出的函数的名字和可选的序号(ordinal value)。要导出的函数名可以是程序源码中的函数名,也可以定义新的函数别名(但后面必须紧跟[=<原函数名>]);序号必须在范围1到N之间且不能重复,其中N是DLL中导出的函数个数。因此,EXPORTS语句语法形式为:
EXPORTS
[=]
[=]
;...
(3)虽然不是必须的,一个.def文件也常常包含DESCRIPTION语句,用来描述该DLL的用途之类,语法形式为:
DESCRIPTION ""
(4)在任意位置,可以包含注释语句,以分号(;)开始。
例如,在本文中后面将用到的.def文件为:
; DLL_Lib.def LIBRARY DLL_Lib ; the dll name DESCRIPTION "Learn how to use the dll." EXPORTS GetMax @1 Max=GetMax @2 ; alias name of GetMax ; Ok, over |
现在,让我们回到DLL_Lib工程,修改GetMax函数的声明,把EXPORT去掉,重新编译该工程。然后,运行dumpbin命令,我们发现此时没有导出函数。再将上面的DLL_Lib.def文件添加进DLL_Lib工程,再次编译,并运行dumpbin命令,得到如下结果(引用部分结果):
1 ordinal base 2 number of functions 2 number of names ordinal hint RVA name 1 0 0000100A GetMax 2 1 0000100A Max |
正如我们所预期的,有两个导出函数GetMax和Max。注意,此时源码中的GetMax函数的导出名不再是默认的“_GetMax@8”。另外,需要注意的是,两个导出函数有相同的相对虚拟地址(RVA),也说明了两个导出名实质是同一个函数的不同名字而已,都是源码中GetMax函数的导出名。
现在,回到DLL_Test工程,修改testMain.c文件内容如下:
/* * testMain.c */ #include <windows.h> #include <stdio.h> typedef int (WINAPI* PGetMax)(int, int); int main() { int a = 2; int b = 3; HINSTANCE hDll; // DLL句柄 PGetMax pGetMax; // 函数指针 hDll = LoadLibrary(".\\Debug\\DLL_lib.dll"); if (hDll == NULL) { printf("Can't find library file \"dll_lib.dll\"\n"); exit(1); } pGetMax = (PGetMax)GetProcAddress(hDll, "GetMax"); if (pGetMax == NULL) { printf("Can't find function \"GetMax\"\n"); exit(1); } printf(" GetMax(2, 3) = %d\n", pGetMax(2, 3)); pGetMax = (PGetMax)GetProcAddress(hDll, "Max"); if (pGetMax == NULL) { printf("Can't find function \"GetMax\"\n"); exit(1); } printf(" Max(2, 3) = %d\n", pGetMax(2, 3)); FreeLibrary(hDll); return 0; } |
编译链接、运行,结果如下:
> process attach of dll
GetMax(2, 3) = 3
Max(2, 3) = 3
> process detach of dll
Press any key to continue
运行结果正如前面分析的那样,GetMax和Max都得到了相同的结果。
到这里,我们解决了DLL导出函数名在各种调用约定下的默认名可能不同于源码中函数名的问题。此时,你就可以制作跟Windows的自带API函数库相同的库了:使用__stdcall调用约定以满足Windows下的任何语言都可以调用DLL库,同时使用函数名作为导出名,以方便用户使用DLL里的函数。
导出全局变量
前面我们介绍了DLL中的函数的导出方法,这里也介绍一下DLL中全局变量的导出。
首先需要明确的是,当多个应用程序同时使用同一个DLL时,系统中只有一个DLL实例(这里主要指代码段,一般不包含数据段)。也就是说,如果没有特殊处理,DLL中的数据都是每个使用DLL的应用都保留一份副本的(但是,可以根据需要实现DLL数据的共享,后面进行介绍)。因此,使用DLL的各应用程序之间不会发生干扰。
要导出DLL中的全局变量,方法与导出函数基本一样。只是,在定义.def文件时,在EXPORTS定义语句之后用DATA标识符表明这是变量。例如: g_oneNumber DATA 或者 g_oneNumber @3 DATA。
在使用DLL中导出的全局变量时,对于前面DLL的两种链接方式,有不同的方法。其中,对于运行时链接的DLL,其使用方法与函数一样(流程:LoadLibrary, GetProcAddress),只是在使用时要知道这是一个变量的地址,而不再是一个函数的地址即可(其实,用dumpbin工具查看DLL的导出列表,会发现导出的数据也被当作函数计数)。 对于装载时链接,要导入DLL中的变量,有点与函数不一样的地方,那就是必须显示地用关键字__declspec(dllimport)导入DLL中的变量。例如,在使用前面的g_oneNumber前,应先导入: __declspec(dllimport) extern int g_oneNumber 。然后,其它与函数的使用方法无异。
共享DLL中的数据
有时,可能需要在使用DLL的多个应用之间共享DLL的数据,而默认情况下,DLL的数据是每个应用拥有一份副本的。要实现这个需求,就需要做些特殊处理。
首先,定义一个数据段,里面有需要共享的变量,并要初始化这些变量。然后设置该数据段为共享即可,比较简单。例如,要在DLL中共享int型变量g_oneNumber,那么应按如下方式定义该变量:
#pragma data_seg ("shared")
int g_oneNumber = 0;
#pragma data_seg ()
#pragma comment(linker,"/SECTION:shared,RWS")
对上面的代码做些解释:#pragma data_seg ("shared")创建了一个数据段,命名为Shared;#pragma data_seg()标记该数据段的结束;它们之间定义的是该数据段中的变量。注意:这里对变量的初始化是必须的,否则,编译器会把未初始化的变量放在普通的未初始化数据段,而不是在共享的数据段。
#pragma comment(linker, "SECTION:shared,RWS")告诉链接器shared数据段具有RWS属性。这里的RWS是指Read、Write和Shared三个属性。也可以在IDE中设置工程属性:在Settings|Link|Project Options中,添加链接参数:/SECTION:shared,RWS。
资源DLL的制作及使用
有了前面的基础,资源DLL的制作及使用相对简单多了。如果是纯资源DLL的话(没有导出函数),那么只需要定义一个有DLLMain函数的文件即可,然后加入资源,编译成DLL库即可。在使用时,只需要动态加载这个资源库,然后加载库里的资源即可。例如,资源库里有位图资源,那么只需要LoadBitmap即可。