C#/.NET程序集详解

在写完代码之后进行生成(build)时,CLR 将 .NET 应用程序打包为由模块(module)组成的程序集(assembly)。

一个程序集由一或多个托管模块组成,程序代码被编译为 IL 代码,存在于托管模块之中。

程序集是一个可以寄宿于 CLR 中的、拥有版本号的、自解释、可配置的二进制文件,程序集的扩展名为 exe 或 dll。

程序集中的代码可以被其他程序集中的 C# 代码调用,例如几乎所有的 C# 代码都会用到 mscorlib.dll 这个程序集中的对象。

程序集是自解释的,因为它记录了它需要访问的其他程序集(在清单中)。

另外,元数据描述了程序集内部使用的类型的所有信息,包括类型的成员和构造函数等。

程序集可以私有或共享的方式配置,如果以共享方式进行配置,则同一台机器的所有应用程序都可以使用它。

程序集也可以手动进行生成,这需要选择对应语言的编译器。

C# 的编译器是csc.exe。可以通过 /t 参数指定编译目标,最常见的几个为:

  • /t:library:目标为一个 dll 文件(需要指定托管模块)。
  • /t:exe:目标为一个可执行 exe 文件,必须指定入口点。
  • /t:module:目标为一个托管模块。

其中,前两个目标的结果文件都是程序集,而最后一个目标的结果文件是托管模块。

反向工程——使用 ILSpy 观察 IL

ILSpy 是一个免费的、开源的反编译工具,可以将程序集反编译为 C# 代码。

ILSpy 的下载地址为:http://ilspy.net,和 .NET 自带的 ildasm 相比,它有一个很大的优点,就是在打开文件之后,其他的程序仍然可以修改它。

使用 ildasm 打开一个工程的 .dll 之后,你在 Visual Studio 就无法再生成它了(Visual Studio 会提示有其他程序正在使用),但 ILSpy 还不支持 C# 的较新版本的反编译(不过,它正在与时俱进)。

我们平常将 C# 代码通过编译器转换为 IL 的动作叫做正向工程;而反过来,将程序集文件内部的 IL 和元数据重新组合并转换为 C# 代码的动作就叫反向工程(reverse engineering)。

ILSpy 还允许在反向工程的同时,对某些语法关键字不进行反编译,这将会生成一个较长的 C# 代码,但可以让你更加清楚地了解编译器幕后所做的工作(本书默认全部选择), 如下图所示。

ILSpy的选项

上图的选项中,第一个选项为是否反编译匿名方法。如果不选择该项,就不会对匿名方法进行反编译(它会保持原来的样子),这样生成的代码和本来的代码不同(更低级一些)。

另外,我们在得到反编译的 C# 代码之后,还可以将这个代码再次编译为 IL,这相当于打了一个转,这种可以在 C# 和 IL 中来回切换的行为,称为正反向工程(round-trip engineering)。

下图简单显示了各种代码和编译器之间的关系。

反向工程

对于非开源软件公司(例如,银行系统)的开发者来说,他们的代码是不能外泄的。

但别有用心的人一旦获得了 .dll 或 .exe 文件,可以轻松地通过反编译工具还原出代码原本的面貌。

这种担心一般通过两种方式解决,一是将程序集放在服务器上,从而使得无人可以获得原始的程序集,另外就是使用混淆器工具打乱程序集的元数据。

程序集与托管模块

假设我们现在建立一个新的控制台应用,然后在 Program.cs 中键入如下的代码。

class Program
{
    static void Main(string[] args)
    {
        var a = new A();
        var b = new B();
        Console.ReadKey();
    }
}

新建一个文件,命名为 A.cs,并键入如下代码:

public class A
{
    public A()
    {
        Console.WriteLine("A被创建");
    }
}

之后我们再新建一个文件,命名为 B.cs,并键入如下代码:

public class B
{
    public B()
    {
        Console.WriteLine("B被创建");
    }
}

此时的程序应该可以通过编译了,但我们不进行编译,并使用 vS2017 的开发人员命令提示符(不能是普通的命令提示符)定位到工程文件夹。

如果你的工程文件夹中已经存在 bin 和 obj 文件夹,可以把它们删除。

1) 编译托管模块

现在我们将 A 和 B 两个类编译为托管模块(managed module):

