C#筆記 – CLR的執行模型(二) 程序集

程序集
    • 程序集是一個或多個模塊的邏輯性分組,在CLR中,程序集相當於組件

    • 托管模塊和資源文件被編譯器所處理,並生成一個代表文件邏輯分組的PE32/PE32+文件
      • 文件包含一個manifest數據塊,為「元數據表的集合」,描述了:
      • 構成程序集的文件
      • 程序集中的文件所實現的public類型

      • 與程序集關聯的資源或數據文件

  • 編譯器默認將編譯源代碼後生成的托管模塊合并轉換成程序集

    • C#編譯器生成的是含有清單(manifest)的托管模塊

    • 如果只有一個托管模塊,且沒有資源文件,程序集就等於托管模塊

兩種程序集
  • 弱命名程序集、強命名程序集

  • 弱命名 vs 強命名

    • 程序集結構上,弱命名和強命名的結構完全一致兩者的區別在於:

      • 強命名程序集使用發布者的公鑰/私鑰進行了簽名。這對密鑰允許對程序集進行唯一性的標識、保護和版本控制,并允許程序集部署在用戶機器的任何地方,甚至是internet上。

加載CLR
  • 生成的每個程序集由CLR管理程序集中的代碼的執行,機器上必須安裝好.NET Framework

  • 如果%SystemRoot%\System32目錄中存有mscoree.dll文件,就代表.NET Framework已經安裝

  • 而在%SystemRoot%\Microsoft.NET\Framework和%SystemRoot%\Microsoft.NET\Framework64的子目錄中,則可了解到安裝了哪些版本的.NET Framework
  • .NET Framework SDK提供了CLRVer.exe的命令行實用程序,可列出機器上安裝的所有CLR版本;也可以通過-all命令列出機器中正在運行的進程使用的CLR版本號
