程序员的自我修养九Windows下的动态链接

9.1 DLL简介

DLL即动态链接库的缩写,它相对于Linux下的共享对象。
Windows下的DLL文件和EXE文件实际上是一个概念,它们都是有PE格式的二进制文件。
微软希望通过DLL机制加强软件的模块化设计,使得各种模块之间能够松散地组合、重用和升级。

9.1.1 进程地址空间和内存管理

一个DLL在不同进程中拥有不同的使用数据副本。在ELF中,由于代码段是地址无关的,所以它可以实现多个进程之间共享一份代码,但是DLL的代码却并不是地址无关的,所以它只是在某些情况下可以被多个进程间共享。

9.1.2 基地址和RVA

PE里面有两个概念就是基地址相对地址。当一个PE文件被加载时,其进程空间中起始地址就是基地址。对于任何一个PE文件来说,它都有一个优先装载的基地址,这个值就是PE文件头中的Image Base。
对于一个可执行EXE文件来说,Image Base一般值是0x400000,对于DLL文件来说,这个值一般是0x10000000。

9.1.3 DLL共享数据段

正常情况下,每个DLL的数据段在各个进程中都是独立的,每个进程都拥有自己的副本。但是Windows允许将DLL的数据段设置成共享的,即任何进程都可以共享该DLL的同一份数据段。比较常见的做法是将一些需要共享的变量分离出来,放到另外一个数据段中,然后设置成进程之间共享的。也就是说,一个DLL有两个数据段,一个是进程间共享,一个是私有的。

9.1.4 DLL的简单例子

导出概念:在ELF中,共享库所有的全局函数和变量在默认情况下都可以被其他模块使用,也就是说ELF默认导出所有的全局符号。但是在DLL中情况是,我们需要显式的告诉编译器我们需要导出的某个符号,否则编译器默认所有符号都不导出。当我们在程序中使用DLL导出符号时,这个过程被称为导入
在C/C++中,可以使用”_declspec”属性关键字来修饰某个函数或者变量。当使用_declspec(dllexport)时,表示该符号是从本DLL导出符号,_declspec(dllimport)是表示该符号是从别的DLL导入符号。

9.1.5 创建DLL

__declspec(dllexport) double Add(double a, double b) {
return a + b;
}

__declspec(dllexport) double Sub(double a, double b) {
return a - b;
}

__declspec(dllexport) double Mul(double a, double b) {
return a * b;
}

执行:

cl /LDd Math.c

9.1.6 使用DLL

程序使用DLL的过程其实是引用DLL中的导出函数和符号过程,即导入过程。

#include<stdio.h>
__declspec(dllimport) double Sub(double a,double b);

int main(int argc,char **argv)
{
double result=Sub(3.0,2.0);
printf("Result = %f/n",result);
return 0;
}

使用下面命令将TestMath.c编译成TestMath.obj。

cl /c TestMath.c

使用链接器将TestMath.obj和Math.lib链接起来产生一个可执行文件TestMath.exe。

link TestMath.obj Math.lib

这个过程如下图:

Math.lib中并不包含正在Math.c的代码和数据,它描述Math.dll的导出符号,它包含了TestMath.o链接到Math.dll导入符号以及一部分桩代码,又称作”胶水”代码。Math.lib文件被称为导入库

9.1.7 使用模块定义文件

声明DLL中某个函数为导出函数的办法有两种:

  • 一种就是前面使用的”__declspec(dllexport)”
  • 另外一种就是采用模块定义(.def)文件声明。

.def文件用于控制链接过程,为链接器提供有关链接程序的导出符号、属性、以及其他信息。
使用”_stdcall”调用规范的函数Add就会被修饰成”_Add@16”,前面以开头,后面以@n结尾,n表示函数调用时参数所占堆栈空间大小。使用.def文件可以将导出函数重新命名。
微软以DLL的形式提供Windows的API,而每个DLL中的导出函数又以这种”__stdcall”的方式声明。但也采用了导出函数重命名的方法。

9.1.8 DLL显示运行时链接

