利用DelayLoad来优化应用程序的性能,拦截API

3/8/2008来源:C/C++教程人气:7294

    在 1998年12月的MSJ出版刊物中, Jeffrey和我写了关于 在 vc6中使用DelayLoad 功能的专栏.最终结果,是证实了它是多么cool.但是,不幸的是,还有很多人不了解DelayLoad,他们以为这个新特点是 最新版本的WINNT才有的.
    在开始的时候,让我重申一遍:DelayLoad不是最新的操作系统带的特有功能,它可以在任何win32系统中起作用.我将写一个简单例子来说明. DelayLoadPRofile, 实现了一个很小功能,很多程序都可以得益于它.


预览:
    通常的,当调用一个dll中的函数时,连接器会将dll和函数加入你的可执行文件.最后,所有引用的函数会放在imports段中.  当加载该程序的时候,win32程序加载器会扫描所有imports段的每个dll.加载,和重新定位imports段的所有函数,将信息写入 引入地址表(ImportAddress Table, IAT).简单说来,IAT就是一个函数指针的表.调用该 引入函数的时候,就到IAT中去找.  那么,DelayLoad的机理是什么呢?当你为一个Dll进行"DelayLoad"的时候,连接器不将原来的值放入imports段,相反,它为每个DelayLoad的引入函数的名称和地址,生成一个小的根区, 备份下来。第一次引用的时候,它调用LoadLibrary加载Dll,然后,它调用GetProcAddress取得该函数的地址。最后,改写自己在IAT的值,以便以后的程序可以直接调用.
    上面的是简化的步骤.实际上,根区是一小段代码,它以静态的方式连接到可执行文件中.代码在delayimp.lib中,必须被 连接程序引用.并且,该代码要足够智能,当一个函数第一次被引用的时候,要调用LoadLibrary,以后调用就不用引用了.  和引用Dll相比,DelayLoad不会加太多的时间和空间,这种方式 调用LoadLibrary只会引起稍微一点点的性能损失.每次程序启动,在针对引入表的函数地址定位的时候,依次对DelayLoad引入的调用GetProcAddress,相对于Win32加载器来说,所损失的性能也可以忽略不记.
    然而,DelayLoad带来的好处也是不可比拟的.例如:假如你的程序从来没有 从Delay调用引入的函数,Dll的第一次是不会被加载的。有时候,这个情况的出现频率出乎你想象。假如,你的程序中,包含打印的代码,毫无疑问,即使用户没有使用打印功能,你的程序也一定要加载winspool.drv。在这种情况下,使用DelayLoad,你就不必加载和初始化Winspool.drv.
    另外一个好处就是:DelayLoad可以避免调用某些目标平台不存在的API。例如,假如你的程序需要调用AnimateWindow,这个API在Win2000和Win98中存在,但是在Win95和WinNT4中,就不存在,假如你用常规的方式调用AnimateWindow,那么,你的程序将不能再早期的平台中运行。然而,你可以用DelayLoad进行对AnimateWindow的加载检查。这样,你就不必改写你的代码为LoadLibrary和GetProcAddress的方式了。
    DelayLoad是很轻易使用的。当你决定哪个dll你想使用DelayLoad,只需要简单的增加/DELAYLOAD:DLLNAME。其中,DLLNAME是相关的DLL文件名。你还需要增加DELAYIMP.LIB到连接库中,你也需要原来的LIB,例如,SHELL32.LIB。把全部放到一块,连接的命令就如下: SHELL32.LIB /DELAYLOAD:SHELL32.DLL DELAYIMP.LIB  很不幸,Visual Studio 6.0 IDE 不提供一个简单的方法去实现一个Dll的DelayLoad。所以,你必须手工加入:/DELAYLOAD:XXX 命令行到 "Project settings"->"Link"->"Project Options"中。
   
什么时候需要DelayLoad:
    当你有小的工程,它调用了多个dll,就是一个好的DelayLoad候选例子。然而,工程可能在以后由于其它开发者的加入而变大,很轻易丢失调用dll的跟踪。我通常用sdk中的depends.exe。一个只有少数函数要引入的dll就是一个好的开始。
    然而,我想找到一个简单的,自动的方法来跟踪。于是,出来了DelayLoadProfile程序。它是一个exe,可以监视你的exe文件对dll的调用,直到你的exe结束。它打印出dll被调用的情况的汇总,包括多少个dll被调用,每个dll有多少个函数被引入。
    我在这里强调:DelayLoadProfile只是针对exe有效,当它涵盖你的程序所关联的所有dll的时候,有时会造成一点点复杂。DelayLoadProfile只给你哪个dll可以用DelayLoad开关的暗示,你最好在不确定的时候,使用原来的处理方法。

