程式的啟動與載入

http://www.microsoft.com/taiwan/msdn/columns/DoNet/loader.htm

大內高手專欄:

程式的啟動與載入

作者:蔡學鏞

2004 年 3 月

.NET PE 檔如何將控制權交給 .NET CLR?在進入 Main() 之前,.NET CLR 做了哪些事?.NET 組件 (assembly) 與模組 (module) 是如何被載入記憶體的?何謂類別載入器 (class loader)?JIT 編譯器的運作原理為何?本文章以一個實際的例子,幫助讀者理解 .NET 執行引擎 (execution engine) 內部的運作,在閱讀完本文章之後,這一切的困惑都將消失。

範例程式

本範例需要三個原始碼,分別是 Main.cs、A.cs、BC.cs。下面是 BC.cs 的原始碼 (包含兩個類別):

// BC.cs
public class ClassB {
    public static string GetString() {
        return "Class B";
    }
}
public class ClassC {
    public static string GetString() {
        return "Class C";
    }
}

下面是 A.cs 的原始碼:

// A.cs
public class ClassA {
    public static string GetString() {
        return "Class A";
    }
}

下面是 Main.cs 的原始碼:

// Main.cs
using System;
public class MainClass {
  public static void Main(string[] args) {
    for (int i=0; i< args.Length; i++) {
      switch (args[i]) {
        case "A" :
          ShowA(); break;
        case "B" :
          ShowB(); break;
        case "C" :
          ShowC(); break;
      }
    }
  }
  private static void ShowA() {
    Console.WriteLine(ClassA.GetString());
  }
  private static void ShowB() {
    Console.WriteLine(ClassB.GetString());
  }
  private static void ShowC() {
    Console.WriteLine(ClassC.GetString());
  }
}

先將 BC.cs 編譯成一個模組 (module),編譯完畢之後,可以得到一個名為 BC.netmodule 的模組檔案,其內部的格式也是 .NET PE。編譯方式如下:

csc /t:module BC.cs

再將 A.cs 編譯成一個 DLL 組件,同時將 BC.netmodule 加入此組件。編譯完畢之後,可以得到一個多模組 (multi-module),也是多檔案 (multi-file) 的組件,名為 A.dll。作法如下:

csc /t:library /addmodule:BC.netmodule A.cs

最後,將 Main.cs 編譯成一個 EXE 組件。編譯完畢之後,可以得到一個名為 Main.exe 的組件。編譯方式如下:

csc /r:A.dll Main.cs

這些組件與模組的關係如圖 1 所示:

圖 1
圖 1

進入 Main() 之前

執行 Main.exe 時,.NET CLR 啟動的方式如下:

  1. Windows 所提供的 PE Loader 將 Main.exe 載入記憶體
  2. Windows 所提供的 PE Loader 將 MsCorEE.dll 載入記憶體 (MsCorEE.dll 位於 C:\WINDOWS\System32 或 C:\WINNT\System32)
  3. 程式跳到 MsCorEE.dll 內的 _CorExeMain()
  4. _CorExeMain() 會載入「適當版本的」.NET CLR。如果你的 .NET 是 1.0.3705 版,則 .NET CLR 位於 C:\WINDOWS\Microsoft.NET\Framework\v1.0.3705 或者 C:\WINNT\Microsoft.NET\Framework\v1.0.3705
  5. 程式跳到 .NET CLR,進行一些初始化工作 (initialization)
  6. 載入assembly「MsCorLib」,載入module「MsCorLib.dll」,載入 MsCorLib 內的某些 class
  7. 產生主執行緒 (main thread)
  8. 載入「Main」組件,載入「Main.exe」模組,載入「MainClass」類別
  9. 讓主執行緒開始執行 MainClass.Main()

透過上述各點,我們可以對於 .NET CLR 的啟動過程有最基本的認識。下面是一些更詳細的說明和補充。

上述的 1~3 點,也就是在一開始載入 Main.exe 與 MsCorEE.dll,並跳到 _CorExeMain() 的這部分,會因為 Windows 作業系統版本的不同,而有所差異。Windows XP 與 Windows Server 2003 的 loader 因應 .NET 做了修改,更能符合 managed PE 的需求。而 Windows 2000/me/98 的 loader 則未針對 .NET 做出修改。下面分別解釋它們的作法。

如果作業系統是 Windows XP,當 Windows XP 的 loader 將 Main.exe 載入之後,會檢查 PE header 的 data directory table (請參考圖 2)。如果 COM_Header (亦稱為 CLR_Header) 內的值不為 0,就表示是 .NET PE,loader 會立刻載入 MsCorEE.dll,並直接執行 MsCorEE.dll 內的 _CorExeMain()。

