[.NET大牛之路 007] 詳解 .NET 程式集

精緻碼農發表於2021-08-18

.NET大牛之路 • 王亮@精緻碼農 • 2021.07.13

上一篇我們介紹了 Roslyn 編譯器,我們知道,我們編寫的 C#/VB 程式碼經過 Roslyn 編譯器編譯後會生成程式集檔案。按照之前講的 .NET 執行模型的順序,這一篇我具體講講程式集。

什麼是程式集

我們編寫的 C# 程式碼經過編譯會生成 .dll.exe 檔案,這些檔案就是 .NET 的程式集(Assembly)。

儘管 .NET 的程式集檔案與非託管的 Windows 二進位制檔案採用相同的副檔名(*.dll),但它們的內部完全不同。具體來說,.NET Core 程式集檔案不包含平臺(泛指作業系統和 CUP 架構的組合)特定的指令,而是平臺無關的中間語言(IL)和型別後設資料。

你可能在一些 .NET/.NET Core 的文件中看到過 IL 的另外兩種縮寫:MSIL(Microsoft Intermediate Language,微軟中間語言) 和 CIL(Common Intermediate Language,通用中間語言)。IL、MSIL 和 CIL 都是一個概念,其中 MSIL 是早期的叫法,現在已經很少有人用了。

但 .NET Core 與 .NET Framework 不一樣,.NET Core 始終只會生成 *.dll 格式的程式集檔案,即使是像控制檯應用這樣的可執行專案也不再會生成 *.exe 格式的程式集檔案。

那我們在 .NET Core 專案的 bin 目錄中看到和專案同名的 *.exe 檔案是怎麼回事呢?這個檔案並不是一個程式集檔案,而是專門為 Windows 平臺生成的一個可執行的快捷方式。在 Windows 平臺雙擊這個檔案等同於執行 dotnet <assembly name>.dll 命令。在我們安裝的 .NET Core 目錄中有個 dotnet.exe 命令檔案(如 Windows 系統預設位置是C:\Program Files\dotnet\dotnet.exe),在編譯時,該檔案會被複制到構建目錄,並重新命名為與專案名稱同名的 <assembly name>.exe 檔案。

程式集的組成

總的來說,每個程式集檔案主要由 IL 程式碼、後設資料(Metadata)、清單(Manifest) 和資原始檔(如 jpg、html、txt 等)組成。其中,IL 程式碼和後設資料會先被編譯為一個或多個託管模組,然後託管模組和資原始檔會被合併成程式集。

託管模組,或者叫託管資源或託管程式碼,顧名思義,這種資源是由 .NET Core 的 CLR 執行時來管理執行的,它包含 IL 程式碼和後設資料。比如物件的回收是由 CLR 中垃圾回收器(GC)自動執行的,不需要手動管理。

程式集檔案中佔比最大的一般是 IL 程式碼。IL 程式碼和 Java 位元組碼相似,它不包含平臺特定的指令,它只在必要的時候被 .NET Core 執行時中的 JIT 編譯器編譯成本機程式碼(機器碼)。

程式集檔案中的後設資料詳細地描述了程式集檔案中每個型別的特徵。比如有一個名為 Product 的類,型別後設資料描述了 Product 的基類、實現的介面(如果有的話)和每個成員的完整描述等細節。後設資料由語言編譯器(Roslyn)自動生成。

除了託管模組,程式集檔案還可以嵌入資原始檔,如 jpg、gif、html 等格式的靜態檔案,這些檔案是非託管資源。

當託管模組和資原始檔合併成程式集時,會生成一份清單,它是專門用來描述程式集本身的後設資料。清單包含程式集的當前版本資訊、本地化資訊(用於本地化字串等),以及正確執行所需的所有外部引用程式集列表等。

在第 5 篇文章中我們講了 .NET 的兩種執行模型,其中,當基於本地執行時執行模型釋出時,雖然你的應用程式可以釋出為可直接執行的單一檔案,但這個單一的檔案其實是多個檔案的包裝。它包含了由 IL 程式碼編譯成的原生程式碼和 Native AOT 本地執行時。你的程式碼仍然在一個託管的容器中執行,執行時它的資源的管理和它作為多個檔案釋出是一樣的。

