下图展现了编译源代码文件的过程。如图所示,可用支持 CLR 的任何一种语言建立源代码文件。然
后,用一个对应的编译器检查语法和分析源代码。不管选用哪个编译器,结果都是一个托管模块(managed
module)。托管模块是一个标准的 32 位 Microsoft Windows 可移植执行体(PE32)文件 6 ,或者是一个标准
的 64 位 Windows 可移植执行体(PE32+)文件,它们都须要 CLR 才能执行。顺便说一句,托管的程序集总
是利用了 Windows 的数据执行保护(Data Execution Prevention,DEP)和地址空间布局随机化(Address Space
Layout Randomization,ASLR);这两个功能旨在加强整个系统的安全性。后端
PE32 或 PE32+头:标准 Windows PE 文件头,相似于“公共对象文件格式(Common Object
File Format,COFF)”头。若是这个头使用 PE32 格式,文件能在 Windows
的 32 位或 64 位版本上运行。若是这个头使用 PE32+格式,文件只能
在 Windows 的 64 位版本上运行。这个头还标识了文件类型,包括 GUI,
CUI 或者 DLL,并包含一个时间标记来指出文件的生成时间。对于只包
含 IL 代码的模块,PE32(+)头的大多数信息会被忽视。对于包含本地 CPU
代码的模块,这个头包含了与本地 CPU 代码有关的信息安全
CLR 头:包含使这个模块成为一个托管模块的信息(可由 CLR 和一些实用程序
进行解释)。头中包含了须要的 CLR 版本,一些 flag,托管模块入口方
法(Main 方法)的 MethodDef 元数据 token,以及模块的元数据、资
源、强名称、一些 flag 以及其余不过重要的数据项的位置/大小数据结构
元数据:每一个托管模块都包含元数据表。主要有两种类型的表:一种类型的表
描述源代码中定义的类型和成员,另外一种类型的表描述源代码引用的
类型和成员架构
IL(中间语言)代码:编译器编译源代码时生成的代码。在运行时,CLR 将 IL 编译成本地 CPU指令。less
本地代码编译器(native code compilers)生成的是面向特定 CPU 架构(好比 x86,x64 或 IA64)的代码。
相反,每一个面向 CLR 的编译器生成的都是 IL(中间语言)代码。IL 代码有时称为托管代码,由于 CLR 要管理它的执行。dom
除了生成 IL,面向 CLR 的每一个编译器还要在每一个托管模块中生成完整的元数据。简单地说,元数据
(metadata)是一组数据表。其中一些数据表描述了模块中定义的内容,好比类型及其成员。还有一些元
数据表描述了托管模块引用的内容,好比导入的类型及其成员。元数据是一些老技术的超集。这些老技术
包括 COM 的“类型库(Type Library)”和“接口定义语言(Interface Definition Language,IDL)”文件。要
注意的是,CLR 元数据远比它们完整。另外,和类型库及 IDL 不一样,元数据老是与包含 IL 代码的文件关联。
事实上,元数据老是嵌入和代码相同的 EXE/DLL 文件中,这使二者密不可分。因为编译器同时生成元数据
和代码,把它们绑定一块儿,并嵌入最终生成的托管模块,因此元数据和它描述的 IL 代码永远不会失去同步。
元数据有多种用途,下面仅列举一部分。
* 编译时,元数据消除了对本地 C/C++头和库文件的需求,由于在负责实现类型/成员的 IL 代码文件
中,已包含和引用的类型/成员有关的所有信息。编译器可直接从托管模块读取元数据。
* Microsoft Visual Studio 使用元数据帮助你写代码。它的“智能感知(IntelliSense)”技术能够解析
元数据,指出一个类型提供了哪些方法、属性、事件和字段。若是是一个方法,还能指出方法需
要什么参数。
* CLR 的代码验证过程使用元数据确保代码只执行“类型安全”的操做。(稍后就会讲到验证。)。
* 元数据容许将一个对象的字段序列化到一个内存块中,将其发送给另外一台机器,而后反序列化,
在远程机器上重建对象的状态。
* 元数据容许垃圾回收器跟踪对象的生存期。垃圾回收器能判断任何对象的类型,并从元数据知道
那个对象中的哪些字段引用了其余对象函数
CLR 实际不和模块一块儿工做。相反,它是和程序集一块儿工做的。程序集(assembly)是一个抽象的概念,
初学者每每很难把握它的精髓。首先,程序集是一个或多个模块/资源文件的逻辑性分组。其次,程序集是
重用、安全性以及版本控制的最小单元。取决于你对于编译器或工具的选择,既能够生成单文件程序集,
也能够生成多文件程序集。在 CLR 的世界中,程序集至关于一个“组件”。工具
下图有助于理解程序集。在这幅图中,一些托管模块和资源(或数据)文件准备交由一个工具处理。
该工具生成单独一个 PE32(+)文件来表示文件的逻辑性分组。实际发生的事情是,这个 PE32(+)文件包含一
个名为“清单”(manifest)的数据块。清单是由元数据表构成的另外一种集合。这些表描述了构成程序集的文件,由程序集中的文件实现的公开导出的类型 7 ,以及与程序集关联在一块儿的资源或数据文件。布局
默认是由编译器将生成的托管模块转换成程序集。换言之,C#编译器生成含有清单的一个托管模块。
清单指出程序集只由一个文件构成。性能
你生成的每一个程序集既能够是一个可执行应用程序,也能够是一个 DLL(其中含有一组由可执行程序
使用的类型)。固然,最终是由 CLR 管理这些程序集中的代码的执行。这意味着必须在目标机器上安装好.NET
Framework。
C#编译器生成的程序集要么包含一个 PE32 头,要么包含一个 PE32+头。除
此以外,编译器还会在头中指定要求什么 CPU 架构(若是使用默认值 anycpu,则不明确指定)。Microsoft
发布了 SDK 命令行实用程序 DumpBin.exe 和 CorFlags.exe,可用它们检查编译器生成的托管模块所嵌入的信
息。
为了执行一个方法,首先必须把它的 IL 转换成本地 CPU 指令。这是 CLR 的 JIT (just-in-time 或者“即时”)
编译器的职责。下图展现了一个方法首次调用时发生的事情
就在 Main 方法执行以前,CLR 会检测出 Main 的代码引用的全部类型。这致使 CLR 分配一个内部数据
结构,它用于管理对所引用的类型的访问。在图中,Main 方法引用了一个 Console 类型,这致使 CLR分配一个内部结构。在这个内部数据结构中,Console 类型定义的每一个方法都有一个对应的记录项 10 。每一个
记录项都容纳了一个地址,根据此地址便可找到方法的实现。对这个结构进行初始化时,CLR 将每一个记录
项都设置成(指向)包含在 CLR 内部的一个未文档化的函数。我将这个函数称为 JITCompiler。
JITCompiler 函数被调用时,它知道要调用的是哪一个方法,以及具体是什么类型定义了该方法。而后,
JITCompiler 会在定义(该类型的)程序集的元数据中查找被调用的方法的 IL。接着,JITCompiler 验证 IL 代
码,并将 IL 代码编译成本地 CPU 指令。本地 CPU 指令被保存到一个动态分配的内存块中。而后,JITCompiler
返回 CLR 为类型建立的内部数据结构,找到与被调用的方法对应的那一条记录,修改最初对 JITCompiler 的
引用,让它如今指向内存块(其中包含了刚才编译好的本地 CPU 指令)的地址。最后,JITCompiler 函数跳
转到内存块中的代码。这些代码正是 WriteLine 方法(获取单个 String 参数的那个版本)的具体实现。这些
代码执行完毕并返回时,会返回至 Main 中的代码,并跟往常同样继续执行。
如今,Main 要第二次调用 WriteLine。这一次,因为已对 WriteLine 的代码进行了验证和编译,因此会
直接执行内存块中的代码,彻底跳过 JITCompiler 函数。WriteLine 方法执行完毕以后,会再次返回 Main。
下图展现了第二次调用 WriteLine 时发生的事情。
一个方法只有在首次调用时才会形成一些性能损失。之后对该方法的全部调用都以本地代码的形式全
速运行,无需从新验证 IL 并把它编译成本地代码。
JIT 编译器将本地 CPU 指令存储到动态内存中。一旦应用程序终止,编译好的代码也会被丢弃。因此,
若是未来再次运行应用程序,或者同时启动应用程序的两个实例(使用两个不一样的操做系统进程),JIT 编
译器必须再次将 IL 编译成本地指令。
对于大多数应用程序,因 JIT 编译形成的性能损失并不显著。大多数应用程序都会反复调用相同的方法。
在应用程序运行期间,这些方法只会对性能形成一次性的影响。另外,在方法内部花费的时间颇有可能比
花在调用方法上的时间多得多。
还要注意的是,CLR 的 JIT 编译器会对本地代码进行优化,这相似于非托管 C++编译器的后端所作的工
做。一样地,可能要花费较多的时间来生成优化的代码。可是,和没有优化时相比,代码在优化以后将获
得更出色的性能。
有两个 C#编译器开关会影响代码的优化:/optimize 和/debug。下面总结了这些开关对 C#编译器生成
的 IL 代码的质量的影响,以及对 JIT 编译器生成的本地代码的质量的影响。
虽然这样说很难让人信服,但许多人(包括我)都认为托管应用程序的性能实际上超过了非托管应用
程序。有许多缘由使咱们对此深信不疑。例如,当 JIT 编译器在运行时将 IL 代码编译成本地代码时,编译
器对执行环境的认识比非托管编译器更加深入。下面列举了托管代码相较于非托管代码的优点:
JIT 编译器能判断应用程序是否运行在一个 Intel Pentium 4 CPU 上,并生成相应的本地代码来利用
Pentium 4 支持的任何特殊指令。相反,非托管应用程序一般是针对具备最小功能集合的 CPU 编译
的,不会使用能提高应用程序性能的特殊指令。
JIT 编译器能判断一个特定的测试在它运行的机器上是否老是失败。例如,假定一个方法包含如下
代码:
if (numberOfCPUs > 1) {
...
}
若是主机只有一个 CPU,JIT 编译器不会为上述代码生成任何 CPU 指令。在这种状况下,本地代码
将针对主机进行优化,最终的代码变得更小,执行得更快。
应用程序运行时,CLR 能够评估代码的执行,并将 IL 从新编译成本地代码。从新编译的代码能够
从新组织,根据刚才观察到的执行模式,减小不正确的分支预测。虽然目前版本的 CLR 还不能作
到这一点,但未来的版本也许就能够了。
除了这些理由,还有另外一些理由使咱们相信在执行效率上,将来的托管代码会比当前的非托管代码更
优秀。大多数托管应用程序目前的性能已至关不错,未来还有望进一步提高。
L 是基于栈的。这意味着它的全部指令都要将操做数压入(push)一个执行栈,并从栈弹出(pop)结
果。因为 IL 没有提供操做寄存器的指令,因此人们能够很容易地建立新的语言和编译器,生成面向 CLR 的
代码。
IL 指令仍是“无类型”(typeless)的。例如,IL 提供了一个 add 指令,它的做用是将压入栈的最后两
个操做数加到一块儿。add 指令不分 32 位和 64 位版本。add 指令执行时,它判断栈中的操做数的类型,并执
行恰当的操做。
我我的认为,IL 最大的优点并不在于它对底层 CPU 的抽象。IL 提供的最大的优点在于应用程序的健壮
性 11 和安全性。将 IL 编译成本地 CPU 指令时,CLR 会执行一个名为验证(verification)的过程。这个过程会
检查高级 IL 代码,肯定代码所作的一切都是安全的。例如,验证会核实调用的每一个方法都有正确数量的参
数,传给每一个方法的每一个参数都具备正确的类型,每一个方法的返回值都获得了正确的使用,每一个方法都有
一个返回语句,等等。在托管模块的元数据中,包含了要由验证过程使用的全部方法和类型信息。
使用.NET Framework 配套提供的 NGen.exe 工具,能够在一个应用程序安装到用户的计算机上时,将 IL代码编译成本地代码。因为代码在安装时已经编译好,因此 CLR 的 JIT 编译器不须要在运行时编译 IL 代码,这有助于提高应用程序的性能。NGen.exe 能在两种状况下发挥重要做用: 加快 应用程序的启动速度 运行 NGen.exe 能加快启动速度,由于代码已编译成本地代码,运行时不须要再花时间编译。 减少应用程序的工做集 13 若是一个程序集会同时加载到多个进程中,对该程序集运行 NGen.exe可减少应用程序的工做集(working set)。NGen.exe 会将 IL 编译成本地代码,并将这些代码保存到一个单独的文件中。这个文件能够经过“内存映射”的方式,同时映射到多个进程地址空间中,使代码获得了共享,避免每一个进程都须要一份单独的代码拷贝。