CLR的执行模型

文章导读

1.将源代码编译为托管模块

2.将托管模块合并为程序集

3.加载CLR

4.执行程序集代码

5.Framework类库---FCL

6.通用类型系统---CTS

7.公共语言规范---CLS

什么是CLR

简单的翻译过来:公共语言运行时。

这家伙跟使用那种编程语言无关,只要你的编译器是面向CLR的就行,他跟随.net framework 一起安装。

当然了,只有托管模块的运行才需要CLR,非托管代码生成的模块,那就另当别论了!

目前,微软已经编写出了多种面向CLR的语言编译器,比如:微软C/C++,C#,VB,F#,以及IL。

好了,知道了什么是CLR,接下来就开始探索程序背后的秘密吧!

出于个人原因,本系列文章中的代码语言,以及开发工具均为C#,VisualStudio 2010

将源代码编译为托管模块

我们用自己擅长的语言完成代码的编写之后,编译器首先要把源代码编译成一个托管模块。

无论使用哪种编译器,最终的结果都是一个托管模块

那么什么是托管模块呢?

托管模块:简单来说就是一个标准的windows PE文件,32位程序就是PE32,64位程序就是PE32+。

托管模块包含的内容:

  PE32或PE32+头,这个头标识了文件是GUI,CUI还是DLL,以及和本地CPU代码有关的信息;

  CLR头,这个头里面包含了使一个模块成为一个托管模块所需的信息,他包含CLR版本,Main方法的MethodDef元数据标记等信息;

  元数据,元数据就是一组数据表的集合,主要包含两种表:描述模块中定义的类型和成员,描述模块引用的类型和成员;

  IL代码(注意:IL代码存在于托管模块中),编译器将IL代码编译成本地的CPU指令;

注意:本地代码编译器生成的是面向特定CPU架构的代码;面向CLR的编译器生成的都是IL代码。

通常情况下,编译器生成的元数据总是嵌入到和IL代码相同的EXE/DLL文件中,这样就保证了这两种数据总是保持同步。

说到这里,我们顺便再说一下[元数据]的作用:

  首先,有了元数据,编译器可以直接从托管模块中读取元数据,消除了对本地C/C++头和库文件的需求;

  然后,也是我们最常用的一个功能:VisualStudio的智能感知,通过解析元数据,可以指出一个类型提供了哪些的字段,属性,方法,如果是方法的话还能指出方法所需的参数类型;

  还有,CLR的代码验证也是通过元数据来确保你的代码只能执行类型安全的操作;

  再者,有了元数据,类型中的字段也可以被序列化到内存块儿中,通过网络发送到另外一台机器,反序列化之后重建类型对象;

  最后,元数据还允许垃圾回收器跟踪对象的生命周期,以确定何时进行垃圾回收;

题外话:在所有的面向CLR的编译器中,只有C/C++编译器最特殊,因为他既能编译托管代码,也能编译非托管代码;

将托管模块合并为程序集

前面说了那么多托管模块,其实,CLR并不直接和托管模块关联,CLR直接面向的是程序集。

程序集是一个抽象的感念,简单来说,他就是对模块和资源文件进行的逻辑分组;同时,程序集也是程序重用,安全和版本控制的最小单元;在CLR的世界里,程序集就是一个组件。

编译器先是把源代码编译生成托管模块,然后再把托管模块合并为一个程序集文件,这个程序集文件其实也是一个标准的PE文件(没错,我们前面说过,托管模块也是一个PE文件,这是正确的!!!),与托管模块类似,程序集文件也包含一组数据块儿,称作[清单],这个清单就是另外一组简单的元数据表,这些表记录了组成这个程序集的文件信息(托管模块)以及这些文件中定义的公共类型。然后,这些清单文件还记录了与这个程序集相关联的资源或数据文件信息。

注意了:托管模块是由编译器编译而来的,程序集是由托管模块文件以及资源文件等[合并]而来的;

加载CLR

