回炉重造之重读Windows核心编程-019-DLL基础

第19章 动态链接库

动态链接库一直是Windows的基础,WindowsAPI的所有函数都包含在DLL中。三个最重要的DLL是Kernel32.dll(管理内存、进程和线程的函数)、User32.dll(包含用于执行UI任务的函数)和GDI32.dll(包含画图和显示文本的各个函数)。还有别的DLL,用来做特殊任务的DLL,如AdvAPI.dll(用于实现对象安全性、注册表操作和事件记录的函数)、ComDlg32.dll(常用对话框)、ComCtrl32.dll则支持所有的常用窗口控件。
动态链接库之所以重要的原因:

  • 使得应用程序便于扩展。由于dll可以动态地装载到进程的地址空间中,因此应用程序可以在运行的使用决定使用什么特性。
  • 动态链接库可以使用多种语言来编写。用户界面使用Visual Basic是相对好的选择,但是处理商用逻辑就得用C++了。Windows可以接收用C++、Cobol和Fotran等语言开发的DLL。
  • 实现了功能的模块化,简化了项目管理,让不同的程序员搞定不同的业务,只要放出DLL就可以。
  • 还节省了内存。由于DLL装载之后,所有应用程序都可以共享它,那就可以在加载DLL后,其中常用的API不必多次加载才能获得地址。
  • 共享了资源。动态链接库中可以有对话框模板、字符串、图标和位图资源。这些都是可以被其他应用程序共享的。
  • 用于解决平台差异。不用版本的Windows有不同的函数,你的源代码如果包含了这些函数,就需要加载对应的动态链接库。
  • 用于特殊的目的,比如Hook、用于Windows Explorer的COM对象外壳程序。

19.1 DLL与进程的地址空间

创建DLL相对更容易,因为DLL往往包含的是可以供外部程序使用的函数,没有消息循环也不用创建窗口。在DLL代码被编译之后,链接的方式有/DLL开关,链接程序就会输出一个DLL。

DLL需要加载进程的地址空间才能被使用,至于加载的方式则有隐式和显式2种。后面将提到这点。而当DLL被加载之后,DLL中的代码和数据就像是原本就在进程中一样,而DLL已经失去了它作为DLL的特征。当DLL中的函数被调用的时候,线程会先去查看其堆栈,取出其中可能需要的参数,并传递给函数。还有DLL中创建的任何对象都归线程所有,DLL本身不拥有任何资源。

根据可执行文件的特性,它的全局变量和静态变量是不能被这种文件的不同实例所共享的。Win2K保证这一点的方式是使用所谓的写时拷贝(copy-on-write)机制。对于DLL也遵循同样的特性。

现在有一个问题,假如一个exe加载了一个dll,dll中有一个函数,返回函数中malloc出来的内存mem,而exe接收了这块内存。现在问题来了,谁应该来释放mem呢?exe方并不清楚这块内存是通过malloc还是new产生的,只有dll知道。只能是dll提供释放的操作。

19.2 DLL 的总体运行情况

下面将介绍DLL被隐式链接的过程,这是最常用的方式。在DLL被链接成功之后,DLL中的所有变量、函数、对象等信息都会被嵌入到进程空间中。

创造DLL:

  1. 建立带有输出原型/结构/符号的头文件。
  2. 建立实现输出函数/变量/的C/C++源文件。
  3. 编译器为每个C/C++源文件生成obj文件。
  4. 链接程序将生成DLL 的obj文件链接起来。
  5. 如果至少输出一个函数/变量,那么链接程序也生成lib文件。

创造EXE:

  1. 建立带有输出原型/结构/符号的头文件。
  2. 建立实现输出函数/变量/的C/C++源文件。
  3. 编译器为每个C/C++源文件生成obj文件。
  4. 链接程序将生成DLL 的obj文件链接起来,产生一个EXE文件。

运行应用程序:

  1. 加载程序为EXE创造地址空间。
  2. 将加载程序需要的DLL加载到上面的EXE的地址空间中。
  3. 进程的主线程开始执行:应用程序启动运行

