动态链接库(Dynamic Link Library)学习笔记(附PE文件分析)

作者:EricYou 转载请注明出处
 

注:本文所写的动态链接库指传统的DLL,并非是.NET中的Assembly.

         我对动态链接和动态链接库的概念并不陌,但一直以来就停留在概念的层面上,没有更深入的了解。今天抽空看了一下有关动态链接和动态链接库的文章,有了一些新的认识,当然不能忘了写在这里。那么现在就开始...
什么是动态链接和动态链接库
         动态链接(Dynamic Linking)是相对于静态链接(Static Linking)而言的。程序设计中,为了能做到代码和模块的重用,程序设计者常常将常用的功能函数做成库,当程序需要实现某种功能时,就直接调用库文件中的函数,从而实现了代码的重用。早期的程序设计中,可重用的函数模块以编译好的二进制代码形式放于静态库文件中,在MS的操作系统中是Lib为后缀的文件。程序编写时,如果用户程序调用到了静态库文件中的函数,则在程序编译时,编译器会自动将相关函数的二进制代码从静态库文件中复制到用户目标程序,与目标程序一起编译成可执行文件。这样做的确在编码阶段实现了代码的重用,减轻了程序设计者的负担,但并未在执行期实现重用。如一个程序a.exe使用了静态库中的 f() 函数,那么当a.exe有多个实例运行时,内存中实际上存在了多份f()的拷贝,造成了内存的浪费。
        随着技术的进步,出现了新的链接方式,即动态链接,从根本上解决了静态链接方式带来的问题。动态链接的处理方式与静态链接很相似,同样是将可重用代码放在一个单独的库文件中(在MS的操作系统中是以dll为后缀的文件,Linux下也有动态链接库,被称为Shared Object的so文件),所不同的是编译器在编译调用了动态链接库的程序时并不将库文件中的函数执行体复制到可执行文件中,而是只在可执行文件中保留一个函数调用的标记。当程序运行时,才由操作系统将动态链接库文件一并加载入内存,并映射到程序的地址空间中,这样就保证了程序能够正常调用到库文件中的函数。同时操作系统保证当程序有多个实例运行时,动态链接库也只有一份拷贝在内存中,也就是说动态链接库是在运行期共享的。
        使用动态链接方式带来了几大好处:首先是动态链接库和用户程序可以分开编写,这里的分开即可以指时间和空间的分开,也可以指开发语言的分开,这样就降低了程序的耦合度;其次由于动态链接独特的编译方式和运行方式,使得目标程序本身体积比静态链接时小,同时运行期又是共享动态链库,所以节省了磁盘存储空间和运行内存空间;最后一个是增加了程序的灵活性,可以实现诸如插件机制等功能。用过winamp的人都知道,它的很多功能都是以插件的形式提供的,这些插件就是一些动态链接库,主程序事先规定好了调用接口,只要是按照规定的调用接口写的插件,都能被winamp调用。
        WIndow 95、98、NT系列等系统都提供了动态链接库的功能,并且这些操作系统的系统调用大多都是通过动态链接库实现的,最常见的NT系列OS中的KENEL32.dll,USER32.dll,GDI32.dll等动态链接库文件就包含了大量的系统调用。在windows家族中,NT内核的操作系统在动态链接库机制上较之前的95、98系统要更安全。95、98系统在程序调用动态链接库时,将动态链接库加载到2G-3G之间的被称为进程共享空间的虚拟地址空间,并且所有进程关于这1G的虚拟地址空间的页表都是相同的,也就是说对于所有的进程,这片共享区的页表都指向同一组物理页,这样一来,加载入内存的的动态链接库对所有正在运行的进程都是可见的。如果一个动态链接库被其中一个进程更改,或其自身崩溃,将影响到所有调用它的进程,如果该动态链接库是系统的动态链接库,那么将导致系统的崩溃。在Windows NT系统中,动态链接库被映射到进程的用户地址空间中,并用Copy On Write机制保证动态链接库的共享安全,Copy On Write可以理解为写时拷贝。一般情况下,多个运行的进程还是按原来的模式共享同一个动态链接库,直到有进程需要向动态链接库的某个页面写数据时,系统将该页做一个拷贝,并将新复制页面的属性置为可读可写,最后修改进程的页表使之指向新拷贝的物理页。这样无论该进程怎么修改此页的数据,也不会影响到其他调用了此动态链接库的进程了。

