第一章 CLR 的执行模型

CLR via C# 读书笔记:第一章 CLR 的执行模型(1)

部分CLR基础。这部分为三章(第一章:CLR的只想能够模型,第二章:生成、打包、部署和管理应用程序及类型,第三章:共享程序集和强命名程序集)大部分都是概念性的东西,看起来比较的枯燥无味,由于都是概念性的东西所以也比较难于理解,不过这些知识也是后面深入的前提,要想深入的了解.net平台及C#语言,这些概念性的知识也是必须懂得的。废话不多说了,咱们还是硬着头皮一起来看看第一章吧!
由于一章内容较多,我会分开来写。
本篇内容大纲:

  1. 将源代码编译成托管模块
  2. 将托管模块合并成程序集
  3. 加载公共语言运行时
  4. 执行程序集的代码

一、将源代码编译成托管代码

1、公共语言运行时(Common Language Runtime,CLR)

CLR 是一个运行环境,一个可由多种编程语言使用的运行环境。CLR的核心功能包括:如内存管理、程序集加载、安全性、异常处理和线程同步等,可由面向CLR的所有语言使用。CLR是.NET Framework的主要执行引擎。
事实上,在程序运行时,CLR并不关心开发人员使用的是哪一种编程语言。因为我们使用任何一种支持CLR的编程语言创建源代码,最后都会被对应的编译器编译成一个托管模块(managed module)。如下图所描述:
第一章 CLR 的执行模型
托管模块 是一个标准的32位的Windows 可移植执行体(Portable Executable)文件简称(PE32),或者是个64位的(PE32+)文件。他们都需要CLR才能执行。
下表展示了一个托管模块的各个组成部分

组成部分 说明
PE32或PE32+ 头 标准windows PE 文件头,类似于“公共对象文件格式”(Common Object File Format,COFF)头。如果这个头使用PE32格式,文件能在Windows 的32位和64位版本上运行。如果这个头使用PE32+格式,文件只能在Windows的64位版本上运行。这个头还标识了文件类型,包括 GUI,CUI或者DLL,并包含一个时间标记来指出文件的生成时间。对于只包含IL代码的模块,这个头包含了与本地CPU代码有关的信息。
CLR 头 包含使这个模块成为一个托管模块的信息(可由CLR和一些实用程序进行解释)。头中包含了需要的CLR版本,一些标志(Flag),托管模块的入口方法(Main方法)的MethodDef 元数据标记(Token),以及模块的元数据、资源、强名称、一些flag以及其他不太重要的数据项的位置/大小。
元数据 每个托管模块都包含元数据表。主要有两种类型的表:一种类型的表描述源代码中定义的类型成员;另一种类型的表描述源代码中引用的类型成员。
IL(中间语言)代码 编译器编译源代码时生成的代码。在运行时,CLR将IL代码编译成本地CPU指令。

2、元数据

每个面向CLR的编译器生成的都是IL(Intermediate language)中间语言代码。IL代码有时也成为托管代码,因为CLR要管理它的执行。除了生成IL代码,面向CRL的每个编译器还要在每个托管模块中生成生成完整的元数据。我们知道IL是各种面向CLR的编程语言生成的统一由CLR管理执行的中间语言,那元数据又是什么呢?简单的说,元数据(metadata)是一组数据表。其中一些数据表描述了托管模块中定义的内容,比如模块中定义的类型、成员等。还有一些数据表描述了托管模块中导入的类型及成员。正如上面表格中的描述的一样,元数据表分为两种类型,一种类型的表是描述模块自身源代码中定义的类型和成员;另一种类型是描述引用的类型和成员。总之元数据就是要用来描述我们源代码的一种数据表。这里的源代码是指的编译后IL代码,元数据总是与它描述的IL代码关联在一起。事实上,元数据总是嵌入和代码相同的exe/dll文件中,这使元数据和它所描述IL代码密不可分。由于编译器同时生成元数据和IL代码,把他们绑定在一起,并嵌入最终生成的托管模块,所以元数据和它描述的IL代码永远都不会失去同步。元数据有多种用途,下面列出一部分常用的用途