下面讓我們更詳細地瞭解一下 IL 程式碼、後設資料和程式集清單。

IL 程式碼

我們先來看看下面這樣一段簡單的 C# 程式碼被編譯成 IL 程式碼會是什麼樣子。C# 程式碼如下:

class Calculator
{
    public int Add(int num1,int num2)
    {
        return num1 + num2;
    }
}

經過編譯後,在專案的 bin\Debug 目錄會生成一個與專案名稱同名的 dll 程式集檔案。我們使用 ildasm.exe 工具開啟這個檔案,定位到 CalculatorAdd 方法,可以看到 Add 方法的 IL 程式碼如下:

.method public hidebysig
  instance int32 Add (
    int32 num1,
    int32 num2
  ) cil managed
{
  // Code size 9 (0x9)
  .maxstack 2
  .locals init (
    [0] int32
  )

  IL_0000: nop
  IL_0001: ldarg.1
  IL_0002: ldarg.2
  IL_0003: add
  IL_0004: stloc.0
  IL_0005: br.s IL_0007

  IL_0007: ldloc.0
  IL_0008: ret
}

以我的安裝環境為例,你可以在這個位置找到 ildasm.exe 工具:C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8 Tools\ildasm.exe。為了使用方便,你可以把該工具配置到 Visual Studio 的外部工具中。

這就是 IL 程式碼的樣子,如果使用 VB 或 F# 編寫相同的 Add 方法,它生成的 IL 程式碼會是一樣的。關於 IL 程式碼語法後面有機會再講,這裡我們暫且不關心。

由於程式集中的 IL 程式碼不是平臺特定的指令,所以 IL 程式碼必須在使用前呼叫 JIT 編譯器進行即時編譯,將其編譯成特定平臺(特定的作業系統和 CUP 架構,如 Linux x64)的原生程式碼,才能在該平臺執行起來。

.NET Core 執行時會在 JIT 編譯過程中針對特定平臺再次進行底層優化。比如將 IL 程式碼編譯成特定於某平臺的原生程式碼時,它會把平臺無關的程式碼剔除。並且,它會以適合目標作業系統的方式將編譯好的原生程式碼快取在記憶體中,供以後使用,下次不需要重新編譯 IL 程式碼。

後設資料

除了 CIL 程式碼外,.NET Core 程式集還包含完整、全面、細緻的後設資料,它描述了程式集中定義的每個型別(如類、結構、列舉),以及每個型別的成員(如屬性、方法、事件),這些資訊生成都由編譯器自動完成的。

我們繼續使用 ildasm.exe 來看看 .NET Core 後設資料具體長什麼樣。以前面的程式碼為例,選擇該程式集,依次點選“檢視->元資訊->顯示”,可以看到當前程式集的所有後設資料資訊。我們可以在後設資料資訊中找到 Calculator 類的 Add 方法,它的後設資料是這樣的:

TypeDef #2 (02000003)
-------------------------------------------------------
  TypDefName: ConsoleApp.Calculator  (02000003)
  Flags     : [NotPublic] [AutoLayout] [Class] [AnsiClass] [BeforeFieldInit]  (00100000)
  Extends   : 0100000C [TypeRef] System.Object
  Method #1 (06000003)
  -------------------------------------------------------
    MethodName: Add (06000003)
    Flags     : [Public] [HideBySig] [ReuseSlot]  (00000086)
    RVA       : 0x00002090
    ImplFlags : [IL] [Managed]  (00000000)
    CallCnvntn: [DEFAULT]
    hasThis
    ReturnType: I4
    2 Arguments
      Argument #1:  I4
      Argument #2:  I4
    2 Parameters
      (1) ParamToken : (08000002) Name : num1 flags: [none] (00000000)
      (2) ParamToken : (08000003) Name : num2 flags: [none] (00000000)

後設資料會被 .NET Core 執行時以及各種開發工具所使用。例如,Visual Studio 等工具所提供的智慧提示功能就是通過讀取程式集的後設資料而實現的。後設資料也被各種物件瀏覽工具、除錯工具和 C# 編譯器本身所使用。後設資料是眾多 .NET Core 技術的支柱,比如反射、物件序列化等。

程式集清單

