创建、检查和反编译世界上(几乎)最短的 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/