創建、檢查和反編譯世界上(幾乎)最短的 C# 程序
原文來自https://www.stevejgordon.co.uk/creating-inspecting-decompiling-the-worlds-smallest-csharp-program
在這篇文章中,我認為創建世界上(幾乎)最短的 C# 程序然后深入研究幕后發生的一些細節可能會很有趣。這篇文章不是為了解決現實世界的問題,但我希望你花時間閱讀它是值得的。通過花時間深入研究我們日常認為理所當然的一些功能,我希望我們可以一起了解更多關于我們的代碼如何轉換為可以執行的東西的知識。
創建控制臺應用程序
我們將通過從新項目對話框中選擇“Console App”模板開始在 Visual Studio 中使用。

我們提供項目名稱、位置和解決方案名稱。這只是為了好玩,所以你可以看到我沒有選擇任何花哨的東西!不錯的 ConsoleApp3,如果我不是在新安裝的機器上寫這個,我們可能至少在 ConsoleApp80 上!

從 .NET 5 和 C# 9 開始,控制臺應用程序模板默認使用Top-Level語句。我們將在此處使用Top-Level語句,但對于那些不是粉絲的人,在 Visual Studio 17.2 及更高版本中,您現在可以選中標記為“不使用Top-Level語句”的選項以更喜歡經典模板。

片刻之后,相關文件被創建并且 Program.cs 文件被加載到編輯器中。

最初的應用程序已經很基礎了,但我們可以進一步簡化它。如果我們刪除現有代碼,我們可以用一條語句替換它。
return;

這幾乎是我們可以開發的最小、最短的 C# 程序,長度為 7 個字符。也許有人知道寫更短的東西的技巧。
編輯:事實證明,有人這樣做。正如nietras[1]在 Twitter 上[2]向我指出的那樣,您可以使用空語句塊 {} 減少到兩個字符。好的!查看他們的博客文章[3]以獲取更多詳細信息。這就是現在最短的 C# 程序之一!
我們的單行代碼是一個語句——它執行一個動作。C# 是一種編程語言,與所有人類語言一樣,在結構、語法和語法方面必須遵循一些規則。該語言的語法由標記組成,這些標記可以一起解釋形成更大的結構來表示聲明、語句、表達式等。在我們的代碼行中,我們有一個返回關鍵字標記,后跟一個分號標記。這一起表示將執行的單個語句。
return 語句屬于一組稱為跳轉語句的語句。跳轉語句將控制權轉移到程序的另一部分。當在方法中到達 return 語句時,程序返回到調用它的代碼,即調用者。為了理解這個特定的跳轉語句,我們需要在幾分鐘內深入挖掘。
在我們運行應用程序之前,我將進行進一步的更改,以幫助我們在后面的帖子中區分事物。我要將 Program.cs 文件重命名為 TopLevel.cs 并保存應用程序。

