分享

DLL基础

 royy 2013-07-25

自从Microsoft Windows第一个版本的诞生之日起,动态链接库(dynamic-link library,DLL)一直以来都是该操作系统的基石。Windows应用程序编程接口(application programming interface,API)提供的所有函数都包含在DLL中,其中3个最重要的DLL分别是:Kennel32.dll,包含的函数用来管理内存、进程、线程;User32.dll,包含的函数用来执行与用户界面相关的任务,如创建窗口和发送消息;GDI32.dll,包含的函数用来绘制图像和显示文字。

      Windows还提供了其它一些DLL,用来执行更加专门的任务。例如:AdvAPI32.dll包含的函数 与 对象的安全性、注册表的操控以及事件日志有关。ComDlg32.dll包含的一些常用的对话框(如:打开和保存文件对话框),ComCtl32.dll支持所有常用的窗口控件。为什么使用DLL在此就不做过多的描述了。

1、DLL和进程的地址空间

      创建DLL通常比创建应用程序容易,因为DLL通常由一组可供任何应用程序使用的独立函数组成。在DLL中,通常没有用来处理消息循环或创建窗口的代码。DLL只不过是一组源代码模块,每个模块包含一些可供应用程序(可执行文件)或其它DLL调用的函数。在所有的源文件编译完成之后,链接器会像链接应用程序的可执行文件那样,对它们进行链接,但在创建DLL的时候,我们必须给链接器指定DLL开关(如:_declspec(dllexport))。 这个开关会使链接器在生成的DLL文件映像中保存一些与可执行文件略微不同的信息,这样操作系统的加载程序就能够将该文件映像识别为DLL,而不会将它识别为应用程序。

      在应用程序(或其它DLL)能够调用一个DLL中的函数之前,必须将该DLL的文件映像 映射到 调用进程的地址空间中。我们可以通过2中方法来达到这种目的:隐式 载入时链接(implicit load-time linking) 或 显式运行时链接(explicit run-time linking)。

      一旦系统将一个DLL的文件映像 映射到调用进程的地址空间中之后,进程中的所有线程就可以调用该DLL中的函数了。事实上,该DLL几乎完全丧失了它的DLL身份:对调用进程中的线程来说,该DLL中的代码和数据就像是一些附加的代码和数据,碰巧被放在进程地址空间中。当线程调用DLL中的一个函数的时候,该函数会在线程中取得传给他的参数,并使用线程栈来存放它需要的局部变量。此外,该DLL中的函数创建的任何对象都为调用线程或调用进程所拥有----DLL绝对不会拥有任何对象。

      举个例子,如果DLL中的一个函数调用了VirtualAlloc,系统就会从调用进程的地址空间中预定地址空间区域。如何稍候从进程的地址空间中撤销对DLL的映射,那么这块地址空间区域仍将保持被预定状态,因为虽然该区域事实上是由DLL中的函数所预定的,但系统并不会对此进行记录。被预定的区域被进程所拥有,只有当线程调用了VirtualFree函数或者当进程终止的时候,该区域才会被释放。

      正如我们所知道的那样,如果运用同一个可执行文件的多个实例,这些实例将不会共享可执行文件中的全局变量和静态变量。Windows通过使用写时复制机制来保证这一点。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);

}

PVVOID DLLFunc()

{

      return (malloc(100));//Allocate block from DLL's C/C++ run-time heap

}

前面的代码能正常工作吗?DLL中的函数分配的内存块是否能被EXE中函数所释放呢?答案是:也许。前面显示的代码没有给我们足够的信息。如果EXE和DLL都链接到C/C++运行库的DLL版本,那么代码将能够正常工作。但是,如果其中之一或2个模块都链接到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

      free(pv);

}

PVVOID DLLFunc()

{

      return (malloc(100));//Allocate block from DLL's C/C++ run-time heap

}

BOOL DLLFreeFunc()

{

      //Free block from DLL's C/C++ run-time heap

      return (free(pv));

}

