您的位置:首页 > 其它

Windows下动态链接之二:DLL优化加速

2017-12-27 17:08 831 查看
1. Windows动态链接下的导入函数的调用过程

在ELF结构下,函数调用因为有全局符号介入的可能,所以除非用static关键词修饰,否则只要是函数调用,无论是否是模块内还是模块外,都需要经过.got.plt间接跳转,来实现ELF结构下代码段的地址无关性。

在PE文件结构下,是不存在全局符号介入的,所以对于模块内部的函数调用,编译器将产生直接调用指令
CALL  XXXXXXXX
(不是相对地址偏移,是直接地址调用。这是因为Windows PE下,任何一个PE文件在编译时都会给出自己的一个优先装载位置,然后根据此位置产生一系列的定位,当然这个绝对地址是需要在实际装载运行时再重新修正的,采用了一种重定基地址的方法)。而对于模块外部函数调用,则会采用在IAT表中的地址间接调用
call Dword PTR [0x0040D11C]
,这里面的IAT表元素地址0x0040D11C也是绝对地址,这是也需要后续修正的。故而可以看到PE结构下,DLL的代码段并非地址无关的,所以Windows系统就是大气,根本不像Linux那么在意代码段指令的重复利用。

所以在PE结构下,如果通过
_declspec(import)
修饰符显式声明了某个函数外部函数,则编译器将直接跳转到IAT该函数对应项,

CALL DWORD  PTR  [0x0040D11C]


如果不显式声明,也是可以的,但会产生两次跳转

CALL  0x0040100C
…
0x0040100C:
JMP  DWORD  PTR [0x0040D11C]


其中JMP指令处被称为桩stub代码,是链接器在链接过程中通过同名.lib文件中的胶水代码插入的。

(注:以Math.dll为例,一般DLL文件加载是同同名的.lib文件加载的,比如
link TestMath.obj  Math.lib
,其中Math.lib是用来描述Math.dll的导出符号信息,除此之外,.lib文件还包含了一些胶水代码(桩代码)用来将主程序和DLL符号黏在一起。)

现在MSVC编译器对于以上两种导入方式都是允许的,但是还是推荐采用
_declspec(import)
修饰符显式声明符号导入,减少了一步跳转指令加速运行过程。

2. DLL优化手段

DLL的代码段和数据段本身并非地址无关的,即它默认需要被转载到由ImageBase指定的目标地址中,如果目标地址被占用,那么就需要装载到其他地址,便会引起整个DLL的Rebase。这对于拥有大量DLL的程序来说,频繁的Rebase是不可避免的(意味着系统需要频繁遍历当前进程空间找到空间合适的空档将DLL插进去),故而这也是影响DLL性能的另外一个原因。

在动态链接过程中,导入函数的符号在运行时需要被逐个解析。在这个解析过程中,免不了会设计到符号字符串的比较和查找过程,这个查找过程中,动态链接器会在目标DLL的导出表中进行符号字符串的二分查找,但即使使用二分查找,对于拥有DLL数量很多,并且有大量导入导出符号的程序来说,这个过程仍然会是非常耗时的,这是另一个影响DLL性能的原因。

一般来说,PE文件结构中.exe文件的基地址默认是0x00400000,而DLL文件基地址默认是0x10000000。

讨论到共享对象的地址冲突问题,主要有三种方法:1.前面介绍的“静态库”方法,人为地限制进程空间中某区域为特定库的装载地址;2.ELF的代码段地址无关;3.便是PE的基地址重定

基地址重定位

DLL模块装载默认的base如何已经被占用,那么操作系统会重新再进程空间中分配一块区域,但是DLL的代码段并非地址无关的,故而DLL代码段中所有的绝对地址引用都要重定位。但是这个重定位比较特殊,是所有绝对地址整体加上一个相同的基地址偏移量即可。所以这种重定基地址的方法比一般重定位要快。

PE文件的重定位信息放在.reloc段,可以从PE文件头中的DataDirectory的16组数据中提取到重定位段的信息。(.exe一般MSVC编译器是不产生.reloc段的,DLL也可以通过/FIXED参数取消重定位信息,但是这种固定加载起点的方式可能会导致DLL装载失败)