DLL也支持运行时链接,即运行时加载,Windows提供了3个API为:

  • LoadLibrary,这个函数用来装载一个DLL到进程空间,它的功能和dlopen类似
  • GetProcAddress,用来查找某个符号地址,与dlsym类似
  • FreeLibrary,用来卸载某个已加载的模块,与dlclose类似
#include<Windows.h>
#include<stdio.h>

typedef double(*Func)(double, double);

int main(int argc, char **argv) {
Func function;
double result;
float i=1;
HINSTANCE hinstLib = LoadLibrary("Math.dll");
if (hinstLib == NULL) {
printf("ERROR");
}
function =(Func)GetProcAddress(hinstLib, "Add");
if (function==NULL)
{
printf("ERROR");
}
result = function(1.0, 6.0);
FreeLibrary(hinstLib);
printf("Result=%f\n", result);
scanf_s("%f", i);
return 0;
}

9.2 符号导出导入表

9.2.1 导出表

当一个PE文件需要将一些函数或变量提供给其他PE文件使用时,我们把这种行为叫做符号导出,最典型的情况就是一个DLL将符号给EXE文件使用。
在Windows PE中,所有导出的符号被集中存放在被称作导出表的结构中。导出表最简单的结构上来看,它提供了一个符号名与符号地址的映射关系。
导出表的结构,它被存放在”Winnt.h”中:

typedef struct _IMAGE_EXPORT_DIRECTORY {
DWORD Characteristics;
DWORD TimeDateStamp;
WORD MajorVersion;
WORD MinorVersion;
DWORD Name;
DWORD Base;
DWORD NumberOfFunctions;
DWORD NumberOfNames;
DWORD AddressOfFunctions; // RVA from base of image 导出地址表
//导出地址表EAT,它存放的是各个导出函数的RVA。
DWORD AddressOfNames; // RVA from base of image 符号名表
//它保存导出函数名字,所有函数名按照ASCII顺序排序,以便于动态链接器在查找函数名字时可以速度更快。
DWORD AddressOfNameOrdinals; // RVA from base of image 名字序号对应表
//对应函数名表中的函数名所对应的序号值。
} IMAGE_EXPORT_DIRECTORY, *PIMAGE_EXPORT_DIRECTORY;

9.2.2 EXP文件

在创建DLL的同时也会得到一个EXP文件,这个文件实际上是链接器在创建DLL时的临时文件。
EXP文件实际上是一个标准的PE/COFF目标文件,只不过它的扩展名不是.obj而是.exp。

9.2.3 导出重定向

将某个导出符号重定向到另外一个DLL。
实现机制:导出表的地址数组中包含的是函数的RVA,但是如果这个RVA指向的位置位于导出表中,那么表示这个符号被重定向了。

9.2.4 导入表

如果我们的程序使用到了来自DLL的函数和变量,那么我们就把这种行为叫做符号导入
在ELF中,有”.got”,在windows中也有类似的机制,叫做导入表,当某个PE文件被加载时,Windows加载器会将所需要导入的函数地址确定并且将导入表中的元素调整到正确的地址,以实现动态链接的过程。
使用dumpbin查看一个模块依赖哪些DLL,又导入哪些函数:

Microsoft (R) COFF/PE Dumper Version 14.10.25019.0
Copyright (C) Microsoft Corporation. All rights reserved.


Dump of file Math.dll

File Type: DLL

Section contains the following imports:

KERNEL32.dll
1001A000 Import Address Table
1001A1D0 Import Name Table
0 time date stamp
0 Index of first forwarder reference

1A6 FreeLibrary
2A6 GetProcAddress
3B4 LoadLibraryA
212 GetCurrentProcess
5BB VirtualQuery
2AC GetProcessHeap
33F HeapFree
33B HeapAlloc
259 GetLastError
270 GetModuleHandleW
37B IsProcessorFeaturePresent
2C8 GetStartupInfoW
55B SetUnhandledExceptionFilter
59A UnhandledExceptionFilter
358 InitializeSListHead
11A DisableThreadLibraryCalls
2E1 GetSystemTimeAsFileTime
217 GetCurrentThreadId
213 GetCurrentProcessId
43E QueryPerformanceCounter
5E8 WideCharToMultiByte
3E0 MultiByteToWideChar
453 RaiseException
374 IsDebuggerPresent
579 TerminateProcess