執行應用程序
我們可以構建和運行這個應用程序,正如我們所料,它做的很少。Visual Studio 開發者控制臺的輸出如下:
C:\Users\SteveGordon\Code\Temp\ConsoleApp3\ConsoleApp3\bin\Release\net6.0\ConsoleApp3.exe (process 34876) exited with code 0. ``Press any key to close this window . . .
如果我們使用 dotnet run 和終端的發布配置執行項目,我們根本看不到任何事情發生。
PS C:\Users\SteveGordon\Code\Temp\ConsoleApp3\ConsoleApp3> dotnet run -c release``PS C:\Users\SteveGordon\Code\Temp\ConsoleApp3\ConsoleApp3>
因此,我們的簡單應用程序是有效的,并且可以毫無例外地執行。它返回一個零退出代碼,這意味著它完成而沒有錯誤。下一個問題是,怎么做?運行時是否已更新以支持此類程序?
答案是,不,這是一個編譯器功能,它似乎可以神奇地處理此類代碼,在編譯期間生成有效的 C# 程序。讓我們來看看實際發生了什么。
匯編“魔術”
我們在編輯器或 IDE 中編寫的代碼可以利用許多 C# 語言功能。當我們構建應用程序時,編譯器獲取我們的代碼并生成 .NET IL(中間語言)字節碼。IL(在某些文檔中又稱為 MSIL 和 CIL)包括一組通用指令,可以通過編譯 .NET 語言生成。這種中間形式是最終機器代碼指令的墊腳石。.NET 通過稱為即時編譯的過程來實現這一點。當第一次調用方法時,JIT (RyuJIT) 采用 IL 字節碼并生成特定于機器架構的指令。我們現在不會深入研究更詳細的細節,重要的一點是有兩個階段可以得到最終的機器碼。第一階段,編譯為 IL 發生在我們構建應用程序時,然后再部署它。第二階段,
一些新的語言特性可能需要運行時更改來支持它們,但通常會避免這種情況。大多數功能都是在編譯時實現的。后面的這些功能使用稱為降低的東西將某些高級語言結構轉換為更簡單的結構,然后可以更輕松、更優化地轉換為 IL。降低經常發生,通常不是我們需要考慮得太深的事情。編譯器知道如何最好地轉換我們編寫的代碼,以便將其編譯成最終的 IL。
Top-Level語句是編譯器功能,當我們使用它們時會發生一些神奇的事情。好吧,好吧,這不是魔術,只是在我們的代碼中滿足各種條件時巧妙地使用編譯器。我們可以通過反編譯我們的代碼來了解更多。
檢查和反編譯代碼
為了理解使我們的簡短語句成為有效 C# 程序的機制,我們將檢查生成的 DLL 并反編譯代碼。
作為構建過程的輸出生成的 DLL 文件包含 IL 指令,以及運行時用來執行托管代碼的 .NET 元數據。我們可以用來檢查該文件中數據的一種工具是 ILDASM,它與 Visual Studio 一起安裝。在我的機器上,我可以打開 Visual Studio 開發人員命令提示符并導航到包含我的控制臺應用程序的構建工件的目錄,針對位于那里的 DLL 文件啟動 ILDASM。
ConsoleApp3\ConsoleApp3\bin\Release\net6.0> ildasm consoleapp3.dll
ILDAM 加載,顯示控制臺應用程序的類型和元數據。

最值得注意的觀察是,我們似乎有一個名為 Program 的東西,它看起來非常像是一個類,它就是!它包括類元數據、構造函數方法和另一種方法。這個方法被命名為
$,看起來像一個 void 返回方法,接受一個字符串數組參數。這個簽名是不是很熟悉?我們可以在 ILDASM 上多花一些時間,但讓我切換到另一個反編譯器工具。對于下一步,我們有幾個選擇,所有這些都是免費工具。?iLSpy[4]?Jetbrains dotPeek[5]?Telerik JustCompile[6]
所有這些都是有效的選擇,主要取決于偏好問題。它們在核心功能方面具有非常相似的特性。我將使用 dotPeek,這是我在這些情況下最常用的工具。使用 dotPeek 打開 DLL 后,我們會看到程序集的樹視圖,與我們在 ILDASM 中看到的并無太大區別。

