19  DLL基础

本章概览:

DLL和进程的地址空间... 2

纵览全局... 4

 

自从Microsoft Windows第一个版本的诞生之日起,动态链接库(dynamic-link library,后面简称为DLL)一直以来都是该操作系统的基石。Windows应用程序编程接口(application programming interfaceAPI)提供的所有函数都包含在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中的函数之前,必须将该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中的函数所释放呢?答案是:也许。上面显示的代码没有给我们以足够的信息。如果EXEDLL都链接到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++编写的,因此可能不会用mallocfree来进行内存分配。请务必小心,不要在代码中做这样的假设。顺便提一句,同样的道理也适用于C++newdelete操作符,因为它们会在内部调用mallocfree

纵览全局

为了完全理解DLL的工作方式,以及我们和系统是如何使用DLL的,让我们先纵览一下全局。图19-1概括了各组件是如何结合到一起的。

就目前而言,我们将集中讨论可执行文件和DLL模块是如何隐式地链接到一起的。隐式链接是迄今为止最常见的链接类型。此外,Windows还支持显式链接(会在第20章进行介绍)。


19-1  DLL是如何创建的,以及应用程序是如何隐式地链接到DLL

在图19-1中我们可以看到,当一个模块(比如一个可执行文件)用到了一个DLL中的函数或变量的时候,会牵涉到许多文件和组件。为了便于讨论,我们将从一个DLL中导入函数和变量的模块称为“可执行模块”,将导出函数和变量以供可执行文件使用的模块称为“DLL模块”。但是,请注意DLL模块也可以(而且经常会)导入一些包含在其他DLL模块中的函数和变量。

如果一个可执行模块需要从另一个DLL模块中导入函数和变量,那么我们必须先构建该DLL模块,然后才能构建该可执行模块。

构建DLL需要以下步骤:

  1. 必须先创建一个头文件,在其中包含我们想要在DLL中导出的函数原型、结构以及符号。为了构建该DLLDLL的所有源文件需要包含这个头文件。正如我们稍后可以看到,在构建可执行文件的时候需要用到同一个头文件。
  2. 创建C/C++源文件来实现想要在DLL模块中导出的函数和变量。由于在构建可执行模块的时候不需要这些源文件,因此创建该DLL的公司可以将这些源代码作为公司的机密。
  3. 在构建该DLL模块的时候,编译器会对每个源文件进行处理并产生一个.obj模块(每个源文件对应一个.obj模块)。
  4. 当所有.obj模块都创建完毕后,链接器会将所有.obj模块的内容合并起来,产生一个单独的DLL映像文件。这个映像文件(或模块)包含了DLL中所有的二进制代码以及全局/静态变量。为了执行可执行模块,这个文件是必需的。
  5. 如果链接器检测到DLL的源文件输出了至少一个函数或变量,那么链接器还会生成一个.lib文件。这个.lib文件非常小,这是因为它并不包含任何函数或变量。它只是列出了所有被导出的函数和变量的符号名。为了构建可执行模块,这个文件是必需的。

一旦构建了DLL模块,我们就可以通过下列步骤来构建可执行模块:

  1. 在所有引用了导出的函数、变量、数据结构或符号的源文件中,必须包含由DLL的开发人员所创建的头文件。
  2. 创建C/C++源文件来实现想要包含在可执行模块中的函数和变量。当然,代码可以引用在DLL的头文件中定义的函数和变量。
  3. 在构建可执行模块的时候,编译器会对每个源文件进行处理并产生一个.obj模块(每个源文件对应一个.obj模块)。
  4. 当所有.obj模块都创建完毕后,链接器会将所有.obj模块的内容合并起来,产生一个单独的可执行映像文件。这个映像文件(或模块)包含了可执行文件中所有的二进制代码以及全局/静态变量。该可执行模块还包含一个导入段[6],其中列出了所有它需要的DLL模块的名字。(有关段的更多信息,请参阅第17章。)此外,对列出的每个DLL,该段还记录了可执行文件的二进制代码从中引用的函数和变量的符号名。操作系统的加载程序会解析这个导入段,我们一会就会看到。

一旦DLL和可执行模块都已构建完毕,进程就可以执行了。当我们试图运行可执行模块的时候,操作系统的加载程序会执行下面的步骤。

  1. 加载程序先为新的进程创建一个虚拟地址空间,并将可执行模块映射到新进程的地址空间中。加载程序接着解析可执行模块的导入段。对导入段中列出的每个DLL,加载程序会在用户的系统中对该DLL模块进行定位,并将该DLL映射到进程的地址空间中。注意,由于DLL模块可以从其他DLL模块中导入函数和变量,因此DLL模块可能有自己的导入段并需要将它所需的DLL模块映射到进程的地址空间中。我们可以看到,初始化一个进程可能会耗费很长的时间。

一旦加载程序将可执行模块和所有DLL模块映射到进程的地址空间之后,进程的主线程可以开始执行,这样应用程序就能够运行了。接下来的几节我们将进一步分析这个过程。

 



[1] 译注:rich Web pages

[2] 译注:implicit load-time linking

[3] 译注:explicit run-time linking

[4] 译注:implicit linking

[5] 译注:explicit linking

[6] 译注:import section