DLL(Dynamic Link Library,动态连接库)是一个可以被其他应用程序调用的程序模块,其中封装了可以被调用的资源或函数。动态连接库的扩展名一般是DLL,不过有时也可能是其他的扩展名。DLL文件属于可执行文件,它符合Windows系统的PE文件格式,不过它是依附于EXE文件创建的进程来执行的,不能单独运行。一个DLL文件可以被多个进程所装载调用。
Windows操作系统下有非常多的DLL文件,有的是操作系统的DLL文件,有的是应用程序的DLL文件。使用DLL文件有什么好处呢?DLL是动态连接库,相对应地,有静态连接库。动态连接库是在EXE文件运行时被加载执行的,而静态连接库是OBJ文件进行连接时同时被保存到程序中的。动态连接库可以减少可执行文件的体积,在需要的时候进入内存;将软件划分为多个模块,可以按照模块进行开发,对于发布与升级也非常方便。在某些情况下,必须使用DLL才能完成一些工作内容。
本文通过一个简单的DLL程序来初步了解DLL程序的编写。
1. 编写简单的DLL程序
首先从一个简单的DLL程序开始,并在DLL程序中添加一个导出函数。所谓导出函数,就是DLL提供给外部EXE或其他类型的可执行文件调用的函数。当然,DLL本身也可以自身进行调用。
DLL程序的入口函数不是main()函数,也不是WinMain()函数,而是DllMain()函数,该函数的定义如下:
- BOOL WINAPI DllMain(
- HINSTANCE hinstDLL, // handle to the DLL module
- DWORD fdwReason, // reason for calling function
- LPVOID lpvReserved // reserved
- );
参数说明如下。
hinstDLL:该参数是当前 DLL 模块的句柄,即本动态连接库模块的实例句柄。
fdwReason:该参数表示 DllMain()函数被调用的原因。
该参数的取值有4种,也就是说存在4种调用DllMain()函数的情况,这4个值分别是DLL_PROCESS_ATTACH(当DLL被某进程加载时,DllMain()函数被调用)、DLL_PRO CESS_DETACH(当DLL被某进程卸载时,DllMain()函数被调用)、DLL_THREAD_ATTACH(当进程中有线程被创建时,DllMain()函数被调用)和DLL_THREAD_DETACH(当进程中有线程结束时,DllMain()函数被调用)。
lpvReserved:保留参数,即不被程序员使用的参数。
启动VC6集成开发环境,创建一个DLL工程。创建一个“A simple DLL Project”类型的工程,VC生成代码如下:
- BOOL APIENTRY DllMain( HANDLE hModule,DWORD ul_reason_for_call,LPVOID lpReserved)
- {
- return TRUE;
- }
在生成的代码中,函数定义处有一个APIENTRY的函数修饰符。该修饰符为一个宏,其定义如下:
- #define APIENTRY WINAPI
由于DllMain()函数不止一次地被调用,根据调用的情况不同,需要执行不同的代码,比如当进程加载该DLL文件时,可能在DLL中要申请一些资源;而在卸载该DLL时,则需要将先前自身所申请的资源进行释放。出于种种原因,在编写DLL程序时,需要把DllMain()函数的结构写成如下形式:
- BOOL APIENTRY DllMain( HANDLE hModule,DWORD ul_reason_for_call,LPVOID lpReserved)
- {
- switch ( ul_reason_for_call )
- {
- case DLL_PROCESS_ATTACH:
- {
- break;
- }
- case DLL_PROCESS_DETACH:
- {
- break;
- }
- case DLL_THREAD_ATTACH:
- {
- break;
- }
- case DLL_THREAD_DETACH:
- {
- break;
- }
- }
- return TRUE;
- }
这是一个switch/case结构,这样写可以达到根据不同的调用原因执行不同的代码。
2. 给DLL添加一个简单的导出函数
上面的代码只是一个简单的DLL程序的开始,并没有实际的意义。对于DLL文件来说,DllMain()并不是必需的。按照DLL文件的本质作用是为其他的可执行文件提供使用,那么DLL程序中需要编写能够提供其他程序使用的函数,这些公开提供给其他程序使用的函数被称为导出函数。在上面代码的基础上添加一个导出函数,定义如下:
- extern "C" __declspec(dllexport) VOID MsgBox(char *szMsg);
extern "C"表示该函数以 C 方式导出。由于源代码是.CPP 文件,因此,如果按照 C++的方式导出的话,那么在编译后函数名会被名字粉碎,导致在动态调用该函数时就会极为不方便。__declspec(dllexport)的作用是声明一个导出函数,将该函数从本 DLL 中开放提供给其他模块使用。
MsgBox()函数的实现如下:
- VOID MsgBox(char *szMsg)
- {
- char szModuleName[MAX_PATH] = { 0 };
- GetModuleFileName(NULL, szModuleName, MAX_PATH);
- MessageBox(NULL, szMsg, szModuleName, MB_OK);
- }
该函数在被调用时会在MessageBox窗口的栏处显示其所在进程的进程名。
这样,第一个DLL文件的编写就完成了。编译连接该代码,查看编译和连接的输出情况会发现VC共生成了2个文件,分别是“FirstDll.dll”和“FirstDll.lib”,前者是供其他可执行程序使用的DLL文件,其中包含了程序员编写的代码、导出函数,而后者是一个库文件,其中包含一些导出函数的相关信息,供调用DLL文件中导出函数函数的程序员编译时使用。
导出DLL中的函数有两种方法,这是其中的一种。另外一种方式是建立一个.DEF的文件来定义导出哪些函数。函数除了可以通过函数名导出外,还可以通过序号进行导出。建立.DEF文件可以较为方便地管理DLL项目中的导出函数(总比在代码中逐个找__declspec(dllexport)要方便很多)。由于这里的代码比较短小,因此使用了__declspec(dllexport)这种定义方法。
3. 对DLL程序的调用方法一
DLL程序是无法单独运行的,它需要通过编写一个EXE程序(当然也可以在另外的DLL程序中调用)来调用这个DLL文件中的导出函数。在VC集成开发环境中添加一个测试项目,在工作区的“Workspace ‘FirstDll’:1 project(s)”上单击右键,在弹出的菜单中选择“Add New Project to Workspace”,如图1所示。
图1 添加对DLL进行测试的项目
添加一个控制台的项目,然后编写对DLL进行调用的测试代码,具体如下:
- #include <windows.h>
- #pragma comment (lib, "FirstDll")
- extern "C" VOID MsgBox(char *szMsg);
- int main(int argc, char* argv[])
- {
- MsgBox("Hello First Dll !");
- return 0;
- }
#pragma comment (lib, "FirstDll")告诉连接器需要在FirstDll.lib文件中找到DLL中导出函数的信息。
对以上代码进行编译连接,VC会产生一个连接错误,如图2所示。
图2 连接出错信息
这个错误是因为连接器找不到“FirstDll.lib”文件。将“FirstDll.lib”复制到测试项目的目录下,然后添加到测试工程中,再次进行编译连接就成功了。运行编写好的测试程序,会弹出一个错误对话框,如图3所示。
图3 运行测试程序时的错误信息
根据错误提示可以看出是缺少要测试的DLL文件,也就是“FirstDll.dll”文件。将其复制到与可执行文件相同的目录下,然后再次运行,程序可以顺利地被执行。
一般在发布DLL文件时,需要将DLL文件、Lib文件和.h文件同时发布,当然有一个说明文档或手册会显得更加专业。
4. 对DLL程序的调用方法二
前一种方法属于静态调用,其方式是通过连接器将DLL函数的导出函数写进可执行文件。现在使用第二种方法来调用DLL中的函数,这种方法相对于前一种方法是动态调用。动态调用不是在连接时完成的,而是在运行时完成的。动态调用不会在可执行文件中写入DLL的相关信息。现在来写一个关于动态调用的测试程序,该程序的创建方法与静态调用的方法相同,这里不再复述。
动态调用DLL函数的代码如下:
- #include <windows.h>
- typedef VOID (*PFUNMSG)(char *);
- int main(int argc, char* argv[])
- {
- HMODULE hModule = LoadLibrary("FirstDll.dll");
- if ( hModule == NULL )
- {
- MessageBox(NULL, "FirstDll.dll 文件不存在","DLL 文件加载失败", MB_OK);
- return -1;
- }
- PFUNMSG pFunMsg = (PFUNMSG)GetProcAddress(hModule, "MsgBox");
- pFunMsg("Hello First Dll !");
- return 0;
- }
对代码进行编译连接都正常通过。但是请注意,这个程序中并没有用到#pragma comment()指令,也没有通过lib在程序中留下相关的导入信息。运行编译连接好的程序,程序会给出提示“FirstDll.dll文件不存在”。按照前面的方法,将FirstDll.dll文件复制到与测试程序相同的目录下,运行测试程序,程序执行成功。
DLL的动态加载调用是非常有用的。在第一个测试程序中,如果测试系统的装载器无法找到DLL文件,那么系统会直接报错而退出。而在第二个测试程序中,如果测试程序无法找到DLL文件,则由程序给出一个错误的提示,同时程序其实可以继续往下执行,而不会影响其他代码的运行(当然,由于DLL无法加载可能会损失部分的功能)。明白了动态加载调用和静态加载调用的区别,那么它们的优缺点就很清楚了。静态加载调用使用方便,而动态加载调用灵活性较好。
在有些情况下,必须使用动态加载调用的方法来使用DLL中的导出函数。比如函数OpenThread(),该函数在VC6自带的PSDK中没有提供LIB文件和函数原型定义,没有LIB文件就无法连接成功(在新版的PSDK中有该函数对应的LIB文件)。在这种情况下,只能使用LoadLibrary()和GetProcAddress()这两个函数来动态加载调用OpenThread()函数(其实有很多情况下,在使用DLL文件中的导出函数时是找不到对应的LIB文件的,比如ntdll.dll中的很多函数虽然有导出,但是系统没有提供与其对应的LIB文件)。
现在了解一下LoadLibrary()函数和GetProcAddress()函数的定义。LoadLibrary()函数的定义如下:
- HMODULE LoadLibrary( LPCTSTR lpFileName);
该函数只有一个参数,即要加载的DLL文件的文件名。该函数调用成功,则返回一个模块句柄。
GetProcAddress()函数的定义如下:
- FARPROC GetProcAddress( HMODULE hModule, LPCSTR lpProcName);
该函数有两个参数,分别如下。
hModule:该参数是模块句柄,通常通过 LoadLibrary()函数或 GetModuleHandle()函数获得;
lpProcName:该参数指定要获得函数地址的函数名称。
该函数调用成功,则返回lpProcName指向的函数名的函数地址。
5. 查看DLL程序导出函数的工具介绍
前面介绍DLL编程时提到了导出函数,这里介绍两款查看DLL程序的导出函数的工具。其中一款是VC自带的工具“Depends”,另一款工具是一个功能更加强大的可以用来查看PE结构和识别加壳信息的工具“PEID”。
首先用“Depends”来查看DLL的导出函数,该工具可以在VC6的安装菜单下找到,具体位置为“开始”→“程序”→“Microsoft Visual Studio 6.0”→“Microsoft Visual Studio 6.0 Tools”→“Depends”。打开该程序,依次单击菜单项“File”→“Open”,在“打开”对话框中找到所写的FirstDll.dll文件,选中并打开(也可以直接进行拖曳),其工作窗口中显示了FirstDll.dll的信息,如图4所示。
图4 Depends显示界面
在图4的右下角区域范围显示的是该DLL文件导出的函数。从图4中可以看出,FirstDll.dll文件只导出一个MsgBox函数。
对于Depends的介绍就这么多,现在来看另外一个工具“PEID”。该工具是用来识别软件“指纹”信息(开发环境、版本、加壳信息等)的。将FirstDll.dll文件拖曳到PEID界面上,PEID会自动解析出该DLL文件的PE结构信息,界面如图5所示。
图5 PEID显示界面
从图5可以看出,PEID最下方的只读编辑框中显示了FirstDll.dll文件是由VC6开发的,并且版本是Debug版本。单击“子系统”右边的“大于号”按钮,会显示PE结构的详细信息,如图6所示。
图6 PE结构详情
在图6中的PE结构详细信息的下半部分有个“目录信息”,其中的第一个目录信息就是导出表信息,单击“导出表”最右侧的“大于号”按钮,出现“导出查看器”界面,如图7所示。
图7 导出查看器
从图7中可以看出,FirstDll.dll文件只有一个导出函数MsgBox(),只存在一个导出项。导出函数的信息与Depends相同。