混合組件的初始化
在 Visual C++ .NET 和 Visual C++ 2003 中,使用 /clr 編譯器選項編譯的 DLL,可能會在載入時造成非決定性的死結,這個問題稱為混合 DLL 載入或載入器鎖定問題。 幾乎已經解決混合 DLL 載入程序中,所有不具決定性的鎖定問題。 然而在一些情況下,仍然有可能 (具決定性地) 發生載入器鎖定。 如需這個問題的詳細資訊,請參閱 MSDN Library 中的<混合 DLL 載入問題>主題 (英文)。
DllMain 內的程式碼不得存取 CLR。 這表示 DllMain 不應該直接或間接呼叫 Managed 函式,Managed 程式碼不該在 DllMain 中宣告或實作,且記憶體回收或自動程式庫載入不應在 DllMain 內發生。
注意事項 |
---|
Visual C++ 2003 提供了 _vcclrit.h 以加速 DLL 初始化,同時將發生死結的可能性降到最低。 已不再需要使用 _vcclrit.h,若真的使用會導致在編譯時產生已被取代警告訊息。 建議您採用HOW TO:移除 _vcclrit.h 的相依性 中的步驟,移除這個檔案上的相依性。 而較不理想的作法,包括在包含 _vcclrit.h 之前,先定義 _CRT_VCCLRIT_NO_DEPRECATE 來隱藏警告訊息,或僅忽略取代警告訊息。 |
載入器鎖定的原因
採用 .NET 平台後,會有兩種不同的載入執行模組 (EXE 或 DLL) 機制:一種適用於 Windows,會載入 Unmanaged 模組,另一種則適用於載入 .NET 組件的 .NET Common Language Runtime (CLR)。 混合 DLL 載入大部分的問題都與 Microsoft Windows 作業系統載入器有關。
當只包含 .NET 建構的組件載入處理序時,CLR 載入器可以自己執行所有必要的載入與初始化工作。 然而,因為混合組件能包含機器碼和資料,所以也一定要使用 Windows 載入器。
Windows 載入器可確保在 DLL 初始化之前,所有程式碼都無法存取 DLL 的程式碼或資料,且無法在 DLL 部分初始化的時候,重複載入 DLL。 為了能夠做到這一點,Windows 載入器會使用處理序全域關鍵區段 (Critical Section,通常稱為「載入器鎖定」),避免在模組初始化期間產生不安全存取。 因此,載入程序很容易受到許多常見死結情況的危害。 使用混合組件時,下列兩種情況會增加發生死結的風險:
首先,如果使用者嘗試執行編譯為 Microsoft Intermediate Language (MSIL) 的函式,在進行載入器鎖定時 (例如,從 DllMain 或在靜態初始設定式中),就可能會造成死結。 另一種情況是 MSIL 函式參考尚未載入之組件的型別。 CLR 會嘗試自動載入該組件,這可能會需要 Windows 載入器來對載入器鎖定進行封鎖。 因為載入器鎖定已經由稍早在呼叫序列中的程式碼所進行,所以會產生死結。 然而,在載入器鎖定下執行 MSIL 不一定會發生死結,因此這種情況難以診斷和修正。 在某些狀況下,例如參考型別的 DLL 沒有包含原生建構,且所有的相依性也不包含原生建構時,就不需要 Windows 載入器來載入參考型別的 .NET 組件。 此外,需要的組件或其混合的原生 (.NET) 相依性可能已經由其他程式碼所載入。 因此死結的發生很難預測,且會根據目標電腦的組態而情況有所不同。
第二,在載入 .NET Framework 1.0 及 1.1 版 DLL 時,CLR 會假設沒有進行載入器鎖定,而會執行在載入器鎖定下無效的數個動作。 假定對 .NET DLL 而言,未進行載入器鎖定是有效假設,然而,因為混合的 DLL 會執行原生初始設定常式,所以會需要 Windows 載入器和載入器鎖定。 因此,即使開發人員並未嘗試在 DLL 初始化時執行任何 MSIL 函式,使用 .NET Framework 1.0 及 1.1 版還是會有部分機會發生非決定性的鎖定情況。
已經解決混合 DLL 載入程序中,所有不具決定性的鎖定問題。 此動作是由這些變更所完成的:
在載入混合的 DLL 時,CLR 再也不會做出不實的假設。
Unmanaged 和 Managed 初始化會在兩個不同且特定的階段中執行。 透過名為 .cctor 的 .NET 支援建構,會先發生 Unmanaged 初始化 (經由 DllMain),然後才是 Managed 初始化。 後者對於使用者是完全透明的,除非使用了 /Zl 或 /NODEFAULTLIB。 如需詳細資訊,請參閱 /NODEFAULTLIB (忽略程式庫) 和 /Zl (省略預設程式庫名稱)。
載入器鎖定仍然有可能發生,但現在是可重現的而且能夠偵測出來。 如果 DllMain 包含了 MSIL 指令,編譯器將會產生警告訊息編譯器警告 (層級 1) C4747。 此外,CRT 或 CLR 將會嘗試偵測並報告在載入器鎖定的情況下執行 MSIL 的嘗試。 CRT 偵測會產生執行階段診斷 C 執行階段錯誤 R6033。
本文件的其他部分將說明其他可以在載入器鎖定中執行的 MSIL 的案例、每個案例內的問題解析以及偵錯技術。
案例和解決方法
使用者程式碼可以在載入器鎖定之下執行 MSIL 會發生在好幾種不同的情況下。 開發人員必須確保,使用者程式碼實作不會嘗試在每一個情況下都去執行 MSIL 指令。 下列子章節會對最常見案例中的問題進行討論與解析,並對全部的可能性加以說明。
DllMain
靜態初始設定式
使用者提供函式影響啟動
自訂地區設定
DllMain
DllMain 函式是 DLL 的使用者定義進入點 (Entry Point)。 除非使用者有所指定,否則每次處理序或執行緒附加至包含 DLL,或從包含 DLL 中斷連結時,都會叫用 DllMain。 由於在進行載入器鎖定時會發生這個呼叫,所以不應該將任何使用者提供的 DllMain 函式編譯為 MSIL。 此外,根源於 DllMain 的呼叫樹狀圖中的函式,都不可編譯為 MSIL。 若要解決此處的問題,應該用 #pragma unmanaged 修改定義 DllMain 的程式碼區塊。 針對 DllMain 呼叫的每個函式,也應該執行相同的動作。
在某些情況下,這些函式必須呼叫要求其他呼叫內容有 MSIL 實作的函式,這時可以使用複製策略,以建立相同函式的 .NET 和原生版本。
另一個方法是,如果不需要 DllMain,或不需要在載入器鎖定下執行它,就可以移除使用者提供的 DllMain 實作,這樣就可以排除問題了。
如果 DllMain 嘗試直接執行 MSIL,就會產生編譯器警告 (層級 1) C4747。 但是,當 DllMain 呼叫位於其他嘗試執行 MSIL 的模組中的函式時,編譯器就無法偵測到這些狀況。
如需這個案例的詳細資訊,請參閱<診斷的障礙>。
初始化靜態物件
如果需要動態初始設定式,初始化靜態物件就可能會產生死結。 對於簡單的案例,例如當靜態變數僅指派給編譯時間已知的值時,就不需要動態初始化,所以便沒有死結的風險。 然而,由函式呼叫、建構引動過程,或無法在編譯時間評估的運算式所初始化的靜態變數,在模組初始化期間時都需要程式碼來加以執行。
下列程式碼示範了需要動態初始化之靜態初始設定式的使用範例:函式呼叫、物件建構和指標初始化 (這些範例並非靜態,但假設為定義在擁有相同效果的全域範圍內)。
// dynamic initializer function generated
int a = init();
CObject o(arg1, arg2);
CObject* op = new CObject(arg1, arg2);
死結的風險取決於包含模組是否使用 /clr 來編譯,以及是否將執行 MSIL 而定。 特別是,如果靜態變數未使用 /clr 來編譯 (或存在於 #pragma unmanaged 區塊中),且需要用來初始化此靜態變數的動態初始設定式導致了 MSIL 指令的執行,就可能發生死結。 這是因為對於未使用 /clr 來編譯的模組而言,靜態變數的初始化是由 DllMain 所執行。 相反地,使用 /clr 來編譯的靜態變數是在完成 Unmanaged 初始化階段,以及釋放載入器鎖定之後,由 .cctor 來進行初始化。
對於由靜態變數的動態初始化所引起的死結,有很多解決方案 (大致按照修正問題的所需時間排列):
包含了可以使用 /clr 來編譯的靜態變數之原始程式檔。
靜態變數呼叫的所有函式都可以使用 #pragma unmanaged 指示詞編譯成機器碼。
手動複製 (Clone) 靜態變數所依據的程式碼,為 .NET 和原生版本指定不同的名稱。 接著,開發人員可從原生靜態初始設定式呼叫原生版本,並從別處呼叫 .NET 版本。
使用者提供函式影響啟動
程式庫在啟動時的初始化,會依賴數個使用者提供函式。 例如,在全域性地多載 C++ 的運算子 (如 new 和 delete 運算子) 時,使用者提供的版本會使用於各處,包括 STL 初始化和解構。 因此,STL 和使用者提供的靜態初始設定式,會叫用這些運算子的任何使用者提供版本。
如果使用者提供版本是編譯為 MSIL,則這些初始設定式會在載入器鎖定期間,嘗試執行 MSIL 指令。 使用者提供的 malloc 會擁有相同的後果。 若要解決這個問題,任何這些多載或使用者提供的定義,都必須使用 #pragma unmanaged 指示詞來實作為機器碼。
如需這個案例的詳細資訊,請參閱「診斷的障礙」。
自訂地區設定
如果使用者提供了自訂全域地區設定 (Locale),這個地區設定就會用來初始化日後所有的 I/O 資料流 (I/O stream),包括靜態初始化的那些在內。 如果這個全域地區設定物件是編譯為 MSIL,就可能在載入器鎖定時,叫用編譯為 MSIL 的地區設定物件成員函式。
要解決這個問題,有三個選項:
包含所有全域 I/O 資料流定義的原始程式檔,可使用 /clr 選項進行編譯。 如此可避免在載入器鎖定下執行靜態初始設定式。
自訂的地區設定函式定義可以使用 #pragma unmanaged 指示詞編譯成機器碼。
請等到釋放載入器鎖定之後,再設定自訂全域地區設定為全域地區設定。 然後再使用自訂地區設定,明確設定建立於初始化期間的 I/O 資料流。
診斷的障礙
在某些情況下,很難偵測死結的來源。 下列子章節會討論這些案例以及解決問題的方法。
標頭檔中的實作
在少數情況下,位於標頭檔內的函式實作可能會使診斷變得很複雜。 內嵌函式和範本程式碼都要求必須在標頭檔中指定函式。 C++ 語言會指定「一個定義規則」(One Definition Rule),強制所有名稱相同的函式實作在語意上要對等。 因此,C++ 連結器在合併有指定函式之重複實作的物件檔案時,不需要做任何特別的考量。
在 Visual C++ .NET 和 Visual C++ .NET 2003 中,連結器在這些語意相等的定義中,只會選擇最大的一個,以配合向前宣告以及不同最佳化選項使用於不同原始資料檔的案例。 這樣會建立混合的原生 (.NET) DLL 的問題。
因為已啟用和已停用 /clr 的 CPP 檔可能同時包含相同的標頭,或是 #include 可能包裝在 #pragma unmanaged 區塊中,所以有可能同時擁有 MSIL 版本和原生版本的函式,這些函式會在標頭中提供實作。 相對於載入器鎖定下的初始化,MSIL 和原生實作具有不同的語意,而這有效地違反了一個定義原則。 因此,當連接器選擇了最大實作時,它可以選擇 MSIL 版本的函式,即使該函式是使用 #pragma unmanaged 指示詞,在別處明確編譯為機器碼。 若要確保在載入器鎖定下絕不會呼叫 MSIL 版本的範本或內嵌函式,在載入器鎖定下所呼叫每一個這類函式的定義,都必須使用 #pragma unmanaged 指示詞進行修改。 如果標頭檔來自協力廠商,要達到這個目的,最簡單的方法就是在違規標頭檔的 #include 指示詞附近,推入和提出 #pragma unmanaged 指示詞 (如需範例,請參閱 managed, unmanaged)。但是這個策略將不適用於含有必須直接呼叫 .NET API 之程式碼的標頭檔。
為方便使用者處理載入器鎖定,若原生實作和 Managed 實作同時存在,連結器會優先選擇原生實作。 這可以避免上述問題。 但是在這個版本中,因為編譯器有兩個尚未解決的問題,所以這項規則有兩個例外:
- 呼叫內嵌函式是透過全域靜態函式指標。 這個案例特別值得注意,因為虛擬函式的呼叫是透過全域函式指標。 例如:
#include "definesmyObject.h"
#include "definesclassC.h"
typedef void (*function_pointer_t)();
function_pointer_t myObject_p = &myObject;
#pragma unmanaged
void DuringLoaderlock(C & c)
{
// Either of these calls could resolve to a managed implementation,
// at link-time, even if a native implementation also exists.
c.VirtualMember();
myObject_p();
}
- 針對 Itanium 進行編譯時,在所有函式指標的實作中有個錯誤。 在上述程式碼片段中,如果於 during_loaderlock() 內部將 myObject_p 定義為區域性,則呼叫可能也會解析成 Managed 實作。
在偵錯模式中診斷
所有載入器鎖定問題的診斷,都應該使用偵錯組建來完成。 發行的組建 (Release Build) 可能不會產生診斷,而在發行模式中執行的最佳化可能會遮罩載入器鎖定情況下的部分 MSIL。
如何偵錯載入器鎖定問題
在叫用 MSIL 函式時,CLR 所產生的診斷會造成 CLR 暫止執行。 而這會造成在同處理序執行偵錯項目時,Visual C++ 混合模式偵錯工具也會跟著暫止。 然而,在附加於處理序的同時,不可能使用混合模式偵錯工具取得偵錯項目的 Managed 呼叫堆疊。
若要辨認在載入器鎖定時所呼叫的特定 MSIL 函式,開發人員應該完成下列步驟:
確定可以使用 mscoree.dll 和 mscorwks.dll 的符號。
有兩種方法可以達到這個目的: 第一,可將 mscoree.dll 和 mscorwks.dll 的 PDB 加入至符號搜尋路徑。 若要這麼做,請開啟 [符號搜尋路徑選項] 對話方塊 (從 [工具] 功能表按一下 [選項]。 在 [選項] 對話方塊的左邊窗格中,開啟 [偵錯] 節點,然後按一下 [符號])。將路徑加入至 mscoree.dll,並將 mscorwks.dll PDB 檔加入至搜尋清單。 這些 PDB 會安裝到 %VSINSTALLDIR%\SDK\v2.0\symbols。 按一下 [確定]。
第二,可從 Microsoft Symbol Server 下載 mscoree.dll 和 mscorwks.dll 的 PDB。 若要設定 Symbol Server,請開啟 [符號搜尋路徑選項] 對話方塊 (從 [工具] 功能表按一下 [選項]。 在 [選項] 對話方塊的左邊窗格中,開啟 [偵錯] 節點,然後按一下 [符號])。將下列搜尋路徑加入至搜尋清單:http://msdl.microsoft.com/download/symbols。 加入符號快取目錄至符號伺服器快取文字方塊。 按一下 [確定]。
將偵錯模式設定為僅支援模式。
若要這麼做,請開啟在方案中啟始專案的 [屬性] 方格。 在 [組態屬性] 樹狀子目錄下,選取 [偵錯節點]。 將 [偵錯工具類型] 欄位設定為 [僅支援]。
啟動偵錯工具 (F5)。
在產生 /clr 診斷之後,按一下 [重試],然後按一下 [中斷]。
開啟 [呼叫堆疊] 視窗 (從 [偵錯] 功能表中按一下 [Windows],然後按一下 [呼叫堆疊])。如果驗證 DllMain 或靜態初始設定式是以綠色箭號做為識別。 如果並未識別驗證函式,就必須採取下列步驟來尋找該函式。
開啟 [即時運算] 視窗 (從 [偵錯] 功能表中按一下 [Windows],然後按一下 [即時運算])。
輸入 .load sos.dll 至 [即時運算] 視窗以載入 SOS 偵錯服務。
輸入 !dumpstack 至 [即時運算] 視窗以取得內部 /clr 堆疊的完整清單。
查詢 _CorDllMain (如果 DllMain 造成問題) 或 _VTableBootstrapThunkInitHelperStub 或 GetTargetForVTableEntry (如果靜態初始設定式造成問題) 的第一個執行個體 (最接近堆疊的下方)。 在這個呼叫下方的堆疊項目,是在載入器鎖定下嘗試執行之 MSIL 實作函式的引動過程。
移至第 9 步驟所識別的原始程式檔和行號,並使用在<案例>章節中說明的案例和解決方案來校正問題。
範例
說明
下列範例將示範如何將程式碼從 DllMain 移到全域物件的建構函式中,以避免載入器鎖定。
在此範例中,有一個全域 Managed 物件,其建構函式包含了原來位在 DllMain 中的 Managed 物件。 此範例的第二部分會參考此組件,建立 Managed 物件的執行個體,以叫用進行初始化的模組建構函式。
程式碼
// initializing_mixed_assemblies.cpp
// compile with: /clr /LD
#pragma once
#include <stdio.h>
#include <windows.h>
struct __declspec(dllexport) A {
A() {
System::Console::WriteLine("Module ctor initializing based on global instance of class.\n");
}
void Test() {
printf_s("Test called so linker does not throw away unused object.\n");
}
};
#pragma unmanaged
// Global instance of object
A obj;
extern "C"
BOOL WINAPI DllMain(HINSTANCE hInstance, DWORD dwReason, LPVOID lpReserved) {
// Remove all managed code from here and put it in constructor of A.
return true;
}
範例
程式碼
// initializing_mixed_assemblies_2.cpp
// compile with: /clr initializing_mixed_assemblies.lib
#include <windows.h>
using namespace System;
#include <stdio.h>
#using "initializing_mixed_assemblies.dll"
struct __declspec(dllimport) A {
void Test();
};
int main() {
A obj;
obj.Test();
}
Output
Module ctor initializing based on global instance of class.
Test called so linker does not throw away unused object.