本文将解释 PE、Windows 加载器、应用程序域、程序集清单、元数据、类型、对象、线程栈、托管堆等,与运行时的相互关系。因此,我首先写了一个简单 Demo 用于调试,其代码如下:
using System;
namespace CLRTest
{
public class Circle
{
public double Radius { get; set; }
public Circle() { }
public Circle(double r)
{
this.Radius = r;
}
public double GetCircumference()
{
return 2 * Math.PI * Radius;
}
public double GetArea()
{
return Math.PI * Math.Pow(this.Radius, 2.0);
}
public override string ToString()
{
return string.Format("半径:{0} 周长:{1} 面积:{2}", this.Radius, this.GetCircumference(), this.GetArea());
}
}
}
using System;
namespace CLRTest
{
class Program
{
static void Main(string[] args)
{
Circle circle = new Circle(4.0);
Console.WriteLine(circle.ToString());
Console.ReadKey();
}
}
}
在Windows上运行的程序可以通过多种不同的方式进行启动。Windows 负责处理所有的相关工作,包括设置进程地址空间、加载可执行程序,以及指示处理器开始执行等。当处理器开始执行程序指令时,它将一直执行下去,直到进程退出。
现在扩充我们对 PE 文件的认识,PE 格式是 Windows 可执行程序的文件格式,可执行程序包括:*.exe、*.dll、*.obj、*.sys 等。为了支持.NET,在 PE 文件格式中增加了对程序集的支持,PE文件格式如下:
为了支持PE映像的执行,在PE的头包含了一个域叫做 AddressOfEntryPoint。这个域表示 PE 文件的入口点(EntryPoint)的位置。在.NET程序集中,这个值指向.text 段中的一小段存根(stub)代码(“JMP _CorExeMain”)。当.NET 编译器生成程序集时,它会在 PE 文件中增加一个数据目录项。具体来说,这个数据目录项的索引为 15,其中包含了 CLR 头的位置和大小。然后,根据这个位置在 PE 文件中找到位于.text 段中的 CLR 头。在 CLR 头中包含了一个结构 IMAGE_COR20_HEADER。在这个结构中包含了许多信息,例如托管代码应用程序入口点,目标 CLR 的主版本号和从版本号,以及程序集的强名称签名等。根据这个结构中包含的信息,Windows 可以知道要加载哪个版本的 CLR 以及关于程序集本身的一些信息。在.text 段中还包含了程序集的元数据表,IL以及非托管启动存根码。非托管启动存根码包含了由 Windows 加载器执行以启动 PE 文件执行的代码。
当 Windows 加载一个.NET 程序集时,mscoree.dll 的_CorExeMain(或者是_CorDllMain,取决于加载的是可执行文件还是库) 函数被第一个调用,以启动 CLR。 mscoree.dll 在启动 CLR 时将执行一系列操作:
(1) 通过查看 PE 文件中的元数据(具体来说是 CLR 头中的 MajorRuntimeVersion 和 MinorRuntimeVersion)找出.NET 程序集是基于哪个版本的 CLR 构建的。
(2) 找出 OS 中正确版本 CLR 的路径。
(3) 加载并初始化 CLR。
在 CLR 被初始化之后,在 PE 文件的 CLR 头中就可以找到程序集的入口点(Main())。然后,JIT 开始编译并执行入口点。
综上所述,.NET 程序集的加载步骤如下:
(1) 执行一个 .NET 程序集。
(2) Windows 加载器查看 AddressOfEntryPoint 域,并找到 PE 文件中的.text 段。
(3) 位于 AddressOfEntryPoint 位置上的字节是一个 JMP 指令,用于跳转到 mscoree.dll 中的一个导入函数。
(4) 将执行控制转移到 mscoree.dll 中的函数 _CorExeMain 中,这个函数将启动 CLR 并把执行控制转移到程序集的入口点。
注意,在 Windows XP 及以后版本中,对加载器进行了优化,使其能够识别出一个 PE 文件,是否是.NET 程序集。这样,在加载一个.NET 程序集时,就不再需要通过存根函数调用 mscoree.dll的导入函数了,而是变为自动加载 CLR。
Windows 使用进程来隔离应用程序,.NET 在此基础上进一步引人了另一种逻辑隔离层,即应用程序域。构造和管理进程的开销是非常高的,应用程序域极大地降低在创建与销毁隔离层时所需的开销。
进程与应用程序域的关系如下:
在任何启动了 CLR 的 Windows 进程中都会定义一个或多个应用程序域,在这些域中包含了可执行代码、数据、元数据结构以及资源等。除了进程本身的保护机制外,应用程序域还进一步引人了以下保护机制:
- 一个应用程序域中的错误代码不会影响到同一个进程中另一个应用程序域中运行的代码。
- 一个应用程序域中的代码不能直接访问另一个应用程序域中的资源。
- 每个应用程序域中都可以配置与代码特定的信息,如安全设置。
对于没有显式创建应用程序域的应用程序来说,CLR 会创建三个应用程序域:系统应用程序域、共享应用程序域、默认应用程序域。
(一) 系统应用程序域
系统应用程序域主要功能如下:
- 创建其它两个应用程序域(共享应用程序域、默认应用程序域)。
- 将 mscoree.dll加载到共享应用程序域中。
- 记录进程中所有其它的应用程序域,包括提供加载、卸载应用程序域等功能。
- 记录字符串池中的字符串常量,因此允许任意字符串在每个进程中都存在一个副本。
- 初始化特定类型的异常。
(二) 共享应用程序域
在共享应用程序域中包含的是与应用程序域无关的代码。mscoree.dll 将被加载到这个应用程序域中,此外还包括在 System 命名空间中的一些基本类型(eg.String、Array等)。在大多数情况下,非用户代码将被加载到共享应用程序域中。启用了 CLR 的应用程序域可以通过加载器的优化属性来注入用户代码。
(三) 默认应用程序域
通常,.NET 程序在默认应用程序域中运行。位于默认应用程序域中的所有代码都只有在这个域中才是有效的。由于应用程序域实现了一种逻辑并且可靠的边界,因此任何跨越应用程序域的访问操作都必须通过.NET 远程对象来进行。
下图显示了本文开头创建的 Demo 的应用程序域信息:
运行应用程序时,CLR 会加载并初始化它。然后 CLR 读取程序集的 CLR 头,查找标识了应用程序入口的方法(Main())的 MethodDefToken。然后,CLR 会搜索 MethodDef 元数据表,找到该方法的 IL 代码在文件中的偏移量,把这些 IL 代码 JIT 编译为本地代码。编译时会对代码进行验证以确保类型安全性。最后,将执行本地代码。在 JIT 编译时,CLR 会检查对类型和成员的所有引用,并加载定义了它们的程序集(如果尚未加载),CLR 必须定位并加载程序集。解析一个引用的类型时,CLR 可能在以下三个地方找到类型:
- 同一个文件
- 不同文件,相同程序集
- 不同文件,不同程序集
解析一个类型引用时如果发送任何错误,如找不到文件、文件无法加载、哈希值不匹配等,就会抛出异常。下图演示了类型绑定的过程:
(注意 ModuleDef、ModuleRef、FileDef 元数据表使用文件名及其扩展名来引用文件。而 AssemblyRef 元数据表使用不带扩展名的文件名来引用程序集。要和一个程序集绑定时,系统通过探测目录尝试定位文件。)
对于 CLR 来说,所有程序集都是根据名称、版本、语言文化、公钥来标识的。但是,GAC 根据名称、版本、语言文化、公钥和 CPU 架构来标识程序集。在 GAC 中搜索程序集时,CLR 判断应用程当前在什么类型的进程中运行(32位、64位)。然后,CLR 首先搜索程序集的这种 CPU 架构专用版本,如果没有找到,就搜索不区分 CPU 的版本。
类型是.NET 程序中的基本编程单元。在.NET 应用程序中,要么使用自定义的类型,要么使用现有的类型。类型分为两类:值类型和引用类型。值类型是指保存在线程栈上的类型,包括:枚举、结构以及简单类型(如 int、bool、char等)。通常,值类型是一些占据内存空间较小的类型。另一种类型叫做引用类型,它是在堆上分配的,并由垃圾回收器(GC)负责管理。在引用类型中也可以包含值类型,在这种情况下,值类型将同样位于堆上并且由垃圾收集器来管理。
托管堆上对象的结构如下:
在托管堆上的每个对象实例中都包含了以下信息:
- 同步块(sync block):同步块可以是一个位掩码,也可以是由 CLR 维持的同步块表中的索引,其中包含了关于对象本身的辅助信息。
- 类型句柄(type handle):类型句柄是 CLR 类型系统的基础单元,可以用来对托管堆上的类型进行完整描述。
- 对象实例:在同步块索引和类型句柄之后紧接着是实际的对象数据。
下图显示了 Demo 的 Circle 对象的内容:
(一) 同步块表
在托管堆上每个对象的前面都有一个同步块索引,它指向 CLR 中私有堆上的同步块表。在同步块表中包含的是指向各个同步块的指针,在同步块中包含了许多信息,如对象的锁、互用性数据、应用程序域索引、对象的散列码(hash code)等。当然,在对象中也可能不包含任何同步块数据,此时的同步块索引值为0。需要注意的是,在同步块中并不一定只包含简单的索引,也可以包含对象的其它辅助信息。
(在使用索引时要注意,CLR 可以自由移动/增长同步块表,同时却不一定对所有包含同步块的对象头进行调整。)
(二) 类型句柄
引用类型的所有实例都被放在托管堆上,这个堆是由 GC 来控制。在所有的实例中都包含了一个类型句柄。简单地说,类型句柄指向的是某个类型的方法表。在方法表中包含了各种元数据,它们完整地描述了这个类型。下图说明了方法表的整体内存布局:
类型句柄是 CLR 类型系统中的粘合剂,它把对象实例及其所有的相关类型数据关联起来。对象实例的类型句柄存储在托管堆上,它是一个指针,指向类型的方法表。在方法表中包含了关于对象类型的大量信息,包括指向其它关键 CLR 数据结构(如 EEClass)的指针。在类型句柄指向的第一类数据中包含了关于类型本身的一些信息(如标志、大小、方法数量、父方法表等)。下一个要注意的域是一个指针,指向一个 EEClass。方法表的下一部分也是一个指针,指向与类型相关的模块信息。在剩余的域中包含了类型的虚方法表。需要注意的是,在方法表中的一些方法指针可能会指向非托管代码。出现这种情况的原因是,一些方法可能还没有被 JIT 编译器编译。事实上,启动编译过程的 JIT 存根代码是一段非托管代码,当方法没有被 JIT 编译器编译时,它会指向这段非托管代码,在编译之后会把执行控制权转移到新编译生成的代码。
(三) 方法描述符
在方法表中包含了虚方法表,里面包含了一些指向隐藏在类型方法背后的代码的指针。虚方法表中包含了指向代码的指针,这些方法本身可以自行描述,这都归功于方法描述符。在方法描述符中包含了关于方法的详细信息,如方法的文本表示、它所在的模块、标记以及实现方法的代码地址。
下图显示了 Demo 的 Circle 对象的方法表及方法描述符:
查看 GetCircumference 方法的 IL:
进一步获取方法的信息:
(四) 模块
查看类型 Circle 所在模块的信息:
(五) 元数据标记
CLR 的元数据以表格的形式存储在运行时引擎中,元数据标记是一个4字节的值,其布局如下:
查看 Circle 的方法表可以看到元数据标记:
值为 02000004 的元数据标记可以解释为:指向类型定义表中的第4个索引。
(六)EEClass
EEClass 数据结构可以看成是方法表的一个逻辑等价物,因此它可以作为实现 CLR 类型系统自描述性的一种机制。本质上,EEClass 和方法表是两种截然不同的结构,但从逻辑来看,它们都表示相同的概念。之所以分成这两种数据结构,是因为 CLR 使用类型域的频繁程度不同。频繁使用的域被保存到方法表中,而不频繁使用的域被保存到 EEClass 中。EEClass 的大体结构如下:
C# 中的层次结构在 EEClass 中同样适用。当 CLR 加载类型时,会创建一个类型的 EEClass 节点层次结构,其中包含了指向父节点和兄弟节点的指针,这样就可以遍历整个层次结构。EEClass 中的方法描述块域,包含了一个指针,指向类型中的第一组方法描述符,这样就能遍历任意类型中的方法描述符。在每组方法描述符中又包含指向链表中下一组方法描述符的指针。
查看 Circle 的 EEClass:
CLR管理的内存主要分为3部分,如下:
- 线程栈 用于分配值类型实例。线程栈主要由操作系统管理,而不受垃圾收集器的控制,当值类型实例所在方法结束时,其存储单位自动释放。栈的执行效率高,但存储容量有限。
- 小型对象堆(SOH) 用于分配小对象实例。如果引用类型对象的实例大小小于85000字节,实例将被分配在SOH堆上,当有内存分配或者回收时,垃圾收集器可能会对SOH堆进行压缩。
- 大型对象堆(LOH) 用于分配大对象实例。如果引用类型对象的实例大小不小于85000字节时,该实例将被分配到LOH堆上,不同于SOH堆,垃圾收集器不会对LOH堆进行压缩。
运行 Demo 时,会启动一个进程,因为程序本身是单线程的所有只有一个线程。一个线程被创建时会分配到 1MB 大小的栈。这个栈的空间用于向方法传递实参,并用于方法内部定义的局部变量。
现在,Windows 进程已经启动,CLR 已经加载到其中,托管堆已初始化,而且已创建一个线程(连同它的 1MB 栈空间)。现在已经进入 Main() 方法,马上就要执行 Main 中的语句,所以栈和堆的状态如下图所示(为了简化示意图,我只画出了自定义的类型):
当 JIT 编译器将 Main() 方法的 IL 代码转换成本地 CPU 指令时,会注意到其内部引用的所有类型。这个时候,CLR 要确保定义了这些类型的所有程序集都已加载。然后利用程序集的元数据,CLR 提取与这些类型有关的信息,并创建一些数据结构来表示类型本身。在线程执行本地代码前,会创建所需的所有对象。下图显示了在 Main 被调用时,创建类型对象后的状态:
当 CLR 确定方法需要的所有类型对象都已创建,而且 Main 的代码已经编译之后,就允许线程开始执行编译好的本地代码。首先执行的是 “Circle circle = new Circle(4.0);”,这会创建一个 Circle 类型的局部变量,并为其赋值。当调用构造函数时,会在托管堆中创建 Circle 的实例。任何时候在堆上新建一个对象 CLR 都会自动初始化内部类型对象指针成员,将它引用与对象对应的类型对象。此外,CLR 会先初始化同步块索引,将对象的所有实例字段设置为 null 或 0,再调用类型的构造器。new 操作符会返回 Circle 对象的内存地址,该地址将保存在局部变量 circle 中(在线程栈上)。此时的状态如下图:
接着执行“Console.WriteLine(circle.ToString());”。ToString() 方法是一个虚方法,在调用虚方法时,JIT 编译器要在方法中生成一些额外的代码,方法每次调用时都会执行这些代码。这些代码首先检查发出调用的变量,然后跟随地址来到发生调用的对象。在本例中,变量 circle 引用的是 Circle 类型的一个对象。然后,代码检查对象内部的“类型句柄”成员,这个成员指向对象的实际类型。然后,代码在类型对象的方法表中查找引用了被调用方法的记录项,对方法进行 JIT 编译(如果需要),再调用 JIT 编译过的代码。就本例来说,调用的是 Circle 类型的 ToString 实现。(在调用非虚方法时,JIT 编译器会找到调用对象的类型对应的类型对象。如果该类型没有定义那个方法,JIT 编译器就会回溯类层次结构,一直回溯到 Object,并在沿途的每个类型中查找该方法。)
WriteLine(string) 是静态方法。调用一个静态方法时,CLR 会定位与静态方法的类型对应的类型对象。然后,JIT 编译器在类型对象的方法表中查找与被调用的方法对应的记录项,对方法进行 JIT 编译(如果需要),再调用 JIT 编译的代码。综上所述,“Console.WriteLine(circle.ToString());”的操作结果如下图所示:
最后,执行“Console.ReadKey();”,与WriteLine(string) 类似,这里就不再赘述。我们可以看到,Circle 类型对象也包含“类型句柄”成员。这是因为类型对象本质上也是对象。CLR 创建类型对象时,必须初始化这些成员。CLR 开始在一个进程中运行时,会立即为 mscorlib.dll 中定义的 System.Type 类型创建一个特殊的类型对象。Circle 类型对象是该类型的实例。因此,在初始化时,Circle 类型对象的类型句柄会初始化为对 System.Type 类型对象的引用。如下图所示:
System.Type 类型对象本身也是一个对象,内部的类型句柄指向它本身。System.Object 的 GetType 方法返回的是存储在指定对象的类型句柄(是一个指针)。