在根命名空間下面,我們可以再次觀察到具有$ 方法的 Program 類。這個是從哪里來的?我們很快就會回答這個問題。在我們開始之前,讓我們探索一下 dotPeek 還能向我們展示什么。
通過右擊Program類,我們可以選擇查看反編譯的源碼。這將獲取程序集的 IL 代碼并反轉編譯過程以返回 C# 代碼。反編譯代碼的確切性質可能因工具而異。有時,必須使用最佳猜測來確定原始代碼的外觀以及可能使用了哪些 C# 語言功能。
這是我從 dotPeek 得到的結果:
using System.Runtime.CompilerServices;[CompilerGenerated]
internal class Program
{private static void <Main>$(string[] args){}public Program(){base..ctor();}
}
關于這里發生的事情的第一個提示是 Program 類的 CompilerGenerated 屬性。這個類在我們的代碼中不存在,但是編譯器已經為我們生成(發出)了一個。該類包含一個靜態 void 方法,其名稱稍有不同尋常:
$這是編譯器代表我們生成的合成入口點。編譯器生成的類型和成員的名稱通常帶有不尋常的符號。雖然這樣的名稱在我們自己的 C# 代碼中是非法的,但就 IL 和運行時而言,它們實際上是合法的。編譯器生成的代碼使用這些名稱來避免與我們自己代碼中定義的類型和成員的潛在沖突。否則,這個 Main 方法看起來就像我們在不使用Top-Level語句時可能包含在傳統應用程序中的任何其他方法。該類型的另一個方法是空構造函數。我明確配置了 dotPeek 來顯示這一點。通常可以在我們自己的代碼中跳過一個空的默認構造函數,但是如果我們沒有顯式聲明一個,編譯器仍然會添加一個。這個空的構造函數只是調用基類型 Object 的構造函數。
在這一點上,我們開始看到Top-Level語句的“魔力”在起作用。編譯器有幾個規則來確定應用程序的入口點。編譯器現在尋找的一件事是我們的應用程序包含一個包含Top-Level(全局)語句的編譯單元的情況。當找到這樣的編譯單元時,編譯器將嘗試在編譯時發出標準的 Program 類和 main 方法。您會注意到,即使我們將Top-Level語句文件命名為 TopLevel.cs,這對合成 Program 類的類型命名沒有影響。按照慣例,模板中的新應用程序有一個名為 Program.cs 的文件,主要是為了與開發人員期望的歷史命名保持一致。
不過等一下,我剛才扔了一個新詞,我們應該稍微回滾一下。編譯單元是什么意思?
在編譯期間,編譯器對我們的代碼進行詞法分析(讀取標記)并解析,最終構建一個語法樹,根據語言規范在樹視圖中表示源代碼。有幾種方法可以查看語法樹,但一種非常簡單的方法是訪問SharpLab.io[7]。SharpLab 是另一個非常有用的工具,用于檢查瀏覽器中的反編譯代碼和 IL 代碼。另一個方便的功能是能夠查看我們代碼的語法樹。

我們從 TopLevel.cs 文件中的單個 return 語句被解析為上面的樹結構,包含幾個節點。樹的根是 CompilationUnit,它代表我們的源文件。因為我們所有的代碼(是的,所有的一行!)都屬于這個文件。每個元素都是根下的一個節點。
由 return 關鍵字標記和分號標記組成的 return 語句是該編譯單元所擁有的全部內容。return 語句位于 GlobalStatement 節點下,這是樹中Top-Level語句的表示方式。
當編譯器遇到包含全局語句的 CompilationUnit,并且沒有其他具有全局語句的 CompilationUnit 時,編譯器能夠識別Top-Level語句功能的使用并在 Program 類中生成合成 main 方法。我們的反編譯揭示了這個過程的結果。反編譯源中的合成 main 方法為空。我們的頂級代碼包含一個 return 語句。任何Top-Level語句都將成為綜合 main 方法主體的一部分。在我們的例子中,因為我們有一個空返回,所以方法體中不需要顯式聲明。當到達方法體的末尾時,它將默認返回。當到達 Main 方法的末尾時,我們的應用程序已完成執行,退出代碼為零。
雖然我們不會在這篇文章中對 IL 進行深入探討,但值得通過探索實際 IL 的樣子來總結一下。IL 是一種非常簡潔的字節碼格式。反編譯工具都支持以某種人類可讀的形式查看 IL 的方法。請記住,構成該方法的實際指令代碼通常只有 DLL 文件中的一或兩個字節。這是 dotPeek 的 IL 查看器輸出。
.class public auto ansi beforefieldinit Program extends [System.Runtime]System.Object
{.custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor()= (01 00 00 00 ).method public hidebysig specialname rtspecialname instance void .ctor () cil managed {IL_0000: ldarg.0IL_0001: call instance void [System.Runtime]System.Object::.ctor()IL_0006: ret}.method private hidebysig static void '<Main>$' (string[] args) cil managed {.entrypointIL_0000: ret}
}
詳細介紹這一點可能最好留給以后的帖子。我們將把注意力集中在最后一個塊上,它包括
$ 方法的信息和說明。我們可以在這個方法中看到一條名為“ret”的 IL 指令。出現在 DLL 文件中的實際指令代碼是 0x2A。此語句從方法返回,可能帶有返回值。如果您對 IL 的細節和本說明感到好奇,您可以花幾個小時閱讀ECMA 335 規范[8]。這是一個與 ret 指令有關的例外:
從當前方法返回。當前方法的返回類型(如果有)確定要從堆棧頂部獲取并復制到調用當前方法的方法的堆棧中的值的類型。當前方法的評估堆棧應為空,除了要返回的值。
生成的 IL 不包括為我們生成的 void 返回方法壓入堆棧的任何內容。
在運行時,即時編譯器將 IL 指令進一步編譯為運行時機器架構的適當匯編代碼。
另一個有趣的亮點是該塊頂部的 .entrypoint。這只能包含在應用程序的單個方法中。CIL 標頭是 DLL 文件的一部分,它包含一個 EntryPointToken,它將方法定義為入口點。

