创建、检查和反编译世界上(几乎)最小的 C# 程序

作者:微信公众号:【架构师老卢】
1-30 10:22
59

概述:在这篇文章中,我认为创建世界上(几乎)最短的 C# 程序,然后深入研究幕后发生的一些细节可能会很有趣。这篇文章不是为了解决现实世界的问题,但我希望它非常值得你花时间阅读它。通过花时间深入研究一些我们日常认为理所当然的功能,我希望我们可以一起更多地了解我们的代码如何转化为可以执行的东西。创建控制台应用程序我们将通过从新建项目对话框中选择“控制台应用”模板,开始在 Visual Studio 中入门。我们提供项目名称、位置和解决方案名称。这只是为了好玩,所以你可以看到我没有选择任何花哨的东西!好旧的 ConsoleApp3 是。如果我不是在新重新安装的机器上写这篇文章,我们可能至少会在 Conso

在这篇文章中,我认为创建世界上(几乎)最短的 C# 程序,然后深入研究幕后发生的一些细节可能会很有趣。这篇文章不是为了解决现实世界的问题,但我希望它非常值得你花时间阅读它。通过花时间深入研究一些我们日常认为理所当然的功能,我希望我们可以一起更多地了解我们的代码如何转化为可以执行的东西。

创建控制台应用程序

我们将通过从新建项目对话框中选择“控制台应用”模板,开始在 Visual Studio 中入门。

我们提供项目名称、位置和解决方案名称。这只是为了好玩,所以你可以看到我没有选择任何花哨的东西!好旧的 ConsoleApp3 是。如果我不是在新重新安装的机器上写这篇文章,我们可能至少会在 ConsoleApp80 上!

默认情况下,自 .NET 5 和 C# 9 以来的控制台应用模板使用顶级语句。我们将在此处使用顶级语句,但对于不是粉丝的用户,在 Visual Studio 17.2 及更高版本中,现在可以选中标记为“不使用顶级语句”的选项,以首选经典模板。

片刻之后,将创建相关文件,并在编辑器中加载 Program.cs 文件。

最初的应用程序已经非常基本,但我们可以进一步简化它。如果我们删除现有代码,我们可以用单个语句替换它。

1return;

这几乎是我们可以开发的最小、最短的 C# 程序,长度为 7 个字符。也许有人知道写更短的东西的诀窍。

我们的一行代码就是一个语句——它执行一个动作。 C# 是一种编程语言,与所有人类语言一样,在结构、语法和语法方面必须遵循一些规则。该语言的语法由标记组成,这些标记可以一起解释以形成更大的结构来表示声明、语句、表达式等。在我们的代码行中,我们有一个 return 关键字标记,后跟一个分号标记。这一起表示将要执行的单个语句。

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 执行项目,我们根本看不到任何事情发生。

C:\Users\SteveGordon\Code\Temp\ConsoleApp3\ConsoleApp3> dotnet run -c release
C:\Users\SteveGordon\Code\Temp\ConsoleApp3\ConsoleApp3> 

因此,我们的简单应用程序是有效的,并且无一例外地执行。它返回一个零的退出代码,这意味着它完成时没有错误。下一个问题是,如何?运行时是否已更新以支持此类程序?

答案是否定的,这是一个编译器功能,它似乎可以神奇地处理此类代码,在编译过程中生成有效的 C# 程序。让我们来看看实际发生了什么。

汇编“魔术”

我们在编辑器或 IDE 中编写的代码可以利用许多 C# 语言功能。当我们生成应用程序时,编译器会获取我们的代码并生成 .NET IL(中间语言)字节码。IL(在某些文档中也称为 MSIL 和 CIL)包含一组通用指令,可以通过编译 .NET 语言来生成。这种中间形式是最终机器代码指令的垫脚石。.NET 通过称为“实时编译”的过程实现此目的。JIT (RyuJIT) 在首次调用方法时采用 IL 字节码并生成特定于机器架构的指令。我们暂时不会深入探讨更详细的细节,重要的一点是,获得最终机器代码有两个阶段。第一阶段,编译为 IL 发生在我们构建应用程序时,在部署它之前。第二阶段,编译为机器代码,在运行时进行,由 JIT 编译器处理。

