1. 将源代码编译成托管模块
<1>使用支持CLR的任何语言创建源代码文件。
创建一个Cat.cs文件,内容如下:
using System;
namespace Animal
{
public class Cat
{
public void Print()
{
Console.WriteLine("cat");
}
}
}
再创建一个Dog.cs文件,内容如下:
using System;
namespace Animal
{
public class Dog
{
public void Print()
{
Console.WriteLine("dog");
}
}
}
<2>使用对应的编译器检查语法和分析源代码,生成托管模块。
现在可以将Cat.cs和Dog.cs文件生成相应的模块,利用csc.exe编译器
(Microsoft Visual Studio 2010 -> Visual Studio Tools -> Visual Studio Command Prompt(2010)..):
csc /t:module /out:D:\test\Cat.netmodule D:\test\Cat.cs
csc /t:module /out:D:\test\Dog.netmodule D:\test\Dog.cs
这个会在D:\test目录下生成两个普通模块:
Cat.netmodule
Dog.netmodule
以上执行过程如下图:
名词解释:
1 CLR .NET FrameWork的核心是其运行库执行环境,称为公共语言运行库(Common Language Runtime)。 作用: (1)CLR是一个类似于JVM的虚拟机,为微软的.Net产品提供运行环境。 (2)CLR上实际运行的并不是我们通常所用的编程语言(例如C#、VB等),而是一种字节码形态的“中间语言”。这意味着只要能将代码编译成这种特定的“中间语言”(MSIL), 任何语言的产品都能运行在CLR上。 (3)CLR通常被运行在Windows系统上,但是也有一些非Windows的版本。这意味着.Net也很容易实现“跨平台”。(至于为什么大家的印象中.Net的跨平台性不如Java,更多的是微软商业战略导致的)。 语言支持: 微软已经为多种语言开发了基于CLR的编译器,这些语言包括:C++/CLI、C#、Visual Basic、F#、Iron Python、 Iron Ruby和IL。除此之外,其他的一些公司和大学等机构也位一些语言开发了基于CLR的编译器,例如Ada、APL、Caml、COBOL、Eiffel、Forth、Fortran、Haskell、Lexicon、LISP、LOGO、Lua、Mercury、ML、Mondrian、Oberon、Pascal、Perl、PHP、Prolog、RPG、Scheme、Smaltak、Tcl/Tk。 CLR为不同的编程语言提供了统一的运行平台,在很大程度上对上层开发者屏蔽了语言之间才特性差异。对于CLR来说,不同语言的编译器(Compiler)就相当于一个这种语言的代码审查者(Checker),所做的工作就是检查源码语法是否正确,然后将源码编译成CLR所需要的中间语言(IL)。所以编程语言对于CLR是透明的,也就是说CLR只知道IL的存在,而不知道IL是由哪种语言编译而来。 功能 (1)基类库支持 (Base Class Library Support) (2)内存管理 (Memory Management) (3)线程管理 (Thread Management) (4)垃圾回收 (Garbage Collection) (5)安全性 (Security) (6)类型检查 (Type Checker) (7)异常处理 (Exception Manager) (8)即使编译 (JIT) 2 托管模块 托管模块是标准的32位Windows可移植执行体(PE32)文件,或者是标准的64位Windows可移植执行体(PE32+)文件,它们都需要CLR才能执行。 托管模块包括:PE32或PE32+头、CLR头、元数据和IL(中间语言)代码。 下图为各个部分的说明: 2.1 MSIL/托管代码 中间语言(Microsoft Intermediate Language MSIL)代码, 这些代码并非专门用于任何一种操作系统, 也非专门用于C#,称为托管代码。 编译器把代码编译成中间语言(IL),而不是能直接在你的电脑上运行的机器码。中间语言(IL)在公共语言运行库(CLR)中运行,这个运行库给你的运行代码提供各种各样的服务(如内存管理和类型检查)。 .NET和C#只能产生托管代码。如果你用这类语言写程序,那么所产生的代码就是托管代码。 优点 中间语言代码与Java字节码共享一种理念:它们都是低级语言,语法很简单(使用数字代码,而不是文本代码),可以非常快速地转换为本地机器码。对于代码,这种精心设计的通用语法有很重要的优点:平台无关性、提高性能和语言的互操作性。 2.2 非托管代码 非托管代码就是在Visual Studio .NET 2002发布之前所创建的代码。例如Visual Basic 6, Visual C++ 6, 最糟糕的是,连那些依然残存在你的硬盘中、拥有超过15年历史的陈旧C编译器所产生的代码都是非托管代码。 非托管代码直接编译成目标计算机的机械码,这些代码只能运行在编译出它们的计算机上,或者是其它相同处理器或者几乎一样处理器的计算机上。非托管代码不能享受一些运行库所提供的服务,例如安全和内存管理等。如果非托管代码需要进行内存管理等服务,就必须显式地调用操作系统的接口,通常来说,它们会调用Windows SDK所提供的API来实现。 跟Visual Studio平台的其他编程语言不一样,Visual C++可以创建非托管程序。当你创建一个项目,并且选择名字以MFC,ATL或者Win32开头的项目类型,那么这个项目所产生的就是非托管程序。 两者之间的区别: a. 托管代码是一种中间语言,运行在CLR上; a. 非托管代码被编译为机器码,运行在机器上。 b. 托管代码独立于平台和语言,能更好的实现不同语言平台之间的兼容; b. 非托管代码依赖于平台和语言。 c. 托管代码可享受CLR提供的服务(如安全检测、垃圾回收等),不需要自己完成这些操作; c. 非托管代码需要自己提供安全检测、垃圾回收等操作。 2.2 元数据 元数据概述:元数据是一种二进制信息,用以对存储在公共语言运行库可移植可执行文件 (PE) 文件或存储在内存中的程序进行描述。将您的代码编译为 PE 文件时,便会将元数据插入到该文件的一部分中,而将代码转换为 Microsoft 中间语言 (MSIL) 并将其插入到该文件的另一部分中。在模块或程序集中定义和引用的每个类型和成员都将在元数据中进行说明。当执行代码时,运行库将元数据加载到内存中,并引用它来发现有关代码的类、成员、继承等信息。 元数据以非特定语言的方式描述在代码中定义的每一类型和成员。元数据存储以下信息: (1)程序集的说明 标识(名称、版本、区域性、公钥)。 导出的类型。 该程序集所依赖的其他程序集。 运行所需的安全权限。 (2)类型的说明 名称、可见性、基类和实现的接口。 成员(方法、字段、属性、事件、嵌套的类型)。 (3)属性 修饰类型和成员的其他说明性元素。
2. 将托管模块合并成程序集
CLR实际不和模块工作。它和程序集工作。
现将上诉生成的两个普通模块合并一个程序集。由于这个程序集是一个被其他程序集加载的,因此,生成的文件名可以为:Animal.dll。
csc /out:D:\test\Animal.dll /t:library /addmodule:D:\test\Cat.netmodule;D:\test\Dog.netmodule
执行此命令之后,会在D:\test目录下生成Animal.dll
以上执行过程如下图:
编译器默认将生成的托管代码转换成程序集。也就是说,C#编译器生成的是含有清单的托管模块。
当清单指出程序集是只由一个文件过程的时候,对于只有一个托管模块而且无资源(或数据)文件的项目,程序集就是托管模块,生成过程中无需执行任何额外的步骤。
上面的各个模块中:
主模块(负责人):Animal.dll
普通模块(成员): Cat.netmodule, Dog.netmodule。
程序集(团队):由主模块Animal.dll与普通模块Cat.netmodule、Dog.netmodule组成。但是在一般情况下我们会将Animal.dll称为程序集。特别是在IDE 的visual studio的生成的情况下,因为在IDE默认生成的dll中,已经将各个模块包含进dll中,除了dll中,并没有其他的模块文件。
在这里,我们可以运行命令:
csc /out:D:\test\Animal.dll /t:library D:\test\Cat.cs D:\test\Dog.cs直接生成Animal.dll文件
此时将不再生成Cat.netmodule与Dog.netmodule文件;
调用生成的程序集:
创建一个Program.cs文件,内容如下:
using Animal;
public class Program
{
static void Main(string[] args)
{
new Dog().Print();
new Cat().Print();
}
}
执行命令:
csc /out:D:\test\Program.exe /R:D:\test\Animal.dll D:\test\Program.cs
将会在D:\test生成Program.exe可运行文件
注意:这里的Animal.dll文件代表的是一个程序集,如果这个程序集由多个文件组成,必须保证其他文件都存在,否则编译会失败。如当程序集由主模块Animal.dll、普通模块Cat.netmodule与Dog.netmoudle组成时,必须保证这三个文件同时存在。
在命令行中可以直接执行: Program.exe
输入结果:
dog
cat
名词解释:
程序集是一个或多个模块/资源文件的逻辑性分组。说白了就是基于.NET平台的一个 .dll 或者 .exe为后缀的文件。 一个程序集多个模块的作用:CLR只会加载被引用到的模块,没有引用的模块不会加载。因此,可以将程序集分为多个模块,运行程序时,只要保证有被引用到的模块存在即可,可以减少加载的程序集文件大小;特别是当程序集是通过网络传输加载时。
//示例:
//修改Program.cs文件,使它不再引用Cat.cs文件中的类型。
using Animal;
public class Program
{
static void Main(string[] args)
{
new Dog().Print();
// new Cat().Print();
}
}
重新编译Program.cs文件:
csc /out:D:\test\Program.exe /R:D:\test\Animal.dll D:\test\Program.cs
现在在D:\test文件夹中具有文件如下:
运行Program.exe,结果:
现在将Cat.netmodule删除:
运行Program.exe,结果:
原因是:CLR并没有加载Cat.netmodule,因此,就算这个普通模块不存在也无所谓
3.加载公共语言运行时
生成的每个程序集既可以是可执行应用程序,也可以是DLL。当然,最终是由CLR管理这些程序集中的代码的执行。这意味着目标机器必须安装好.Net Framework。
要知道是否已安装.Net Framwork,只需检查%SystemRoot%\System32目录中的MsCorEE.dll文件。存在该文件,表明.Net Framework已安装。
如果程序集文件值包含类型安全的代码,代码在32位和64位Windows上都能正常工作。在这两种Windows上运行,源代码无需任何改动。事实上,编译器最终生成的EXE/DLL文件在Windows的x86和x64版本上都能正常工作。在极少数情况下,开发人员希望代码只在一个特定版本的Windows上运行。例如,要使用不安全的代码,或者要和面向一种特定CPU架构的非托管代码进行互操作,就可能需要这样做。
C#编译器提供了一个/platform命令行开关选项。这个开关允许指定最终生成的程序集只能在运行32位Windows版本的x86机器上使用,只能在运行64位Windows版本的x64机器上使用。不指定具体平台的话,默认选项就是anycpu,表明最终生成的程序集能在任何版本的Windows上运行。
下表总结了两方面的信息。其一,为C#编译器指定不同/platform命令行开关将得到哪种托管代码。其二,应用程序在不同版本的Windows上如何运行。
Windows检查EXE文件头,决定是创建32位还是64位进程后,会在进程地址空间加载MSCorEE.dll的x86,x64或ARM版本。如果是Windows的x86或ARM版本,MSCorEE.dll的x86版本在%SystemRoot%\System32目录中。如果是Windows的x64版本,MSCorEE.dll的x86版本在%SystemRoot%\SysWow64目录中,64位版本则在%SystemRoot%\System32目录中。然后,进程的主线程调用MSCorEE.dll中定义的一个方法。这个方法初始化CLR,加载EXE程序集,再调用其入口方法(Main)。随即,托管应用程序启动并运行。
4.执行程序集的代码
为了执行一个方法,首先必须把它的IL转换成本地CPU指令。这是CLR的JIT(just-in-time或者“即时”)编译器的职责。
下图展示了一个方法首次调用时发生的事情。
就在Main方法执行之前,CLR会检测出Main的代码引用的所有类型。这导致CLR分配一个内部数据结构,它用于管理对所引用的类型的访问。在图1-4中,Main方法引用了一个Console类型,这导致CLR分配一个内部结构。在这个内部数据结构中,Console类型定义的每个方法都有一个对应的记录项。每个记录项都容纳了一个地址,根据此地址即可找到方法的实现。对这个结构进行初始化时,CLR将每个记录项都设置成(指向)包含在CLR内部的一个文档化的函数。我将这个函数称为JITCompiler。
Main方法首次调用WriteLine时,JITCompiler函数会被调用。JITCompiler函数负责将一个方法的IL代码编译成本地CPU指令。由于IL是“即时”(just in time)编译的,所以通常将CLR的这个组件成为JITter或者JIT编译器。
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编译造成的性能损失并不显著。大多数应用程序都会反复调用相同的方法。在应用程序运行期间,这些方法只会对性能造成一次性的影响。另外,在方法内部花费的时间很有可能被花在调用方法上的时间多得多。
5.通用类型系统
CLR是完全围绕类型展开的,这一点到现在为止应该很明显了。类型为应用程序和其他类型公开了功能。通过类型,用一种编程语言写的代码能与用另一种语言写的代码沟通。由于类型是CLR的根本,所以Microsoft制定了一个正式的规范,叫做“通用类型系统”(Common Type System,CTS),它描述了类型的定义和行为。
CTS规范规定,一个类型可以包含零个或者多个成员。本书第II部分“设计类型”将更详细地讨论这些成员。目前只是简单地介绍一下它们。
- 字段(Field) 一个数据变量,是对象状态的一部分。字段根据名称和类型来区分。
- 方法(Method) 一个函数,能针对对象执行一个操作,通常会改变对象的状态。方法有一个名称、一个签名以及一个或多个修饰符。签名指定参数的数量(及其顺序);参数的类型;方法是否有返回值;如果有返回值,还要指定返回值的类型。
- 属性(Property) 对于调用者,该成员看起来像是一个字段。但对于类型的实现者,它看起来像是一个方法(或者两个方法,称为getter和setter,或者称为取值方法和赋值方法)。属性允许实现者在访问值之前对输入参数和对象状态进行校验,以及/或者只有在必要的时候才计算一个值。属性还允许类型的用户采用简化的语法。最后,可利用属性创建只读或只写的“字段”。
- 事件(Event) 事件在对象以及其他相关对象之间实现了一个通知机制。例如,利用按钮提供的一个事件,可以在按钮被单击之后通知其他对象。
CTS还指定了类型可视化规则以及类型成员的访问规则。例如,如果将类型标记为public(在C#中使用public修饰符),任何程序集都能看见并访问该类型。但是,如果将类型标记为assembly(在C#中使用internal修饰符),只有同一个程序集中的代码才能看见并访问该类型。所以,利用CTS制定的规则,程序集为一个类型建立了可视边界,CLR则强制(贯彻)了这些规则。
调用者虽然能“看见”一个类型,但并不是说就能随心所欲地访问它。利用一下选项,可进一步限制调用者对类型中的成员的访问。
- private 成员只能由同一个类(class)类型中的其他成员访问。
- family 成员可由派生类型访问,不管那些类型是否在同一个程序集中。注意,许多语言(比如C++和C#)都用protected修饰符来表示family。
- family and assembly 成员可由派生类型访问,但这些派生类型必须是在同一个程序集中定义的。许多语言(比如C#和Visual Basic)都没有提供这种访问控制。当然,IL汇编语言不在此列。
- assbmly 成员可由同一个程序集中的任何代码访问。许多语言都用internal修饰符来标识assembly。
- family or assembly 成员可由任何程序集中的派生类型访问。成员也可由同一个程序集中的任何类型访问。在C#中,是用protected internal修饰符来标识family or assembly。
- public 成员可由任何程序集中的任何代码访问。
除此之外,CTS还为类型继承、虚方法、对象生存期等定义了相应的规则。这些规则在设计之初,并顺应了可以用现代编程语言来表示的语义。事实上,根本不需要专门去学习CTS规则本身,因为你选择的语言会采用你熟悉的方式公开它自己的语言语法与类型规则。通过编译来生成程序集时,它会将语言特有的语法映射到IL–也就是CLR的“语言”。
下面是另一条CTS规则:所有类型最终必须从预定义的System.Object类型继承。可以看出,Object是System命名空间中定义的一个类型的名称。Object是其他所有类型的根,因为保证了每个类型实例都有一组最基本的行为。具体地说,System.Object类型允许做下面这些事情:
- 比较两个实例的相等性(Equals)
- 获取实例的哈希码(GetHashCode)
- 查询一个实例的真正类型(GetType)
- 执行实例的(浅)拷贝(MemberwiseClone)
- 获取实例对象的当前状态的一个字符串表示(ToString)
6.公共语言规范
要创建很容易从其他编程语言中访问的类型,只能从自己的编程语言中挑选其他所有语言都确定支持的那些功能。为了在这个方面提供帮助,Microsoft定义了一个“公共语言规范”(Common Language Specification,CLS),它详细定义了一个最小功能集。任何编译器生成的类型要想兼容于由其他“符合CLS、面向CLR的语言”所生产的组件,就必须支持这个最小功能集。
CLR/CTS支持的功能比CLS定义的子集多得多。如果不关心语言之间的互操作性,可以开发一套功能非常丰富的类型,它们仅受你选用的那种语言的功能集的限制。具体地说,在开发类型和方法的时候,如果希望它们对外“可见”,能够从符合CLS的任何一种编程语言中访问,就必须遵守由CLS定义的规则。注意,假如代码只是从定义(这些代码的)程序集的内部访问,CLS规则就不适用了。
下图形象地演示了这一段想要表达的意思。
上图所示,CLR/CTS提供了一个功能集。有的语言公开了CLR/CTS的一个较大的子集。例如,假定开发人员使用IL汇编语言写程序,就可以使用CLR/CTS提供的全部功能。但是,其他大多数语言(比如C#、Visual Basic和Fortran)只向开发人员公开了CLR/CTS的一个功能子集。CLS定义了所有语言都必须支持的一个最小功能集。
以下代码使用C#定义一个符合CLS的类型。然而,类型中含有几个不符合CLS的构造,造成C#编译器报错:
using System;
// 告诉编译器检查CLS相容性
[assembly: CLSCompliant(true)]
namespace SomeLibrary
{
// 因为是public类,所以会显示警告
public sealed class SomeLibraryType
{
// 警告:SomeLibrary.SomeLibraryType.Abc()的返回类型不符合CLS
public UInt32 Abc() { return 0; }
// 警告:仅大小写不同的标识符SomeLibrary.SomeLibraryType.abc()不符合CLS
public void abc() { }
// 不会显示警告:该方法是私有的
private UInt32 ABC() { return 0; }
}
}
上述代码将[assembly:CLSCompliant(true)]这个attribute1应用于程序集。这个attribute告诉编译器检查public类型,判断是否存在任何不合适的构造,阻止了从其他编程语言中访问该类型。上述代码编译时,C#编译器会报告两条警告消息。第一个警告是因为Abc方法返回了一个无符号整数;有一些语言是不能操作无符号整数值的。第二个警告是因为该类型公开了两个public方法,这两个方法(Abc和abc)只是大小写和返回类型有别。Visual Basic和其他一些语言无法区别这两个方法。
有趣的是,删除sealed class SomeLibraryType之前的public字样,然后重新编译,两个警告都会消失。因为这样一来,SomeLibraryType类型将默认为internal(而不是public),将不再向程序集的外部公开。要获得完整的CLS规则列表,请参见.NET Framework SDK文档的“跨语言互操作性”一节(http://msdn.microsoft.com/zh-cn/library/730f1wy3.aspx)。
现在,让我们提炼一下CLS的规则。在CLR中,一个类型的每个成员要么是一个字段(数据),要么是一个方法(行为)。这意味着每一种编程语言都必须能访问字段和调用方法。这些字段和方法通过特殊或者通用的方式来使用。为了编程进行编程,语言通常提供了额外的抽象,对这些常见的编程模式进行简化。例如,语言可能公开枚举、数组、属性、索引器、委托、事件、构造器、析构器、操作符重载、转换操作符等概念。编译器在源代码中遇到上述任何一种构造,必须将其转换成字段和方法,使CLR和其他编程语言能够访问这些构造。
7.编译和执行过程总结
下图简要说明了上述特性在编译和执行过程中如何发挥作用。