如果作業系統是 Windows 2000,當 Windows 2000 的 loader 將 Main.exe 載入之後,會檢查 PE header 的 data directory table (請參考圖 2),將 Import_Table 所記錄的檔案都載入記憶體,以本例來說,就是 MsCorEE.dll。接著找出 PE header 內所記錄的程式進入點 (請參考圖 3),並執行此處的程式,這是 x86 機器碼,由編譯器自動產生,只有一道指令 (6 bytes),為「FF 25 00 20 40 00」,翻譯成 x86組合語言就是「JMP DWORD PTR [402000]」,其中 0x00400000 是 Main.exe 的 image base,而 0x2000 是 import address table 的 RVA (此處記錄著 _CorExeMain() 的記憶體位址),所以執行「JMP DWORD PTR [402000]」的結果會跳到 MsCorEE.dll 的 _CorExeMain()。

圖 2
圖 2

圖 3
圖 3

綜觀 1~3 點的作法,可以和 Java 作一個比較。Java 執行方式如下所示:

java Hello

Java 的作法是先執行 VM (由 java.exe 進入),再請 VM 去執行 Java bytecode。.NET 的作法是,先執行 .NET PE,立刻將控制權轉給 .NET CLR,再由 .NET CLR 來執行 .NET PE。.NET 的作法固然使用上比 Java 方便,但其實 .NET 需要 OS 的 loader 配合,而 Java 不需要。

上述第 4 點提及 _CorExeMain() 會載入「適當版本的」.NET CLR。其實 MsCorEE.dll 不是 CLR,只是一個填隙程式 (shim),負責搭起 .NET PE 和 .NET CLR 之間橋樑。MsCorEE 會匯集足夠的資料,然後據以判斷要載入 Workstation 版 (MsCorWks.dll) 或者 Server 版 (MsCorSvr.dll) 的 .NET CLR。以本例來說,被載入的是 Workstation 版的 MsCorWks.dll。在載入 MsCorWks.dll 之後,還會陸續載入 fusion.dll、MsCorSn.dll、MsCorJIT.dll 等 DLL 檔。

事實上 java.exe 的角色和 MsCorEE.dll 的角色一樣,負責喚起適當版本的 JVM。如果你安裝的是 1.4.x 的 J2SE SDK 而非 1.4.x 的 JRE,你的 JDK 內會同時具備 client 和 server 兩個 JVM,分別在 JDK 目錄下的 jre\bin\client\jvm.dll 與 jre\bin\server\jvm.dll。

第 5 點提到,程式跳到 .NET CLR,進行一些初始化工作 (initialization)。這些初始化的動作包括了:

  • 準備一塊記憶體,以為 managed heap 之用。
  • 準備好一個 thread pool,以便 CLR 以及 .NET 程式庫稍後使用。
  • 準備好一個 application domain (簡稱為 AppDomain)。

AppDomain 是一個很複雜的主題,在此我只簡略地說明。基本上,AppDomain 可以被視為 sub-process,也就是對 process 所切割出來的小單位。AppDomain 用來隔離不同的應用,任何被載入 process 的 assembly,都歸屬於某個 AppDomain 的管轄。一個 process 內可以有多個 AppDomain,第一個建立的 AppDomain 是由 .NET CLR 自動建立的,稱為 default AppDomain。default AppDomain 剛開始時被命名為「DefaultDomain」,稍後會被更名。

第 6 點,載入「MsCorLib」組件。對於 .NET 來說,MsCorLib 一定是第一個被載入的組件。MsCorLib 是很特殊的組件,從某種角度來說,它可被視為是 .NET CLR 的一部份。MsCorLib 只能屬於 default AppDomain,不能屬於其他的 AppDomain,因為它一定是被 process 內所有 AppDomain 共用的。

接著要載入「MsCorLib.dll」模組。對於 MsCorLib 這種單模組 (single-module) 的組件來說,雖然模組和組件是同一個檔案,但是邏輯上還是有所區隔。所以載入「MsCorLib」組件之後,還是需要載入「MsCorLib.dll」模組。

接著要請 class loader 來載入 MsCorLib 內相關的 class。利用工具觀察,此時載入的 class 依序如下:

System.Object
System.ICloneable
System.Collections.IEnumerable
…為節省篇幅,故略去中間一部份。
System.AppDomain
System.LoaderOptimization
System.Runtime.Remoting.Proxies.__TransparentProxy

請注意,此時並未載入 MsCorLib 內全部的 class,只有載入目前需要的 class。

第 7 點,產生主執行緒 (main thread),也因此觸發了對某些 class 的需求,於是又載入下列的這些 class:

System.Threading.Monitor
System.IAppDomainSetup
System.AppDomainSetup
System.Char
System.Runtime.InteropServices.RuntimeEnvironment
System.RuntimeFieldHandle
System.Runtime.CompilerServices.RuntimeHelpers
$$struct0x6000136-1
System.Environment