csc /t:module A.cs
csc /t:module B.cs

编译成功之后,工程文件夹会多出 A.netmodule 和 B.netmodule 两个文件。

我们使用 ILSpy 工具打开 A.netmodule 瞧瞧(需要在打开文件时选择所有文件),如下图所示。

托管模块的结构

我们会发现,托管模块麻雀虽小但五脏俱全,包括 IL(右边的代码)和元数据。

2) 托管模块的结构

托管模块的结构比较复杂,主要的几部分为Windows PE文件头、CLR文件头、IL代 码和元数据。

Windows PE 文件头

拥有 Windows PE 文件头的文件可以被 Windows 操作系统加载和操作。

显然,如果打算在 Windows 下运行该程序集,则必须要有 Windows PE 文件头。

Windows PE 文件头有 PE32 和 PE32+ 两种格式,拥有后者作为文件头的文件只能在 64 位 Windows 上运行,拥有前者作为文件头的文件则能在 32 或 64 位 Windows 上运行。

通过使用下面的命令来查看托管模块的 Windows PE 文件头:

dumpbin/headers  a.netmodule

获得的文件头如下(以下只显示了一部分):

Dump of file a.netmodule
PE signature found
File Type: DLL
FILE HEADER VALUES
                                14C machine (x86)
                                    2 number of sections
                     5A123E1D time date stamp Mon Nov 2 0 10:29:49 2 017
                                    0 file pointer to symbol table
                                    0 number of symbols
                                   E0 size of optional header
                               2102 characteristics
                                            Executable
                                            32 bit word machine
                                            DLL

OPT工ONAL HEADER VALUES
                       10B magic # (PE32)
                           11.00 linker version
                              400 size of code
                              200 size of initialized data
                                  0 size of uninitialized data
                            22CE entry point (100022CE)
                            2000 base of code
                            4000 base of data
                    10000000 image base (10000000 to 10005FFF)
                            2000 section alignment
                              200 file alignment
                             4.00 operating system version
                             0.00 image version
                             4.00 subsystem version
                                  0 Win32 version
                            6000 size of image
                              200 size of headers
                                  0 checksum
                                  3 subsystem (Windows CUI)
……

一般来说,无需深究这些内容的含义。

CLR文件头

程序集中的 CLR 文件头使得文件可以寄宿于 CLR。

CLR 文件头告诉操作系统这个文件是一个 .NET 程序集,区别于其他类型的可执行程序。

通过使用下面的命令来查看托管模块的 CLR 文件头:

dumpbin/clrheader a.netmodule

获得的文件头如下:

Dump of file a.netmodule
File Type: DLL
     clr Header:
                      48 cb
                      2.05 runtime version
                     2068 [ 218] RVA [size] of MetaData Directory
                           1 flags
                                 IL Only
                           0 entry point token
                           0 [ 0] RVA [size] of Resources Directory
                           0 [ 0] RVA [size] of StrongNameSignature Directory
                           0 [ 0] RVA [size] of CodeManagerTable Directory
                           0 [ 0] RVA [size] of VTableFixups Directory
                           0 [ 0] RVA [size] of ExportAddressTableJumps Directory
                           0 [ 0] RVA [size] of ManagedNativeHeader Directory
Summary
          2000 .reloc
          2000 .text

通常 .NET 开发者不需要去关心上面内容的细节,下面的部分才是托管模块以及程序集 的重点所在。

IL 代码

这是将 C# 通过 csc 编译器编译成的中间语言代码。

IL 代码也称为托管代码,因为它是由 CLR 生成的,也会由 CLR 进行管理。

中间语言代码在运行时还会被 JIT 再次转换为高级码(面向特定 CPU 架构,例如 x86、x64 等)。

IL 提供了 CLR 所有的功能,可以将 IL 视为一个面向对象的机器语言。

元数据

托管模块的元数据记录了模块中的类型及其成员。

它的用途非常广泛,例如,它是反射的基石、IDE 的智能感知、序列化和反序列化也靠它来实现。

通过反射可以访问元数据, 而通过特性,你可以自定义自己想要的元数据。

3) 程序集的结构

虽然程序存在于模块中,但仅仅有模块还不够。

CLR 是和程序集一起工作的,而不是和模块一起工作。