每个程序集都被生成为一个可执行应用程序,或是DLL文件(包含一组被可执行应用程序使用的类型文件),CLR的任务就是管理这些程序集中的代码执行,所以,在执行这些文件之前,我们必须确保.net framework已经在我们的机器上正确安装。

在继续探索之前,我们先了解一下32位系统和64位系统。如果程序集中只包含类型安全的托管代码,那么,这些代码文件将能够在任何安装了.net framework的操作系统中运行。

但是,有时候,我们写的代码只希望在特定的windows版本中运行,为了实现这个目标,C#编译器提供了一个/platform命令行开关(在属性,生成,目标平台中),通过这个开关,我们就能指定我们的程序只能在特定的平台(CPU架构)中运行;

windows在执行一个可执行文件时,会先查看文件的头信息,以确定程序是要32位还是64位的地址空间,同时windows还会检查头文件中的CPU架构信息,以确保计算机的CPU架构与程序的CPU类型匹配。

在64位的windows操作系统中,还提供了一种称作“WoW64”(windows on windows64)的技术,这种技术通过模拟x86指令集,使那些包含了x86本地代码的32位应用程序运行在Itanium机器上,但是这样做的的性能损失也是比较大的!

在检查完程序集的头信息之后,windows会创建用于该程序的进程,windows加载MSCorEE.dll程序集到进程的地址空间中,然后进程的主线程会调用定义在MSCorEE.dll中的方法,该方法初始化(如果CLR为初始化)CLR,加载exe程序集,接着调用其入口函数:Main,此时,托管应用程序开始执行!

总结来说,一个应用程序的执行过程可以分为以下几个步骤:

  1.windows检测EXE文件头,是32?64?WoW64?;

  2.创建对应版本的windows进程;

  3.在(2)中创建的进程里面加载对应版本的MSCoreEE.dll;

  4.由(2)创建的进程主线程调用MSCoreEE.dll中定义的方法,暂时称为MethodA(注意:真实dll中并不存在此方法名);

  5.MethodA初始化CLR,加载EXE程序集;

  6.调用Main()方法;

注意:使用x86开关编译的dll无法在非64位windows系统的64位进程中运行,但是可以在64位windows系统中的64位进程中作为WoW64应用程序运行;

执行程序集代码

为了执行一个方法,必须把该方法的IL代码转换成本地CPU指令,而这一功能是由CLRJIT编译器(记着这个NB的[编译器])提供的;

在执行Main方法之前,CLR会检测Main方法中的所有引用类型,并为这些类型分配一个内部数据结构,该数据结构用于管理对所有引用类型的访问;

举例来说,如果Main方法中调用了Console类的WriteLine(string message)方法,那么WriteLine方法是这样执行的:

  1.CLR分配[内部数据结构],暂且称其为ObjectA吧,Console类型中的每个方法在ObjectA中都有一个对应的记录项,每个记录项都指向一个[CLR内部未文档化的函数:  JITCompiler],这个函数就是前面说的NB的[编译器]

  2.WriteLine方法第一次调用时,JITCompiler函数会被调用,JITCompiler函数在程序集的元数据中查找WriteLine方法的IL代码;

  3.JITCompiler函数验证IL代码,将IL代码编译为本地CPU指令,并且将这些CPU指令保存到一个动态分配的内存块中;

  4.JITCompiler函数返回ObjectA,找到WriteLine方法对应的记录项,修改其最初对JITCompiler函数的指向,让它现在指向(3)中的内存块的地址;

  5.JITCompiler函数跳转到内存块中的代码,执行这些代码;

  6.代码执行完成,JITCompiler函数重新返回到Main方法中,继续执行下面的代码;

在同一个应用程序进程中,一个方法只有在首次执行的时候才会调用JITCompiler函数,JITCompiler函数将本地CPU代码保存到内存块中,以后对该方法的重复调用都会使用同一份本地CPU代码,但是,一旦应用程序终止,编译好的CPU代码也会被丢弃!

