Share via


混合組件的初始化

在 期間 DllMain 執行程式碼時,Windows 開發人員必須一律警惕載入器鎖定。 不過,處理 C++/CLI 混合模式元件時,需要考慮一些其他問題。

DllMain 內的 程式碼不得存取 .NET Common Language Runtime (CLR)。 這表示 DllMain 不應直接或間接呼叫 Managed 函式;不應在 中宣告或實作 Managed 程式碼;而且不應在 內 DllMainDllMain 進行垃圾收集或自動程式庫載入。

載入器鎖定的原因

隨著 .NET 平臺的引進,載入執行模組有兩個不同的機制(EXE 或 DLL):一個用於非受控模組的 Windows,另一個用於載入 .NET 元件的 CLR。 混合 DLL 載入問題主要與 Microsoft Windows 作業系統載入器相關。

當只包含 .NET 建構的元件載入至進程時,CLR 載入器可以自行執行所有必要的載入和初始化工作。 不過,若要載入可包含機器碼和資料的混合元件,也必須使用 Windows 載入器。

Windows 載入器保證在初始化 DLL 之前,沒有任何程式碼可以存取該 DLL 中的程式碼或資料。 而且可確保程式碼在部分初始化時,無法重複載入 DLL。 若要這樣做,Windows 載入器會使用進程全域關鍵區段(通常稱為「載入器鎖定」,以防止在模組初始化期間不安全的存取。 因此,載入程序很容易受到許多典型死結案例的危害。 若是混合組件,下列兩種案例會增加發生死結的風險:

  • 首先,如果使用者嘗試執行載入器鎖定保留時編譯為 Microsoft 中繼語言 (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 初始化會在兩個不同的階段完成。 Unmanaged 初始化會先進行(透過 DllMain ),然後透過 進行 Managed 初始化。NET 支援的 .cctor 建構。 除非 /Zl 使用 或 /NODEFAULTLIB ,否則後者對使用者完全透明。 如需詳細資訊,請參閱 /NODEFAULTLIB (忽略程式庫) /Zl (省略預設程式庫名稱)。

載入器鎖定仍然有可能發生,但現在能夠重現並偵測所發生的情況。 如果 DllMain 包含 MSIL 指示,編譯器會產生警告編譯器警告 (層級 1) C4747 。 此外,CRT 或 CLR 會嘗試偵測並回報在載入器鎖定下嘗試執行 MSIL。 CRT 偵測會產生執行階段診斷 C 執行階段錯誤 R6033。

本文的其餘部分說明 MSIL 可在載入器鎖定下執行的其餘案例。 它示範如何解決上述每個案例下的問題,以及偵錯技術。

案例和因應措施

在幾種不同的情況下,使用者程式碼可能會在載入器鎖定下執行 MSIL。 開發人員必須確保使用者程式碼實作不會在這些情況下嘗試執行 MSIL 指示。 下列各節說明所有可能性,並討論如何解決最常見案例中的問題。

DllMain

DllMain 式是 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 執行。 相反地,使用 .cctor 編譯 /clr 的靜態變數會在 Unmanaged 初始化階段完成且載入器鎖定已釋放之後初始化。

靜態變數動態初始化所造成的死結有許多解決方案。 它們大致會依修正問題所需的時間安排在這裡:

  • 包含靜態變數的來源檔案可以使用 編譯 /clr

  • 靜態變數呼叫的所有函式都可以使用 #pragma unmanaged 指示詞編譯為機器碼。

  • 以手動方式複製靜態變數所依賴的程式碼,並為 .NET 和原生版本指定不同的名稱。 接著,開發人員可從原生靜態初始設定式呼叫原生版本,並從別處呼叫 .NET 版本。

影響啟動的使用者提供函式

程式庫在啟動期間的初始化,會依賴數個使用者提供的函式。 例如,當 C++ 中的全域多載運算子,例如 newdelete 運算子時,使用者提供的版本會隨處使用,包括 C++ 標準程式庫初始化和解構。 因此,C++ 標準程式庫和使用者提供的靜態初始化運算式會叫用這些運算子的任何使用者提供版本。

如果使用者提供的版本會編譯為 MSIL,則這些初始設定式會在持有載入器鎖定時嘗試執行 MSIL 指令。 使用者提供 malloc 的結果相同。 若要解決此問題,這些多載或使用者提供的定義都必須使用 #pragma unmanaged 指示詞實作為機器碼。

如需此案例的詳細資訊,請參閱 診斷 障礙。

自訂地區設定

如果使用者提供自訂全域地區設定,此地區設定會用來初始化所有未來的 I/O 資料流程,包括靜態初始化的資料流程。 如果此全域地區設定物件會編譯為 MSIL,就可能在持有載入器鎖定時,叫用編譯為 MSIL 的地區設定物件成員函式。

有三個解決此問題的選項:

包含所有全域 I/O 資料流程定義的來源檔案可以使用 選項進行 /clr 編譯。 它可防止在載入器鎖定下執行其靜態初始化運算式。

自訂地區設定函式定義可以使用 指示詞編譯為機器碼 #pragma unmanaged

請等到釋放載入器鎖定之後,再將自訂地區設定設定為全域地區設定。 然後再明確設定初始化期間使用自訂地區設定建立的 I/O 資料流。

診斷的阻礙

在某些情況下,很難偵測死結的來源。 下列各節將討論這些案例及解決這些問題的方法。

標頭中的實作

在選取的案例中,標頭檔內的函式實作可能會使診斷變得複雜。 內嵌函式和範本程式碼都要求必須在標頭檔中指定函式。 C++ 語言會指定「一個定義規則」,強制所有同名的函式實作在語意上相等。 因此,當合併的物件檔案具有指定函式的重複實作時,C++ 連結器不需要進行任何特殊的考量。

在 Visual Studio 2005 之前的 Visual Studio 版本中,連結器只會選擇其中最大的語意對等定義。 這麼做是為了配合轉送宣告,以及針對不同來源檔案使用不同的優化選項時的案例。 它會為混合原生和 .NET DLL 建立問題。

由於 C++ 檔案 /clr 可能包含已啟用和停用的相同標頭,或 #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();
}

在偵錯模式中診斷

載入器鎖定問題的所有診斷都應該使用偵錯組建來完成。 發行組建可能不會產生診斷。 此外,在發行模式中所做的優化可能會遮罩載入器鎖定案例下的一些 MSIL。

如何偵錯載入器鎖定問題

在叫用 MSIL 函式時,CLR 所產生的診斷會造成 CLR 暫停執行。 這又會導致 Visual C++ 混合模式偵錯工具在執行同進程偵錯時暫停。 不過,附加至進程時,您無法使用混合偵錯工具取得偵錯gee 的 Managed 呼叫堆疊。

若要識別在載入器鎖定下所呼叫的特定 MSIL 函式,開發人員應該完成下列步驟:

  1. 確定可以使用 mscoree.dll 和 mscorwks.dll 的符號。

    您可以透過兩種方式提供符號。 首先,您可以將 mscoree.dll 和 mscorwks.dll 的 PDB 加入符號搜尋路徑。 若要新增它們,請開啟符號搜尋路徑選項對話方塊。 (從 [工具] 功能表,選擇 [ 選項 ]。在 [選項] 對話方塊的 左窗格中,開啟 [ 偵錯] 節點,然後選擇 [ 符號 ]。 將 mscoree.dll 和 mscorwks.dll PDB 檔案的路徑新增至搜尋清單。 這些 PDB 會安裝到 %VSINSTALLDIR%\SDK\v2.0\symbols。 選擇確定

    其次,您可以從 Microsoft 符號伺服器下載 mscoree.dll 和 mscorwks.dll 的 PDB。 若要設定符號伺服器,請開啟 [symbol search path options] (符號搜尋路徑選項) 對話方塊。 (從 [工具] 功能表,選擇 [ 選項 ]。在 [選項] 對話方塊的 左窗格中,開啟 [ 偵錯] 節點,然後選擇 [ 符號 ]。 將此搜尋路徑新增至搜尋清單: https://msdl.microsoft.com/download/symbols 。 將符號快取目錄加入符號伺服器快取文字方塊。 選擇確定

  2. 將偵錯工具模式設定為僅限原生模式。

    開啟方案中啟動專案的 [屬性 ] 方格。 選取 [ 組態屬性 > 偵錯]。 [偵錯工具類型] 屬性設定為 [僅限原生 ]。

  3. 啟動偵錯工具 (F5)。

  4. /clr產生診斷時,選擇 [ 重試 ],然後選擇 [ 中斷 ]。

  5. 開啟呼叫堆疊視窗 (在功能表列上,選擇 > 錯 Windows > 呼叫堆疊 。)冒犯 DllMain 或靜態初始化運算式會以綠色箭號來識別。 如果未識別出違規函式,則必須採取下列步驟來尋找它。

  6. 開啟 [即時運算] 視窗 (在功能表列上,選擇 > [偵錯 Windows 即時 運算]。 >

  7. 輸入 .load sos.dll [ 即時 運算] 視窗以載入 SOS 偵錯服務。

  8. 在 [ 即時 運算] 視窗中輸入 !dumpstack ,以取得內部 /clr 堆疊的完整清單。

  9. 尋找第一個實例(最接近堆疊底部)_CorDllMain(如果 DllMain 造成問題),或_VTableBootstrapThunkInitHelperStub或 GetTargetForVTableEntry(如果靜態初始化運算式造成問題)。 此呼叫正下方的堆疊項目,會是在載入器鎖定下嘗試執行之 MSIL 實作函式的引動過程。

  10. 移至上一個步驟中識別的來源檔案和行號,並使用案例一節中所述的案例和解決方案來更正問題。

範例

描述

下列範例示範如何將程式碼從 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 doesn't 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();
}

此程式碼會產生下列輸出:

Module ctor initializing based on global instance of class.

Test called so linker doesn't throw away unused object.

另請參閱

混合 (原生和 Managed) 組件