1.CLR的执行模型(通读)
1.1将源代码编译成托管模块
CLR是公共语言运行库(Common Language Runtime)库是很早前的翻译,容易误解 ,又叫公共语言运行时。CLR和Java虚拟机一样也是一个运行时环境,是一个可由多种编程语言使用的运行环境,支持C#、F#、VB、C++、Python、IL汇编等语言,CLR使“混合语言编程”成为可能,因为Microsoft为这些语言创建好了面向CLR的语言编译器。它负责内存管理、程序集加载、安全性、异常处理和线程同步等,并保证应用和底层操作系统之间必要的分离。而 .NET(即 .NET Framework,是C#开发的软件的运行环境)是一种以CLR为基础的软件开发平台,CLR目前作为.NET的一部分提供。
IL代码又叫托管代码,可以理解为IL是CLR的“语言”
,因为CLR管理IL的执行。面向CLR的编译器除了要生成IL,还要生成完整的元数据(metadata)
。元数据是自描述的类型信息,也就是一个数据表集合,主要有三种表,一种表描述了源代码中定义的类型及其成员,即定义表;另一种表描述了源代码中引用的类型及其成员,即引用表;另一种表是清单表,描述了构成程序集的文件、程序集中的文件所实现的public类型、与程序集关联的资源或数据文件,后面会有章节来介绍。由于编译器同时生成IL和元数据,并嵌入最终生成的托管模块(事实上,元数据总是嵌入和IL代码相同的EXE/DLL文件中),所以,元数据和它所描述的IL代码会一直同步。
- 元数据的部分用途:
VS利用元数据智能提示
。“智能感知”技术会解析元数据,告诉你一个类提供了哪些方法、属性、事件和字段,对于方法,还能告诉你需要的参数。- CLR的代码验证过程使用元数据来确保代码只执行“类型安全”的操作。(“1.4.1IL和验证”详讲)
- 元数据允许将对象的字段序列化到内存块,将其发送给另一台机器,然后在另一台机器上反序列化,就可重建对象的状态
元数据允许垃圾回收器跟踪对象生存期
。垃圾回收器从元数据知道该对象中的哪些字段引用了其他对象。 Microsoft的C++编译器默认生成包含非托管(即原生)代码的EXE/DLL模块,并在运行时操纵非托管数据。只有C++编译器才允许开发人员同时写托管和非托管代码,并生成到一个模块中。
1.2将托管模块合并成程序集
CLR和程序集(Assembly)一起工作,对CLR来说,程序集相当于“组件”。程序集是一个或多个托管模块(IL和元数据)或资源文件(.gif,.html等)的逻辑性分组,也是重用、安全性以及版本控制的最小单元。利用程序集这种抽象概念,一组文件就可作为一个单独的实体来对待了。
编译器默认将生成的托管模块转换成程序集,程序集里有清单,也就是清单表,清单是用于将托管模块转换为程序集的,有了清单的存在,程序集的用户就不必关心程序集的划分细节了。另外,清单也使程序集具有自描述性。
1.3加载CLR
- 生成的每一个程序集既可以是EXE,也可以是DLL(由EXE调用),最终由CLR管理这些程序集中代码的执行。
- Windows检查.EXE文件头,决定创建32位或64位进程之后,会在进程地址空间加载MSCorEE.dll,然后,进程的主线程调用MSCorEE.dll中定义的一个方法,这个方法初始化了CLR,加载EXE程序集,再调用其入口方法(Main)。
1.4执行托管程序集的代码
高级语言通常只公开了CLR全部功能的一个子集,然而,IL汇编语言允许开发人员使用CLR的全部功能。
为了执行方法,要把方法的IL在运行时转换成CPU指令(又叫本机代码),这是CLR的JIT(just-in-time或者叫“即时”)编译器的职责
。
上图第5步的解释:回到CLR为引用类型创建的内部数据结构(即Type表),并找到与被调用的方法对应的那条纪录项,修改最初其对JIPCompiler函数的引用(首次调用WriteLine时,JIPCompiler函数会被调用),以使其指向内存块的地址(刚编译好的本机代码)。第5步的作用就是做Cache。 JIPCompiler函数执行完毕后,会回到Main中的代码,并继续执行。
第二次调用同一个方法,就不需要再次调用JIPCompiler函数了,因为JIT编译器将本机代码存储到了动态内存中。JIT首次编译的确会造成性能损失
,但只是一次性的
。以至于JIT编译相比“非托管应用程序”造成的性能损失大,但CLR的JIT编译器还会对代码进行优化,导致托管代码性能可能会更好。
1.4.1IL和验证
IL最大的优势不是它对CPU的抽象,而是应用程序的健壮性和安全性。将IL编译成CPU指令时,CLR会执行一个名为验证的过程,确保代码所做的一切都是安全的。托管模块的元数据包含了验证过程要用到的所有方法及类型信息。
另外,进程数量太多会损害性能,所以,CLR提供了在一个进程中执行多个托管应用程序的能力,每个托管应用程序都在各自的AppDomain中执行(一个进程中允许多个AppDomain)。
1.4.2不安全的代码
C#编译器默认生成安全代码,这种代码的安全性可以验证。也允许写不安全代码来直接操作内存地址和地址处的字节,主要用于:1.与非托管代码进行互操作,2.提升高效率算法的性能。
有人担心IL没有为他们的代码提供足够的保护,换言之,在生成托管模块后,别人可以用IL反汇编器来进行逆向工程,轻松还原应用程序的代码。解决方法:就连CPU指令都能逆向,没有完美的解决方法,不如把自己的代码写优雅点,hhh。
1.5本机代码(CPU指令)生成器:NGen.exe
NGen.exe可以在安装应用时,将IL代码编译成本机代码。但NGen.exe被CLR搞得基本没意义,因为:1.在运行时,CLR要求访问程序集的元数据用于反射和序列化等功能,这就要求发布包含IL和元数据的程序集,所以NGen.exe即使提前生成了IL代码对应的本机代码也并不能保护IL代码;2.如果NGen.exe生成的文件失去同步或着说任何特性不匹配,都会改为使用正常的JIT编译,而不会使用提前编译好的本机代码;3.NGen.exe编译的本机代码性能不如JIT编译的。 总之,NGen.exe比较乏力,仅存的优势:提高应用程序的启动速度。
1.6Framework类库
.NET Framework包含Framework类库,Framework类库是一组dll程序集的统称。由于Framework类库包含的类型数量太多,所以有必要把相关的类型放到单独的命名空间,比如:System命名空间包含Object(基类型)、System.IO、System.Text、System.Threading等类型。
另外,开发人员可轻松自定义命名空间来包含自己的类型。
1.7通用类型系统
CLR一切都围绕类型展开,所以Microsoft制定了一个正式的规范来描述类型的定义和类型的行为,这就是“通用类型系统(CTS)”,CTS制定的规则简单介绍:
- 字段:对象状态一部分的数据变量,根据类型和名称来区分;
- 方法:针对对象执行操作的函数,通常会改变对象状态;
- 属性:对于调用者来说,属性像字段;对于类型的实现者来说,属性像方法;
- 索引器:输入一个值,得到另一个值,有点类似于字典;
- 事件(Event):Event在对象以及其他相关对象之间实现了通知机制;
- 修饰符:protected成员可由派生类型访问;internal成员可由同一个程序集(Assembly)中的代码访问;其他修饰符就略了。
1.8公共语言规范
1.9与非托管代码的互操作性
托管代码能调用dll中的非托管函数,比如:C#应用程序可调用从Kernel32.dll导出的CreateSemaphore函数。
2.生成、打包、部署和管理应用程序及类型(粗读)
当今的应用程序都由多个类型构成,如果使用的语言是面向CLR的,那么,用其中一种语言写的类型就可以使用 用另外一种语言写的类型 作为自己的基类。
很多人可能都遇到过这样的问题:安装新应用时,它可能莫名其妙地破坏了另一个已经安装好的应用程序,这就是所谓的“DLL hell”。
2.2 将类型生成到模块中
本节介绍如何将包含多个类型的源代码文件转变为可以部署的文件。
该应用程序自定义了Program类型,并使用Microsoft提供的System.Console这个外部类型,该类型的各个方法的IL代码存储在MSCorLib.dll(Microsoft Core Library)程序集里。 C#编译器生成的Program.exe文件是标准PE(可移植执行体,Portable Executable)文件,这意味着32位或64位Windows都能加载它。
在命令行里输入:csc.exe /out:Program.exe /t:exe /r:MSCorLib.dll Program.cs
(需要自己配置下 .NET Framework环境变量才能用csc.exe),就可以在Program.cs所在的目录生成Program.exe。其中,/out:Program.exe
是生成名为Program的exe,/t:exe
即/target:exe
表示生成的文件是Win32控制台应用程序类型(即标准PE文件)。由于,C#编译器会自动引用MSCorLib.dll程序集,且/out:Program.exe
和/t:exe
都是C#编译器的默认设定,所以命令可简化为csc.exe Program.cs
2.3 元数据概述
但是,Program.exe里到底有什么?托管PE文件由4部分构成:PE32(+)头、CLR头、元数据以及IL。
元数据是由几个表构成的二进制数据块,元数据表集合有三种(不是三个)表,分别是定义表、引用表、清单表。
一、常用的元数据定义表(部分):
模块定义表ModuleDef:包含对模块进行标识的一个纪录项,该纪录项包含模块文件名和扩展名、模块ID; 类型定义表TypeDef:模块定义的每个类型在类型定义表中都有一个纪录项,每个纪录项都包含类型的名称、基类型、一些标志(public、private等)以及一些索引(这些索引指向MethodDef表中该类型的方法、FieldDef表中该类型的字段、PropertyDef表中该类型的属性、EventDef表中该类型的事件); 方法定义表MethodDef:模块定义的每个方法在方法定义表中都有一个纪录项,每个纪录项都包含方法的名称、一些标志(public、private等)、签名以及方法的IL代码在模块中的偏移量,每个纪录项还引用了ParamDef表中的一个纪录项,ParamDef表中的这个纪录项包括与方法参数有关的更多信息; 字段定义表FieldDef:模块定义的每个字段在字段定义表中都有一个纪录项,每个纪录项都包含标志、类型和名称; 参数定义表ParamDef:XXXX,每个纪录项都包含标志、类型和名称; 属性定义表PropertyDef:XXXX,每个纪录项都包含标志、类型和名称; 事件定义表EventDef:XXXX,每个纪录项都包含标志和名称。
二、常用的引用元数据表(部分):
前言:一个程序集里包含多个模块,一个模块里包含多个类型,一个类型里包含多个成员。
- 程序集引用AssemblyRef:模块引用的每个程序集在这个表中都有一个纪录项,每个纪录项包含绑定该程序集所需的信息等;
- 模块引用ModuleRef:该模块 所引用的每个类型 所在的PE模块 在模块引用表中都有一个纪录项,每个纪录项包含模块的文件名和扩展名。补充:可能是别的模块实现了你需要的类型,这个表的作用便是建立同那些类型的绑定关系;
类型引用TypeRef:模块引用的每个类型在类型引用表中都有一个纪录项,每个纪录项都包含类型的名称和一个引用(指向类型的位置)
成员引用MemberRef:模块引用的每个成员(字段、方法、属性、事件)在这个表中都有一个纪录项,每个纪录项包含成员的名称和签名,并指向对成员进行定义的那个类型的TypeRef纪录项。
可以使用ILDasm.exe来查看托管PE文件中的元数据,挺有意思的。强烈建议多使用ILDasm.exe
,它提供了丰富的信息,你对自己看到的东西理解得越多,对CLR及其功能的理解就越好。
2.4 将模块合并成程序集
Program.exe并非只是一个含有元数据的PE文件,它是程序集。在程序集的所有文件中,有一个文件容纳了清单,清单也是一个元数据表集合。
CLR操作的是程序集,换言之,CLR总是首先加载包含清单元数据表的文件,再根据清单来获取程序集中的其他文件的名称。
程序集大多数时候只有一个文件,就像刚刚提到的Program.exe那样,一个程序集可视为一个EXE或DLL。 Microsoft为啥引入程序集?举例说明:假设程序集都从网上下载,有一个程序集里面有常用类型和不常用类型,那就可以把不常用类型的分离成一个单独的文件,假如客户端永远不使用那些不常用的类型,该文件就永远不会下载到客户端。总之,程序集是进行重用、版本控制和应用安全性设置的基本单元。它允许将类型和资源文件划分到单独的文件中。
- 生成的程序集要么选择现有的PE文件作为清单的宿主,要么创建单独的PE文件并只在其中包含清单。清单元数据表有以下几种:
- 程序集本身信息表AssemblyDef
- 包含的文件表FileDef
- 包含的资源表ManifestResourceDef
- 导出类型表ExportedTypesDef
- 遗憾的是,不能直接从VS中创建多文件程序集,只能用命令行工具创建多文件程序集。
CLR并非一上来就加载所有可能用到的程序集,而是只有在调用的方法确实引用了未加载程序集中的类型时,才会加载程序集。
使用VS将程序集添加到项目中:先打开解决方案资源管理器,再在想添加的项目上点击右键,再添加引用。比如:当前项目可以引用同一个解决方案中的另一个项目创建的程序集(一个解决方案中可以有多个项目)。
2.5 程序集版本资源信息
版本信息如下图:
VS新建C#项目时,会在一个Properties文件夹中自动创建AssemblyInfo.cs文件,该文件除了包含本节讲的所有程序集版本特性,还包含第三章讨论的几个特性,可以直接打开AssemblyInfo.cs修改自己的程序集特有信息。
3.共享程序集和强命名程序集(粗读)
本章将解释.NET框架为了解决版本控制问题而建立的基础结构。