下圖展示了編譯源代碼文件的過程。如圖所示,可用支持 CLR 的任何一種語言創建源代碼文件。然後,用一個對應的編譯器檢查語法和分析源代碼。無論選用哪一個編譯器,結果都是一個托管模塊(managedmodule)。托管模塊是一個標准的 32 位 Microsoft Windows 可移植執行體(PE32)文件 6 ,或者是一個標准的 64 位Windows 可移植執行體(PE32+)文件,它們都需要 CLR 才能執行。順便說一句,托管的程序集總是利用了 Windows 的數據執行保護(Data Execution Prevention,DEP)和地址空間布局隨機化(Address SpaceLayout Randomization,ASLR);這兩個功能旨在增強整個系統的安全性。
托管模塊的組成部分
PE32 或 PE32+頭:標准 Windows PE 文件頭,類似於“公共對象文件格式(Common Object File Format,COFF)”頭。如果這個頭使用 PE32 格式, 文件能在Windows的 32 位或 64 位版本上運行。如果這個頭使用 PE32+格式,文件只能在 Windows 的 64 位版本上運行。這個頭還標識了文件類型,包括 GUI,CUI 或者 DLL,並包含一個時間標記來指出文件的生成時間。對於只包含 IL 代碼的模塊,PE32(+)頭的大多數信息會被忽視。對於包含本地 CPU代碼的模塊,這個頭包含了與本地 CPU 代碼有關的信息
CLR 頭:包含使這個模塊成為一個托管模塊的信息(可由 CLR 和一些實用程序進行解釋)。頭中包含了需要的 CLR 版本,一些 flag,托管模塊入口方法(Main 方法)的 MethodDef 元數據 token,以及模塊的元數據、資源、強名稱、一些 flag 以及其他不太重要的數據項的位置/大小
元數據:每個托管模塊都包含元數據表。主要有兩種類型的表:一種類型的表描述源代碼中定義的類型和成員,另一種類型的表描述源代碼引用的類型和成員
IL(中間語言)代碼:編譯器編譯源代碼時生成的代碼。在運行時,CLR 將 IL 編譯成本地 CPU指令。
本地代碼編譯器(native code compilers)生成的是面向特定 CPU 架構(比如 x86,x64 或 IA64)的代碼。相反,每個面向 CLR 的編譯器生成的都是 IL(中間語言)代碼。IL 代碼有時稱為托管代碼,因為 CLR 要管理它的執行。
除了生成 IL,面向 CLR 的每個編譯器還要在每個托管模塊中生成完整的元數據。簡單地說,元數據(metadata)是一組數據表。其中一些數據表描述了模塊中定義的內容,比如類型及其成員。還有一些元數據表描述了托管模塊引用的內容,比如導入的類型及其成員。元數據是一些老技術的超集。這些老技術包括 COM 的“類型庫(Type Library)”和“接口定義語言(Interface Definition Language,IDL)”文件。要注意的是,CLR 元數據遠比它們完整。另外,和類型庫及 IDL 不同,元數據總是與包含 IL 代碼的文件關聯。事實上,元數據總是嵌入和代碼相同的 EXE/DLL 文件中,這使兩者密不可分。由於編譯器同時生成元數據和代碼,把它們綁定一起,並嵌入最終生成的托管模塊,所以元數據和它描述的 IL 代碼永遠不會失去同步。元數據有多種用途,下面僅列舉一部分。
* 編譯時,元數據消除了對本地 C/C++頭和庫文件的需求,因為在負責實現類型/成員的 IL 代碼文件中,已包含和引用的類型/成員有關的全部信息。編譯器可直接從托管模塊讀取元數據。
* Microsoft Visual Studio 使用元數據幫助你寫代碼。它的“智能感知(IntelliSense)”技術可以解析元數據,指出一個類型提供了哪些方法、屬性、事件和字段。如果是一個方法,還能指出方法需要什麼參數。
* CLR 的代碼驗證過程使用元數據確保代碼只執行“類型安全”的操作。(稍後就會講到驗證。)。
* 元數據允許將一個對象的字段序列化到一個內存塊中,將其發送給另一台機器,然後反序列化,在遠程機器上重建對象的狀態。
* 元數據允許垃圾回收器跟蹤對象的生存期。垃圾回收器能判斷任何對象的類型,並從元數據知道那個對象中的哪些字段引用了其他對象
將托管模塊合並成程序集
CLR 實際不和模塊一起工作。相反,它是和程序集一起工作的。程序集(assembly)是一個抽象的概念,初學者往往很難把握它的精髓。首先,程序集是一個或多個模塊/資源文件的邏輯性分組。其次,程序集是重用、安全性以及版本控制的最小單元。取決於你對於編譯器或工具的選擇,既可以生成單文件程序集,
也可以生成多文件程序集。在 CLR 的世界中,程序集相當於一個“組件”。
下圖有助於理解程序集。在這幅圖中,一些托管模塊和資源(或數據)文件准備交由一個工具處理。該工具生成單獨一個 PE32(+)文件來表示文件的邏輯性分組。實際發生的事情是,這個 PE32(+)文件包含一個名為“清單”(manifest)的數據塊。清單是由元數據表構成的另一種集合。這些表描述了構成程序集的文件,由程序集中的文件實現的公開導出的類型 7 ,以及與程序集關聯在一起的資源或數據文件。
默認是由編譯器將生成的托管模塊轉換成程序集。換言之,C#編譯器生成含有清單的一個托管模塊。清單指出程序集只由一個文件構成。
加載公共語言運行時
你生成的每個程序集既可以是一個可執行應用程序,也可以是一個 DLL(其中含有一組由可執行程序使用的類型)。當然,最終是由 CLR 管理這些程序集中的代碼的執行。這意味著必須在目標機器上安裝好.NETFramework。
C#編譯器生成的程序集要麼包含一個 PE32 頭,要麼包含一個 PE32+頭。除此之外,編譯器還會在頭中指定要求什麼 CPU 架構(如果使用默認值 anycpu,則不明確指定)。Microsoft發布了 SDK 命令行實用程序 DumpBin.exe 和 CorFlags.exe,可用它們檢查編譯器生成的托管模塊所嵌入的信息。
執行程序集的代碼
為了執行一個方法,首先必須把它的 IL 轉換成本地 CPU 指令。這是 CLR 的 JIT (just-in-time 或者“即時”)編譯器的職責。下圖展示了一個方法首次調用時發生的事情。
就在 Main 方法執行之前,CLR 會檢測出 Main 的代碼引用的所有類型。這導致 CLR 分配一個內部數據結構,它用於管理對所引用的類型的訪問。在圖中,Main 方法引用了一個 Console 類型,這導致 CLR分配一個內部結構。在這個內部數據結構中,Console 類型定義的每個方法都有一個對應的記錄項 10 。每個記錄項都容納了一個地址,根據此地址即可找到方法的實現。對這個結構進行初始化時,CLR 將每個記錄項都設置成(指向)包含在 CLR 內部的一個未文檔化的函數。我將這個函數稱為 JITCompiler。
JITCompiler 函數被調用時,它知道要調用的是哪個方法,以及具體是什麼類型定義了該方法。然後,JITCompiler 會在定義(該類型的)程序集的元數據中查找被調用的方法的 IL。接著,JITCompiler 驗證 IL 代碼,並將 IL 代碼編譯成本地 CPU 指令。本地 CPU 指令被保存到一個動態分配的內存塊中。然後,JITCompiler返回 CLR 為類型創建的內部數據結構,找到與被調用的方法對應的那一條記錄,修改最初對 JITCompiler 的引用,讓它現在指向內存塊(其中包含了剛才編譯好的本地 CPU 指令)的地址。最後,JITCompiler 函數跳轉到內存塊中的代碼。這些代碼正是 WriteLine 方法(獲取單個 String 參數的那個版本)的具體實現。這些代碼執行完畢並返回時,會返回至 Main 中的代碼,並跟往常一樣繼續執行。現在,Main 要第二次調用 WriteLine。這一次,由於已對 WriteLine 的代碼進行了驗證和編譯,所以會
直接執行內存塊中的代碼,完全跳過 JITCompiler 函數。WriteLine 方法執行完畢之後,會再次返回 Main。
下圖展示了第二次調用 WriteLine 時發生的事情。
一個方法只有在首次調用時才會造成一些性能損失。以後對該方法的所有調用都以本地代碼的形式全速運行,無需重新驗證 IL 並把它編譯成本地代碼。JIT 編譯器將本地 CPU 指令存儲到動態內存中。一旦應用程序終止,編譯好的代碼也會被丟棄。所以,如果將來再次運行應用程序,或者同時啟動應用程序的兩個實例(使用兩個不同的操作系統進程),JIT 編譯器必須再次將 IL 編譯成本地指令。對於大多數應用程序,因 JIT 編譯造成的性能損失並不顯著。大多數應用程序都會反復調用相同的方法。
在應用程序運行期間,這些方法只會對性能造成一次性的影響。另外,在方法內部花費的時間很有可能比花在調用方法上的時間多得多。還要注意的是,CLR 的 JIT 編譯器會對本地代碼進行優化,這類似於非托管 C++編譯器的後端所做的工作。同樣地,可能要花費較多的時間來生成優化的代碼。但是,和沒有優化時相比,代碼在優化之後將獲得更出色的性能。
有兩個 C#編譯器開關會影響代碼的優化:/optimize 和/debug。下面總結了這些開關對 C#編譯器生成
的 IL 代碼的質量的影響,以及對 JIT 編譯器生成的本地代碼的質量的影響。
雖然這樣說很難讓人信服,但許多人(包括我)都認為托管應用程序的性能實際上超過了非托管應用程序。有許多原因使我們對此深信不疑。例如,當 JIT 編譯器在運行時將 IL 代碼編譯成本地代碼時,編譯器對執行環境的認識比非托管編譯器更加深刻。下面列舉了托管代碼相較於非托管代碼的優勢:
1、JIT 編譯器能判斷應用程序是否運行在一個 Intel Pentium 4 CPU 上,並生成相應的本地代碼來利用Pentium 4 支持的任何特殊指令。相反,非托管應用程序通常是針對具有最小功能集合的 CPU 編譯的,不會使用能提升應用程序性能的特殊指令。
2、JIT 編譯器能判斷一個特定的測試在它運行的機器上是否總是失敗。例如,假定一個方法包含以下代碼:
if (numberOfCPUs > 1) {
...
}
如果主機只有一個 CPU,JIT 編譯器不會為上述代碼生成任何 CPU 指令。在這種情況下,本地代碼將針對主機進行優化,最終的代碼變得更小,執行得更快。
3、應用程序運行時,CLR 可以評估代碼的執行,並將 IL 重新編譯成本地代碼。重新編譯的代碼可以重新組織,根據剛才觀察到的執行模式,減少不正確的分支預測。雖然目前版本的 CLR 還不能做到這一點,但將來的版本也許就可以了。
除了這些理由,還有另一些理由使我們相信在執行效率上,未來的托管代碼會比當前的非托管代碼更優秀。大多數托管應用程序目前的性能已相當不錯,將來還有望進一步提升。
IL 和驗證
IL 是基於棧的。這意味著它的所有指令都要將操作數壓入(push)一個執行棧,並從棧彈出(pop)結果。由於 IL 沒有提供操作寄存器的指令,所以人們可以很容易地創建新的語言和編譯器,生成面向 CLR 的代碼。
IL 指令還是“無類型”(typeless)的。例如,IL 提供了一個 add 指令,它的作用是將壓入棧的最後兩個操作數加到一起。add 指令不分 32 位和 64 位版本。add 指令執行時,它判斷棧中的操作數的類型,並執行恰當的操作。
我個人認為,IL 最大的優勢並不在於它對底層 CPU 的抽象。IL 提供的最大的優勢在於應用程序的健壯性 11 和安全性。將 IL 編譯成本地 CPU 指令時,CLR 會執行一個名為驗證(verification)的過程。這個過程會檢查高級 IL 代碼,確定代碼所做的一切都是安全的。例如,驗證會核實調用的每個方法都有正確數量的參數,傳給每個方法的每個參數都具有正確的類型,每個方法的返回值都得到了正確的使用,每個方法都有一個返回語句,等等。在托管模塊的元數據中,包含了要由驗證過程使用的所有方法和類型信息。
本地代碼生成器:NGen.exe
使用.NET Framework 配套提供的 NGen.exe 工具,可以在一個應用程序安裝到用戶的計算機上時,將 IL代碼編譯成本地代碼。由於代碼在安裝時已經編譯好,所以 CLR 的 JIT 編譯器不需要在運行時編譯 IL 代碼,這有助於提升應用程序的性能。NGen.exe 能在兩種情況下發揮重要作用:
1、加快 應用程序的啟動速度 運行 NGen.exe 能加快啟動速度,因為代碼已編譯成本地代碼,運行時不需要再花時間編譯。
2、減小應用程序的工作集 13 如果一個程序集會同時加載到多個進程中,對該程序集運行 NGen.exe可減小應用程序的工作集(working set)。NGen.exe 會將 IL 編譯成本地代碼,並將這些代碼保存到一個單獨的文件中。這個文件可以通過“內存映射”的方式,同時映射到多個進程地址空間中,使代碼得到了共享,避免每個進程都需要一份單獨的代碼拷貝。