PE的DLL代码段非地址无关性相比于ELF最大的缺点是多个进程无法共享代码段,浪费内存;但是正是DLL代码段放弃了共享所需的动态性,在整个过程中以直接调用为主的偏向静态链接的方式,可以让DLL机制比ELF的PIC机制运行的更快,不需要每次调用外部数据和函数都需要计算下该符号在GOT的位置,而DLL在重定基地址后直接给出的是各符号在IAT(导入地址数组 Import Address Table)中的地址。


Fig.1 COFF PE文件结构下的符号导入导出表对应关系

2. 1 DLL优化手段之一:改变默认装载基地址

如果程序的DLL装载顺序固定,并且这些DLL变动并不频繁,为了让这些日常使用到的程序启动加速,则可以根据DLL装载顺序和空间使用情况,直接设置各DLL的装载Base,这样就可以加速装载时重定位过程。



如果bar.dll默认基地址为0x1000 0000,当时因为在该程序的装载过程中,DLL文件的装载顺序是固定的(广度优先),那么显然bar.dll的默认基地址是被foo.dll抢占去的,但既然如此干脆直接将bar.dll的默认装载基地址改成0x1001 0000即可,这样省去了每次装载时重定位基地址的过程。可以采用如下

$link /BASE:0x10010000, 0x10000 /DLL  bar.obj


MSVC还提供了一个editbin工具用来直接改变已有DLL的基地址,在DLL文件自身的装载点更改数据

editbin /REBASE:BASE=0x10010000  bar.dll


2. 2 系统DLL专用加载区间

将改变默认基地址这一思想给具体化、通用化,每个程序在运行时都要使用一些系统DLL,这些系统DLL基本上都固定了,如kernel32.dll, ntdll.dll, shell32.dll, user32.dll, msvcrt.dll等,故而Windows操作系统直接将进程的虚拟空间中0x70000000~0x80000000固定下来专门用来存放这些系统DLL,这样就可以部分加速任意程序的重定位过程。然后系统在安装这些.dll时便调整了这些dll的装载基地址,程序装载时无需再次更改基地址。如下便是一个案例:



通过
dumpbin /IMPORTS main.exe
可以查看各系统DLL的加载信息,我的系统中kernel32.dll的默认基地址是78D20000 to 78E3EFFF;user32.dll的默认基地址是78C20000 to 78D19FFF。

2. 3 DLL优化之导入函数绑定

前面说的优化过程是从DLL这些辅助对象出发的,现在考虑主模块的优化。

每一次一个程序运行时,所有被依赖的DLL都会被装载,并且一系列的导入导出符号依赖关系都会被重新解析。在大多数情况下,这些DLL都会以同样的顺序被装载到相同的内存地址,所以它们的导出符号的地址应该都是不变的,既然这些符号的地址不变,那程序主模块的导入表应该还是和上次程序运行时相同,故而可以保留下来,这样就可以省去每次启动时符号解析的过程。这种方法称为DLL绑定。

DLL绑定实现

思路很简单,editbin对被绑定的程序的导入表内符号进行遍历,将该符号在运行时的目标地址写入到被绑定程序的导入表内,还记得前面IAT旁边还有个冗余的INT吗?这个INT就是用来记录DLL绑定信息的,故而下次运行时,一旦检测到INT里面有信息,则不需要再次进行符号重定位了,如果遇到问题(如依赖的DLL更新,DLL装载顺序打乱了和此前装载位置不一致),导致INT中绑定符号信息失效,则也可以依靠IAT的信息再重来一次重定位。Windows系统中很多系统自带程序便采用DLL绑定用以加速程序启动

dumpbin /IMPORTS C:\Winodws\notepad.exe




失效情况和应对措施一:依赖的DLL更新,导致该DLL内部RVA信息发生变化

链接器把DLL的时间戳(timestamp)和校验和(eg:MD5)都封存在PE文件的导入表中,每次重新装载时,先验证一下,如果没有变,则启动INT高速路,否则就按照IAT这条正常的主干道运行。

(注:Windows系统所附带的程序都是和它依赖的DLL绑定的,至少在DLL升级之前,这些DLL绑定是有效。当然绑定过程会让可执行文件大小和内容发生变化,对于一些经过加密的或是有数字签名的程序而言需要考虑这些问题。)

失效情况和应对措施二:DLL由于运行环境变化导致装载时发生了重定基地址

可以在验证信息中再添加一条绑定默认base(如何下次装载该DLL的base不同,则知道发生了重定基地址),故而需要从正常的IAT途径走。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息