.NET Core 程式集還包含描述程式集本身的後設資料,我們稱之為清單。清單記錄了當前程式集正常執行所需的所有外部程式集、程式集的版本號、版權資訊等等。與型別後設資料一樣,生成程式集清單也是由編譯器的工作。

同樣地,還是以上面 Calculator 類所在專案為例,我們也來看看程式集清單長什麼樣子。在 ildasm.exe 工具開啟的程式集的目錄樹中,雙擊 MAINFEST 即可檢視程式集的清單內容:

.assembly extern System.Runtime
{
  .publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A ) // .?_....:
  .ver 5:0:0:0
}
.assembly extern System.Console
{
  .publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A ) // .?_....:
  .ver 5:0:0:0
}
.assembly ConsoleApp
{
  ...
  .custom instance void ... TargetFrameworkAttribute ...
  .custom instance void ... AssemblyCompanyAttribute ...
  ...
  .hash algorithm 0x00008004
  .ver 1:0:0:0
}
.module ConsoleApp.dll
.imagebase 0x00400000
.file alignment 0x00000200
.stackreserve 0x00100000
.subsystem 0x0003       // WINDOWS_CUI
.corflags 0x00000001    //  ILONLY

可以看到,程式集清單首先通過 .assembly extern 指令記錄了它所引用的外部程式集。接著是當前程式集本身的資訊,記錄了程式集本身的各種特徵,如版本號、模組名稱等。

提前編譯 IL 程式碼

前面提到,IL 程式碼需要先通過 JIT 編譯器編譯成特定平臺的原生程式碼,才能在該平臺執行。你可能會問,.NET 為什麼要將原始碼編譯成 IL 程式碼,而不直接編譯成特定平臺的原生程式碼呢?

這樣做主要有兩個好處:一是語言整合,一套執行時環境可以執行多種語言編寫的程式,.NET 團隊不用開發和維護多套執行時;二是平臺無關,方便程式和庫的移植,編譯後的程式集可以釋出到多個平臺,而不用為不同的平臺釋出特定的程式檔案。雖然 IL 程式碼帶來了可移植性等的好處,但需要以犧牲一點點啟動時的效能作為代價。

一般我們的 Web 應用程式最終只會部署在一種平臺(如 Linux x64),為了更快的啟動效能,在啟動時,我們確實可以不需要中間語言編譯這個環節,省去啟動時的 JIT 編譯的時間。.NET Core 為我們提供了兩種方式把 IL 程式碼提前編譯成特定平臺的原生程式碼。

一種方式是使用 ReadyToRun 功能。.NET Core 執行時(CoreCLR)中的一個叫做 CrossGen 的工具,它可以預先將 IL 程式碼編譯成原生程式碼。要使用這個功能,只需在程式釋出的時候,選擇特定平臺,在釋出選項中勾選 Enable ReadyToRun compilation 即可。不過 ReadyToRun 功能目前只適用於 Windows 系統。

另一種方式是使用 .NET 5 新增加的 AOT 編譯功能。釋出時選擇 Self-Contained 模式,釋出後生成單個檔案。AOT 編譯也是提前將 IL 程式碼編譯成原生程式碼,不同的是,它在釋出時生成的單個檔案還包含一個精簡版的本地執行時。這點在第 5 篇文章講過,不再累述了。

這兩種方式都有弊端,第一種目前只適用於 Windows 系統,第二種 Self-Contained 單個檔案釋出要比多檔案釋出大幾十 M。不過對於第一次啟動慢那麼一點點(可能甚至不到一秒的時間),大部分的 Web 應用程式都是完全可以接受的。如果實在對啟動時效能有嚴格的要求,也可以使用預熱的方案。

小結

本文介紹了程式集以及它的內部組成:IL 程式碼、後設資料、資原始檔和程式集清單。總的來說,程式集就是 .NET Core 在編譯後生成的 *.dll 檔案,它包含託管模組、資原始檔和程式集清單,其中託管模組由 IL 程式碼和後設資料組成。

需要強調的是,IL 程式碼不包含特定平臺的指令,它只在需要的時候才會被 CoreCLR 執行時中的 JIT 編譯器編譯成特定於平臺的原生程式碼。

通過本文,相信大家對 .NET Core 程式集和它的內部組成已經有了一個整體的認識。

相關文章