到这里,我们应该会明白,托管代码(比如我们用的C#)在真正执行之前是经历了两次编译的:

  1.由对应的语言编译器(C#编译器)编译为包含IL代码的托管模块;

  2.由JIT编译器(JITCompiler函数)将程序集中的IL代码编译为本地CPU指令;

经过了这两个步骤,托管代码就可以畅快的执行了!

可是,有人就要问了,非托管代码(C/C++)都是针对一种具体的CPU平台编译的,一旦调用,就能直接执行;托管代码却要编译两次,那托管程序是不是性能就差呢?是的,经历两次编译之后,确实会产生一些额外的性能开销。这点是我们所有使用托管代码的人必须承认的!

但是,CLR的JIT编译器会针对本地代码进行优化,尽可能将这些性能损失降到最低,而且,在某些情况下,经过优化的代码性能还要好过非托管代码(本人为验证,水平不够!!!)。我们也可以使用.net framework提供的NGen.exe本地代码生成器预生成一份本地代码,来提高程序的性能,;

前面讲到JIT编译器会将IL再编译为本地CPU代码,其实,在进行这次编译之前,CLR会执行一个名为[验证]的过程,这个过程会检查高级IL代码,确保代码所做的一切操作都是安全可靠的。

在windows中,每一个进程都有它自己的地址空间,这样每个应用程序就会占用一些地址空间,如果同时运行多个程序的话,势必会造成性能浪费,但是,CLR提供了一种很NB的能力:让多个托管应用程序在同一个系统进程中执行。在这种情况下,每个托管应用程序都在系统进程中的一个独立AppDomain中运行,一个系统进程中可以同时存在多个AppDomain。

题外:默认情况下C#编译器只允许生成安全的(safe)代码,然而,同时也允许开发人员编写不安全(unsafe)代码;C#编译器要求所有包含不安全代码的方法都用unsafe关键字标记,而且,C#编译器还要求使用/unsafe编译器开关编译源代码;当JIT编译器试图编译一个unsafe方法时,会首先验证包含该方法的程序集是否被授予了System.Security.Permissions.SecurityPermission权限,而且System.Security.Permissions.SecurityPermissionFlag的SkipVerification标记是否已被设置,如果已被设置,JIT编译器就会编译不安全代码,并且允许它执行,否则,会抛出System.InvalidProgramException或System.Security.VerificationException异常;不过,默认情况下,从本地或是“网络共享”加载的程序集都会被授予完全信任,通过Internet执行的程序集则不会授予执行不安全代码的权限;

我们可以通过Peverify.exe这个工具来验证我们想要验证的程序集的安全性,这个工具随VisualStudio一起安装具体使用方法可以参考MSDN

Framework类库

.net framework中包含了framework类库(Framework Class Library),FCL就是一组程序集的统称,其中定义了大量的类型,每个类型都公开了一些功能,具有相关功能的一组类型可以放在同一命名空间下;开发人员可以利用这些类型创建各种应用程序:

  1.Web Service:利用Asp.net XML Web Service技术和Windows Communication Foundation(WCF)技术可以轻松的处理通过Internet发送的消息;

  2.Web Form:利用Asp.net可以开发基于HTML的应用程序;

  3.Windows Form:利用Windows窗体编程技术或是WPF编程技术,通过使用操作系统提供的功能,开发GUI应用程序;

  4.富Internet应用程序:利用Sliverlight技术;

  5.Windows控制台应用程序:编译器,应用程序和工具一般都是作为控制台实现;

  6.Windows服务;

  7.数据库存储过程;

  8.组件库;

部分常规FCL命名空间:

命名空间 内容说明
System 包含每个类型都要用到的所有基本类型
System.Data 包含用于数据库通信以及处理数据的类型
System.IO 包含用于执行流I/O以及浏览目录/文件的类型
System.Net 包含进行低级网络通信,并与一些常用Internet协议协作的类型
System.Runtime.InteropServices 包含允许托管代码访问非托管操作系统平台功能的类型(比如com组件,win32函数,定制dll中的函数)
System.Security 包含用于保护数据和资源的类型
System.Text 包含处理各种编码方式的文本的类型
System.Threading 包含用于异步操作和同步资源访问的类型
System.Xml 包含用于处理XML架构和数据的类型

更多FCL类型的介绍,可以参考Microsoft SDK配套文档;

通用类型系统

通用类型系统:Common Type System(CTS);

framework中定义了大量的类型,如何定义一个类型,必须有一个规范,准则,这个工作就是CTS来做的。CTS描述了类型的定义和行为。

一个类型可以包含零个或者多个成员,具体的成员都有哪些呢?

  字段(Field):用来描述对象的状态,可以通过类型和名字区分;

  方法(Method):主要是针对对象执行一个操作,通常会改变对象的状态,方法包含:名称,签名(参数的类型和个数),若干修饰符,返回值(如果有的话);

  属性(Property):对调用者像是字段,对类型实现者像是方法,通常可以利用属性创建只读或是只写字段;

  事件(Event):实现了一种在对象和其他对象之间的通知机制;

除此之外,CTS还定义了类型的可访问性和类型成员的访问规则;

对于类型:

  有public,assembly(C#中使用internal修饰符);

对于类型成员:

  public:无限制访问;

  family or assembly:可由任何程序集的派生类型访问,也可以由同一程序集的任何类型访问,在C#中用protected internal修饰符标识;

  assembly:由同一程序集内的类型访问,通常用internal修饰符标识;

  family and assembly:成员可由派生类访问,但这些派生类必须在同一程序集内定义,C#没有实现这种机制;

  family:可由派生类访问,C#使用protected关键字标识;

  private;

除此之外,CTS还为类型继承,虚方法,对象生存周期等定义了相应的规则。

无论使用哪种语言,类型的行为都是完全一致的,因为最终都是由CLR的CTS来定义这些行为;

注:所有类型都必须从预定义的System.Object类型继承,这个类型定义了一组基本的行为:

  比较两个实例的想等性;

  获取实例的哈希码;

  查询一个实例的真正类型;

  执行实例的浅(按位)拷贝;

  获取实例对象的当前状态的一个字符串表示;

公共语言规范

COM允许使用不同的语言创建的对象之间互相通信,可是语言的种类很多,各自的语法,以及各自支持的类型都不相同,如何实现这种[通信]呢?

俗话说:无规矩不成方圆,所以,为了实现这个宏伟的目标,就需要制定一套规矩,CLS(Common Language Specification)就是干这个的。CLR能够实现这种语言间的集成,是因为CLR使用了标准的类型集,元数据以及公共执行环境;那么CLS究竟是如何规范这种集成呢?其实,CLS定义了一个所有语言都支持的最小功能子集,任何编译器生成的类型想要兼容于由其它“符合CLS,面向CLR的语言”所生成的组件,就必须支持这个最小功能集。

需要注意的是,如果要定义一个供其它语言使用的类型,该类型得是public或是protected,而且不能在该类型中使用位于CLS外部的任何功能功能;如果你定义了一个这样的类型,那么最好在类型上面添加一个验证:[assembly:CLSCompliant(true)],将该attribute应用于程序集即可;

至此,我们稍微总结一下:在CLR中,一个类型的成员要么是一个字段(数据),要么是一个方法(行为),所以,每种编程语言都必须都必须能访问字段和方法。可是,每种编程语言都会公开各种概念,像是:枚举,数组,索引器,委托,事件,构造器,析构器,操作符重载,转换操作符等等。编译器在源代码中遇到上述任何一种构造,必须将其转化为字段或方法,使CLR和其他任何编程语言都能够访问这些构造。

posted @ 2014-10-27 15:23  寒江鸟  阅读(244)  评论(0编辑  收藏  举报