VCRUNTIME140D.dll
1001A0A4 Import Address Table
1001A274 Import Name Table
0 time date stamp
0 Index of first forwarder reference

25 __std_type_info_destroy_list
35 _except_handler4_common
2E __vcrt_GetModuleFileNameW
2F __vcrt_GetModuleHandleW
31 __vcrt_LoadLibraryExW
48 memset

ucrtbased.dll
1001A0EC Import Address Table
1001A2BC Import Name Table
0 time date stamp
0 Index of first forwarder reference

2E1 _register_onexit_function
197 _initialize_onexit_table
196 _initialize_narrow_environment
DC _configure_narrow_argv
2ED _seh_filter_dll
8E __stdio_common_vsprintf_s
57E wcscpy_s
548 strcpy_s
19A _initterm_e
199 _initterm
15 _CrtDbgReportW
14 _CrtDbgReport
82 __stdio_common_vfprintf
45 __acrt_iob_func
3E4 _wsplitpath_s
3C8 _wmakepath_s
565 terminate
10C _execute_onexit_table
E7 _crt_at_quick_exit
544 strcat_s
E8 _crt_atexit
CA _cexit

Summary

1000 .00cfg
1000 .data
1000 .idata
2000 .rdata
1000 .reloc
1000 .rsrc
6000 .text
10000 .textbss
View Code

在上面,我们会看到很多没有用到的函数也在里面,这是因为在构建Windows DLL时,还链接了支持DLL运行的基本运行库,这个基本运行库需要用到Kerne132.dll,所有就有了这些导入函数。
在Windows中,系统的装载器会确保任何一个模块的依赖条件都得到满足,每个PE文件所依赖的文件都被加载。
在动态链接过程中,如果某一个被依赖的模块无法正确的加载,那么系统将会错误提升(比如:缺少某个DLL),并且终止运行该进程。
导入表结构体也在”Winnt.h”中:

typedef struct _IMAGE_IMPORT_DESCRIPTOR {
union {
DWORD Characteristics; // 0 for terminating null import descriptor
DWORD OriginalFirstThunk; // RVA to original unbound IAT (PIMAGE_THUNK_DATA)
//导入名称表,简称INT。和IAT一样
} DUMMYUNIONNAME;
DWORD TimeDateStamp; // 0 if not bound,
// -1 if bound, and real date\time stamp
// in IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT (new BIND)
// O.W. date/time stamp of DLL bound to (Old BIND)

DWORD ForwarderChain; // -1 if no forwarders
DWORD Name;
DWORD FirstThunk; // RVA to IAT (if bound this IAT has actual addresses)
//指向一个导入地址数组,IAT是导入表中最重要的结构,IAT中每个元素对应一个被导入的符号,元素的值在不同情况下有不同的含义。
} IMAGE_IMPORT_DESCRIPTOR;

 

 

延迟加载

当链接一个支持延迟载入的DLL时,链接器会产生与普通DLL导入非常类似的数据。但操作系统会忽略这些数据。当延迟载入的API第一次被调用时,由链接器添加特殊的桩代码,这个桩代码负责对DLL的装载工作。然后这个桩代码通过调用GetPROCAddress来找到被调用API地址

9.2.5 导入函数的调用

与GOT类似

9.3 DLL优化

DLL的代码段和数据段本身并不是地址无关的,它默认需要被装载到由ImageBase指定的目标地址中。如果目标地址被占用,那么就需要装载到其他地址,便会引起整个DLL的Rebase。
符号和字符串的比较和查找过程也会影响DLL性能。

9.3.1 重定基地址(Rebasing)