这段代码是正确的,并且始终都能正常工作,在编写一个模块的时候,不要忘记其它模块中的函数甚至不是用C/C++编写的,因此,可能不会用malloc和free来进行内存分配。请务必小心,不要再代码中做这样的假设。顺便提一句,同样的道理也适用于C++的new和delete操作符,因为它会在内部调用malloc和free。

2、纵观全局

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

就目前而言,我们将集中讨论课执行文件和DLL模块是如何隐式的链接到一起的。隐式链接是迄今为止最常见的链接类型。此外,Windows还支持显式链接。当一个模块(比如一个可执行文件)用到了一个DLL中的函数或变量的时候,会牵涉到许多文件和组件。为了便于讨论,我将从一个DLL中导入函数和变量的模块称为“可执行模块”,将 导出函数和变量以供可执行文件使用的模块称为“DLL模块”。但请注意,DLL模块也可以导入一些包含在其它DLL模块中函数和变量。如果一个可执行模块需要从另一个DLL模块中导入函数和变量。我们必须先构建DLL模块,然后再构建该可执行模块。

构建DLL需要以下步骤:

1)必须先创建一个头文件,在其中包含我们想要在DLL中导出的函数原型、结构、符号等。为了构建该DLL,DLL的所有源文件需要包含这个头文件。正如我们稍后可以看到,在构建可执行文件的时候需要用到同一个头文件。

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模块的内容合并起来,产生一个单独的可执行映像文件。这个映像文件(或模块)包含了可执行文件中所有的二进制代码以及全局/静态变量。该可执行模块还包含一个导入段(import section),其中列出了所有它需要的DLL模块的名称,此外对列出的每个DLL,该段还记录了 可执行文件的二进制代码 从中引用的函数和变量的符号名。操作系统的加载程序会解析这个导入段,我们一会就会看到。一旦DLL和可执行模块都构建完毕,进程就可以执行了。当我们试图运行可执行模块的时候,操作系统的加载程序会执行下面的步骤。

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

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

2.1构建DLL模块

      在创建一个DLL的时候,我们事实上是在创建一组可供一个可执行模块(或其它DLL)调用的函数。一个DLL可以导出变量、函数或C++类来供其它模块使用。在实际开发中,我们应该避免从一个DLL中导出变量,因为这等于是去掉了代码的一个抽象层,从而使得DLL的代码更加难以维护。此外,只有当 导出 C++类的模块 使用的编译器 与 导入 C++类的模块使用的编译器由同一厂商提供时,我们才可以导出C++类。因此,除非知道可执行模块的开发人员与DLL模块的开发人员使用的是相同的工具包,否则我们应该避免从DLL中导出C++ 类。

      在创建DLL的时候,我们应该首先创建一个头文件来包含想要导出的变量和函数(原型),这个头文件还必须定义导出的函数或变量所用到的任何符号和数据结构。DLL的所有源文件都应该包含这个头文件。另外,我们必须分析这个头文件,这样任何可能需要导入这些函数或变量的源文件就可以包含该头文件。让DLL的构建者和可执行模块的构建者使用同一个头文件可以使维护变得更加容易。

下面的代码展示了如何构建这个头文件,以便让可执行模块和DLL的源文件都能够包含它:

[c-sharp] view plaincopy?
  1. //---------------------------------------------------------------.h文件  
  2. #ifdef MYLIBAPI  
  3. #else  
  4. #define MYLIBAPI extern "C" _declspec(dllimport)  
  5. #endif  
  6. MYLIBAPI int g_nResult;  
  7. MYLIBAPI int Add(int nLeft,int nRight);  
  8. //---------------------------------------------------------------.cpp文件  
  9. #define MYLIBAPI extern "C" _declspec(dllexport)  
  10. #include "Dll1.h"  
  11. int g_nResult;  
  12. int Add(int nLeft,int nRight)  
  13. {  
  14.     g_nResult=nLeft+nRight;  
  15.     return nLeft+nRight;  
  16. }  
 