DelayLoadProfile:具体描述
    其实DelayLoadProfile的原理很简单:重定向 exe中,IAT的函数的指针到一段根区。根区简单的标志一下,引入的函数被调用了。然后,跳入原来的Win32加载提供的IAT地址。只是,难的是如何实现。
    第一,你必须决定,要在哪里运行你的代码,实现对exe的IAT入口的更改,把他们指定到那段根区去。这些都是在进程外完成。这样可以避免你的代码牵涉到目的exe进程中。这个可以用遍历所有的数据结构,定位和修改IAT结构的方法。我在这里利用了很多ReadProcessMemory调用。

    接着的艰苦工作是要在和目的exe相同的进程空间里完成。几乎是很琐碎的工作:遍历所有的数据结构,建立根区,从定向IAT入口,然后在完成的时候,汇总结果。然而,为了完成进程空间的工作,在exe进程运行的时候,一些 DelayLoadProfile代码必须被加载到目的exe的进程空间。这个是我要做的。
    当确认到需要在目的进程中,加载我的代码的时候,下个问题就是如何把我的代码加入到目的进程中。其中一个选择就是,要求用户连接我的DelayLoadProfile库,这个会造成用户的很大量的对他们源代码工程,或者Makefile的更改,所以,我不能采用,现在需要一个完全自动化的方法。
    在这点上,我想到了加载程序,然后,插入我的DelayLoadProfiledll进去,一个技术就是用CreateRemoteThread,在目标进程,创建一个LoadLibrary的线程。我放弃了这个,因为,win9x中,不提供CreateRemoteThread.
    很久以前的MSJ读者可能记得我5年前写的一个叫APISPY32的程序。它加载一个进程,插入一个dll来记录API的调用。那个有点像我今天的DelayLoadProfile工作。然而,我在Win200中,调用那个dll失败。有一点点问题。我觉得现在是时候要重读那段代码,并且改正那个错误了。
   
继续深入:
    重新温习一下,DelayLoadProfile包含2部分,一,是进程加载功能,它会注射一个dll到你的进程的地址空间。然后,那个dll扫描你的所有的exe IAT,重新定向他们到dll创建的根区中。当你的程序完成后,注射的dll会扫描所有的根区,统计出多少dll和函数它调用的。假如你曾经用过APIMON的相关部件,你将认出类似的技术细节。
    完成所有的工作,包括 监视 一个程序的引入的dll,叫DelayLoadProfileDLL.(看Figure 1).它用到DLL_PROCESS_ATTACH和DLL_PROCESS_DETACH来初始化2个主要的工作。
    当DllMain获得DLL_PROCESS_ATTACH的消息的时候,DelayLoadProfileDLL调用PrepareToProfile(),在PrepareToProfile中,代码加载目的EXE的IAT,对于每个它发现的引用的DLL,代码还检测是否安全的重定向IAT。通过IsModuleOKToHook函数来检测,大多数情况下,是安全的,因此,PrepareToProfile包括了RedirectIAT函数。
    RedirectIAT是比较复杂的函数。假如你理解了 winini.h中的引入相关数据结构,你将得到很大的帮助。首先,函数定位IAT和相关的引入名字表,然后,计算有多少个IAT入口,扫描所有的IAT,查找NULL的指针。得到了数目后,程序将创建一个DLPD_IAT_STUB根区,每个根区对应一个IAT入口。
    最后,代码重新扫描IAT,获取每个IAT入口的地址,用根区的一个包含JMP指令的地址,替换IAT入口。它还扫描下一个IATDLPD_IAT_STUB根区。我在后面将还会继续解析。
    在重定向IAT入口的根区中,有2个值得提起:1,IAT经常被放到EXE的只读段,通常,尝试改写只读段,会引起访问违规,幸运的,VirtualProtect答应你更改一个目的地址的属性。现在必须更改iat的属性为读/写。完成后,代码要恢复IAT段原来的属性。
    另外一个要注重的地方,就是在重定向IAT的时候,有数据引入的问题。虽然程序员们很少这样做,但是,很轻易用增加的代码去导入数据。vc++运行库DLL(MSVCRT.DLL)有数据导出。假如重定向一个数据IAT的入口,会导致问题。
    那么,如何判定一个IAT是数据呢?一个商业的软件,应该用准确的算法来判定一个IAT入口的类型。但是,我在这里用了一个快捷方法。就是IsBadWritePtr。假如IAT包含的指针是可写的,那么,很可能是一个数据指针。假如是只读的,那么,应该是一段代码。这个测试合适吗?不,但是,它对DelayLoadProfile是足够了。
    现在看一下根区,在DelayLoadProfileDLL.h中定义的DLPD_IAT_STUB结构包含着代码和数据。简单来说,就是如下:
  CALL DelayLoadProfileDLL_UpdateCount
  JMP xxxxxxxx //original IAT 地址
  DWord count
  DWORD pssNameOrOrdinal

    当exe调用其中一个重定向的函数时,控制权被转到根区的CALL指令中,调用DelayLoadProfileDLL.CPP