某些新的语言功能可能需要运行时更改才能支持它们,但通常会避免这种情况。大多数功能都是在编译时实现的。后一种功能使用一种称为降低的东西将某些高级语言结构转换为更简单的结构,然后可以更容易、更优化地转换为 IL。编译器知道如何最好地转换我们编写的代码,以便将其编译为最终的 IL。

顶级语句是编译器的一个特性,当我们使用它们时,就会发生一些神奇的事情。好吧,好吧,这不是魔术,只是在我们的代码中满足各种条件时巧妙地使用编译器。我们可以通过反编译代码来学习更多。

检查和反编译代码

为了理解允许我们的简短语句成为有效 C# 程序的机制,我们将检查生成的 DLL 并反编译代码。

作为生成过程的输出生成的 DLL 文件包含 IL 指令,以及运行时用于执行托管代码的 .NET 元数据。我们可以用来检查此文件中的数据的一个工具是 ILDASM,它与 Visual Studio 一起安装。在我的计算机上,我可以打开 Visual Studio 开发人员命令提示符,并导航到包含控制台应用程序的生成项目的目录,然后针对位于该位置的 DLL 文件启动 ILDASM。

ConsoleApp3\ConsoleApp3\bin\Release\net6.0> ildasm consoleapp3.dll

ILDAM 加载,显示控制台应用程序的类型和元数据。

最值得注意的观察是,我们似乎有一个名为 Program 的东西,它看起来非常像一个类,而且确实如此!它包括类元数据、构造函数方法和另一种方法。此方法名为 <Main>$,看起来像一个 void 返回方法,接受字符串数组参数。这个签名听起来很熟悉吗?我们可以在 ILDASM 上多花一些时间,但让我切换到另一个反编译器工具。对于下一步,我们有几个选择,所有这些都是免费工具。

所有这些都是有效的选择,大多数情况下,它归结为偏好问题。它们在核心功能方面具有非常相似的功能。我将使用 dotPeek,这是我在这些情况下最倾向于使用的工具。使用 dotPeek 打开 DLL 后,我们会看到程序集的树视图,与我们在 ILDASM 中看到的视图没有太大区别。

在根命名空间下,我们可以再次观察到一个带有 <Main>$ 方法的 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 方法,其名称略有不同<Main>$。这是编译器代表我们生成的合成入口点。编译器生成的类型和成员的名称中通常包含不寻常的符号。虽然这些名称在我们自己的 C# 代码中是非法的,但就 IL 和运行时而言,它们实际上是合法的。编译器生成的代码使用这些名称来避免与我们自己的代码中定义的类型和成员发生潜在冲突。否则,这个 Main 方法看起来就像我们在不使用顶级语句时可能包含在传统应用程序中的任何其他方法一样。

该类型的另一个方法是空构造函数。我明确配置了 dotPeek 来显示这一点。通常,可以在我们自己的代码中跳过一个空的默认构造函数,但是如果我们不显式声明一个,编译器仍然会添加一个构造函数。此空构造函数仅调用基类型 Object 上的构造函数。

在这一点上,我们开始看到顶级语句在起作用的“魔力”。编译器具有多个用于确定应用程序入口点的规则。编译器现在寻找的一件事是,我们的应用程序包含包含顶级(全局)语句的编译单元。当找到这样的编译单元时,编译器将尝试在编译时发出标准的 Program 类和 main 方法。您会注意到,即使我们将顶级语句文件命名为 TopLevel.cs,但这对合成 Program 类的类型命名没有影响。按照惯例,模板中的新应用程序具有一个名为 Program.cs 的文件,该文件主要与开发人员期望的历史命名保持一致。如果您使用顶级语句,我建议坚持使用默认名称,因为其他开发人员仍然可以轻松找到入口点代码。

但是等一下,我刚才抛出了一个新术语,我们应该稍微回滚一下。编译单元是什么意思?

在编译过程中,编译器对代码进行词法分析(读取标记)并解析我们的代码,最终构建一个语法树,该语法树根据语言规范在树视图中表示源代码。有几种方法可以查看语法树,但其中一种非常简单,就是访问 SharpLab.io。SharpLab 是另一个非常有用的工具,用于检查浏览器中的反编译和 IL 代码。另一个方便的功能是能够查看我们代码的语法树。