在编译前面的DLL源文件的时候,MYLIBAPI在包含.h文件之前被定义为 _declspec(dllexport)。如果编译器看到一个变量、函数、C++类是用 _declspec(dllexport)修饰的,那么他就知道应该在生成的DLL模块中导出该变量、函数、C++类。注意:对那些要被导出的变量和函数,我们必须在头文件的变量和函数定义的前面加上MYLIBAPI标识符。

      另外要注意的是,在源文件中,不必要在导出的变量和函数前面加MYLIBAPI标识符的原因是编译器在解析头文件的时候会记住应该导出哪些函数或变量。

      MYLIBAPI符号后面包含了extern “C”修饰符,只有在编写C++代码的时候,才应该使用这个修饰符,在编写C代码的时候,不应该使用该修饰符。C++编译器通常会对函数名和变量名进行改编(mangle),这在链接的时候会导致严重的问题。举个例子,假设一个DLL是用
C++编写的,而可执行文件使用C编写的。在构建DLL的时候,编译器会对名字进行改编,但在构建可执行文件的时候,编译器不会对函数名进行改编。当链接器试图链接可执行文件的时候, 会发现可执行文件引用了一个不存在的符号并报错。extern “C”用来告诉编译器不要对变量名或函数名进行改编,这样用C、C++或任何编程语言编写的可执行模块都可以访问该函数或变量。

      现在我们已经看到了DLL源文件应该如何使用这个头文件,那么可执行文件的源文件又如何呢?可执行文件不应该在包含这个头文件之前定义MYLIBAPI。由于MYLIBAPI未定义,因此头文件会将MYLIBAPI定义为_declspec(dllimport),这样编译器就知道该可执行文件的源文件要从DLL模块中导入一些变量和函数。

2.2、何为导出

      前一节介绍的最有意思的内容,莫过于_declspec(dllexport)修饰符了,当Microsoft的C/C++编译器看到用这个修饰符修饰的变量、函数原型或C++类的时候,会在生成的.obj文件中嵌入一些额外的信息。当链接器在链接DLL中所有的.obj文件时,会解释这些信息。

      在链接DLL的时候,链接器会检测到这些 与导出的变量、函数、类有关的嵌入信息。并生成一个.lib文件。这个.lib文件列出了改DLL导出的符号。在链接任何可执行模块的时候,只要可执行模块引用了该DLL导出的符号,那么这个.lib文件当然是必需的。除了创建这个.lib文件之外,链接器还会在生成的DLL文件中嵌入一个导出符号表。这个导出段(export section)列出了导出的变量、函数、和类的符号名。链接器还会保存相对虚拟地址(relative virtual address RVA),表示每个符号可以在DLL模块中的何处找到。

      我们可以使用Microsoft Visual Studio提供的dumpbin.exe工具(加上-export开关)来查看一个DLL的导出段。导出段的输出信息中,导出函数的名字是按字母顺序排列的,RVA这一列中的数值表示一个偏移量,导出的函数位于DLL映像文件中的这个位置,ordinal这一列是为了与16位Windows源代码保持向后兼容而保留的(导出函数的序号),现在的应用程序不应该再使用。hint这一列是系统用例提供性能的,对我们来说无关紧要。

2.3、为非Visual C++工具包创建DLL

      如果在创建DLL和可执行文件的时候使用的都是MicroSoft Visual C++ ,那么可以略过这一节而且不必担心会漏掉重要内容。但是,如果用Visual C++创建的DLL要与其它厂商的工具包构建的可执行文件链接,就必须做一些额外的工作。

      如前所述,在混合使用C和C++编程的时候,要使用extern "C"修饰符,我们还提到过,由于C++类的名字改编问题,我们必须使用同一家编译器厂商提供的工具包。即使完全用C来编程,但使用了不同厂商提供的工具包,还是会遇到另外一个问题。这个问题就是,如何函数的调用约定发生了变化,那么即使使用了extern "C"限定符,该函数的名字仍会发生改编。C++编译器默认的调用约定是C调用约定,如果改为标准调用约定WINAPI(即:_stdcall、pascal调用约定)来导出C函数的时候,MicroSoft的编译器会对函数名进行改编,具体的方法是给函数名添加下划线前缀和一个特殊的后缀,该后缀由一个@符号跟作为参数传递给函数的字节数组成。例如:函数_declspec(dllexport) LONG _stdcall MyFunc(int a,int b);在DLL的导出段中被导出为_MyFunc@8。如果用另一家厂商提供的工具包来构建可执行文件,链接器将视图链接到一个名为MyFunc的函数,由于该函数在MicroSoft编译器生成的DLL中并不存在,因此链接会失败。

      为了用Microsoft的工具包来构建一个能与其它编译厂商的工具包链接的DLL,我们必须告诉Microsoft编译器不要对函数名进行改编。我们可以通过2中方法来达到这一目的。第1种方法是为我们的项目创建一个.def文件,并在.def文件中包含一段类似下面的EXPORT段:

LIBRARY 动态链接库的内部名称

EXPORTS

      MyFunc

其中LIBRARY 语句用来指定动态链接库的内部名称,该名称与生成的动态链接库的名称一定要匹配。这句代码并不是必须的。EXPORTS语句的作用是表明DLL将要导出的函数,以及为这些导出函数指定的符号名。当链接器在链接时,会分析这个.def文件,当发现EXPORT下面有

MyFunc这个符号名,并且它与源文件中定义的MyFunc函数的名字是一样的时候,它就会以MyFunc这个符号名导出相应的函数。如果将要导出的符号名和源文件中定义的函数名不一样,则可以按照下述语法指定导出函数:

entryname=internalname其中等号左边的entryname项是导出的符号名,右边的internalname是DLL中将要导出的函数的名字。

      如果不想使用.def文件,那么我们可以用第二种方法来导出未经改编的函数名,我们可以在DLL的源文件中添加一行类似下面的代码:

#pragma comment(linker,"/export:add=_add@8")

这行代码会使得编译器产生一个链接器指示符,该指示符告诉链接器要导出一个名为add的函数,该函数的入口点与_add@8相同。与第一种方法相比,第二种方法相对来说不太方便,因为在写这行代码的时候,我们必须自己对函数名进行改编。另外,在使用这行代码的时候,DLL实际上导出了2个符号,即add和_add@8,他们都对应于同一个函数,而第一种方法只导出了add符号。第二种方法并没有特别之处,它只不过能让我们避免使用.def文件而已。代码如下:

  1. #pragma comment(linker,"/export:add=?add@@YGHHH@Z")  
  2. _declspec(dllexport)  int _stdcall add(int a,int b)  
  3. {  
  4.     return a+b;  
  5. }  
  6. //输出结果:  
  7.   ordinal hint RVA      name  
  8.         1    0 00001005 ?add@@YGHHH@Z  
  9.         2    1 00001005 add  
 

2.4构建可执行模块

      在编写可执行模块的源文件的时候,我们必须包含DLL作者提供的头文件,如果不这样做,导入的符号将得不到定义,编译器会产生大量的警告和错误。

      可执行模块的源文件在包含 DLL的头文件 之前不要定义DLL_API,在编译这个可执行模块源文件时,DLL_API在MyLib.h中被定义为_declspec(dllimports)。如果编译器看到一个变量、函数、C++类是用_eclspec(dllimports)来修饰的,那么它会知道应该从某个DLL模块中导入该符号。编译器不知道,也不需要知道具体的DLL模块是哪一个。编译器只想确认我们以正确的方式来访问这些导入的符号。现在我们就可以在源代码中使用导入的符号了,一切都如我们预计的那样正常工作。

      接下来,为了创建可执行模块,链接器须将所有的.obj模块合并到一起。由于链接器必须确定代码中引用的导入符来自哪个DLL,因此我们必须将DLL的.lib文件传给链接器。正如前面我们已经提过,.lib文件只不过是列出了DLL模块导出的符号。链接器只想知道被引用的符号确实存在,以及该符号来自哪个DLL模块,如果链接器能够解决对所有外部符号的引用,那么他将生成可执行模块。