IL代碼
  • 程序集中包含托管模塊,托管模塊裡包含元數據和IL

    • IL是與CPU無關的機器語言,比大多數CPU機器語言高級

    • IL能執行訪問和操作對象類型、創建/初始化對象、調用虛方法、操作數組元素、異常處理等工作

      • 一種「面向對象的機器語言」

  • IL一般是開發人員編寫高級語言代碼(如C#)後,被編譯器所生成

    • 一般高級語言只公開了CLR的部分功能

    • IL匯編語言允許開發人員訪問CLR的全部功能

    • IL可以使用匯編語言編寫

      • IL基於棧,因此所有指令都要將操作數Push進一個執行棧,再從棧Pop出結果

      • IL指令是「無類型」(typeless)的,它判斷棧中的操作數的類型,並執行恰當的操作

    • IL的驗證

      • 在JIT把IL編譯成本機CPU指令時,CLR會檢查IL代碼所做的一切是否安全,如參數、類型、返回值的正確使用

      • 托管模塊的元數據包含驗證過程要用到的所有方法及類型信息

    • 在Visual Studio中,可以使用ILAsm.exe的IL匯編器

      • 如:進行打包DLL/EXE的工作

  • 還可以使用ILDasm.exe的IL反匯編器來查看代碼被編譯成怎樣的IL代碼

程序集代碼的執行過程
  • 為了執行托管程序集裡面的方法,首先要由CLR的「JIT(Just-In-Time)編譯器」把方法的IL代碼轉換成本機的CPU指令

    • 以該代碼為例:

      • static void Main()
        {
           Console.WriteLine("Hello");
           Console.WriteLine("Goodbye");
        }
    • 以下是Console.WriteLine方法被首次調用時的流程(Console.WriteLine(“Hello”)):

      • 第一步:在源碼被編譯器編譯成托管模塊後,在執行這個程序集中的Main方法前。

        • CLR會檢測出方法體中的代碼所引用的所有類型。在本例中,類型為「Console」

      • 第二步:CLR會分配內部數據結構來管理對引用類型的訪問。

        • 在該數據結構中,Console類型定義的每個方法都有一個對應的記錄項(entry)

        • 每個記錄項都含有一個地址,指向方法的實現

        • 該數據結構初始化時,CLR將每個記錄項都指向「包含在CLR內部的一個未編檔函數」(JITCompiler)

      • 第三步:Main中的WriteLine被執行時。

        • JITCompiler函數會被調用,該函數負責將方法的IL代碼編譯成本機CPU指令

        • 由於這個步驟對IL的編譯是「即時」(Just – In – Time)進行的,因此這個組件才被稱為「JIT編譯器」或 「JITter」

      • 第四步:JITCompiler函數被調用時。

        1. 在實現該方法(WriteLine)的類型(Console)元數據中找到該方法(WriteLine)

        2. 在定義該類的程序集元數據中獲取該方法的IL

        3. JITCompiler驗證其IL代碼,并編譯成本機CPU指令

        4. 把該指令分配到一個動應分配的內存塊中

        5. JITCompiler返回CLR為類型創建的內部數據結構中,把對應方法的記錄項地址指向該內存塊(已包含本機CPU指令)

        6. JITCompiler跳轉到該內存塊,執行代碼

        7. 執行完成後返回托管程序集中,執行下一行代碼

    • 現在代碼執行到了下一行(Console.WriteLine(“Goodbye”)):

      • 由於同樣是使用Console.WriteLine(string)方法,因此,WriteLine的代碼已經經過驗證和編譯

      • 內部數據結構中的WriteLine(string)記錄項指向不再是JITCompiler,而是包括了Console.WriteLine(string)具體本機CPU指令的內存塊,因此,CLR會直接執行內存塊中的代碼,完全跳過JITCompiler函數

  • JIT將方法的IL編譯成本機代碼時,會利用其元數據中的TypeRefAssemblyRef確定定義了引用的類型的程序集
    • JIT獲取AssemblyRef元數據表記錄項中,構成程序集強名稱的各個部分(名稱、版本、語言文化、公鑰標記),並把它們連接成一個字符串

    • 然後根據該字符串標識,嘗試把程序集加載到AppDomain

CLR解析類型引用
  • 運行應用程序時,CLR會加載並初始化自身,讀取程序集的CLR頭,查找入口方法的MethodDefToken,然後檢索MethodDef元數據表找到方法的IL,將IL JIT編譯成機器碼,最後執行本機代碼。

    • 對IL進行JIT編譯時,CLR會檢測所有類型和成員引用,加載它們的定義程序集

    • Program.cs中有以下代碼,其中有一個Main方法

      • public sealed class Program
        {
           public static void Main(string[] args)
          {
               System.Console.WriteLine("Hello World!");
               System.Console.ReadLine();
          }
        }
    • Main方法編譯後的IL代碼

      • .method public hidebysig static void  Main(string[] args) cil managed
        // SIG: 00 01 01 1D 0E
        {
        .entrypoint
        // 方法開始於 RVA 0x2050
        // 程式碼大小       19 (0x13)
        .maxstack 8
        IL_0000: /* 00   |                 */ nop
        IL_0001: /* 72   | (70)000001       */ ldstr     "Hello World!"
        IL_0006: /* 28   | (0A)000004       */ call       void [mscorlib]System.Console::WriteLine(string)
        IL_000b: /* 00   |                 */ nop
        IL_000c: /* 28   | (0A)000005       */ call       string [mscorlib]System.Console::ReadLine()
        IL_0011: /* 26   |                 */ pop
        IL_0012: /* 2A   |                 */ ret
        } // end of method Program::Main
      • 從IL代碼中看出,Main方法包含了System.Console.WriteLine的引用

        • 具體來說,IL call指令引用了元數據token:(0A)000004。

        • 0A = 0x0a,在CorHdr.h中的CorTokenType枚舉中指出0x0a標識的元數據表為「MemberRef」。因此(0A)000004等價於MemberRef的第4個記錄項

          • // Token tags.
            typedef enum CorTokenType
            {
              ...
               mdtMemberRef            = 0x0a000000,       //
              ...
            } CorTokenType;
      • 根據這個信息,CLR接下來會在Program.cs生成的程序集元數據中檢查MemberRef第4個記錄項(token:0a000004)的位置。然後發現它位於TypeRef的一個記錄項下面。

        • TypeRef #6 (01000006)
          -------------------------------------------------------
          Token:             0x01000006
          ResolutionScope:   0x23000001
          TypeRefName:       System.Console
          MemberRef #1 (0a000004)
          -------------------------------------------------------
          Member: (0a000004) WriteLine:
          CallCnvntn: [DEFAULT]
          ReturnType: Void
          1 Arguments
          Argument #1: String
      • 這個記錄項中的ResolutionScope代表了引用類型的實現位置。

        • 如果類型在另一個類型中實現,引用指向一個TypeRef記錄項,ResolutionScope = 0x01

        • 如果類型在同一個模塊中,引用指向一個ModuleDef記錄項,ResolutionScope = 0x00

        • 如果類型在同一個程序集的另一個模塊中,引用指向一個ModuleRef記錄項,ResolutionScope = 0x1a

        • 如果類型在不同程序集的其他模塊中,引用指向一個AssemblyRef記錄項,ResolutionScope = 0x23

      • 在本例中,按照TypeRef的Resolution又把CLR引導至AssemblyRef的第一個記錄項(0x23000001)。在Program.cs生成的程序集元數據文件中找到AssemblyRef的第一個記錄項,得知它需要的是哪個程序集

        • AssemblyRef #1 (23000001)
          -------------------------------------------------------
          Token: 0x23000001
          Public Key or Token: b7 7a 5c 56 19 34 e0 89
          Name: mscorlib
          Version: 4.0.0.0
          Major Version: 0x00000004
          Minor Version: 0x00000000
          Build Number: 0x00000000
          Revision Number: 0x00000000
          Locale:
          HashValue Blob:
          Flags: [none] (00000000)
        • “mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089”

      • CLR定位並加載該程序集

        • 解析引用的類型時,CLR可能在以下三個地方找到類型:

          • 同一個程序集的同一個文件中

            • 「編譯時」就能發現對相同文件中的類型的訪問,稱為「早期綁定」(early binding),直接從文件中加載

          • 同一個程序集的另一個文件中

            • 「運行時」確保被引用的文件在當前程序集元數據的FileDef表<中,檢查加載程序集清單文件的目錄,加載被引用的文件,檢查哈希值以確保文件完整性,發現類型的成員

          • 另一個程序集的另一個文件中

            • 「運行時」加載被引用程序集的清單文件。如果所需類型不在該文件中,就繼續加載包含了類型的文件,發現類型的成員

    • 在上例中,CLR發現System.Console在和調用者不同的程序集中實現

      • CLR須找到那個程序集,加載包含清單元數據的PE文件

      • 掃瞄清單,判斷具體是哪個PE文件實現了類型

      • 如果類型在程序集的另一個文件中(不包含清單元數據的文件)

        • CLR加載那個文件,掃瞄其元數據來定位類型

        • 然後CLR創建內部數據結構來表示該類型

        • 方法首次調用時,JIT編譯完成對Main方法的編譯

    • 整體解析流程:

托管vs非托管
  • 在托管環境下,代碼的編譯分兩階段完成

    1. 編譯器遍歷源碼,生成IL

    2. IL在運行的時候即時編譯成本機CPU指令

    • 會比非托管花費較多的內存和CPU時間

      • 非托管直接是針對一種具體的CPU平台編譯

  • 托管代碼的優勢

    • JITCompiler能判斷運行機器的CPU並生成相應的本機代碼,使用提升性能的特殊指令;非托管應用程序通常是指對具有最小功能集合的CPU編譯,不會使用這些有用的特殊指令

    • JITCompiler能進行邏輯判斷,決定某些代碼是否需要生成出CPU指令。使得本機代碼可針對主機進行優化

    • 應用程序運行時,CLR可評估代碼的執行,將IL重新編譯成本機CPU代碼。

    • 在托管代碼中,由於IL的驗證過程,可確保代碼不會不正確地訪問內存,因此可以把多個托管應用程序放在一個Windows虛擬地址空間中運行

      • 從而減少Windows開啟的進程數量,增強性能

      • 每個托管EXE默認在自己的獨立地址空間中運行,這個地址空間只有一個AppDomain。但CLR的宿主進程可決定在一個進程中運行多個AppDomain

與非托管代碼的互操作性
不安全的代碼
  • C#編譯器允許開發人員寫「不安全」(unsafe)的代碼

    • 允許直接操作內存地址以及這些地址處的字節

  • 風險大,C#編譯設有多重保護措施,都通過後才能編譯unsafe代碼

    • 使用unsafe關鍵字標記不安全代碼

    • 打開/unsafe編譯器開關

    • JIT編譯unsafe方法時:

      • 檢查方法是否有System.Security.Permissions.SecurityPermission權限

      • System.Security.Permissions.SecurityPermissionFlag的SkipVerification標志是否設置

    • 不通過時拋出異常:System.InvalidProgramException或System.Security.VerificationException

  • 檢查程序集中的不安全代碼

    • 從本地計算機或「網絡共享」加載的程序集默認被授與完全信任,包括執行不安全代碼

      • 但通過Internet執行的程序集默認不會被授與執行不安全代碼的權限,如果含有不安全的代碼,則會拋出異常(System.InvalidProgramException或System.Security.VerificationException)

      • 當然,管理員和最終用戶可以修改這些默認設置

    • PEVerify.exe可檢查一個程序集的所有方法是否包含不安全的方法

      • 該驗證需要訪問所有依賴程序集中包含的元數據,因此使用CLR來定位這些程序集

      • 因此會採用和平時執行程序集一樣的綁定(binding)和探測(probing)規則來定位程序

參考書目

  • 《CLR via C#》(第4版) Jeffrey Richter