本文是《CLR via C#》(第四版)第1章的要点总结。
下一篇:读书笔记(2)—— 程序集的强名称、部署与查找
目录
1 托管模块与程序集的概念
1.1 托管模块的概念与构成
1.2 程序集
2 CLR 与 .Net Framework 的关系
3 CLR的启动过程
4 程序集的执行
4.1 IL代码的执行
4.2 JIT编译与执行
4.3 安全性验证
5 CTS,CLI,CLS,FCL等的含义与区别
5.1 FCL 与 BCL
5.2 CTS
5.3 CLS
5.4 CTS 与 CLS 的区别
5.5 CLI
5.6 .NET Framework
5.7 .NET Standard
参考资料
所有面向CLR的编译器,都将其对应代码文件编译成一个托管模块(managed module),不管是用那种语言编写的。一个托管模块就是一个标准的 PE32/PE32+ 文件。托管模块只能在CLR环境中执行。
托管模块的结构如下图所示:
元数据本质上就是一系列的数据表,描述了托管模块定义了什么(例如类及其成员)、引用了什么(导入的类型及其成员)等信息。元数据包含了模块所有的结构性信息,因此,我们说托管模块是自描述的,不需要C或C++中的头文件,也不需要注册表来保存额外的信息。实际上,反射或者VS 的 智能提示就是利用了元数据读取程序集的内部信息。
CLR 的执行单元并不是托管模块,而是程序集。程序集是个逻辑概念,是模块/资源文件的分组。它可以是一个单独的模块文件,也可以由多个托管模块文件组成,这取决于编译器或工具。在CLR世界中,程序集就是一个组件,实现程序的重用、安全性以及版本控制。
程序集也是一个(或多个)PE32(+) 文件,与托管模块不同的是,程序集的元数据中包含一个 清单(manifest)表。清单表描述了构成程序集的文件(托管模块、资源文件)以及各个文件中定义的 public 的类型,以及与程序集关联的资源或数据文件。一般情况下,编译器默认将生产的托管模块转成一个程序集。
托管模块扩展名为.netmodule,程序集一定包含一个主托管模块,主托管模块名称为.exe或.dll,其中可以没有代码,但必须有清单(也只有主模块有清单,CLR加载程序集时,首先找到含有manifest的文件,再根据manifest内容找到其他模块或资源)。
例如,对于只含有一个托管模块的程序集来说,其结构如下:
然而对于一个包含多个托管模块的程序集 C.dll 来说,其结构为:
C.dll 清单表中拥有指向各个组成模块以及资源文件的token。注意 C.dll 本身也是一个托管模块,即为程序集的主模块。
如今,CLR 通常作为 .Net Framework 的一部分提供,但 CLR 和 .Net 并不是一回事儿。CLR 是一个运行时环境,而 .Net Framework 是一个软件包。要运行一个托管程序,必须安装CLR,而不一定需要 .Net Framework。不过如今的 windows 都预装了 .Net ,于是CLR 也有了,我们不必操心运行时环境了。
.Net Framework 除了提供 CLR以外,最重要的是为我们开发人员提供了一些基础类库(BCL)和语言编辑器、调试器等工具。
.Net Framework = CLR + BCL + 语言编辑器调试器等工具
CLR 主要提供内存管理、程序集加载、及时编译(将IL编译成平台专用代码)、安全性、异常处理、线程同步等通用功能。因此,可以通俗总结:.Net Framework 提供类库、工具帮助我们写代码并编译成程序集,CLR负责程序集的加载、JIT、运行管理。
CLR 与 .NET 的版本对照关系如下:
一个托管程序集被OS加载到进程中之后,是如何启动CLR虚拟机来运行它的呢?Windows 系统有一个关键的文件对CLR的加载起到了重要作用,他就是 mscoree.dll。这个程序集全名是 Microsoft .Net Runtime Execution Engine,顾名思义他就是加载CLR的引擎。
大致步骤如下:
(1)进程的创建:Windows 检查 exe 文件头,来决定创建 32 位或者 64 位的进程。
(2)加载 mscoree.dll:面向 CLR 的程序集都会依赖这个 mscoree.dll 程序集。Windows 在进程地址空间中加载对应的 mscoree.dll 的x86/x64/ARM版本。如果 Windows 是 x86 或ARM 版,mscoree.dll 在 %SystemRoot%\System32 下;如果 Windows 是64版,64位版本的 mscoree.dll 也在上述目录下以向后兼容,而 x86 版的在 %SystemRoot%\SysWow64 目录中(64位Windows采用 WindowsOnWindows64 技术运行 32 位的程序)。
(3)主线程调用 mscoree.dll 的 _CorExeMain 方法,这个方法是整个程序集的入口方法[参考1]。该方法负责初始化CLR。
(4)CLR的加载:CLR也是一系列的DLL文件。系统可安装多个.Net版本,因此也会有多个CLR。.Net 位于 %SystemRoot%\Microsoft.NET\Framework 或%SystemRoot%\Microsoft.NET\Framework64 路径下。_CorExeMain 方法根据注册表的相关信息(CLR本质是个COM)找到最合适的 CLR版本,调用.net路径下的 MSCorWks.dll(.NET Framework 1.0 | 2.0)或 Clr.dll(.NET Framework 4.0)或 MSCORSVR.DLL(Server版)。这几个程序集就是CLR的主程序。
(5)CLR启动后,创建AppDomain,加载 这个exe 文件,找到其入口方法(main方法),启动并运行这个托管程序。
CLR启动后加载托管程序集,读取其中的IL代码并编译成本机CPU指令。大致步骤如下:
(1)验证 Verification:CLR检查 IL 代码的安全特性,确保程序集不会出现不安全的操作;
(2)分配内存空间;
(3)把IL发送给JIT,把其中一部分翻译成本机代码。其他的IL在需要时被编译,编译后就会存在缓存中,因此只需编译一次。这个编译过程称为“即时(JIT)编译”;
(4) CLR负责监视程序集的运行,进行垃圾回收,数组边界检查,异常处理等工作。
一个IL代码块具体的编译与执行流程:
案例:
过程:
(1)CLR检测 main 代码引用的所有类型(上图中 Console 类型);
(2)加载引用的类型(程序集),并为其创建一个内部结构来存储其方法地址(初始时方法地址都是无效的);
(3)获取调用方法的 IL 并为它分配内存;
(4)调用 JITCompiler 函数,将IL编译成CPU指令,保存在刚才动态分配的内存中;
(5)修改类型的内部结构中该方法引用地址使其指向刚才编译的内存块;
(6)JITCompiler 函数跳转至刚才的内存块执行其中的代码;
(7)执行完返回 main 中的代码,按照相同的流程执行下面的代码。
若一个方法第二次执行,则不必再编译,因此首次执行时编译的本机代码会缓存下来,可以直接使用。因此,虽然托管程序的JIT编译会造成一定的性能损失,但由于只编译一次,这部分的性能损失相对较小。此外,JIT编译会带来一些优势:由于是在运行时编译,运行环境已知,CLR会根据CPU等环境优化编译后的本机代码,从而实现效率的提升。
IL代码和 CLR 一起可构建一个安全的应用程序,保证程序运行时的鲁棒性(健壮性)。可靠性是指系统的正确性,即在确定的输入情况下,有稳定的可预测的输出;而所谓鲁棒性,是指系统对参数变化不敏感,不会受到外界错误的影响。例如,一个程序再运行完后能否正确释放内存,是否对系统外的组件产生影响,这是鲁棒性的一个重要因素。
CLR通过验证(verification)机制,在运行前检查IL代码是否是安全的,由此便可确保程序不会错误地访问内存,从而可以放心地让多个托管应用程序在同一个进程中运行,进而减少进程数量,提高系统效率。这就是AppDomain能够诞生的基础,也是CLR的一个亮点。
FCL是指 Framework Class Library,即Framework类库。它是指.Net Framework提供的一组DLL文件,其中包含很多定义的类库,例如一些命名空间:
FCL中最基础的部分称为基础类库(BCL)。
CLR 一切都围绕着类型运转,CTS就是类型运转的标准。在.NET中,一切数据类型要么是一个 Class(引用类型),要么是一个 Structure(值类型)。
CTS 即通用类型系统,提供了通用类型的定义,描述了类型的定义和行为。这些规范包括:
(1)类型的成员,包括 字段,方法,属性,事件等;
(2)类型的可见性规则和成员的访问规则;
(3)类型的继承、虚方法、对象生存期等其他规则;
(4)制定了基本数据类型等等。
具体可参考[2]。
CTS 定义了如何在 CLR 中声明、使用和管理一个类型,从而实现跨语言交互。CTS 的主要工作包括:
(1)建立了一个规范框架,帮助我们进行跨语言集成、确保类型安全、提高代码运行性能;
(2)提供了一个大多数语言都能完整支持的 面向对象模型;
(3)定义了所有语言都必须遵守的规范,从而实现对象的跨语言交互;
(4)提供了一个基础数据类型库(例如 Boolean, Byte, Char, Int32, and UInt64)。
CTS 定义了两种类型:值类型和引用类型。任何以.NET平台作为目标的语言必须建立它的数据类型与CTS的类型间的映射。
CLS即公共语言规范。它是一个以.NET平台为目标的语言实现语言互操作性的最小的功能集,是CTS的一个子集。特点:
(1)一种语言不是必须遵守CLS
(2)但若要实现语言的互操作,必须在public/protected成员中遵守CLS规范(即CLS兼容)。
(3)可用[assembly:CLSCompliant(true)]特性来让编译器检查代码是否是CLS兼容的。
(4)一些语言相关的委托,构造器,终结器,操作符重载等必须转换成基本字段或方法才能使其他语言访问。例如事件本质是字段,属性本质是方法。面向CLR的编译器会处理这个方面的转换。
具体参考[4]。
CLR 是对CTS 的实现,一个高级语言要想在 .NET/CLR 中运行,必须要遵守CTS。CLR 支持非常多的特性,但并不是每种高级语言都能完整覆盖CLR所提供的特性(但IL可以,所以真正的高手可用IL实现丰富的特性)。如下图所示,不同高级语言覆盖的CLR 特性不同(一个典型例子,CTS中的某些基本数据类型在一些高级语言中不支持)。但要想实现不同语言的互操作行,必须制定一个各个语言都能支持的更小的特性集,即CLS。例如CLS中进一步缩小了基本数据类型的范围(参考[4])。
总之,遵守 CTS 规范可以让不同语言在 .net 平台上顺利运行,然而 若要实现语言间的完全互操作性则必须遵守 CLS 规范。关于语言独立性和语言的互操作性参见[5]。
CLI即公共语言架构,包括CTS、CLS、CIL(即IL)、文件结构、元数据、底层平台访问方式等的综合标准。.Net Framework是CLI在Windows平台下一个完整实现。Mono、.Net Core也是CLI的实现,他们都是为跨平台而生的。
上文中提到,.NET Framework 是 CLI 在 Windows 平台中的一个实现,它提供了 CLR,以及一组内置的标准类库FCL。因此,可以认为 .NET Framework = CLR + FCL。其中,CLR提供了如下功能:
(1)IL 的解析。根据 CTS 规范,CLR 负责读取 .NET 程序中的 IL,并识别其中的模块、类型、成员、方法等;
(2)JIT。IL语言的及时编译,在运行时将可执行文件中的IL实时编译为与硬件相关的机器代码(Native Code);
(3)异常处理机制;
(4)线程管理。CLR 对原生的线程、同步对象进行了封装,称为“托管线程”;
(5)内存管理与GC;
(6)类型安全。CLR 保证了类型安全。
不同平台的 .NET 实现中,提供的 FCL 会有差异,这样不利于代码的移植。因此,微软制定了 .NET Standard 规范,以后所有的 .NET 实现都将遵循 .NET Standard 。说白了,.NET Standard 定义了一组通用类库的接口 API,但是接口具体的实现仍由不同的 .NET平台来负责。这样,只要代码遵循了 .NET Standard,就可以不加修改地运行在支持相同版本 .NET Standard 的所有CLR平台上。我们在 Visual Studio 中可以将项目的目标架构设为 .NET Standard。
更多信息,请参考官方文档。
[1] .NET中的幕后英雄:MSCOREE.DLL
[2] CTS & CLS
[3] CTS
[4] CLS
[5] 语言独立组件与跨语言互操作性
[6] .NET Standard