如果想要输出函数和变量,那么就要有个DLL模块,然后就可以创建可执行模块。而创建一个DLL模块则有下面的步骤:

  1. 创建一个头文件,包含要输出的函数、结构和符号。DLL的源代码模块同样需要这个头文件来创建DLL。当后面利用DLL中的东西的时候,也需要这个头文件。
  2. 创建一个C++源代码模块,用来实现即将输出的函数和变量。这些函数的实现是不必要的。
  3. 创建DLL模块,使得编译器对每个源代码进行处理,产生一个obj模块。
  4. 当所有的obj文件创建完毕,链接程序就把obj中的内容组合在一起,产生一个DLL映像文件,这个影响文件包含了DLL的所有二进制代码和数据。为了执行这个DLL模块,这个文件是必不可少的。
  5. 如果链接程序发现DLL的源代码模块至少输出了一个函数或者变量,那么链接程序也生成一个lib文件。这个lib文件不含任何函数或者变量,只是描述所有已经输出的函数和变量的符号名。为了创建可执行文件,这个文件是必不可少的。
  6. 在引用函数、变量、数据等的所有源代码模块中,必须包含DLL开发人员建立的头文件。
  7. 要创建一个C/C++源代码模块,用于实现你想要在可执行模块中实现的函数和变量。当然该代码可以引用DLL文件中的函数和变量。
  8. 创建可执行模块,将编译器对每个源代码模块进行处理,生成一个OBJ模块。
  9. OBJ模块创建完毕后,链接程序就将所有OBJ模块的内容组合起来,生成一个可执行的影响文件。这个映像文件包含了可执行文件的所有二进制代码和数据。这个可执行模块还包含一个输出节,列举出可执行文件所需要的所有DLL模块名。此外,这个节指明了可执行模块的二进制代码引用了哪些函数和变量符号。
  10. 一旦DLL和可执行模块创建完成,一个进程就可以执行。当驶入执行可执行文件时,操作系统的 加载程序将执行下面的步骤:
    1. 加载程序为新进程创建一个虚拟地址空间。
    2. 可执行模块被映射到新进程的地址空间。
    3. 加载程序对可执行模块的输入节进行分析。对于该节中的每个DLL名字,加载程序要找出用户系统上的DLL模块,再将该DLL映射到进程的地址空间。
      1. 要注意的是DLL本身也可能会需要其他的DLL,所以DLL模块是可以有自己的输入节的。如果需要彻底的初始化,加载程序需要分析每个模块的输入节,将所有需要的DLL模块映射到进程地址空间。

一旦可执行模块和相关的DLL被映射到进程的地址空间中,进程的主线程就可以启动运行,同时应用程序也可以启动运行。下面会更加详细地介绍这个进程的运行情况。

19.3 创建DLL模块

DLL中应该避免输出的数据有2种,变量和类。

  1. 输出变量会删除你的代码中的一个抽象层,这会使得你的DLL代码更加难以维护。
  2. 只有当是哟个同一个供应商提供的编译器对输出c++类的模块进行编译时,才能输出C++类。因此也避免输出C++类。除非知道可执行模块的开发人员使用的工具与DLL模块开发人员使用的工具相同。

一般来说,一个DLL需要同时有一个头文件,供DLL创建程序和可执行模块的创建程序使用,就可以大大简化维护工作。下面的代码说明了如何对一个头文件进行编码:

/**********************************************************************************Module: MyLib.h**********************************************************************************/#ifdef MYLIBAPI// MYLIBAPI should be defined in all of the DLL;s source// code modules before this file is include#else // This header file is include by an EXE source code module.// Indicate that all function/variables are being imported.#ifdef MYLIBAPI extern "C" __declspec(dllexport)// Include the export data structures, symbols, functions, and variables.#include "MyLib.h"