作為有關應用程序的元數據的一部分,存在一個 MethodDef 表,其中包括程序集的方法簽名。我們的程序集中有兩個,編譯器生成的
$ 方法和合成 Program 類的默認構造函數。您會注意到 EntryPointToken 值與 MethodDef 表中$ 方法的標識符相匹配。
當執行引擎(運行時的一部分)加載我們的程序集時,它會在入口點定位并開始執行我們的托管代碼。
我們的入口點所做的就是立即返回。return jump 語句將控制權返回給調用者,在本例中為執行引擎(運行時),應用程序以代碼 0 退出。在功能方面不是很令人興奮,但即便如此,它還是給了我很多可寫的東西!
概括
我認為這可能是結束對這個小型 C# 程序的探索的好地方。即使在這個小應用程序中,我們也可以挖掘許多其他有趣的東西。也許,如果人們有興趣關于內部運作的信息,我將繼續將其作為一系列帖子,重點關注其中的一些內容。就個人而言,我發現挖掘一些內部作品非常有趣。
在這篇文章中,我們創建了幾乎最短的 C# 程序,編譯并執行了它。然后我們對 DLL 進行反編譯,以了解我們的單個語句如何導致編譯器為我們的應用程序生成一個帶有合成入口點的 Program 類。我們了解到,沒有“魔法”,只是一個編譯功能,它可以檢測我們在編譯單元正下方對語句的使用。編譯器采用這些語句并將它們作為合成 main 方法的主體。在此過程中,我們使用了一些方便的工具,這些工具可用于檢查 .NET DLL 中包含的 IL 和元數據,以及將 IL 反編譯回有效的 C# 代碼。
References
[1]
?nietras:?https://twitter.com/nietras1[2]
?在 Twitter 上:?https://twitter.com/nietras1/status/1537026998719197185[3]
?博客文章:?https://nietras.com/2021/10/09/worlds-smallest-csharp-program/[4]
?iLSpy:?https://apps.microsoft.com/store/detail/ilspy/9MXFBKFVSQ13[5]
?Jetbrains dotPeek:?https://www.jetbrains.com/decompiler/[6]
?Telerik JustCompile:?https://www.telerik.com/products/decompiler.aspx[7]
?SharpLab.io:?https://sharplab.io/[8]
?ECMA 335 規范:?https://www.ecma-international.org/publications-and-standards/standards/ecma-335/