我们的单个返回语句,从我们的 TopLevel.cs 文件被解析为上面的树结构,包含多个节点。树的根是 CompilationUnit,它表示我们的源文件。因为我们所有的代码(是的,它的所有一行!)都属于这个文件。每个元素都是根目录下的一个节点。

由 return 关键字标记和分号标记组成的 return 语句是此编译单元的全部内容。return 语句位于 GlobalStatement 节点下,这是顶级语句在树中的表示方式。

当编译器遇到包含全局语句的 CompilationUnit 时,并且不存在其他具有全局语句的 CompilationUnit 时,编译器能够识别顶级语句功能的使用,并在 Program 类中生成合成主方法。我们的反编译揭示了这个过程的结果。合成 main 方法在反编译的源代码中为空。我们的顶级代码包含一个 return 语句。任何顶级语句都将成为合成主方法主体的一部分。在我们的例子中,由于我们有一个空的返回,因此在方法的主体中不需要显式语句。默认情况下,当到达方法主体的末尾时,它将返回。当到达 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.0
        IL_0001: call instance void [System.Runtime]System.Object::.ctor()
        IL_0006: ret
    }
 
    .method private hidebysig static void '<Main>$' (string[] args) cil managed 
    {
        .entrypoint
        IL_0000: ret
    }
}

详细介绍这一点可能最好留到以后的帖子中。我们将把注意力集中在最后一个块上,其中包括 <Main>$ 方法的信息和说明。在此方法中,我们可以看到一个名为“ret”的 IL 指令。出现在DLL文件中的实际指令代码是0x2A。此语句从方法返回,可能带有返回值。如果您对 IL 和本说明的细节感到好奇,您可以花几个小时阅读 ECMA 335 规范

以下是与 ret 指令相关的例外情况:

从当前方法返回。当前方法的返回类型(如果有)确定要从堆栈顶部提取并复制到调用当前方法的方法的堆栈上的值类型。当前方法的计算堆栈应为空,但要返回的值除外。

生成的 IL 不包括为我们生成的 void 返回方法推送到堆栈上的任何内容。

在运行时,Just-In-Time 编译器将 IL 指令进一步编译为运行时计算机体系结构的相应程序集代码。

另一个有趣的亮点是该块顶部的 .entrypoint。这只能包含在应用程序中的单个方法中。CIL 标头是 DLL 文件的一部分,它包括一个 EntryPointToken,它标记定义为入口点的方法。

作为有关应用程序的元数据的一部分,存在一个 MethodDef 表,该表包含程序集的方法签名。我们的程序集中有两个,编译器生成的 <Main>$ 方法和合成 Program 类的默认构造函数。您会注意到 EntryPointToken 值与 <Main>$ 方法的 MethodDef 表中的标识符匹配。

当执行引擎(运行时的一部分)加载我们的程序集时,它会在入口点找到并开始执行我们的托管代码。

我们所做的所有切入点都是立即返回。return jump 语句将控制权返回给调用方,在本例中为执行引擎(运行时),应用程序以代码零退出。在功能方面不是很令人兴奋,但即便如此,它还是给了我很多东西要写!

总结

我认为这可能是结束对这个小型 C# 程序的探索的好地方。我们还可以深入研究许多其他有趣的东西,即使在这个小应用程序中也是如此。也许,如果人们有兴趣阅读更多关于内部运作的信息,我将继续作为一系列文章,重点关注其中的一些事情。就我个人而言,我发现挖掘一些内部作品非常有趣。

在这篇文章中,我们创建了几乎最短的 C# 程序,对其进行编译并执行。然后,我们反编译 DLL,以了解我们的单个语句如何导致编译器发出一个 Program 类,该类具有应用程序的合成入口点。我们了解到,没有“魔法”,只是一个编译功能,它可以检测我们在编译单元正下方使用语句。编译器采用这些语句,并将它们作为合成 main 方法的主体。在此过程中,我们使用了一些方便的工具,这些工具可用于检查 .NET DLL 中包含的 IL 和元数据,以及将该 IL 反编译回有效的 C# 代码。

阅读排行