程序集是一个或多个托管模块的集合,并且作为版本控制、代码重用以及部署的最小单元。

既然程序集本质上是一组托管模块,它当然也具有托管模块的所有成分,例如文件头、IL 和元数据。

值得注意的是,程序集还包括一个清单(manifest),令 CLR 知道该程序集所有托管模块的信息,以及它所需要的外部程序集的信息。

它还描述了程序集本身的信息,例如程序集标识等(名称、版本、文化),这些都是托管模块所不具备的。

这个清单也位于程序集的某个模块之中,而这个模块就称为主模块。

在程序运行时,主模块先加载,然后,根据清单就可以找到其他模块以及其他外部程序集的位置(这个过程称为探测)。

除了清单之外,程序集还包括若干资源文件,例如图片文件等。

在加载程序集中,清单的作用是至关重要的,它能帮助 CLR 定位所有需要的资源。

一个程序集包括一或多个托管模块。

两个程序集共同引用一个托管模块的可能性也是存在的,此时,CLR 将这个公共模块视为两个不同的模块。不过这种情况非常少见。

可以用下面的例子来理解程序集和托管模块:

如果程序集是我们平时吃的菜,例如宫保鸡丁,那么托管模块就是里面所有的成分,例如鸡肉、花生、大葱,甚至盐和酱油。

我们(CLR)是不会直接吃鸡肉和大葱(托管模块)的,而是将宫保鸡丁(程序集)整个看成是一道菜,也就是基本单元。

宫保鸡丁的清单描述了它的做法以及在该菜中,各个部件都是什么,去哪里找,而部件本身就不需要清单了。

但部件仍然可以储存一些元数据,例如,酱油可以储存它的牌子、出厂日期和过期时间(托管模块的元数据)。

不同地方的宫保鸡丁做法也不同,有时我们还会改进它的做法,这正是程序集的版本和文化所体现的。

我们(CLR)是吃宫保鸡丁这道菜,而不是吃它的配料。

多个程序集便组成了一个应用程序,例如我们的一顿饭通常包括几道菜。当然,一个应用程序也可以只有一个程序集。

程序集是可配置的:可以将其配置为私有或共享(全局程序集缓存,GAC)。

当你在一个类库中引用其他程序集(通过Add References)时,系统将该程序集的.dll文件拷贝到类库的子目录 bin/Debug 或 Release 下(这就是私有配置)。

注意:Add References 不会显示 GAC 中的程序集。全局的程序集不需要 Add References,IDE 自动添加。

托管模块和程序集的关系

一个程序集可以包含一或多个托管模块。因此,单模块程序集的结构就是它的托管模块加上清单和资源文件,这使得程序集变成自解释的。

而多模块程序集,需要指定一个主模块,程序集的清单将会放入主模块中,而资源文件将视其调用模块放在对应的模块中。

Visual Studio 只能生成单模块程序集,因此,无论是使用 Debug 还是 Release 模式,都不会得到 .module 文件。

如果要生成多模块程序集,只能按照本书所描述的命令行模式,调用 csc.exe 加上合适的参数。不过,很少有人用这种方式。

合并为程序集

现在我们将刚刚得到的两个托管模块合并为一个程序集 Classes.dll。

注意,在 out 和 addmodule 之后的冒号和文件名之间不能有空格:

csc /out:Classes.dll /t:library /addmodule:A.netmodule;B.netmodule

现在,工程文件夹将会多一个 Classes.dll 文件。

通过 ILSpy 工具,我们可以打开程序集 Classes.dll,看到其中包括了我们之前编译的两个托管模块,还有外部参考 mscorlib.dll,因为我们的代码中使用了Console 静态类,它的定义在 mscorlib.dll 之中,如下图所示。所以,我们也验证了程序集可以包含多个托管模块这一事实。

程序集的结构

因此,我们的程序集结构为:

  • Classes.dll (主模块),包括 PE 文件头、CLR 文件头和清单(显示在上图的右边)。
  • A 和 B 两个托管模块,包括 PE 文件头、CLR 文件头、IL 代码和元数据。

在任何时候,IL 代码和元数据都是存在于托管模块中的。

编译可执行文件

如果我们的工程是类库的话,那么我们的编译到这里就结束了。