元数据部分用途

  • 编译时,元数据消除了对本地C/C++头和文件的需求,因为在负责实现类型/成员的IL代码文件中,已经包含了引用的类型/成员的全部信息。编译器可以从托管代码中读取元数据。
  • 我们强大的VS 编辑器使用元数据帮助我们写代码。它的”智能感知“(IntelliSense)技术可以解析元数据,指出一个类型提供了哪些方法、属性、事件、和字段。如果是一个方法,还能指出该方法需要什么参数。
  • CLR 的代码验证过程使用元数据确保代码只执行”类型安全”的操作。(稍后会讲到)
  • 元数据允许将一个对象的字段序列化到一个内存块中,将其发送到另一台机器,然后反序列化,在远程机器上重建该对象。
  • 元数据允许垃圾回收器跟踪对象的生存期。垃圾回收器能判断任何对象,并从元数据中知道那个对象中的哪些字段引用了其他对象。

二、将托管模块合并成程序集

CLR实际上是不和模块一起工作的。相反,它是和程序集一起工作的。程序集(Assembly)是一个抽象的概念,首先,程序集是一个或多个模块/资源文件的逻辑性分组。其次,程序集是重用、安全性、及版本控制的最小单元。取决于你对编译器或工具的选择,既可以生成单文件程序集,也可以生成多文件程序集。在CLR的世界中,程序集相当于一个“组件"。第二章会深入讲解程序集,这里就不花费太多的篇幅了,我们只需要知道:利用程序集这种概念性的东西,可以将一组文件当作一个单独的实体来对待。下图展示了程序集是怎样将模块和资源文件合并的
第一章 CLR 的执行模型

默认情况下,编译器实际会把生成的托管模块转换成程序集。所以,假如项目只有一个托管模块,没有资源文件,那么程序集就是托管模块,而且在生成过程中不需要采取任何额外的步骤。但是如果希望将一系列文件合并到一个程序集中,及必须掌握更多的工具(比如程序集连接器AL.exe)以及他们的命令选项。第二章会解释这些工具和选项。

在程序集的模块中,还包含与引用的程序集有关的信息(包括它们的版本号)。这些信息使得程序集能够自描述(self-describing)。

三、加载公共语言运行时

1、.NetFramework

我们生成的程序集既可以是一个可执行的应用程序(如winform 程序,控制台程序等),也可以是一个DLL(dynamic link Library)动态连接库。最后都是又CLR来管理这些程序集中的代码执行,这意味着必须在目标机器上安装好.NET Framework。要是知道目标机器上是否已安装.NET Framework,只需要检查 %SystemRoot%\System32 目录中是否有 MSCorEE.dll。然而一台机器上可能同时装有好几个版本的.NET Framework,要确切的知道已经安装了那些版本,请检查以下注册表的子项:
KEY_LOCAL_MACHINE\SOFTWARE\Microsoft\NET Framework Setup\NDP.
如果目标机器上装有 VS 还可以找到Visual Studio 命令提示 运行 CLRVer命令,即可列出当前机器上安装的所有CLR版本。

四、执行程序集

如前所述,托管程序集同时包含元数据和IL。IL是与CPU无关的机器语言,是Microsoft 在请教了外面的几个商业及学术性语言/编译器的作者之后,费尽心思开发出来的。IL表大多数CPU机器语言都要高级。通常,开发人员会用C#、VB、C++等高级语言来编程。这些高级语言的编译器会将代码生成IL中间代码,再交由CLR来管理执行。和其他机器语言一样,IL也能使用汇编语言来写,Microsoft甚至专门提供了一个名为ILAsm.exe的IL汇编器和一个名为 ILDasm.exe的IL反汇编器。

