自从Microsoft
Windows第一个版本的诞生之日起,动态链接库(dynamic-link library,后面简称为DLL)一直以来都是该操作系统的基石。Windows应用程序编程接口(application programming interface,API)提供的所有函数都包含在DLL中。其中三个最重要的DLL分别是:Kernel32.dll,它包含的函数用来管理内存、进程以及线程;User32.dll,它包含的函数用来执行与用户界面相关的任务,比如创建窗口和发送消息;GDI32.dll,它包含的函数用来绘制图像和显示文字。
Windows还提供了其他一些DLL,用来执行更加专门的任务。例如,AdvAPI32.dll包含的函数与对象的安全性,注册表的操控,以及事件日志有关。ComDlg32.dll包含了一些常用的对话框(比如打开文件和保存文件),ComCtl32.dll支持所有常用的窗口控件。
本章我们将会学习如何在自己的应用程序中创建DLL。下面是为什么要使用DLL的一些理由:
创建DLL通常要比创建应用程序容易,这是因为DLL通常由一组可供任何应用程序使用的独立函数组成。在DLL中,通常没有用来处理消息循环或创建窗口的代码。DLL只不过是一组源代码模块,每个模块包含一些可供应用程序(可执行文件)或其他DLL调用的函数。在所有的源文件编译完成之后,链接器会像链接应用程序的可执行文件那样,对它们进行链接,但是,在创建DLL的时候我们必须给链接器指定/DLL开关。这个开关会使链接器在生成的DLL文件映像中保存一些与可执行文件略微不同的信息,这样操作系统的加载程序就能够将该文件映像识别为DLL,而不会将它识别为应用程序。
在应用程序(或其他DLL)能够调用一个DLL中的函数之前,必须将该DLL的文件映像映射到调用进程的地址空间中。我们可以通过两种方法来达到这一目的:隐式载入时链接[2]或显式运行时链接[3]。本章稍后会介绍隐式链接[4],显式链接[5]会在第20章中介绍。
一旦系统将一个DLL的文件映像映射到调用进程的地址空间中之后,进程中的所有线程就可以调用该DLL中的函数了。事实上,该DLL几乎完全丧失了它的DLL身份:对进程中的线程来说,该DLL中的代码和数据就像是一些附加的代码和数据,碰巧被放在进程地址空间中。当线程调用DLL中的一个函数的时候,该函数会在线程栈中取得传给它的参数,并使用线程栈来存放它需要的局部变量。此外,该DLL中的函数创建的任何对象都为调用线程或调用进程所拥有——DLL绝对不会拥有任何对象。
举个例子,如果DLL中的一个函数调用了VirtualAlloc,那么系统会从调用进程的地址空间中预订地址空间区域。如果稍后从进程的地址空间中撤销对DLL的映射,那么这块地址空间区域仍将保持被预订状态,这是因为虽然该区域事实上是由DLL中的函数所预订的,但系统并不会对此进行记录。被预订的区域为进程所拥有,只有当线程调用了VirtualFree函数或者当进程终止的时候,该区域才会被释放。
正如我们所知道的那样,如果运行同一个可执行文件的多个实例,那么这些实例不会共享可执行文件中的全局变量和静态变量。Windows通过使用第13章介绍的写时复制机制来保证这一点。DLL中的全局变量和局部变量也是通过完全相同的方法来处理的。当一个进程将一个DLL映像文件映射到自己的地址空间中时,系统也会为全局变量和静态变量创建新的实例。
注意 我们必须理解一个地址空间是由一个可执行模块和多个DLL模块构成的,这一点非常重要。这些模块中,有些可能会链接到C/C++运行库的静态版本,有些可能会链接到C/C++运行库的DLL版本,还有一些可能根本就不需要C/C++运行库(如果模块不是用C/C++编写的)。许多开发人员常犯的一个错误就是忘记了一个地址空间中可能会存在多个C/C++运行库。让我们来分析下面的代码:
VOID EXEFunc() {
PVOID pv = DLLFunc();
// Access the storage
pointed to by pv...
// Assumes
that pv is in EXE's C/C++ run-time heap
free(pv);
}
PVOID DLLFunc() {
// Allocate block from DLL's C/C++
run-time heap
return(malloc(100));
}
读者觉得怎么样?上面的代码能正常工作吗?DLL中的函数分配的内存块是否能为EXE中的函数所释放呢?答案是:也许。上面显示的代码没有给我们以足够的信息。如果EXE和DLL都链接到C/C++运行库的DLL版本,那么代码将能够正常工作。但是,如果其中之一或两个模块都链接到C/C++运行库的静态版本,那么free调用将会失败。开发人员编写出与此类似的代码并深受其害,这样的事情笔者已经见得太多了。
这个问题有一个简单的解决办法,那就是:当一个模块提供一个内存分配函数的时候,它必须同时提供另一个用来释放内存的函数。让我们来重写刚才那段代码:
VOID EXEFunc() {
PVOID pv = DLLFunc();
// Access the storage pointed to
by pv...
// Makes no assumptions about
C/C++ run-time heap
DLLFreeFunc(pv);
}
PVOID DLLFunc() {
// Allocate block from DLL's C/C++
run-time heap
PVOID pv = malloc(100);
return(pv);
}
BOOL DLLFreeFunc(PVOID pv)
{
// Free block from DLL's C/C++
run-time heap
return(free(pv));
}
这段代码是正确的,并且始终都能够正常工作。在编写一个模块的时候,不要忘记其他模块中的函数甚至可能不是用C/C++编写的,因此可能不会用malloc和free来进行内存分配。请务必小心,不要在代码中做这样的假设。顺便提一句,同样的道理也适用于C++的new和delete操作符,因为它们会在内部调用malloc和free。
为了完全理解DLL的工作方式,以及我们和系统是如何使用DLL的,让我们先纵览一下全局。图19-1概括了各组件是如何结合到一起的。
就目前而言,我们将集中讨论可执行文件和DLL模块是如何隐式地链接到一起的。隐式链接是迄今为止最常见的链接类型。此外,Windows还支持显式链接(会在第20章进行介绍)。

图19-1 DLL是如何创建的,以及应用程序是如何隐式地链接到DLL的
在图19-1中我们可以看到,当一个模块(比如一个可执行文件)用到了一个DLL中的函数或变量的时候,会牵涉到许多文件和组件。为了便于讨论,我们将从一个DLL中导入函数和变量的模块称为“可执行模块”,将导出函数和变量以供可执行文件使用的模块称为“DLL模块”。但是,请注意DLL模块也可以(而且经常会)导入一些包含在其他DLL模块中的函数和变量。
如果一个可执行模块需要从另一个DLL模块中导入函数和变量,那么我们必须先构建该DLL模块,然后才能构建该可执行模块。
构建DLL需要以下步骤:
一旦构建了DLL模块,我们就可以通过下列步骤来构建可执行模块:
一旦DLL和可执行模块都已构建完毕,进程就可以执行了。当我们试图运行可执行模块的时候,操作系统的加载程序会执行下面的步骤。
一旦加载程序将可执行模块和所有DLL模块映射到进程的地址空间之后,进程的主线程可以开始执行,这样应用程序就能够运行了。接下来的几节我们将进一步分析这个过程。