但是,我们的工程是控制台工程,所以可以通过指明程序的入口点和程序集,来编译可执行文件。

这里的 Classes.dll 文件代表的是程序集,如果这个程序集由多个托管模块组成,必须保证托管模块文件都存在,否则编译会失败。

Program.cs 则代表程序的入口点:

csc /out:Program.exe /R:Classes.dll Program.cs

我们执行 .exe 文件,会得到预期的结果,如下图所示。

Program运行结果

通常,我们把扩展名为 .dll 或 .exe 的文件称为主模块,扩展名为 netmodule 的文件称为普通模块,程序集则包括主模块和普通模块。

一般情况下,我们看不到扩展名为 netmodule 的文件,这是因为在 Visual Studio 默认生成的 .dll 中,已经将各个普通模块包含进主模块中,所以除了 .dll 并没有其他的模块文件。

在使用 Visual Studio 进行编译时,将执行上述所有过程,包括:

  • 将所有 C# 源代码编译为托管模块(编译结果保存在 obj 目录中)。
  • 将所有托管模块合并为程序集(Visual Studio 只能生成单模块程序集),输出 .dll 或 .exe 文件(结果保存在 bin 目录中),然后,obj 目录中的托管模块就被删除。

程序集清单

我们使用 ildasm 打开生成的 program.exe 文件,并双击清单(MANIFEST),如下图所示。

ildasm打开program.exe文件

可以看到,清单的头部包括当前程序集运行 所必需的所有外部程序集(本例子中就只有一个 mscorlib.dll):

.assembly extern mscorlib
{
  .publickeytoken = (B7 7A 5C 56 19 34 E0 89 )                         // .z/V.4..
  .ver 4:0:0:0
}

每一个被 extern 修饰的 .assembly 都是外部程序集。

.publickeytoken 意味着该程序集是强名称的程序集,而 .ver 则标注了程序集的版本。

清单中的 .assembly[ 当前程序集名称 ] 部分包括一些修饰当前程序集的特性:

.assembly Program
{
  .custom instance void [mscorlib]System.Runtime.CompilerServices.CompilationRelaxationsAttribute::.ctor(int32) = ( 01 00 08 00 00 00 00 00 )
  .custom instance void [mscorlib]System.Runtime.CompilerServices.RuntimeCompatibilityAttribute::.ctor() = ( 01 00 01 00 54 02 16 57 72 61 70 4E 6F 6E 45 78   // ….T..WrapNonEx
                                                                                                             63 65 70 74 69 6F 6E 54 68 72 6F 77 73 01 )       // ceptionThrows.
  // — 下列自定义属性会自动添加,不要取消注释 ——-
  //  .custom instance void [mscorlib]System.Diagnostics.DebuggableAttribute::.ctor(valuetype [mscorlib]System.Diagnostics.DebuggableAttribute/DebuggingModes) = ( 01 00 07 01 00 00 00 00 )
  .hash algorithm 0x00008004
  .ver 0:0:0:0
}

也可以通过使用项目的属性编辑器进行可视化的修改。

单击工具按钮之后,选择“应用程序”中的“程序集信息”即可,如下图所示。

属性编辑器

4) 为什么很少使用多模块程序集

多模块程序集在生成为 .dll 或 .exe 时,所有需要的 .netmodule 文件都必须存在,但在运行时,如果某个托管模块中的代码没有被使用过,则该文件可以不存在。例如,在上面的例子中,如果我们的主程序没有初始化 B 类型的对象:

class Program
    {
        static void Main(string[] args)
        {
            var a = new A();
            var b = new B();
            Console.ReadKey();
        }
    }

那么即使在生成程序集之后,删除 B.module,程序也可以运行。

因此,如果将不常用的代码存入一个单独的托管模块,那么就可以减少程序集的加载时间。

但是,由于 CLR 本来就是在程序需要资源时才会加载对应的程序集,因此,单模块程序集完全可以达到上面所说的效果。

程序集的部署

正如上面所说的,程序集是部署的最小单元,不能部署托管模块。

1) 部署为私有程序集

通常我们使用的都是这种方式。将程序集部署为私有的意味着必须将程序集放置在应用程序所在目录下(这里的目录指的是编译之后程序集所在的位置,例如 /bin/debug下面)。