Windows下动态链接库的编写

         因为本人对linux没有太多研究,所以这里只介绍windwos环境下动态链接库的编写。
         在VC中新建一个空的Win32动态链接库工程(Win32 Domanic Library),然后添加一个C++ Sourse File到工程,我这里的文件名取DllTest.cpp。然后在文件中添加如下内容:
 //DllTest.cpp
 
 _declspec(dllexport) int add(int a,int b)
 {
  return a+b;
 }
 
 _declspec(dllexport) int subtract(int a,int b)
 {
  return a-b;
 }
        接下来编译链接,就会在debug目录下生成一个调试版本的动态链接库,该链接库包含了add和subtract两个可供外部调用的函数。我们注意到,在源文件中多了一个没有见过的语句 _declspec(dllexport) ,这个语句的作用就是向编译器指出我需要在生成的动态链接库中导出的函数,没有导出的函数是不能被其他程序调用的。要知道一个动态链接库导出了什么函数,可以在命令提示行用命令"dumpbin -exports DllTest.dll"来查看(也可以用VC工具包中的depends使用程序来查看)。以下是用dumpbin命令查看DllTest.dll而生成的信息:
Dump of file DllTest.dll
 
File Type: DLL
 
  Section contains the following exports for DllTest.dll
 
           0 characteristics
    4420BEA4 time date stamp Wed Mar 22 11:04:04 2006
        0.00 version
           1 ordinal base
           2 number of functions
           2 number of names
 
    ordinal hint RVA      name
 
          1    0 0000100A ?add@@YAHHH@Z
          2    1 00001005 ?subtract@@YAHHH@Z
 
  Summary
 
        7000 .data
        1000 .idata
        3000 .rdata
        2000 .reloc
       2A000 .text
 
        可以看到,我们编写的动态链接库导出了两个函数,分别名为?add@@YAHHH@Z 和 ?subtract@@YAHHH@Z,为什么名字不是addsubtract呢?这是因为C++为了支持函数的重载,会在编译时将函数的参数类型信息以及返回值类型信息加入到函数名中,这样代码中名字一样的重载函数,在经过编译后就互相区分开了,调用时函数名也经过同样的处理,就能找到对应的函数了。编译器对函数的重命名规则是与调用方式相关的,在这里采用的是C++的默认调用方式。以此对应的还有stdcall方式、cdecl方式、fastcall方式和thiscall方式,不同调用方式的重命名规则不一样。
        需要特别说一下的是stdcall方式和cdecl方式:
        stdcall方式(标准调用方式)也即pascal调用方式,它的重命名规则是函数名自动加前导的下划线,后面紧跟一个@符号,其后紧跟着参数所占字节数,之所以要跟参数字节数,是因为stdcall采用被调函数平衡堆栈方式,用函数名最后的数字告诉编译器需要为函数平衡的字节数。例如,如果我们的DllTest.dll采用stdcall方式编译的话,导出的函数名将会是 _add@8 和 _subtract@8 ,而函数编译后的汇编代码最后一句一定是 ret8。
        cdecl方式即C语言调用方式,它的重命名规则仅仅是在函数名前加下划线(奇怪的是我用vc6编译的c语言函数,名字没有任何改变),因为C语言采用的是调用函数平衡堆栈的方式,所以不需要在函数名中加入参数所占的字节数,这样的堆栈平衡方式也使C语言可以编写出参数不固定的函数;同时C语言不支持函数重载,因此不需要在函数名中加入参数类型信息和返回值类型信息。
        
        动态链接库已经生成了,接下来就是调用的工作了。调用动态链接库有两种方式:隐式调用和显式调用,下面我们分别来看两种调用方式的具体过程:
动态链接库的隐式调用
        新建一个空的Win32 Console Application,命名为DllCaller,向工程中添加名为DllCaller.cpp 的C++ Sourse File,在文件中写入如下代码:
#include <iostream>
using namespace std;
//extern int add(int a,int b);
_declspec(dllimport) int add(int a,int b);
int main()
{
         cout<<"3+5="<<add(3,5)<<endl;
         return 1;
}
        编译,没有错误,链接,有两个错误:找不到外部引用符号。要怎样才能让我们的程序找到动态连接库中的函数呢?这里是关键的一步。到刚才的DllTest工程目录下,从debug文件夹中拷贝生成的DllTest.dll文件和DllTest.lib文件到DllCaller工程目录。然后依次在vc中选择菜单:Project -->Settings-->Liink, 在Object/library Modules中加入一项文件名:DllTest.lib,这里的DllTest.lib并不是静态库文件,而是DllTest.dll的导入库文件,它包含了DllTest.dll动态链接库导出的函数信息,只有在工程链接设置里添加了该文件,才能够使调用了该动态链接库的工程正确链接。完成以上步骤后,我们再编译链接工程,这次没有任何错误!程序可以顺利调用动态连接库文件,正常运行了(为了能使程序找到并加载需要的动态链接库,动态链接库文件必须与调用程序在同一个目录下,或在path环境变量指定的目录下)。
        这里需要说明一点,工程中的源文件在调用动态链接库中的函数时,需要提前声明,声名有两种方式,一种是传统的extern方式,一种是_declspec(dllimport)方式,这两种方式在代码中我都给出了。其中,第二种方式能使编译过程更快,所以推荐使用。
动态链接库的显式调用
        比起隐式调用,显示调用更加灵活,而且在编译链接时不需要lib导入库文件,也不需要提前声明函数。我们通过windows提供的API函数来动态加载动态连接库并调用其中的函数,用完后可以马上释放内存中的动态链接库,十分方便。下面就是显示调用动态链接库的代码:
#include <iostream>
#include <windows.h>
using namespace std;
int main()
{
         HINSTANCE hInstance=LoadLibrary("DllTest.dll");
         typedef int (*AddProc)(int,int);
         AddProc Add=(AddProc)GetProcAddress(hInstance,?add@@YAHHH@Z);
         if(!Add)
         {
                  cout<<"动态连接库库函数未找到"<<endl;
                  return 0;
         }
         cout<<"3+5="<<Add(3,5)<<endl;
         FreeLibrary(hInstance);
         return 1;
}
        以上代码并不复杂,首先定义一个实例句柄用来引用由Windows API 函数LoadLibrary加载的动态链接库,LoadLibrary函数的参数是一个字符串指针,具体调用时我们需要填入需要加载的动态链接库的位置及文件名,加载成功后返回一个实例句柄。接下来我们定义一个函数指针类型,用该类型声明一个函数指针,用来存储GetProcAddress函数返回的动态库函数入口地址。GetProcAddress能从指定的动态库中查找指定名字的函数,如果查找成功则返回该函数的入口地址,如果失败则返回NULL。更多GetProcAddress函数的用法请参看MSDN。有人可能注意到,GetProcAddress函数中指定的函数名并不是add,而是?add@@YAHHH@Z。这里就和前面将的函数调用方式联系起来了,在GetProcAddress函数中,我们指定的函数名必须是编译后经过重命名的函数名,而不是源文件中定义的函数名。这样实际上给我们的调用带来了相当大的麻烦,因为我们不可能去了解每一个经过重命名的导出函数名。好在微软已经给出了解决方法,那就是在编写动态链接库时同时编写一个以def为后缀的编译命名参考文件,如果动态链接库工程中有该文件,则编译器会根据该文件指定的函数名来导出动态库函数,关于def文件的详细使用方法请参考MSDN,这里就不一一赘述。找到需要的动态库函数后,我们就可以按需要对它进行调用,之后调用FreeLibrary函数释放动态库。因为动态库是多进程共享的,因此调用FreeLibrary函数并不意味着动态库在内存中被释放,每个动态库都有一个变量用来记录它的共享引用计数,而FreeLibrary的功能只是将这个记数减一,只有当一个动态库的引用计数为0时,它才会被操作系统释放。
       
隐式调用与显式调用的对比
         前面已经详细介绍了动态链接库的两种调用方法,相比之下,隐式调用在编程时比较简单,指定导入库文件后,不必考虑函数的重命名,就可以直接调用动态库函数。但由于隐式调用不能指定动态库的加载时机,因此在一个程序开始运行时,操作系统会将该程序需要的动态链接库都加载入内存,势必造成程序初始化的时间过长,影响用户体验。而显式调用采用动态加载的方法,用到什么加载什么,用完即释放,灵活性较高,可以使程序得到优化。具体运用中到底采用哪种方法,还要依实际情况而定。
 
http://qimo601.iteye.com/blog/1397935
上一篇:Spring.net 学习IOC------属性注入


下一篇:常用mimetype