利用DelayLoad来优化应用程序的性能,拦截API
在 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中的DelayLoadProfileDLL_UpdateCount函数,在call指令返回时,继续调用jmp 跳转到IAT原来取得的地址中。Figure2显示了结构示意图。
汇编高手会对DelayLoadProfileDLL_UpdateCount函数能确定根区的COUNT字段的地址,感到疑惑,通过快速的察看代码,会发现DelayLoadProfileDLL_UpdateCount会在堆栈中,查找到返回地址。返回地址指着JMP xxxxxxxx指令。因为,CALL调用总是5个字节,根据这些算法,可以确定COUNT字段的地址。
有一个问题值得提醒,就是DelayLoadProfileDLL_UpdateCount没有调用PUSHAD和POPAD指令来保存/回复CPU寄存器的值。这段代码在很多程序上都工作正常,但是,却在一些函数中,不能正常工作。最后,发现 MSVCRT.DLL的__CxxFrameHandler和 _EH_prolog有问题,这2个函数 期望eax寄存器被设置成某个值。然而,DelayLoadProfileDLL_UpdateCount更改了EAX. 既然这个是由于EAX引起的问题,那么,我增加了PUSHAD和POPAD,昏倒,问题还存在。在遭受挫折后,我检查了汇编生成的代码。通常,VC6编译器会插入将所有本地变量都初始化为0xCC的代码。这些代码会在PUSHAD和POPAD前,将EAX改变。我只好移去/GZ的选项。
结果报告:
当你的进程停止的时候,系统对所有加载的DLL发送一个DLL_PROCESS_DETACH消息。DelayLoadProfileDLL使用这个选项来搜集程序运行过程中,获得的结果。也是说,再次遍历所有的根区单元。收集所有获得的数据,输出。
在DelayLoadProfileDLL安装的阶段,重定向IAT,它保存exe的IAT到一个公共的变量出g_pFirstImportDesc。在关闭的过程中,ReportProfileResults用到这个指针来再次遍历引入段。如果这个IAT是被重定向的,那么,第一个IAT的指针应该指到第一个为该DLL分配的DLPD_IAT_STUB根区内存。当然,代码保持了基本的测试方法,如果某些地方不正确,DelayLoadProfileDLL忽略该特定的dll。
总的说来,所有的都很正常,并且,第一个IAT入口指到我的根区单元。对于每个DLL,代码反复的遍历所有的根区。每个相关的根区,它的包含的字段的值,将加到该DLL的总计数。当遍历完成,ReportProfileResults格式化一个字符串,输出该dll的名字,和调用的总次数。代码还用OutputDebugString广播该结果。
加载和注射:
本程序加载你的exe,注射DelayLoadProfileDLL.dll将会调用,(你猜到了),是DelayLoadProfile.exe(源文件可以在msj的网站找到,http://www.microsoft.com/msj)。这个代码主要继承了CDebugInjector类。我将简单的介绍它。函数主要包含了目的exe的命令行,并且传递到CDebugInjector::LoadProcess。如果进程被成功创建,函数会告诉CDebugInjector,哪个dll会被注射,既然是这样,和DelayLoadProfile.exe同目录的DelayLoadProfileDLL.DLL,将会被加载。
在运行目标程序之前,最后的步骤是调用CDebugInjector::SetOutputDebugStringCallBack。当DelayLoadProfileDLL用OutputDebugString来输出报告结果的时候,CDebugInjector看到他们,然后传递他们到你已经注册的回调函数中。这个回调函数只是用printfs输出字符串到控制台。最后,函数调用CDebugInjector::Run。这样,目的进程开始运行,当时机成熟,注射dll进去。 描述3(hoodtextfigs.htm#fig3)说明了CDebugInjector类。这是代码实现的地方。CDebugtInjector::LoadProcess创建了目的进程,作为一个调试进程,它的分支已经在msdn的很多文档中讨论过了,这里,不想作太多具体的讨论。
调试进程运行后(这里是DelayLoadProfile)进入了一个循环,不断的调用WaitForDebugEvent和ContinueDebugEvent,直到调试停止。每次WaitForDebugEvent返回,都有些东西发生在调试程序身上。可能是一个异常(包括断点),或者加载一个dll,或者创建一个线程,或者其他事件。WaitForDebugEvent文档历包含了所有的可能的事件。CDebugInjector::Run过程包含这个循环的代码。
那么,如何让目的进程作为一个被调试进程,帮助你注射一个dll呢?一个调试进程可以控制的被调试进程的执行过程。每次被调试程序有一个信号事件发生,它都会暂停,等待调试者调用ContinueDebugEvent继续运行。了解了这个,一个调试进程可以增加代码到被调试进程的空间,和临时改变被调试者的寄存器值,以便增加的代码运行。
在某些特定场合,CDebugInjector合成了一小段代码根区来调用LoadLibrary。LoadLibrary的dll名字参数,指到要被注射的dll的名字。CDebugInjector写那个根区(和相关联的dll名字)到被调试者的地址空间。然后,调用SetThreadContext来改变被调试者的指令寄存器,运行LoadLibrary根区。所有的相关代码在CDebugInjector::PlaceInjectionStub过程中。
立刻的,根区中的LoadLibrary调用后,是一个断点(int 3)。这个暂停被调试者的运行,交回控制权给调试的进程。调试者用SetThreadContext,恢复指令寄存器和其他寄存器到原来的值。另一次调用ContinueDebugEvent,被调试者在dll注射的状态下,继续运行。没有人知道发生了什么事情。
如果你不想那么多,这个注射进程不会觉得太难,但是,一些有兴趣的东西,弄复杂了事情。例如,什么时候创建根区,改变运行代码,才是适当呢?你不能在CreateProcess后立刻做这个,因为,引入的dll还没有被映射到内存中,WIN32加载器还没有建立exe的IAT。相当于:太早了。
最后,我决定让被调试者运行,直到碰到了第一个断点。我在程序入口处,设置了一个自己的断点。当第2次中断被触发,CDebugInjector知道目的进程的DLL,都被初始化了(包括Kernel32.dll)。但是,在exe中,还没有代码运行。现在是时候注射DelayLoadProfileDLL.DLL了。
顺便说一下:断点从哪里来呢?通过定义,一个被调试的win32的进程,在运行之前,会调用DebugBreak(也是int3),在我早期的apispy32代码中,我选用了最初的DebugBreak来做注射。在win2k中,非常不幸,这个DebugBreak在Kernel32.dll初始化之前,被调用,那么,CDebugInjector设置它的断点到exe即将获得控制的地方,那么,kernel32.dll被初始化了。
在之前,我提到在LoadLibrary调用后,发生的一个断点。这是第3个CDebugInjector要处理的断点,所有的处理不同断点的技巧,可以参考CDebugInjector::HandleException。
另外一个关于注射dll的有兴趣的问题,就是在那里写LoadLibrary单元,在winnt4.0以后,你可以用VirtualAllocEx来为某个线程申请内存。我采用了这个方法。现在,剩下不能支持VirtualAllocEx的Win9x,针对这个问题,我利用了win9x内存映射文件的一个特殊的特性,这些文件在所有的地址空间都可见。并且,是同一个地址。我简单的利用系统页面文件作为支持,创建了一个小的内存映射文件,写了LoadLibrary根区进去。该根区对于被调试程序,是可见的。更多的详细情况,请看文章首部的连结的CDebugInjector::GetMemoryForLoadLibraryStub。
使用DelayLoadProfile:
DelayLoadProfile是一个输出结果到标准输出的命令行程序。在命令行提示中,运行DelayLoadProfile,制定目的程序,和它需要的参数,例如: DelayLoadProfile notepad c:\autoexec.bat下面是针对(windows 2000 Release Candidate2)的calc.exe, 运行DelayLoadProfile的结果:
[d:\column\col66\debug]delayloadprofile calc
DelayLoadProfile: SHELL32.dll was called 0 times
DelayLoadProfile: MSVCRT.dll was called 9 times
DelayLoadProfile: ADVAPI32.dll was called 0 times
DelayLoadProfile: GDI32.dll was called 60 times
DelayLoadProfile: USER32.dll was called 691 times
我简单的开始calc,然后,立即关闭。注意到,shell32.dll和advapi32.dll都没有调用,这2个dll是最初的calc用来DelayLoad的候选。
你将回觉得奇怪,为什么calc调用shell32.dll,你没有调用它。如果你针对CALC,调用DumpBin /IMPORTS或者Depends.exe分析,你将看到,CALC从SHELL32.DLL中引入的函数只有ShellAboutW。简单来说,只有你选者CALC的HELP|About Calculator菜单项,才会完全的调用SHELL32.DLL入内存。这个是一个最明显的/DELAYLOAD显示其价值的例子。顺便说,SHELL322.DLL简单的,毫无条件的加载SHLWAPI.DLL和COMCTL32.DLL,并且初始化。
如果只是因为DelayLoadProfile报告一个dll没有被调用,或者很少调用,你就可以自动的 延迟加载,你要认真的确定,哪一个暗中连结的dll,你要使用/DELAYLOAD。这种情况下,如果由于其他的依赖,你的DLL要被自动的加载和初始化,那么,/DELAYLOAD就没有意义了。平台sdk带的Depends.exe是一个很有用的工具,可以看到一个dll的使用情况。
在你的测试过程中,你的测试的程序的个数,也是值得考虑的。如果你测试了所有的程序的功能,所有的被引入的dll都包括了。个人认为,我觉得应该尽量缩小初始化时间,这个可能是意味着你只是开始你的程序,然后关闭它。要加快初始化,就依次加载dll。用户都是主观的由启动时间判断你的程序的速度。
我发现几个DLL可以从/DELAYLOAD处得益。从上所述,SHELL32.DLL是其中一个。另外一个是打印支持的WINSPOOL.DRV。既然很多用户都不经常打印,那么,就是很好的采用者。还有,类似的OLE32.DLL和OL3AUT32.DLL。一个多态的程序,在小容器中,用到COM和OLE,那么,相关的DLL也是可以选用的。例如,WIN2000的CDPLAYER.EXE和OLE32.DLL连接,用到了CreateStreamOnHGlobal函数。但是,在通常的情况下,我没有觉察到这个函数被调用。
DelayLoadProfile并不是没有它的毛病,当我在很多程序针对IAT,用DelayLoadProfileDLL成功测试后,你可能还会碰到不正确的运行的情况。要完全解决这个问题,就超出了本次讨论的范围。然而,如果你成功解决了其中一个问题,请让我知道。我将在将来的一天更新DelayLoadProfile。 我知道某些引入mfc42.dll和mfc42u.dll的程序会和DelayLoadProfile冲突,于是,我采用了一个方法,在DelayLoadProfileDLL.cpp,有一个IsModuleOKToHook函数,我放了MFC42.DLL,MFC42U.DLL和KERNEL32.DLL进去。(你不能用 /DELAYLOAD 和KERNEL32.DLL关联,因为,是没有作用的)如果一个特别的DLL会出问题,你应该放到IsModuleOKToHook函数中。 我希望DelayLaodProfile会帮助你的程序采用/DELAYLOAD。我以后应该还会有时间去更新一些专业的带骂,并且,我还希望听到你的成功的故事。
如果你对<under the hook>有任何建议,请mail给matt:matt@wheaty.net,或者 http://www.wheaty.net
摘自 <Microsoft System Journal>2000年2月刊