比如,我们刚才编译的 Classes.dll 可以被其他工程引用,引用之后,Visual Studio 就会将 Classes.dll 复制到该工程的应用程序目录下。

CLR 使用探测(probing)的技术来解析所有需要的外部程序集的位置。

首先,它判断程序集是否是强名称的,如果是的话就去 GAC 查找;否则不去。

然后,如果它没能在应用程序所在目录下以及额外的地方找到程序集,就会尝试去查找具有相同友好名称的可执行程序集(例如,a.dll 的具有相同友好名称的可执行程序集为 a.exe),如果还是没有找到,就会引发运行时异常。如果找到了,就加载这个程序集。

加载可以是隐式的(早期绑定,手动增加引用之后生成项目,外部程序集记录到清单中),也可以是显式的(晚期绑定,运行时动态加载)。

如果你的项目拥有很多私有程序集,你可以在程序目录下建立子目录,然后指示 C# 到一些额外的地方去寻找程序集。

这可通过编辑 App.config 文件来实现:

<configuration>
    <runtime>
        <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.vl">
                <probing privatePath="SubFolderl;SubFolder2;SubFolder3"/>
        </assemblyBinding>
    </runtime>
</configuration>

上面的 XML 中,probing 元素的 privatePath 可以含有多个文件夹,名称用分号分隔。

注意,这些文件夹必须位于应用程序所在目录内部,而不能位于其他地方。也不能在 privatePath 中指定某个绝对路径或相对路径。另外,XML 文件区分大小写。

我们也不能为某个(或某类)程序集指定其所在的位置。

探测过程是针对所有外部程序集的,不能分类。如果你指定了若干个文件夹,探测过程将会一一进行搜索,直到找到第一个匹配的程序集为止。

2) 共享程序集

几乎所有的 C# 程序都需要 mscorlib.dll 的支持,但是我们似乎对这个 .dll 文件非常陌生。

你在应用程序的目录下找不到这个文件,这是因为它是一个共享程序集,被机器上所有的程序共享。

共享程序集位于机器的全局程序集缓存(Global Assembly Cache, GAC)中,它是一个机器级别的程序集,其中包括 mscorlib.dll 等至关重要的程序集。

在添加引用时,它不会被自动包括进来,必须手动浏览才可以找到部署到 GAC 中的程序集。

如果你打算将类库部署到 GAC,—般来说,这个库应当被大量其他工程引用。

GAC 存在于两个地方,第一个地方是 Windows 目录下的 assembly 子目录。第二个地方则存储了 .NET 4 和更高版本的库,位于 Windows 目录下的 Microsoft.NET/assembly/GAC_MSIL。

不能把可执行的程序集部署到 GAC。在部署到 GAC 之前,程序集必须是强名称的。

Visual Studio 不会将强名称程序集拷贝到应用程序的目录下,因为它假定强名称程序集已经被部署到GAC。

3) 将程序集部署到 GAC

将程序集部署到 GAC 之前必须将其变成强名称的。

强名称给予程序集一个独一无二的标识,它包括程序集的名称、版本号、文化和数字签名等。

可以使用 Visual Studio 为程序集添加强名称。定位到工程的属性页面,选择签名,如下图所示。

选择签名

然后,可以为强名称密钥文件提供一个名称(扩展名必须是 snk),还可以选择是否用密码来保护文件,如下图所示。

秘钥文件

在创建完毕之后,就可以在解决方案资源管理器中发现 snk 文件。

每次生成时,该文件会给程序集分配一个强名称。

接下来,就可以将该程序集部署到 GAC 了。部署的方法十分简单,就是使用 gacutil 工具。

首先,定位到程序集所在的目录。然后,使用 -i 参数安装程序集:

gacutil -i Program.exe

注意,这需要管理员权限。gacutil 工具的 -i 参数会列出程序集的列表,-u 则允许你从 GAC 中删除一个程序集。

当配置好程序集之后,在其他工程中便可以引用该程序集的功能。

但是,该程序集不会被自动拷贝到应用程序的目录下。

原创文章,作者:奋斗,如若转载,请注明出处:https://blog.ytso.com/22355.html

(0)
上一篇 2021年7月20日
下一篇 2021年7月20日

相关推荐

发表回复

登录后才能评论