当DLL源代码文件被编译时,在头文件的前面使用__declspec(dllexport)MYLIBAPI进行定义,这个定义使得让编译器知道被定义的变量、函数或者C++类是从DLL中输出的。要注意的是```MYLIBAPI``需要被放在要出的变量和函数之前。

当编写C++代码时,extern “C”修饰符是很必要的。这是因为C和C++编译器对代码进行命名粉碎的方式不同,链接器会抱怨有些可执行模块引用的符号不存在。

DLL这边如何使用头文件就是这样,EXE这边的情况又是怎么样呢?这边的头文件就需要将上面的MYLIBAPI重新定义成__declspec(dllimport),编译器看到这个定义就能从DLL中输入函数和变量。

而编写基础的Windows源代码之时,往往也会只导入一个头文件,如WinBase.h,就能使用头文件中提到的API和变量了。

19.3.1 输出的真正含义

上面提到,是修饰符declspec(dllexport),使得DLL中的函数和变量得以输出。当Microsoft的C/C++编译器看到这个修饰符之时,一些附加信息就被嵌入到生成的DLL中。在链接程序链接DLL的全部lib文件时,链接程序将对这些信息进行分析。

当D L L被链接时,链接程序要查找关于输出变量、函数或 C++类的信息,并自动生成一
个.lib文件。该.lib文件包含一个DLL输出的符号列表。除了创建.lib文件外,链接程序还要将一个输
出符号表嵌入产生的DLL文件。这个输出节包含一个输出变量、函数和类符号的列表(按字母
顺序排列)。该链接程序还将能够指明在何处找到每个符号的相对虚拟地址(RVA)放入DLL
模块。

通过Visual Studio的DumpBin.exe程序(带-export)开关,就能开到DLL 的输出节的内容。内容中的符号按字母排列,RVA这列的数字用于指明在DLL文件映像的什么位置能找到输出符号的位移量。hint提示码可供系统用来改进代码的运行性能,但是由于不同版本的提示码不同,在这里就不重要了。

19.3.2 创建用于非Visual C++工具的DLL

如果使用Visual C++ 创建的DLL是要链接到任何供应商的工具创建的可执行模块,那么必须做一些额外的工作。

根据上面提到的extern “C”的重要性,以及不同供应商的编译器对于C++类做命名粉碎的差异。比如函数的调用规则是__stdcall(也就是WINAPI),那么Microsoft的C编译器编译时会改变函数的名字:一个下划线、一个@符号和一个数字。这个数字表示传递给函数的参数的字节数。比如下面的代码:

__declspec(dllexport) LONG __stdcall MyFun(int a, int b);

如果另一个供应商编译了一个EXE,它将设法链接到函数MyFun,而这个函数的名字已经被改成_@8MyFun了,链接就会失败。为了避免这种情况,必须想办法让Microsoft的编译器输出改变前的函数名。有两种方法:

  1. 为编程项目建立一个def文件,并在def文件中加上类似的节:

    1.  EXPORTS MyFun

      这样当Microsoft的链接程序分析这个def文件时,发现_MyFun@8_MyFun都被输出。由于这两个名字是匹配的,因此链接程序输出的是MyFun的def文件来输出这个函数,而不是__MyFun@8。这样Microsoft的链接程序也会进行正确的操作,链接名字为MyFun的函数了。

  2. 在DLL的源代码中添加一些代码,也可以达到和上面一样的效果:

    1.  #pragma commet(linker, "/export:MyFun=_MyFun@8")

      这条代码会告诉一个名叫MyFun的函数将被输出,其进入点和名称为_MyFun@8的函数的进入点相同。第二种方式麻烦了一些,要自己截断函数名,而且需要两个符号。

19.4 创建可执行模块

下面的代码显示了一个可执行的源代码文件,它输入DLL的输出符号,并在代码中引用这些符号。

/*************************************************************************************Module: MyExeFile1.cpp*************************************************************************************/// Include the standard Windows and C-Runtime header files here.#include <windows.h>// Include the datastructures, symbols, functions and variables.#include "MyLib\MyLib.h"//////////////////////////////////////////////////////////////////////////////////////int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE, LPTSTR pszCmdLine, int) { int nLeft = 10, nRight; TCHAR szBuf[100]; wsprintf(szBuf, TEXT("%d + %d = %d"), nLeft, nRight, Add(nLeft,nRight)); MessageBox(NULL, szBuf, TEXT("Calculation"), MB_OK); wsprintf(szBuf, TEXT("The result from the last ad"))}//////////////////////////////End of file///////////////////////////////////////////

现在exe这边就需要DLL的头文件了,如果没有,输入的符号将不会被定义,而且编译器会发出许多警号和错误消息。

现在MYLIBAPI在EXE这边已经被定义成__declspec(dllimport)了,就在DLL所带的头文件中。编译器看到这个定义,就知道这个符号是从某个DLL模块输入了。编译器不关心是从哪个DLL模块输入的,只确保输入的方式正确。

接着,链接程序必须将所有obj模块组合起来,创建产生的可执行模块。该链接程序必须确定哪些DLL包含代码引用的所有输入符号的DLL。

只要你对calc.exe文件运行dumpbin程序,再加上-import,也就是dumpin -import calc.exe,输出的内容里有很多DLL,例如shell32.dll、msvcrt.dll、advapi32.dll、kernel32.dll、gdi32.dll和user32.dll,又在列举出了每一个dll输出的函数的名称,比如calc.exe就有Kernel32.dll这个文件,Kernel32.dll下则列举出了:lstrcpyW、LocalAlloc、GetCommandLineW和GetProfileIntW等。

19.5 运行可执行模块

当一个可执行模块被执行时,系统就会为新进程创建地址空间。然后加载程序将可执行模块映射到进程的地址空间中。加载程序查看执行模块的输入节,遍历出所有需要的DLL,再将DLL们也映射到新的地址空间中。

由于输入节只有DLL的名称而不是路径,因此加载程序必须搜索用于的磁盘驱动器,找出DLL,下面是搜索的目录的先后顺序:

  1. 包含执行影响文件的目录。
  2. 进程的当前目录。
  3. Windows系统目录。
  4. Windows目录。
  5. PATH环境变量中列举出的所有目录

在DLL模块被映射到进程的地址空间后,加载程序还要检查每一个DLL的输入节。如果输入节已经存在,加载程序就继续将其他需要的DLL映射进地址空间中。加载程序将保持对DLL模块的跟踪,使得模块的加载和映射只进行一次。

如果加载程序无法找到需要的DLL,那么就会弹出一个提示错误的MessageBoxW,标题就告诉你加载程序无法找到DLL,内容则是详细说明需要找的DLL名和在哪些目录查找过DLL。

找到DLL后,加载程序还要去确认DLL中是否所有的符号都有效。如果某些符号无效,那么就会弹出一个提示错误的MessageBoxW,标题就提示你整个应用程序都出错了,原因则是说无法在DLL中的函数对应的地址上找到这个函数。

如果这个符号存在,那么加载程序将要检索该符号的RVA,并添加到符号在进程的地址空间的位置。然后加载程序将虚拟地址保存在可执行模块的输入节中。此时,当代码引用了一个输入的符号,它将查看调用模块的输入节,并找到这个符号的地址。这样可执行程序就能成功地访问输入的变量、函数或者C++类的成员函数了。动态链接完成,进程的主线程开始执行,应用程序终于也开始执行了!

相关文章