第 8 點,載入「Main」組件,載入「Main.exe」模組,載入「MainClass」類別。請注意: 「Main」組件被載入到」DefaultDomain」,「DefaultDomain」也隨即被改名為「Main」。(Default AppDomain 在載入主程式的組件之後,會自動以組件名稱為 AppDomain 的名稱。)

第 9 點,讓主執行緒真正進入程式,開始執行 MainClass.Main()

進入 Main() 之後

class loader 的作用是:將 TypeDef 相關的 metadata 從 PE 檔中讀進來,建立一個內部的資料結構 (CORINFO_CLASS_STRUCT),此資料結構內有一個 Non-Virtual Method Table 和一個 Virtual Method Table,紀錄著此 class 的每個 method 記憶體位址。一開始,method 的記憶體都指向 JIT 編譯器。這造成一種現象:任何 method 第一次被執行時都會跳到 JIT 編譯器。JIT 編譯器會進行下面的動作:

  • 到 metadata 中查出此 method 的 IL 位址
  • 將此 method 的 IL 編譯成 x86 的機器碼 (Machine Code)
  • 將 CORINFO_CLASS_STRUCT 內的 Method Table 中此 method 的位址指向此機器碼的位址,如此一來,當下次再次執行相同的 method 時,就不會再通過 JIT 編譯器了
  • 開始執行此 x86 機器碼

也因此,當程式開始執行 MainClass.Main(),由於是第一次執行,所以先會通過 JIT 編譯器再執行。

實驗一

如果我們在執行 Main.exe 時,未指定任何命令列參數 (common line argument),那麼,Main() 就不會進入 for 迴圈,程式直接結束。整個執行過程中,A.dll 與 BC.netmodule 都不會被載入到記憶體。

實驗二

如果我們在執行 Main.exe 時,指定一個命令列參數「A」,這會使得,Main() 進入 for 迴圈,並呼叫 ShowA()。由於 ShowA() 尚未被呼叫過,所以會先進行 JIT 編譯。

JIT 編譯時,有可能會觸發 class loader。當 Main() 被 JIT 編譯時,由於 Main() 的 IL 完全不涉及其他 class,所以不會觸發 class loader,但是 ShowA() 就不同於 Main()了,ShowA() 的定義牽涉到 Console 和 ClassA,這兩個 class 都尚未被載入記憶體 (也就是說它們都尚未具備 CORINFO_CLASS_STRUCT 資料結構),所以 JIT 編譯器會請求 class loader 的協助,將此二 class 載入之後,JIT 編譯方能繼續。class loader 先載入 ClassA,再載入 Console (此乃依據IL中兩者出現的前後次序,ClassA 出現在前,Console 出現在後)。

class loader 有可能會觸發 module loader。當 class loader 試圖載入 ClassA 時,發現 ClassA 所處的模組 (也就是 A.dll) 並未被載入,於是請求 module loader 的協助;module loader 試圖載入 A.dll 時,發現 A.dll 所處的組件 (也就是 A) 並未被載入,於是請求 assembly loader 的協助;於是 assembly loader 載入「A」組件;module loader 載入「A.dll」模組 (注意:「BC.netmodule」並不會被載入),然後 class loader 就可以將 ClassA 載入記憶體。

class loader 不一定會觸發 module loader。當 class loader 試圖載入 Console 時,發現 Console 所處的 module (也就是 MsCorLib.dll) 已經被載入,於是 class loader 逕自將 ClassA 載入記憶體,不需要 assembly/module loader 的協助。

接下來的一切,讀者應該都可以自行推敲了,我不再贅述。

實驗三

如果我們在執行 Main.exe 時,指定一個命令列參數「B」,這會使得,Main() 進入 for 迴圈,並呼叫 ShowB()。由於 ShowB() 尚未被呼叫過,所以會先進行 JIT 編譯;由於 ClassB 尚未被載入,所以 JIT 會請求 class loader 協助;由於 ClassB 所處的模組 (也就是 BC.netmodule) 尚未被載入,所以 class loader 會請求 module loader 的協助;由於 BC.netmodule 所處的組件 (也就是 A) 尚未被載入,所以 module loader 會請求 assembly loader 的協助。

於是,assembly loader 將 A 載入記憶體,且由於 A.dll 是 prime module,所以必須緊接著組件之後被載入 (儘管不需要用到 ClassA),然後,BC.netmodule 才被載入。class loader 終於可以載入 ClassB。接下來的一切,讀者應該都可以自行推敲了,我不再贅述。

觀念整理

關於這三個實驗,目的在於讓讀者瞭解下面幾點:

  1. JIT 編譯以 method 為單位,只有被呼叫到的 method 才會被編譯。
  2. JIT 編譯器可能會觸發 class loader。
  3. class loader 可能會觸發 module loader。
  4. module loader 可能會觸發 assembly loader。
posted @ 2004-05-15 21:46  dudu  阅读(2474)  评论(1)    收藏  举报