PE的DLL中的代码段并不是地址无光的,它在被装载时有一个固定的目标地址,这个地址也就是PE里面所谓的基地址
对于DLL来说,一个进程中,多个DLL不可以被装载到同一个虚拟地址,每个DLL所占用的虚拟地址区域之间都不可以重叠。
Windows PE采用装载时重定位:在DLL模块装载时,如果目标地址被占用,那么操作系统就会为它分配一块新的空间,并且将DLL装载到该地址。对于DLL每个绝对地址引用都进程重定位。
由于DLL内部地址都是基于基地址的,或者是相对于基地址的RVA。那么所有需要重定位的地址都只需要加上一个固定差值。PE里面把这种特殊的重定位过程叫做重定基地址
EXE是不可以重定位的,不过这也没问题,因为EXE文件是进程运行时第一个装入虚拟空间的,所以它的地址不会被人抢占。

改变默认基地址

对于一个程序来说,它所用到的DLL基本是固定的程序每次运行时,这些DLL的装载顺序和地址也是一样的。

系统DLL

Windows系统在进程空间中专门划出一块0x70000000~0x80000000区域,用于映射这些常用的系统DLL。

9.3.2 序号

一个DLL中每一个导出的函数都有一个对应的序号。一个导出函数甚至没有函数名,但它必须有唯一的序号。序号标示被导出函数地址在DLL导出表中位置。

9.3.3 导入函数绑定

当一个程序运行时,所有被依赖的DLL都会被装载,并且一系列导入导出符号依赖关系都会被重新解析。这些DLL都会以同样的顺序被装载到同样的内存地址,所以它们的导出符号的地址都是不变的。
DLL绑定:使用editbin可以对EXE和DLL绑定。
editbin对绑定的程序的导入符号进行遍历查找,找到以后就把符号的运行时的目标地址写入到被绑定程序的导入表内。
INT这个数组就是用来保存绑定符号的地址的。
DLL绑定地址失效:

  • 一种情况,被依赖的DLL更新导致DLL的导入函数地址发生变化。
  • 另一种情况,被依赖的DLL在装载时发生重定基址,导致DLL的装载地址与被绑定时不一致。

第一种情况的失效,PE做法时当对程序绑定时,对于每个导入的DLL,链接器把DLL的时间戳和校验和保存到被绑定的PE文件的导入表中。在运行时,Windows会核对将要被装载的DLL与绑定时的DLL版本是否相同,并且确认该DLL没有发生重定基址。

9.4 C++与动态链接

C++编写动态链接库在Windows平台下最好遵循以下指导:

  • 所有的接口都应该抽象
  • 所有的全局函数都应该使用”extern C”来防止名字修饰的不兼容
  • 不要使用C++标准库STL
  • 不要使用异常
  • 不要使用虚析构函数
  • 不要在DLL里面申请内存
  • 不要在接口中使用重载方法

9.5 DLL HELL

由于早期Windows缺乏一种很有效的DLL版本控制机制,DLL不兼容文件在Windows非常严重,被人们称为DLL噩梦(DLL hell)。
DLL HELL发生的三种可能原因:

  • 由使用旧版本的DLL替代原来一个新版本的DLL引起
  • 由新版DLL中的函数无意发生改变而引起
  • 由新版DLL按照引入一个新BUG
解决DLL Hell的方法
    • 静态链接
    • 防止DLL覆盖:使用windows保护技术
    • 避免DLL冲突:每个应用程序拥有一份自己依赖的DLL
    • .NET 下DLL Hell的解决方案:在.NET框架中,一个程序集有两种类型:应用程序集以及库程序。一个程序集包块一个或多个文件,所以需要一个清单文件来描述程序集。这个清单文件叫做Manifest文件。Manifest文件描述了程序集的名字,版本号以及程序集的各种资源,同时也描述了该程序集的运行所依赖的资源,包括DLL以及其他资源文件等。操作系统会根据DLL的manifest文件去寻找对应的DLL并调用。

 

posted on 2017-09-28 09:48  Mr.Tan&  阅读(1592)  评论(0编辑  收藏  举报

导航