何为导入:

      前一小节提到了_declspec(dllimport)修饰符,在导入符号的时候,不必使用_declspec(dllimport)关键字,而可以做直接使用C语言的extern关键字。但是,如果编译器能够提前知道我们引用的符号是从一个DLL的.lib文件中导入的,那么他将能够产生略微高效的代码。有鉴于此,我建议再导入函数的时候使用_declspec(dllimport)关键字。如果调用的是标准的Windows函数,那么Microsoft已经替我们准备好了。

      当链接器在解决导入符号的时候,会在生成的可执行模块中嵌入一个特殊的段,它的名字叫导入段(import section)。导入段列出了该模块所需要的DLL模块,以及它从每个DLL模块中引用的符号。

      我们可以使用Visual Studio的dumpbin.exe工具(加上-imports开关)来查看一个模块的导入段。

2.5运行可执行模块

      启动一个可执行模块的时候,操作系统的加载程序会先为进程创建虚拟地址空间,接着把可执行模块映射到进程的地址空间中。之后加载程序会检查可执行模块的导入段,试图对所需的DLL进行定位并将他们映射到进程的地址空间中。

      由于导入段只包含DLL的名称,不包含DLL的路径,因此加载程序必须在用户的磁盘上搜索DLL。下面是加载程序的搜索顺序:

1)包含可执行文件的目录

2)Windows的系统目录,该目录可以通过GetSystemDirectory得到

3)16位的系统目录,即Windows目录中的System子目录

4)Windows目录,该目录可以通过GetWindowDirectory得到

5)进程的当前目录

6)PATH环境变量中所列出的目录

注意:对应于程序当前目录的搜索位于Windows目录之后,这个改变始于Windows XP SP2,其目的是为了防止加载程序在应用程序才当前目录中找到伪造的系统DLL并将他们载入,从而保证系统DLL始终都是从它们在Windows目录中的正式位置载入的。MSDN联机帮助提到HKEY_LOCAL_MACHINE/SYSTEM/CurrentControlSet/Control/Session Manager注册表项中的一个DWORD值可以用来改变这个搜索顺序,但如果不想让恶意软件危害我们的机器,那么我们绝对不应该设置这个DWORD值。另外请注意,还有其它一些东西可能会对加载程序如何搜索DLL产生影响。

      随着加载程序将DLL模块映射到进程的地址空间中,它会同时检查每个DLL的导入段。如果一个DLL有导入段(通常如此),那么加载程序会继续将所需的额外DLL模块映射到进程的地址空间中。由于加载程序会对载入的DLL模块进行记录,因此即使多个模块用到了同一个模块,该模块也只会被载入和映射一次。

      如果加载程序无法对所需的一个DLL模块进行定位,那么用户就会看到一个提示没有找到 DLL文件的 提示消息框。当加载程序将所有DLL模块都载入并映射到进程的地址空间中后,它开始修复所有对导入符号的引用。为了完成这一工作,它会再次查看每个模块的导入段。对导入段中列出的每个符号,加载程序会检查对应DLL的导出段,看该符号是否存在,如果该符号不存在(这种情况实属罕见)那么加载程序会显示 函数在DLL中不存在 的消息提示。

      如果该符号存在,那么加载程序会取得该符号在DLL中的RVA并给他加上DLL模块被载入到的虚拟地址(从而得到符号在进程的地址空间中的地址)。接着加载程序会将这个虚拟地址保存到可执行模块的导入段中。现在,当代码引用到一个导入符号的时候,会查看调用模块的导入段并得到被导入符号的地址,这样就能够成功的访问被导入的变量、函数、C++类成员函数了。看----动态链接库就这样完成了,进程的主线程开始执行,应用程序最终运行起来了。

      加载程序要载入所有这些DLL模块,并用所有 导出符号的正确地址 来修复 每个模块的导入段,这自然需要相当多是时间。由于这项工作是在进程初始化的时候完成的,因此它不会对应用程序的性能产生影响。但是,对许多应用程序来说,初始化过程太慢也是不可接受的,为了减少应用程序的载入时间,我们应该对自己的可执行模块和DLL模块进行 基地址重定位和绑定。这两项技术及其重要,但不幸的是,很少有开发人员知道该如何应用它们。如果每家公司都应用这两项技术,那么整个系统会运行的更好。事实上,我认为操作系统应该发布一个工具来自动的执行这些操作。后面会对 基地址重定位和绑定进行介绍。

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多