在最近的MSJ專欄中,我討論了COM類型庫和數據庫訪問層,例如ActiveX®數據對象(ADO)和OLE DB。MSJ專欄的長期讀者可能認爲我已經不行了(寫不出技術層次比較高的文章了)。爲了重振雄風,這個月我要講解一部分Windows NT®加載器代碼,它是操做系統和你的代碼接合的地方。同時我也會向你演示一些獲取加載器狀態信息的高超技巧,以及能夠用在Developer Studio®調試器中的相關技巧。
考慮一下你對EXE、DLL以及它們是如何被加載和初始化的到底知道多少。你可能知道當一個用C++寫成的DLL被加載時,它的DllMain函數會被調用。想想當你的EXE隱含連接到一些DLL(例如KERNEL32.DLL和USER32.DLL)時到底發生了什麼。這些DLL是以什麼順序被初始化的?某個DLL將要被初始化,而它所依賴的其它DLL還未被初始化,這可能嗎?Platform SDK在「Dynamic Link Library Entry Point Function(動態連接庫入口點函數)」一節中對此描述以下:
「你的函數應該僅進行一些簡單的初始化任務,例如設置線程局部存儲(TLS),建立同步對象和打開文件等。它絕對不能調用LoadLibrary函數,由於這可能在DLL加載順序上形成循環依賴。這可能致使即將使用一個DLL可是系統還未對它進行初始化。一樣,你也不能在入口點函數中調用FreeLibrary函數,由於這可能致使即將使用一個DLL可是系統已經執行完了它的終止代碼。」
「調用除TLS函數、同步函數和文件函數以外的Win32®函數也可能引發很難診斷的問題。例如調用User函數、Shell函數和COM函數可能引發訪問違規,由於這些DLL中一些函數調用LoadLibrary加載其它系統組件。」
看了上述文檔後個人第一感受是它太含糊了。例如你想在本身的DllMain函數中讀取註冊表是再正常不過的事了,它固然能夠做爲初始化的一部分。但不幸的是,在你的DllMain代碼開始執行時ADVAPI32.DLL尚未初始化。這樣,對註冊表API的調用將會失敗。
在上述文檔中對使用LoadLibrary給出了嚴厲的警告。但很是有趣的是,Windows NT的 USER32.DLL卻明確地忽略前面的忠告。你可能知道Widnows NT上的一個註冊表鍵AppInit_Dlls,它用來加載一系列DLL到每一個進程。事實代表,是USER32在初始化時加載這些DLL的。USER32在它的DllMain代碼中查看這個註冊表鍵並調用LoadLibrary加載這些DLL。稍微思考一下就會知道,若是你的應用程序不使用USER32.DLL的話,AppInit_Dlls這個技巧就不能發揮做用。不過,這有點跑題了。
我之因此要講解這方面的內容是由於DLL的加載與初始化仍是一片盲區。在大多數狀況下,對操做系統加載器是如何工做的有一個簡單的印象就足夠了。然而在極少數狀況下,除非你對操做系統加載器的行爲方式有比較詳細的瞭解,不然就會陷入困境之中。
大多數程序員所認爲的模塊加載過程實際上分爲兩個大相徑庭的步驟。第一步是把EXE或DLL映射進內存。此時加載器查看模塊的導入地址表(IAT)來判斷這個模塊是否依賴於其它DLL。若是它依賴的DLL還未被加載進那個進程,加載器也將它們映射進內存。這個過程遞歸進行,直到全部依賴的模塊都被映射進內存。要查看一個可執行文件隱含依賴的全部DLL,最好的方法是使用Platform SDK附帶的DEPENDS程序。
第二步是初始化全部DLL。在第一步中,當操做系統把EXE和DLL映射進內存時,它並不調用相應的初始化例程。初始化例程是在全部模塊都被映射進內存以後才被調用的。關鍵是:DLL被映射進內存的順序並不須要與它們被初始化的順序同樣。我曾經見到有人看到Developer Studio調試器中對DLL映射時的通知而誤認爲DLL是以相同的順序被初始化的。
在Windows NT中,調用EXE和DLL入口點代碼的例程被稱爲LdrpRunInitializeRoutines。在日常的工做中,我已經屢次跟蹤到LdrpRunIntializeRoutines的彙編代碼中。可是,看着大堆的彙編代碼並非理解它的好方法。所以我用相似C++的僞代碼重寫了Windows NT 4.0 SP3 的LdrpRunInitializeRoutines函數,如圖1所示。實際上,在NTDLL.DBG中這個例程的名字按__stdcall調用約定被修飾成了_LdrpRunInitializeRoutines@4。在僞代碼中,除了那些名字前面加了下劃線的,其他的都是我起的名字。
//=============================================================================
// Matt Pietrek, September 1999 Microsoft Systems Journal
//
// NTDLL.DLL中LdrpRunInitializeRoutines例程的僞代碼(NT 4,SP3)
//
// 在一個進程中首次調用LdrpRunInitializeRoutines(也就是在初始化
// 隱含連接的模塊)時,bImplicitLoad參數不爲0;在後續的調用
// (經過調用LoadLibrary而間接調用此例程)中,bImplicitLoad爲0。
//
//=============================================================================
#include // 用於函數末尾的HardError定義
// 如下是全局符號(這些名字是準確的,它們來自NTDLL.DBG文件)
// _NtdllBaseTag
// _ShowSnaps
// _SaveSp
// _CurSp
// _LdrpInLdrInit
// _LdrpFatalHardErrorCount
// _LdrpImageHasTls
NTSTATUS
LdrpRunInitializeRoutines( DWORD bImplicitLoad )
{
// 獲取可能須要被初始化的模塊數。其中的一些可能已經被初始化了
unsigned nRoutinesToRun = _LdrpClearLoadInProgress();
if ( nRoutinesToRun )
{
// 若是存在須要初始化的模塊,就爲保存模塊相關信息的數組分配內存
pInitNodeArray = _RtlAllocateHeap(GetProcessHeap(),
_NtdllBaseTag + 0x60000,
nRoutinesToRun * 4 );
if ( 0 == pInitNodeArray ) // 確保內存分配成功
return STATUS_NO_MEMORY;
}
else
pInitNodeArray = 0;
//
// 進程環境塊(Process Environment Block,Peb)中保存了一個指向已加載
// 模塊鏈表的指針。如今獲取這個指針。
//
pCurrNode = *(pCurrentPeb->ModuleLoaderInfoHead);
ModuleLoaderInfoHead = pCurrentPeb->ModuleLoaderInfoHead;
if ( _ShowSnaps )
{
_DbgPrint( "LDR: Real INIT LIST\n" );
}
nModulesInitedSoFar = 0;
if ( pCurrNode != ModuleLoaderInfoHead )
{
//
// 遍歷鏈表
//
while ( pCurrNode != ModuleLoaderInfoHead )
{
ModuleLoaderInfo pModuleLoaderInfo;
//
// 顯然指向下一個結點的指針在ModuleLoaderInfo結構中的0x10字節處
//
pModuleLoaderInfo = &NextNode - 0x10;
// 這條語句看起來好像沒有什麼做用
localVar3C = pModuleLoaderInfo;
//
// 肯定模塊是否已經被初始化。若是是,就跳過它
//
// X_LOADER_SAW_MODULE = 0x40
if ( !(pModuleLoaderInfo->Flags35 & X_LOADER_SAW_MODULE) )
{
//
// 此模塊還未被初始化。檢查它是否有入口點函數
//
if ( pModuleLoaderInfo->EntryPoint )
{
//
// 這個未初始化的模塊有入口點函數。將它添加到
// pInitNodeArray數組中。此函數會在後面初始化
// 這個數組中的模塊。
//
pInitNodeArray[nModulesInitedSoFar] =pModuleLoaderInfo;
// 若ShowSnaps不爲0,輸出模塊的路徑及其入口點地址。例如:
// C:\WINNT\system32\KERNEL32.dll init routine 77f01000
if ( _ShowSnaps )
{
_DbgPrint( "%wZ init routine %x\n",
&pModuleLoaderInfo->24,
pModuleLoaderInfo->EntryPoint );
}
nModulesInitedSoFar++;
}
}
// 設置此模塊的X_LOADER_SAW_MODULE標誌。注意:此時模塊實際
// 並未被初始化。要等到這個函數快結束時才初始化
pModuleLoaderInfo->Flags35 &= X_LOADER_SAW_MODULE;
// 移向模塊列表中的下一個結點
pCurrNode = pCurrNode->pNext
}
}
else
{
pModuleLoaderInfo = localVar3C; // 可能未被初始化嗎???
}
if ( 0 == pInitNodeArray )
return STATUS_SUCCESS;
//
// 如今pInitNodeArray數組中包含的是未初始化的模塊的信息的指針。
// 是調用它們的初始化例程的時候了。
//
try // 用try塊將整個代碼包裝起來,以防初始化例程失敗。
{
nModulesInitedSoFar = 0; // 從索引爲0的數組元素開始
//
// 開始遍歷整個模塊數組
//
while ( nModulesInitedSoFar < nRoutinesToRun )
{
// 獲取有關模塊信息的指針
pModuleLoaderInfo = pInitNodeArray[ nModulesInitedSoFar ];
// 這條語句好像沒什麼做用
localVar3C = pModuleLoaderInfo;
nModulesInitedSoFar++;
// 將初始化例程的地址保存在一個局部變量中
pfnInitRoutine = pModuleLoaderInfo->EntryPoint;
fBreakOnDllLoad = 0; // 默認加載時不中斷
//
// 若是進程正處於被調試狀態,確認一下是否應該在調用
// 初始化例程以前中斷在調試器中
//
// DebuggerPresent(在PEB結構中的偏移2處)是IsDebuggerPresent()
// 返回的內容。這個API僅存在於Windows NT上
//
if ( pCurrentPeb->DebuggerPresent || pCurrentPeb->1 )
{
LONG retCode;
//
// 查詢註冊表中的「HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\
// Windows NT\CurrentVersion\Image File Execution Options」
// 這個鍵。若是它下面存在一個以這個可執行文件名命名的子鍵,
// 就檢查這個子鍵下的BreakOnDllLoad值
//
retCode =
_LdrQueryImageFileExecutionOptions(
pModuleLoaderInfo->pwszDllName,
"BreakOnDllLoad",
REG_DWORD,
&fBreakOnDllLoad,
sizeof(DWORD),
0 );
// 若是未找到這個值(一般是這樣),在初始化DLL時就不中斷
if ( retCode <= STATUS_SUCCESS )
fBreakOnDllLoad = 0;
}
if ( fBreakOnDllLoad )
{
if ( _ShowSnaps )
{
// 在實際中斷進調試器以前,輸出模塊名稱和初始化例程的地址
_DbgPrint( "LDR: %wZ loaded.",
&pModuleLoaderInfo->pModuleLoaderInfo );
_DbgPrint( "- About to call init routine at %lx\n",
pfnInitRoutine )
}
// 中斷進調試器
_DbgBreakPoint(); // 它實際是一條INT 3指令,後面跟着RET指令
}
else if ( _ShowSnaps && pfnInitRoutine )
{
// 在調用初始化例程以前輸出模塊名稱和初始化例程的地址
_DbgPrint( "LDR: %wZ loaded.",
pModuleLoaderInfo->pModuleLoaderInfo );
_DbgPrint("- Calling init routine at %lx\n", pfnInitRoutine);
}
if ( pfnInitRoutine )
{
// 設置標誌來代表已將DLL_PROCESS_ATTACH通知發送給了DLL
//
// (難道這不該該是在實際調用初始化例程以後才設置嗎?)
//
// X_LOADER_CALLED_PROCESS_ATTACH = 0x8
pModuleLoaderInfo->Flags36 |= X_LOADER_CALLED_PROCESS_ATTACH;
//
// 若是此模塊使用了線程局部存儲(TLS),如今調用TLS初始化函數
// *** 注意 ***
// 這僅發生在一個進程首次調用此函數時(也就是在初始化隱含
// 連接的DLL時)。動態加載的DLL不該該使用TLS變量,正如
// SDK文檔所說的那樣
//
if ( pModuleLoaderInfo->bHasTLS && bImplicitLoad )
{
_LdrpCallTlsInitializers( pModuleLoaderInfo->hModDLL,
DLL_PROCESS_ATTACH );
}
hModDLL = pModuleLoaderInfo->hModDLL
MOV ESI,ESP // 將ESP寄存器的值保存到ESI中
MOV EDI,DWORD PTR [pfnInitRoutine] // 將模塊的入口點
// 地址加載到EDI中
//如下的彙編語言代碼用C++代碼表示就是:
//
// initRetValue =
// pfnInitRoutine(hInstDLL,DLL_PROCESS_ATTACH,bImplicitLoad);
//
PUSH DWORD PTR [bImplicitLoad]
PUSH DLL_PROCESS_ATTACH
PUSH DWORD PTR [hModDLL]
CALL EDI // 調用初始化例程。這是設置斷點的最佳位置。
// 單步跟蹤這個調用就進入到了DLL的入口點中
MOV BYTE PTR [initRetValue],AL // 保存入口點函數的返回值
MOV DWORD PTR [_SaveSp],ESI // 保存入口點函數返回後的
MOV DWORD PTR [_CurSp],ESP // 堆棧指針的值
MOV ESP,ESI // 恢復調用入口點函數以前ESP中的值
//
// 校驗調用先後堆棧指針(ESP)的值。若是它們不一樣,這
// 代表DLL的初始化例程並無正確地清理堆棧。例如,它的
// 入口點函數可能定義的不正確。儘管這極少發生,可是若是
// 它確實發生了,咱們要通知用戶並讓他們決定是否繼續執行
//
if ( _CurSP != _SavSP )
{
hardErrorParam = pModuleLoaderInfo->FullDllPath;
hardErrorRetCode =
_NtRaiseHardError(
STATUS_BAD_DLL_ENTRYPOINT | 0x10000000,
1, // 參數個數
1, // UnicodeStringParametersMask,
&hardErrorParam,
OptionYesNo, // 讓用戶決定
&hardErrorResponse );
if ( _LdrpInLdrInit )
_LdrpFatalHardErrorCount++;
if ( (hardErrorRetCode >= STATUS_SUCCESS)
&& (ResponseYes == hardErrorResponse) )
{
return STATUS_DLL_INIT_FAILED;
}
}
//
// 若是DLL的入口點函數返回0(表示失敗),通知用戶
//
if ( 0 == initRetValue )
{
DWORD hardErrorParam2;
DWORD hardErrorResponse2;
hardErrorParam2 = pModuleLoaderInfo->FullDllPath;
_NtRaiseHardError( STATUS_DLL_INIT_FAILED,
1, // 參數個數
1, // UnicodeStringParametersMask
&hardErrorParam2,
OptionOk, // 只能以「肯定」做爲響應
&hardErrorResponse2 );
if ( _LdrpInLdrInit )
_LdrpFatalHardErrorCount++;
return STATUS_DLL_INIT_FAILED;
}
}
}
//
// 若是這個進程自身的EXE文件定義了TLS變量,如今調用TLS初始化例程。
// 要獲取更詳細的信息,參考前面調用_LdrpCallTlsInitializers時的註釋
//
if ( _LdrpImageHasTls && bImplicitLoad )
{
_LdrpCallTlsInitializers( pCurrentPeb->ProcessImageBase,
DLL_PROCESS_ATTACH );
}
}
__finally
{
//
// 在這個函數退出以前,確保它在前面分配的內存被釋放
//
_RtlFreeHeap( GetProcessHeap(), 0, pInitNodeArray );
}
return STATUS_SUCCESS;
}
在Windows NT加載器代碼中,LdrpRunInitializeRoutines是調用EXE或DLL的指定入口點代碼以前的最後一站。(在下面的討論中,我將把 「入口點」和「初始化例程」互換着使用。)這段加載器代碼在被加載的DLL所在的那個進程環境中執行。也就是說,它並非什麼特別的加載器進程的一部分。在進程啓動過程當中處理隱含加載的DLL時,LdrpRunInitializeRoutines至少被調用一次。同時每當動態加載一個或多個DLL(通常是經過調用LoadLibrary來實現的)時,都要調用它,
每當LdrpRunInitializeRoutines執行時,它就查找並調用已經被映射進內存但還還沒有被初始化的全部DLL的入口點代碼。在看上面的僞代碼時,注意全部提供跟蹤輸出的額外代碼(也就是上面的僞代碼中使用_ShowSnaps變量和_DbgPrint函數的代碼),它們甚至存在於非調試版的Windows NT中。稍候我會接着說這一點。
這個函數大致上分爲四個不一樣的部分。第一部分調用_LdrpClearLoadInProgress函數。這個NTDLL函數返回剛纔被映射進內存的DLL數目。例如若是你在FOO.DLL中調用LoadLibrary函數,而FOO隱含連接到了BAR.DLL和BAZ.DLL,那麼_LdrpClearLoadInProgress將返回3,由於有三個DLL被映射進內存中。
在知道了相關的DLL數目以後,LdrpRunInitializeRoutines調用RtlAllocateHeap(也就是HeapAlloc)來爲一個指針數組分配內存。在僞代碼中我把這個數組稱爲pInitNodeArray。這個數組中的每一個元素(指針)最終分別指向一個包含有關最近加載(但還沒有初始化)的DLL的信息的結構。
在LdrpRunInitializeRoutines的第二部分中,它使用內部進程數據結構來獲取一個包含最近加載的DLL的鏈表。而後它遍歷這個鏈表來肯定加載器是否曾經加載過這個DLL。接下來肯定DLL是否有入口點函數。若是這兩個測試都經過了,它就將指向相應模塊信息的指針添加到pInitNodeArray數組中。在僞代碼中我稱這個模塊信息爲pModuleLoaderInfo。必定要注意:一個DLL徹底有可能不包含入口點函數——例如純資源DLL。所以pInitNodeArray中的元素數可能比前面由_LdrpClearLoadInProgress函數返回的值小。
LdrpRunInitializeRoutines例程的第三部分(也是最大的一部分)纔是真正的重頭戲。它的任務就是枚舉pInitNodeArray數組中的每一個元素並調用相應的入口點函數。因爲DLL的初始化代碼可能會出錯,所以這部分代碼整個用一個__try塊封裝。這就是動態加載DLL時雖然DllMain中出現錯誤但並不會致使整個進程終止的緣由。
遍歷一個數組並調用其中的每一個入口點函數應該是小菜一碟。然而因爲Windows NT中一些灰暗不明的特性使它變得複雜起來。首先須要考慮進程是否正在被像MSDEV.EXE之類的Win32調試器調試。Windows NT有一個選項容許你在DLL初始化以前將一個進程掛起並把控制權發送到調試器。這個功能是基於DLL的,能夠經過向註冊表中一個以DLL名稱命名的鍵中添加一個字符串值(BreakOnDllLoad)來實現。詳細信息能夠參考圖1的僞代碼中函數_LdrQueryImageFileExecutionOptions的調用代碼上面的註釋。
在調用DLL的入口點函數以前可能須要執行的另外一塊代碼是TLS初始化代碼。當你使用__declspec(thread)定義TLS變量時,連接器會包含觸發這個條件的數據。在DLL的入口點函數被調用以前,LdrpRunInitializeRoutines要肯定是否須要初始化TLS。若是須要,它就調用_LdrpCallTlsInitializers。後面會詳細討論。
LdrpRunInitializeRoutines中真正調用DLL入口點函數的代碼終於到來了。我有意用匯編語言來表示這部分代碼。緣由一下子就清楚了。這裏面最關鍵的一條指令是CALL EDI。在這裏,EDI指向DLL的入口點函數,而入口點函數是由DLL的PE文件頭指定的。當CALL EDI返回時,DLL已經完成了它的初始化。對於用C++寫的DLL來講,這意味着它的DllMain函數已經執行完了與DLL_PROCESS_ATTACH相應的那部分代碼。同時要注意傳遞給入口點函數的第三個參數,它一般被稱爲lpvReserved。事實上,對於可執行文件隱含連接(直接連接或經過其它DLL間接連接)到的DLL來講,這個參數非0。對於其它DLL(即經過調用LoadLibrary動態加載的DLL)來講,這個參數爲0。
DLL的入口點函數被調用以後,LdrpRunInitializeRoutines開始進行安全性檢查以確保DLL的入口點代碼中沒有錯誤。它比較調用入口點代碼先後堆棧指針(ESP)的值。若是它們不一樣,那就代表DLL的初始化函數出現了錯誤。因爲大多數程序員從未定義過真正的DLL入口點函數,這種狀況不多發生。可是它一旦發生,Windows就會用一個對話框通知你這個問題(如圖2所示)。我不得不使用調試器並在恰當的地方修改寄存器的值才產生了這個對話框。
圖2 非法DLL入口點
堆棧檢查完畢以後,LdrpRunInitializeRoutines檢查入口點函數的返回值。對於用C++寫的DLL來講,它就是DllMain的返回值。若是DLL返回0,它一般表示出現了錯誤,不能繼續加載這個DLL了。若是發生這種狀況,你就會獲得一個使人懼怕的「DLL初始化失敗」對話框。
在全部的DLL初始化完畢以後開始執行LdrpRunInitializeRoutines函數的第三部分中的最後一些代碼。若是進程自己的EXE文件包含TLS數據,而且若是隱含連接到的DLL已經被初始化,那它就調用_LdrpCallTlsInitializers。
LdrpRunInitializeRoutines函數的第四部分(也是最後一部分)是清理代碼。還記得前面RtlAllocateHeap建立的pInitNodeArray數組嗎?這部份內存須要被釋放,釋放它的代碼在__finally塊中。這樣,即便這些DLL中可能有的在初始化時會失敗,__try/__finally代碼也能保證會調用RtlFreeHeap來釋放pInitNodeArray。
咱們的LdrpRunInitializeRoutines之旅就此結束了,如今讓咱們來看一下與此相關的一些問題。
調試初始化例程
我也曾經遇到過DLL在初始化時失敗的狀況。不幸的是,錯誤多是好幾個DLL中的一個,而操做系統並無告訴我到底哪個纔是罪魁禍首。在這種狀況下,你就可使用調試器斷點來解決問題。
大多數調試器都直接跳過靜態連接的DLL的初始化例程。它們把注意力放在EXE文件的第一條指令或第一行上。可是知道了LdrpRunInitializeRoutines的內部狀況以後,你就能夠在CALL EDI這條指令上設置一個斷點,此時正要執行的是DLL的入口點代碼。一旦設置了這個斷點,每次DLL將要接到DLL_PROCESS_ATTACH通知時,就會中斷在NTDLL的CALL指令上。圖3是在Visual C++® 6.0 IDE(MSDEV.EXE)中的狀況。
圖3 在CALL EDI指令上設置斷點
若是單步跟蹤CALL指令,你會遇到DLL入口點代碼的第一條指令。意識到這段代碼絕大多數狀況下都不是你本身寫的這一點很重要。由於它一般是運行時庫中的代碼,這段代碼先作一些準備工做,而後再調用你的初始化代碼。例如在用Visual C++寫的DLL中,它的入口點函數是_DllMainCRTStratup,這個函數在CRTDLL.C中。在沒有調試符號和源代碼的狀況下,你在MSDEV的彙編窗口中看到內容相似下面這個樣子(圖4):
圖4 單步跟蹤CALL指令
一般我在調試時會按照下面這個過程進行。第一步就是找出哪一個DLL出現了錯誤。一般設置前面講的斷點而後單步跟蹤到每一個DLL的初始化例程就能夠了。使用調試器找出你當前正處於哪一個DLL中,並把它記錄下來。一種方法就是使用調試器的內存窗口來觀察堆棧(ESP),獲取你進入的DLL的HMODULE。
當你知道進入到了哪一個DLL以後,讓進程繼續運行(通常是Go命令)。很快就會在下一個DLL中再次觸發斷點。重複這個過程直到你找到有問題的DLL爲止。你很容易就能找到出錯的DLL,由於它的初始化代碼被調用了,但在這個初始化代碼返回以前,進程卻意外終止了(由於出錯了)。
第二步就是仔細檢查出錯的DLL。若是你有那個DLL的源代碼,你最好在DllMain上設置一個斷點,而後讓進程運行等待斷點被觸發。若是你沒有源代碼,只管讓進程運行,等待你在CALL EDI指令上設置的斷點在那個位置觸發。繼續運行直到你碰到出錯的指令。單步跟蹤進入這個入口點代碼並一直單步跟蹤下去直到你肯定問題所在。這一般須要跟蹤大量彙編代碼!我從沒有說過這很容易,但有時候這是解決問題的唯一方法。
找出CALL EDI指令須要一些技巧(至少是在當前的Microsoft®調試器上)。你如今就能理解我爲何在上面的僞代碼中用匯編語言表示這部分代碼了。首先,很明顯你須要把NDDLL.DLL配套的NTDLL.DBG文件(如今固然是NTDLL.PDB文件)放在你的SYSTEM32目錄中。當你開始單步跟蹤你的程序時,調試器應該會自動加載調試符號。
在Visual C++的彙編窗口,原理上你可使用符號名做爲地址。在這裏,你固然是想轉到_LdrpRunInitializeRoutines@4,而後滾動窗口直到你看到CALL EDI這條指令。不幸的是,除非你中斷在NTDLL.DLL中,不然Visual C++調試器並不能識別NTDLL中的符號名。
若是你碰巧知道_LdrpRunInitializeRoutines@4的地址(在Intel平臺的Windows NT 4.0 SP3上這個地址爲0x77F63242),你能夠鍵入那個地址,彙編窗口很容易就會顯示它。IDE甚至會顯示這個函數的名稱爲_LdrpRunInitializeRoutines@4。若是你不是調試器老手,符號名識別失敗讓人很困惑。若是你和我同樣是個調試器愛好者,這是很是討厭的,由於你不知道到底問題出在哪裏。
Platform SDK中的WinDBG在識別符號名方面稍好一些。一旦你啓動了目標進程,你就能夠用_LdrpRunInitializeRoutines@4的名稱在這個函數上設置一個斷點。不幸的是,當你首次執行這個進程時,你還沒來得及在_LdrpRunInitializeRoutines@4上設置斷點,執行流程已通過了這個函數了。爲了解決這個問題,啓動WinDBG後,先單步跟蹤一步,而後設置斷點並中止調試, 仍然保留調試器。而後你能夠重啓被調試程序,此次斷點就會在每一次調用_LdrpRunInitializeRoutines@4時被觸發。這個技巧也能夠用在Visual C++調試器中。
ShowSnaps是什麼?
ShowSnaps這個全局變量是我在查看LdrpRunInitializeRoutines的代碼時首先注意到的內容之一。趁這個好機會簡要地解釋一下有關GlobalFlag和GFlags.EXE方面的內容。
Windows NT註冊表中包含了影響系統代碼某些行爲的DWORD值。它們大部分與堆和調試有關。註冊表中HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager子鍵下的GlobalFlag值是一組位域。知識庫文章Q147314描述了這些域中的大部分,所以我在這裏就不詳細講了。除了系統範圍內的GlobalFlag值外,各個可執行文件也能夠有它們本身的GlobalFlag值。與單個進程相關的GlobalFlag值被保存在註冊表中HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\imagename這個子鍵下,這裏的imagename是可執行文件的名字(例如WinWord.exe)。全部這些對於編制文檔來講都是極大挑戰的位域以及嵌套極深的註冊表鍵急需有一個程序來簡化。實際上,Microsoft就提供了一個這樣的程序(GFlags.EXE)。(關於GFlags工具以及各個標誌位的詳細含義,能夠參考最新的Microsoft® Debugging Tools for Windows®幫助文檔。)
圖5 GFlags.EXE
圖5顯示的是GFlags.EXE,它來自於Windows NT 4.0資源工具包。GFlags.EXE左上角是三個單選按鈕。選擇最上面的兩個(System Registry或Kernel Mode)中任意一個就能夠改變Session Manager中的GlobalFlags的值。若是你選擇第三個單選按鈕(Image File Options)的話,那麼許多選項就會消失。這是由於一些GlobalFlag選項隻影響內核模式代碼,對每一個進程來講並沒有多大意義。須要注意的一點是,大多數只用於內核模式的選項都假定你使用的是諸如i386kd之類的系統級調試器。若是不使用這樣的調試器深刻內部或接收輸出信息,那使用這些選項也就沒有什麼意義了。(最新版本的GFlags.EXE除了使用了三個選項卡而不是三個單選按鈕外,基本與此相似。)
這些標誌中與ShowSnaps相關的就是Show loader snaps選項。若是它被選中,那麼NTDLL.DLL中的ShowSnaps變量就會被設置成一個非0值。在註冊表中,這個位是0x00000002,它被定義爲FLG_SHOW_LDR_SNAPS。幸運的是,這個標誌是GlobalFlag中能夠被設置爲針對於每一個線程的一些標誌中的一個。要否則你要是在系統範圍使用這個標誌的話,那輸出內容會至關多。
檢查ShowSnaps輸出
如今讓咱們看一下選中Show loader snaps標誌後會輸出什麼類型的信息。我發現沒有討論到的Windows NT加載器的其它部分也會檢查這個標誌並輸出一些信息。圖6就是運行CALC.EXE時輸出內容的一部分。要得到這個文本,我首先運行GFlags打開CALC.EXE的Show loader snaps標誌。而後我在MSDEV.EXE的控制下運行CALC.EXE,這樣就從輸出窗口中得到了那些內容。
CALC.EXE的ShowSnaps輸出信息
##
以##開頭的是個人註釋
Loaded 'C:\WINNT\system32\CALC.EXE', no matching symbolic information found.
Loaded symbols for 'C:\WINNT\system32\ntdll.dll'
LDR: PID: 0x3a started - '"C:\WINNT\system32\CALC.EXE"'
LDR: NEW PROCESS
Image Path: C:\WINNT\system32\CALC.EXE (CALC.EXE)
Current Directory: C:\WINNT\system32
Search Path: C:\WINNT\system32;.;C:\WINNT\System32;C:\WINNT\system;...
LDR: SHELL32.dll used by CALC.EXE
Loaded 'C:\WINNT\system32\SHELL32.DLL', no matching symbolic information found.
LDR: ntdll.dll used by SHELL32.dll
LDR: Snapping imports for SHELL32.dll from ntdll.dll
LDR: KERNEL32.dll used by SHELL32.dll
Loaded symbols for 'C:\WINNT\system32\KERNEL32.DLL'
LDR: ntdll.dll used by KERNEL32.dll
LDR: Snapping imports for KERNEL32.dll from ntdll.dll
LDR: Snapping imports for SHELL32.dll from KERNEL32.dll
LDR: LdrLoadDll, loading NTDLL.dll from
LDR: LdrGetProcedureAddress by NAME - RtlEnterCriticalSection
LDR: LdrLoadDll, loading NTDLL.dll from
LDR: LdrGetProcedureAddress by NAME - RtlDeleteCriticalSection//
其他部分省略....
LDR: GDI32.dll used by SHELL32.dll
Loaded symbols for 'C:\WINNT\system32\GDI32.DLL'
LDR: ntdll.dll used by GDI32.dll
LDR: Snapping imports for GDI32.dll from ntdll.dll
LDR: KERNEL32.dll used by GDI32.dll
LDR: Snapping imports for GDI32.dll from KERNEL32.dll
LDR: USER32.dll used by GDI32.dll
Loaded symbols for 'C:\WINNT\system32\USER32.DLL'
LDR: ntdll.dll used by USER32.dll
LDR: Snapping imports for USER32.dll from ntdll.dll
LDR: KERNEL32.dll used by USER32.dll
LDR: Snapping imports for USER32.dll from KERNEL32.dll
LDR: LdrLoadDll, loading NTDLL.dll from
LDR: LdrGetProcedureAddress by NAME - RtlSizeHeap
LDR: LdrLoadDll, loading NTDLL.dll from
LDR: LdrGetProcedureAddress by NAME - RtlReAllocateHeap
LDR: LdrLoadDll, loading NTDLL.dll from
LDR: LdrGetProcedureAddress by NAME - RtlFreeHeap
LDR: LdrLoadDll, loading NTDLL.dll from
LDR: LdrGetProcedureAddress by NAME – RtlAllocateHeap //
其他部分省略....
## 注意加載器開始查找並校驗COMCTL32導入並綁定的DLL
Loaded 'C:\WINNT\system32\COMCTL32.DLL', no matching symbolic information found.
LDR: COMCTL32.dll bound to ntdll.dll
LDR: COMCTL32.dll has correct binding to ntdll.dll
LDR: COMCTL32.dll bound to GDI32.dll
LDR: COMCTL32.dll has correct binding to GDI32.dll
LDR: COMCTL32.dll bound to KERNEL32.dll
LDR: COMCTL32.dll has correct binding to KERNEL32.dll
LDR: COMCTL32.dll bound to ntdll.dll via forwarder(s) from KERNEL32.dll
LDR: COMCTL32.dll has correct binding to ntdll.dll
LDR: COMCTL32.dll bound to USER32.dll
LDR: COMCTL32.dll has correct binding to USER32.dll
LDR: COMCTL32.dll bound to ADVAPI32.dll
LDR: COMCTL32.dll has correct binding to ADVAPI32.dll//
其他部分省略....
LDR: Refcount COMCTL32.dll (1)
LDR: Refcount GDI32.dll (3)
LDR: Refcount KERNEL32.dll (6)
LDR: Refcount USER32.dll (4)
LDR: Refcount ADVAPI32.dll (5)
LDR: Refcount KERNEL32.dll (7)
LDR: Refcount GDI32.dll (4)
LDR: Refcount USER32.dll (5)## List of implicit link DLLs to be init'ed.
LDR: Real INIT LIST
C:\WINNT\system32\KERNEL32.dll init routine 77f01000
C:\WINNT\system32\RPCRT4.dll init routine 77e1b6d5
C:\WINNT\system32\ADVAPI32.dll init routine 77dc1000
C:\WINNT\system32\USER32.dll init routine 77e78037
C:\WINNT\system32\COMCTL32.dll init routine 71031a18
C:\WINNT\system32\SHELL32.dll init routine 77c41094
##
開始實際調用隱含連接的DLL的初始化例程
LDR: KERNEL32.dll loaded. - Calling init routine at 77f01000
LDR: RPCRT4.dll loaded. - Calling init routine at 77e1b6d5
LDR: ADVAPI32.dll loaded. - Calling init routine at 77dc1000
LDR: USER32.dll loaded. - Calling init routine at 77e78037
## USER32
開始作與AppInit_DLLs有關的工做,所以靜態初始化被暫時中斷
##
這個例子中,「globaldll.dll」是在USER32的初始化代碼中由LoadLibrary加載的
LDR: LdrLoadDll, loading c:\temp\globaldll.dll from C:\WINNT\system32;.;
LDR: Loading (DYNAMIC) c:\temp\globaldll.dll
Loaded 'C:\TEMP\GlobalDLL.dll', no matching symbolic information found.
LDR: KERNEL32.dll used by globaldll.dll//
其他部分省略....
LDR: Real INIT LIST
c:\temp\globaldll.dll init routine 10001310
LDR: globaldll.dll loaded. - Calling init routine at 10001310
##
如今接着初始化隱含連接的DLL
LDR: COMCTL32.dll loaded. - Calling init routine at 71031a18
LDR: LdrGetDllHandle, searching for USER32.dll from
LDR: LdrGetProcedureAddress by NAME - GetSystemMetrics
LDR: LdrGetProcedureAddress by NAME - MonitorFromWindow
LDR: SHELL32.dll loaded. - Calling init routine at 77c41094
//
其他部分省略....
在圖6中,注意全部從NTDLL中輸出的內容前面都加了LDR:前綴。其它行(例如「Loaded symbols for XXX」)是由MSDEV進程插入的。在查看帶有LDR:的行時會發現一些有價值的信息。例如在進程啓動時給出了EXE文件的完整路徑以及當前目錄和搜索路徑。
因爲NTDLL加載各個DLL並修正導入函數的地址,所以你會看到相似下面的信息:
LDR: ntdll.dll used by SHELL32.dll
LDR: Snapping imports for SHELL32.dll from ntdll.dll
第一行代表SHELL32.DLL連接到了NTDLL中的API上。第二行代表了從NTDLL導入的API正常被「snapped(快照)」。當可執行模塊從其它DLL導入函數時,在它裏面就有一個函數指針數組。這個函數指針數組就是IAT。加載器的工做之一就是定位導入函數的地址並把它們填入IAT中。所以術語「snapping」就出如今了LDR:輸出中。
輸出內容中另外一個引發我注意的是正在被處理的DLL的綁定信息。
LDR: COMCTL32.dll bound to KERNEL32.dll
LDR: COMCTL32.dll has correct binding to KERNEL32.dll
在之前的專欄中,我曾經講過使用BIND.EXE程序或IMAGEHLP.DLL導出的BindImageEx這個API來綁定程序。將一個可執行文件綁定到某個DLL上實際就是查找導入函數的地址並把它們寫入到磁盤上的可執行文件中。這能夠加速加載過程,由於加載時再也不須要查找導入函數的地址了。
上面的第一行代表COMCTL32綁定到了KERNEL32.DLL上。第二行代表綁定的地址是正確的。加載器經過比較時間戳來肯定這一點。若是時間戳不匹配,那麼綁定就是無效的。在這種狀況下,加載器就從新查找導入函數的地址,就好像這個可執行文件並無綁定同樣。
TLS初始化
最後我以另外一個例程的僞代碼來結束本期專欄。在LdrpRunInitializeRoutines函數中,在調用模塊的入口點代碼前的最後一刻,NTDLL檢查這個模塊是否須要初始化TLS。若是須要,它就調用LdrpCallTlsInitializers函數來進行初始化。圖7是我爲這個例程寫的僞代碼。
TLSInit.cpp
void _LdrpCallTlsInitializers( HMODULE hModule, DWORD fdwReason )
{
PIMAGE_TLS_DIRECTORY pTlsDir;
DWORD size
// 從IMAGE_OPTIONAL_HEADER.DataDirectory中查找TLS目錄
pTlsDir = _RtlImageDirectoryEntryToData(hModule,
1,
IMAGE_DIRECTORY_ENTRY_TLS,
&size );
__try // 用try/catch塊保護全部代碼
{
if ( pTlsDir->AddressOfCallbacks )
{
if ( _ShowSnaps ) // 輸出診斷信息
{
_DbgPrint( "LDR: Tls Callbacks Found. "
"Imagebase %lx Tls %lx CallBacks %lx\n",
hModule, TlsDir, pTlsDir->AddressOfCallbacks );
}
// 獲取指向包含TLS回調函數地址的數組的起始位置的指針
PVOID * pCallbacks = pTlsDir->AddressOfCallbacks;
while ( *pCallbacks ) // 遍歷數組中的每個元素
{
PIMAGE_TLS_CALLBACK pTlsCallback = *pCallbacks;
pCallbacks++;
if ( _ShowSnaps ) // 輸出更多診斷信息
{
_DbgPrint( "LDR: Calling Tls Callback "
"Imagebase %lx Function %lx\n",
hModule, pTlsCallback );
}
// 實際調用回調函數
pTlsCallback( hModule, fdwReason, 0 );
}
}
}
__except( EXCEPTION_EXECUTE_HANDLER )
{
}
}
這個函數至關簡單。PE文件頭中保存了IMAGE_TLS_DIRECTORY結構(在WINNT.H中定義)的偏移(RVA)。這個函數調用RtlImageDirecotoryEntryToData來獲取指向這個結構的指針。IMAGE_TLS_DIRECTORY結構中保存了一個指針,它指向一個由回調函數的地址組成的數組。這些回調函數是被聲明爲PIMAGE_TLS_CALLBACK類型的函數,這個類型在WINNT.H中定義。TLS初始化回調函數與DllMain函數很是類似。實際上在使用__declspec(thread)定義變量時,Visual C++生成了一些致使這些函數會被調用的數據。可是當前運行時庫並未定義實際的回調函數,所以這個函數指針數組只有一個值爲NULL的元素。
總結
我對Windows NT模塊初始化方面的討論已經結束了。很明顯我跳過了許多相關內容。例如肯定模塊初始化順序的算法是什麼?Windows NT上的這個算法至少已經改變過一次,若是有Microsoft technical note就行了,至少它能夠給咱們一些指導。一樣,我也沒有討論與模塊加載對應的話題:模塊卸載。然而,我但願我對Windows NT加載器內部工做過程的「一瞥」可以爲你更深層次的探索提供一些材料。