自从Microsoft公司推出第一个版本的Windows操作系统以来,动态链接库(DLL)一直是这个操作系统的基础。WindowsAPI中的所有函数都包含在DLL中。3个最重要的DLL是Kernel32.dll,它包含用于管理内存、进程和线程的各个函数;User32.dll,它包含用于执行用户界面任务(如窗口的创建和消息的传送)的各个函数;GDI32.dll,它包含用于画图和显示文本的各个函数。Windows还配有若干别的DLL,它们提供了用于执行一些特殊任务的函数。例如,AdvAPI32.dll包含用于实现对象安全性、注册表操作和事件记录的函数;ComDlg32.dll包含常用对话框(如FileOpen和FileSave);ComCtl32.dll则支持所有的常用窗口控件。
本章将要介绍如何为应用程序创建DLL。下面是为什么要使用DLL的一些原因:
1、它们扩展了应用程序的特性。由于DLL能够动态地装入进程的地址空间,因此应用程序能够在运行时确定需要执行什么操作,然后装入相应的代码,以便根据需要执行这些操作。例如,当一家公司开发了一种产品,想要让其他公司改进或增强该产品的功能时,那么就可以使用DLL。
2、它们可以用许多种编程语言来编写。可以选择手头拥有的最好的语言来编写DLL。也许你的应用程序的用户界面使用MicrosoftVisualBasic编写得最好,但是用C++来处理它的商用逻辑更好。系统允许VisualBasic程序加载C++DLL、CobolDLL和FortranDLL等。
3、它们简化了软件项目的管理。如果在软件开发过程中不同的工作小组在不同的模块上工作,那么这个项目管理起来比较容易。但是,应用程序在销售时附带的文件应该尽量少一些。我知道有一家公司销售的产品附带了100个DLL——每个程序员最多有5个DLL。这样,应用程序的初始化时间将会长得吓人,因为系统必须打开100个磁盘文件之后,程序才能执行它的操作。
4、它们有助于节省内存。如果两个或多个应用程序使用同一个DLL,那么该DLL的页面只要放入RAM一次,所有的应用程序都可以共享它的各个页面。C/C++运行期库就是个极好的例子。许多应用程序都使用这个库。如果所有的应用程序都链接到这个静态库,那么sprintf、strcpy和malloc等函数的代码就要多次存在于内存中。但是,如果所有这些应用程序链接到DLLC/C++运行期库,那么这些函数的代码就只需要放入内存一次,这意味着内存的使用将更加有效。
5、它们有助于资源的共享。DLL可以包含对话框模板、字符串、图标和位图等资源。多个应用程序能够使用DLL来共享这些资源。
6、它们有助于应用程序的本地化。应用程序常常使用DLL对自己进行本地化。例如,只包含代码而不包含用户界面组件的应用程序可以加载包含本地化用户界面组件的DLL。
7、它们有助于解决平台差异。不同版本的Windows配有不同的函数。开发人员常常想要调用新的函数(如果它们存在于主机的Windows版本上的话)。但是,如果你的源代码包含了对一个新函数的调用,而你的应用程序将要在不能提供该函数的Windows版本上运行,那么操作系统的加载程序将拒绝运行你的进程。即使你实际上从不调用该函数,情况也是这样。如果将这些新函数保存在DLL中,那么应用程序就能够将它们加载到Windows的老版本上。当然,你仍然可以成功地调用该函数。
8、它们可以用于一些特殊的目的。Windows使得某些特性只能为DLL所用。例如,只有当DLL中包含某个挂钩通知函数的时候,才能安装某些挂钩(使用SetWindowsHookEx和SetWinEventHook来进行安装)。可以通过创建必须在DLL中生存的COM对象来扩展WindowsExplorer的外壳程序。对于可以由Web浏览器加载的、用于创建内容丰富的Web页的ActiveX控件来说,情况也是一样.
<!--mstheme-->DLL与进程的地址空间创建DLL常常比创建应用程序更容易,因为DLL往往包含一组应用程序可以使用的自主函数。在DLL中通常没有用来处理消息循环或创建窗口的支持代码。DLL只是一组源代码模块,每个模块包含了应用程序(可执行文件)或另一个DLL将要调用的一组函数。当所有源代码文件编译后,它们就像应用程序的可执行文件那样被链接程序所链接。但是,对于一个DLL来说,你必须设定该连链程序的/DLL开关。这个开关使得链接程序能够向产生的DLL文件映像发出稍有不同的信息,这样,操作系统加载程序就能将该文件映像视为一个DLL而不是应用程序。
在应用程序(或另一个DLL)能够调用DLL中的函数之前,DLL文件映像必须被映射到调用进程的地址空间中。若要进行这项操作,可以使用两种方法中的一种,即加载时的隐含链接或运行期的显式链接。隐含链接将在本章的后面部分介绍,显式链接将在第20章中介绍。
一旦DLL的文件映像被映射到调用进程的地址空间中,DLL的函数就可以供进程中运行的所有线程使用。实际上,DLL几乎将失去它作为DLL的全部特征。对于进程中的线程来说,DLL的代码和数据看上去就像恰巧是在进程的地址空间中的额外代码和数据一样。当一个线程调用DLL函数时,该DLL函数要查看线程的堆栈,以便检索它传递的参数,并将线程的堆栈用于它需要的任何局部变量。此外,DLL中函数的代码创建的任何对象均由调用线程所拥有,而DLL本身从来不拥有任何东西。
例如,如果VirtualAlloc函数被DLL中的一个函数调用,那么将从调用线程的进程地址空间中保留一个地址空间的区域,该地址空间区域将始终处于保留状态,因为系统并不跟踪DLL中的函数保留该区域的情况。保留区域由进程所拥有,只有在线程调用VirtualFree函数或者进程终止运行时才被释放。
如你所知,可执行文件的全局变量和静态变量不能被同一个可执行文件的多个运行实例共享。Windows98能够确保这一点,方法是在可执行文件被映射到进程的地址空间时为可执行文件的全局变量和静态变量分配相应的存储器。Windows2000确保这一点的方法是使用第13章介绍的写入时拷贝(copy-on-write)机制。DLL中的全局变量和静态变量的处理方法是完全相同的。当一个进程将DLL的映像文件映射到它的地址空间中去时,系统将同时创建全局数据变量和静态数据变量的实例。
注意 必须注意的是,单个地址空间是由一个可执行模块和若干个DLL模块组成的。这些模块中,有些可以链接到静态版本的C/C++运行期库,有些可以链接到一个DLL版本的C/C++运行期库,而有些模块(如果不是用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都链接到DLL的C/C++运行期库,那么上面的代码将能够很好地运行。但是,如果两个模块中的一个或者两个都链接到静态C/C++运行期库,那么对free函数的调用就会失败。我经常看到编程人员编写这样的代码,结果都失败了。
有一个很方便的方法可以解决这个问题。当一个模块提供一个用于分配内存块的函数时,该模块也必须提供释放内存的函数。让我们将上面的代码改写成下面的样子:
VOID EXEFunc(){
PVOID pv = DLLFunc();
//Access the storage pointed to by pv...
//Assumes that pv is in EXE's 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函数进行内存的分配。应该注意不要在代码中使用这些假设条件。另外,在内部调用malloc和free函数时,这个原则对于C++的new和delete操作符也是适用的。
2DLL的总体运行情况
为了全面理解DLL是如何运行的以及你和系统如何使用DLL,让我们首先观察一下DLL的整个运行情况。图19-1综合说明了它的所有组件一道配合运行的情况。
现在要重点介绍可执行模块和DL模块之间是如何隐含地互相链接的。隐含链接是最常用的链接类型。Windows也支持显式链接(第20章介绍这个问题)。
在图19-1中你可以看到,当一个模块(比如一个可执行文件)使用DLL中的函数或变量时,将有若干个文件和组件参与发挥作用。为了简单起见,我将“可执行模块”称为来自DLL的输入函数和变量,将“DLL模块”称为用于可执行模块的输出函数和变量。但是要记住,DLL模块能够(并且确实常常)输入包含在其他DLL模块中的函数和变量。
创造DLL:
1)建立带有输出原型/结构/符号的头文件。
2)建立实现输出函数/变量的C/C++源文件。
3)编译器为每个C/C++源文件生成.obj模块。
4)链接程序将生成DLL的.obj模块链接起来。
5)如果至少输出一个函数/变量,那么链接程序也生成lib文件。
创造EXE:
6)建立带有输入原型/结构/符号的头文件。
7)建立引用输入函数/变量的C/C++源文件。
8)编译器为每个C/C++源文件生成.obj源文件。
9)链接程序将各个.obj模块链接起来,产生一个.exe文件(它包含了所需要DLL模块的名字和输入符号的列表)。
运行应用程序:
10)加载程序为.exe创建地址空间。
11)加载程序将需要的DLL加载到地址空间中进程的主线程开始执行;应用程序启动运行。
图19-1应用程序如何创建和隐含链接DLL的示意图
若要创建一个从DLL模块输入函数和变量的可执行模块,必须首先创建一个DLL模块。然后就可以创建可执行模块。
若要创建DLL模块,必须执行下列操作步骤:
1)首先必须创建一个头文件,它包含你想要从DLL输出的函数原型、结构和符号。DLL的所有源代码模块均包含该头文件,以帮助创建DLL。后面将会看到,当创建需要使用DLL中包含的函数和变量的可执行模块(或多个模块)时,也需要这个头文件。
2)要创建一个C/C++源代码模块(或多个模块),用于实现你想要在DLL模块中实现的函数和变量。由于这些源代码模块在创建可执行模块时是不必要的,因此创建DLL的公司能够保护公司的秘密。
3)创建DLL模块,将使编译器对每个源代码模块进行处理,产生一个.obj模块(每个源代码模块有一个.obj模块)。
4)当所有的.obj模块创建完成后,链接程序将所有.obj模块的内容组合在一起,产生一个DLL映象文件。该映像文件(即模块)包含了用于DLL的所有二进制代码和全局/静态数据变量。为了执行这个可执行模块,该文件是必不可少的。
5)如果链接程序发现DLL的源代码模块至少输出了一个函数或变量,那么链接程序也生成一个.lib文件。这个.lib文件很小,因为它不包含任何函数或变量。它只是列出所有已输出函数和变量的符号名。为了创建可执行模块,该文件是必不可少的。
一旦创建了DLL模块,就可以创建可执行模块。其创建步骤是:
6)在引用函数、变量、数据、结构或符号的所有源代码模块中,必须包含DLL开发人员创
建的头文件。
7)要创建一个C/C++源代码模块(或多个模块),用于实现你想要在可执行模块中实现的函数和变量。当然该代码可以引用DLL头文件中定义的函数和变量。
8)创建可执行模块,将使编译器对每个源代码模块进行处理,生成一个.obj模块(每个源代码模块有一个.obj模块)。
9)当所有.obj模块创建完成后,链接程序便将所有的.obj模块的内容组合起来,生成一个可执行的映像文件。该映像文件(或模块)包含了可执行文件的所有二进制代码和全局/静态变量。该可执行模块还包含一个输入节,列出可执行文件需要的所有DLL模块名(关于各个节的详细说明,参见第17章)。此外,对于列出的每个DLL名字,该节指明了可执行模块的二进制代码引用了哪些函数和变量符号。下面你会看到操作系统的加载程序将对该输入节进行分析。一旦DLL和可执行模块创建完成,一个进程就可以执行。当试图运行可执行模块时,操作系统的加载程序将执行下面的操作步骤:
10)加载程序为新进程创建一个虚拟地址空间。可执行模块被映射到新进程的地址空间。加载程序对可执行模块的输入节进行分析。对于该节中列出的每个DLL名字,加载程序要找出用户系统上的DLL模块,再将该DLL映射到进程的地址空间。注意,由于DLL模块可以从另一个DLL模块输入函数和变量,因此DLL模块可以拥有它自己的输入节。若要对进程进行全面的初始化,加载程序要分析每个模块的输入节,并将所有需要的DLL模块映射到进程的地址空间。如你所见,对进程进行初始化是很费时间的。
一旦可执行模块和所有DLL模块被映射到进程的地址空间中,进程的主线程就可以启动运行,同时应用程序也可以启动运行。下面各节将更加详细地介绍这个进程的运行情况。
<!--mstheme-->