注意,高级语言通常只公开了CLR的一部分功能,而 IL 汇编语言允许开发人员访问CLR的所有功能。所以如果你选择的一种编程语言隐藏了你迫切需要的一个CLR的功能,你可以换用IL汇编语言或者提供了所需功能的另一种编程语言来写那部分代码。

1、方法的首次调用

为了执行一个方法,首先必须把它的IL代码转换成本地CPU指令。这是CLR的JIT(Just in time)即使编译器的职责。
下图展示是了一个方法首次调用发生的事情:
第一章 CLR 的执行模型

图1-方法的首次调用

上图解析:

1、在Main方法执行前,CLR会检测出Main方法代码引用的所有类型(上图Main方法引用了Console类型),并分配一个内部数据结构,用于管理对所引用的类型的访问。在这个内部数据结构中,Console类型的每个方法都有一个对应的记录项,每个记录项都容纳了一个地址,根据这个地址可以找到方法的实现。

2、CLR对这个内部结构初始化时,会将每个方法对应的记录项都设置成(指向)包含在CLR内部的一个未文档化的函数,我们称这个函数为 JITCompiler(即时编译器)。

3、当我们调用Console对应的方法时,会进到第一步中CLR分配的内部数据结构中,调用方法对应的记录项指向的JITCompiler,JITCompiler函数知道当前调用的是哪个方法,以及定义该方法的是哪个类型。然后,JITCompiler会在定义该类型的程序集的元数据中查找被调用的方法的IL代码,接着,JITCompiler验证IL代码,并将IL代码编译成本地CPU指令。动态分配一块内存空间,将刚编译的本地CPU指令保存进去。

4、JITCompiler返回到 CLR为类型创建的内部数据结构,找到与被调用方法对应的那一条记录,修改最初对JITCompiler的引用,让它现在指向刚分配的内存块(其中包含刚才编译好的本地CPU指令)地址。这些代码执行完毕返回时,就会返回到Main中的代码,并像往常一样继续执行。

以上是一个迭代的过程。每第一次执行一个方法,就会经过上面的几步。

2、方法的第二次调用

当Main方法第二次调用 WriteLine时。这一次由于已对WriteLine的代码进行了验证和编译,所以会直接执行内存块中的编译好的本地CPU指令,完全跳过JITCompiler函数。WriteLine方法执行完毕之后,会再次返回 Main方法。下图 展示了方法的第二次调用

第一章 CLR 的执行模型

图2-方法的第二次调用

由此可见,一个方法只有在首次调用时才会造成一些性能损失。以后对该方法的所有调用都是以本地代码的形式全速运行,无需重新验证IL并编译成本地代码。
JIT编译器将本地CPU指令存储到动态内存中。一旦应用程序终止,编译好的代码也会被丢弃。所以再次运行应用程序,JIT编译器必须再次将IL代码编译成本地指令。

3、JIT编译器对本地代码的优化

有两个C#编译器开关会影响代码的优化:/optimize 和 /debug。下表总结了这些开发对 C# 编译器生成的IL代码的质量的影响,以及对JIT编译器生成的本地代码的质量的影响。

编译器开关设置 C# IL 代码质量 JIT 本地代码质量
/optimize-   /debug-(默认) 未优化 有优化
/optimize-   /debug(+/full/pdbonly) 未优化 未优化
/optimize+  /debug(-/+/full/pdbonly) 有优化 有优化

在Visual Studio 中新建一个 C# 项目时,项目的“调试”(Debug)配置指定的是/optimize-和/debug:full 开关,而 “发布”(Release)配置指定的是/optimize+和/debug:pdbonly开关。

作者:邹毅
如果觉得本文让你有所收获,请键点击右下角的 推荐 按钮
本文版权归作者和博客园共有,欢迎转载,但必须保留原文连接。

 
上一篇:Haskell语言学习笔记(23)MonadReader, Reader, ReaderT


下一篇:Storm介绍及核心组件和编程模型