程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> C語言 >> C++ >> 關於C++ >> 利用DelayLoad來優化應用程序的性能及攔截API

利用DelayLoad來優化應用程序的性能及攔截API

編輯:關於C++

在 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。我以後應該還會有時間去更新一些專業的帶罵,並且,我還希望聽到你的成功的故事.

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved