《CLR via C#》笔记——CLR的执行模型
2011-09-22 20:46 xiashengwang 阅读(365) 评论(0) 编辑 收藏 举报一.将源代码编译成托管代码
1, CLR(Common Language Runtime)公共语言运行时是一个可由多种语言使用的“运行时”,CLR的核心功能(比如内存管理,程序集加载,安全性,异常处理和线程同步)可由面向CLR的所有语言使用。如“运行时”使用异常来报告错误,所以面向“运行时”的所有语言都能通过异常来报告错误。
2, 可以将编译器视为语法检查器和“正确代码”的分析器,他们检查你的代码,确定你的一切都有一些含义,然后输出你对意图进行描述的代码,不同的编程语言允许不同的语法来开发。不要低估选择的价值。如对数学和金融领域,使用APL语法比用Perl表达同样的意图更节约开发时间。
3, MicroSoft已经创建好了几个面向“运行时”的编译器,其中包括:C++/CLI,C#,Visual Basic,F#,iron Python,iron Ruby和一个中间语言(Intermediate Language,IL)汇编器,其他的一些公司和大学还开发了一些,如:Ada,APL,COBOL,PHP等等的编译器,无论是哪一种编译器,编译后都是一个托管模块(Managed Module)。托管代码是一个标准的32位的Windows可移植执行(PE32)文件。
4, PE文件的组成
①PE32或PE32+头:标准的Windows PE文件头,如果这个头使用PE32格式,文件能在Windwos的32位或64位版本上运行。如果使用PE32+可是,文件只能在Windwos 的64位版本上运行。这个头还标识了文件的类型,包括GUI,CUI,DLL,并包含一个时间标记来指出文件的生成时间。对于只包含IL的代码模块,PE(+)头的大多数信息会被忽略,对包含本地CPU代码的模块(如C++编写的),这个头包含了与本地CPU代码相关的信息。
②CLR头:包含这个模块成为一个托管模块的信息(可由CLR和一些实用程序进行解释)。头中包含了需要的CLR的版本,一些flag,托管代码入口方法(Main)的MethodDef元数据token,以及模块的元数据,资源,强名称,一些flag以及其他不太重要的数据项的位置/大小。
③每个托管模块都包含元数据表。主要有两种类型的表:一种类型的表描述源代码中定义的类型和成员,另一种类型的表描述源代码引用的类型和成员。
④IL(中间语言)代码:编译器编译源代码是生成的代码。在运行时CLR将IL编译成本地cpu指令。
5, 本地编译器生成的代码是面向特定cpu架构的(比如:x86,x64,ia64),CLR生成的都是中间代码(IL)。有时也称作托管代码,因为CLR要管理它的执行。
6, 除了生成IL,编译器还在托管模块中生成完整的元数据(也就是PE头的③部分)。元数据(metadata)是一组数据表,描述了模块中定义的内容,比如类型和成员。还有一些描述引用的内容,比如导入的类型和成员。元数据始终和IL代码一起被编译到托管代码中。元数据的用途:
A.编译时,元数据消除了对本地C/C++头和库文件的需求,因为在负责实现类型/成员的IL代码文件中,已包含了引用类型/成员有关的全部信息。编译器可以直接从托管代码读取元数据。
B.Visual Studio使用元数据帮助你写代码。它的“智能感知(Intellisense”技术可以解析元数据,指出一个类型提供了那些方法,属性,时间,字段。
C.CLR代码验证过程使用元数据确保只执行“类型安全”的操作。
D.元数据允许将一个对象的字段序列化到内存中,将其发送到另外一台机器,然后反序列化,在远程机器上重建对象。
E.元数据允许垃圾回收器跟踪对象的生存期。垃圾回收器能判断任何对象的类型,并从元数据知道那个对象中的哪个字段引用了其他的对象。
7.Microsoft的C++编译器默认生成非托管(本地)代码的EXE/DLL模块,并在运行时操纵本地内存。这些模块不需要CLR就可以执行。然而,指定了一个/CLR开关,C++编译器就能生成含托管代码的模块,当然,只有在安装了CLR的机器上才能执行这种代码。在众多的编译器中,C++编译器是最特殊的一个。
二.将托管代码合并成程序集
1. CLR实际不合模块一起工作。相反,他是和程序集一起工作的。程序集(Assembly)是一个抽象的概念,初学者很难把握它的精髓,我就是。程序集是一个或多个模块/资源文件的逻辑分组。其次,程序集是重用,安全性以及版本控制的最小单元。在CLR的世界里,它相当与一个组件,第2章有更深如的介绍,这里略过。
2. 图1-2有助于理解程序集,一些托管模块和资源(或数据)文件准备交由一个工具处理,该工具生成单独一个PE32(+)文件来表示文件的逻辑分组。实际发生的事情是,这个PE32(+)文件包含一个名为“清单”的数据块。清单是有元数据表构成的另一种集合。这些表描述了构成程序集的文件,由程序集的文件实现的公开导出的类型(就是程序集中定义的Public类型,它在程序集的外部可见),以及与程序集关联在一起的资源和数据文件。
默认是由编译器生成托管模块转换成程序集。
三.加载公共语言运行时
1. 要知道是否已安装.Net Framework,只需检查%SystemRoot%System32目录中的MSCorEE.dll文件。存在该文件,表明.Net Framework已安装。一台机器上可能安装了好几个版本,要了解安装了那些版本,可检查以下注册表子项:
KEY_LOCAL_MACHINE/SOFTWARE/Microsoft/NET Framework Setup/NDP
在xp环境下并没有这个子项,应该是:
KEY_LOCAL_MACHINE/SOFTWARE/Microsoft/.NET Framework
2. .NetFramework SDK提供了CLRVer.exe实用工具来查看已安装的.Net版本。
>clrver //查看本机所有已安装的的.NET版本。
>clrver -all //查看所有正在运行的.NET程序使用的.NET版本。
>clrver 322 //查看进程号为322的程序使用的.NET版本。
四.执行程序集的代码
为了执行一个方法,首先必须把它的 IL 转换成本地 CPU 指令。这是 CLR 的 JIT(just-in-time 或者“即时”)编译器的职责。
图 1-4 展示了一个方法首次调用时发生的事情。
就在 Main 方法执行之前,CLR 会检测出 Main 的代码引用的所有类型。这导致 CLR 分配一个内部数据结构,它用于管理对所引用的类型的访问。在图 1-4 中,Main 方法引用了一个 Console 类型,这导致 CLR分配一个内部结构。在这个内部数据结构中,Console 类型定义的每个方法都有一个对应的记录项 10 。每个记录项都容纳了一个地址,根据此地址即可找到方法的实现。对这个结构进行初始化时,CLR 将每个记录项都设置成(指向)包含在 CLR 内部的一个未文档化的函数。我将这个函数称为 JITCompiler。
Main 方法首次调用 WriteLine 时,JITCompiler 函数会被调用。JITCompiler 函数负责将一个方法的 IL 代码编译成本地 CPU 指令。由于 IL 是“即时”(just in time)编译的,所以通常将 CLR 的这个组件称为 JITter或者 JIT 编译器。
注意:如果应用程序在 Windows 的 x86 版本或 WoW64 中运行,JIT 编译器将生成 x86 指令。如果应用程序
以一个 64 位应用程序的形式在 Windows 的 x64 或 Itanium 版本上运行,那么 JIT 编译器将分别生成 x64 或
IA64 指令。
JITCompiler 函数被调用时,它知道要调用的是哪个方法,以及具体是什么类型定义了该方法。然后,JITCompiler 会在定义(该类型的)程序集的元数据中查找被调用的方法的 IL。接着,JITCompiler 验证 IL 代码,并将 IL 代码编译成本地 CPU 指令。本地 CPU 指令被保存到一个动态分配的内存块中。然后,JITCompiler返回 CLR 为类型创建的内部数据结构,找到与被调用的方法对应的那一条记录,修改最初对 JITCompiler 的引用,让它现在指向内存块(其中包含了刚才编译好的本地 CPU 指令)的地址。最后,JITCompiler 函数跳转到内存块中的代码。这些代码正是 WriteLine 方法(获取单个 String 参数的那个版本)的具体实现。这些代码执行完毕并返回时,会返回至 Main 中的代码,并跟往常一样继续执行。
现在,Main 要第二次调用 WriteLine。这一次,由于已对 WriteLine 的代码进行了验证和编译,所以会直接执行内存块中的代码,完全跳过 JITCompiler 函数。WriteLine 方法执行完毕之后,会再次返回 Main。图 1-5 展示了第二次调用 WriteLine 时发生的事情。
一个方法只有在首次调用时才会造成一些性能损失。以后对该方法的所有调用都以本地代码的形式全速运行,无需重新验证 IL 并把它编译成本地代码。
JIT 编译器将本地 CPU 指令存储到动态内存中。一旦应用程序终止,编译好的代码也会被丢弃。所以,如果将来再次运行应用程序,或者同时启动应用程序的两个实例(使用两个不同的操作系统进程),JIT 编译器必须再次将 IL 编译成本地指令。
对于大多数应用程序,因 JIT 编译造成的性能损失并不显著。大多数应用程序都会反复调用相同的方法。在应用程序运行期间,这些方法只会对性能造成一次性的影响。另外,在方法内部花费的时间很有可能比花在调用方法上的时间多得多。
还要注意的是,CLR 的 JIT 编译器会对本地代码进行优化,这类似于非托管 C++编译器的后端所做的工作。同样地,可能要花费较多的时间来生成优化的代码。但是,和没有优化时相比,代码在优化之后将获得更出色的性能。
五.本地代码生成器:NGen.exe
使用.NET Framework 配套提供的 NGen.exe 工具,可以在一个应用程序安装到用户的计算机上时,将 IL代码编译成本地代码。由于代码在安装时已经编译好,所以 CLR 的 JIT 编译器不需要在运行时编译 IL 代码,这有助于提升应用程序的性能。NGen.exe 能在两种情况下发挥重要作用:
l 加快应用程序的启动速度 运行 NGen.exe 能加快启动速度,因为代码已编译成本地代码,运行时不需要再花时间编译。
l 减小应用程序的工作集 如果一个程序集会同时加载到多个进程中,对该程序集运行 NGen.exe可减小应用程序的工作集(working set)。NGen.exe 会将 IL 编译成本地代码,并将这些代码保存到一个单独的文件中。这个文件可以通过“内存映射”的方式,同时映射到多个进程地址空间中,使代码得到了共享,避免每个进程都需要一份单独的代码拷贝。
六.Framework类库
.NET Framework 中包含了 Framework 类库(Framework Class Library,FCL)。FCL 是一组 DLL 程序集的统称,其中含有数千个类型定义,每个类型都公开了一些功能。Microsoft 发布的库并非仅限于此,其他还有Windows SideShow Managed API SDK15 和 DirectX SDK 等等。这些额外的库提供了更多的类型,公开了更多可用的功能。事实上,Microsoft 正在以惊人的速度发布大量库,开发者在使用各种 Microsoft 技术时,变得前所未有的简单。
七.通用类型系统
CLR 是完全围绕类型展开的,这一点到现在为止应该很明显了。类型为应用程序和其他类型公开了功能。通过类型,用一种编程语言写的代码能与用另一种语言写的代码沟通。由于类型是 CLR 的根本,所以Microsoft 制定了一个正式的规范,叫做“通用类型系统”(Common Type System,CTS),它描述了类型的定义和行为。
CTS 规范规定,一个类型可以包含零个或者多个成员。本书第Ⅱ部分“设计类型”将更详细地讨论这些成员。目前只是简单地介绍一下它们。
l 字段(Field) 一个数据变量,是对象状态的一部分。字段根据名称和类型来区分。
l 方法(Method) 一个函数,能针对对象执行一个操作,通常会改变对象的状态。方法有一个名称、一个签名以及一个或多个修饰符。签名指定参数的数量(及其顺序);参数的类型;方法是否有返回值;如果有返回值,还要指定返回值的类型。
l 属性(Property) 对于调用者,该成员看起来像是一个字段。但对于类型的实现者,它看起来像是一个方法(或者两个方法,称为 getter 和 setter,或者称为取值方法和赋值方法)。属性允许实现者在访问值之前对输入参数和对象状态进行校验,以及/或者只有在必要的时候才计算一个值。属性还允许类型的用户采用简化的语法。最后,可利用属性创建只读或只写的“字段”。
l 事件(Event) 事件在对象以及其他相关对象之间实现了一个通知机制。例如,利用按钮提供的一个事件,可以在按钮被单击之后通知其他对象。
CTS 还指定了类型可视性规则以及类型成员的访问规则。例如,如果将类型标记为 public(在 C#中使用 public 修饰符),任何程序集都能看见并访问该类型。但是,如果将类型标记为 assembly(在 C#中使用internal 修饰符),只有同一个程序集中的代码才能看见并访问该类型。所以,利用 CTS 制定的规则,程序集为一个类型建立了可视边界,CLR 则强制(贯彻)了这些规则。调用者虽然能“看见”一个类型,但并不是说就能随心所欲地访问它。利用以下选项,可进一步限制调用者对类型中的成员的访问。
l private 成员只能由同一个类(class)类型中的其他成员访问。
l family 成员可由派生类型访问,不管那些类型是否在同一个程序集中。注意,许多语言(比如C++和 C#)都用 protected 修饰符来标识 family。
l family and assembly 成员可由派生类型访问,但这些派生类型必须是在同一个程序集中定义的。许多语言(比如 C#和 Visual Basic)都没有提供这种访问控制。当然,IL 汇编语言不在此列。
l assembly 成员可由同一个程序集中的任何代码访问。许多语言都用 internal 修饰符来标识assembly。
l family or assembly 成员可由任何程序集中的派生类型访问。成员也可由同一个程序集中的任何类型访问。在 C#中,是用 protected internal 修饰符来标识 family or assembly。
l public 成员可由任何程序集中的任何代码访问。
除此之外,CTS 还为类型继承、虚方法、对象生存期等定义了相应的规则。这些规则在设计之初,便顺应了可以用现代编程语言来表示的语义。
八.公共语言规范
COM 允许使用不同语言创建的对象相互通信。现在,CLR 集成了所有语言,允许在一种语言中使用由另一种语言创建的对象。之所以能实现这样的集成,是因为 CLR 使用了标准类型集、元数据(自描述的类型信息)以及公共执行环境。
语言的集成是一个宏伟的目标,最棘手的问题是各种编程语言存在极大的区别。例如,有的语言在处理符号时不区分大小写,有的语言不支持 unsigned(无符号)整数、操作符重载或者参数数量可变的方法。
要创建很容易从其他编程语言中访问的类型,只能从自己的编程语言中挑选其他所有语言都确定支持的那些功能。为了在这个方面提供帮助,Microsoft 定义了一个“公共语言规范”(Common Language Specification,CLS),它详细定义了一个最小功能集。任何编译器生成的类型要想兼容于由其他“符合 CLS、面向 CLR 的语言”所生成的组件,就必须支持这个最小功能集。
如图 1-6 所示,CLR/CTS 提供了一个功能集。有的语言公开了 CLR/CTS 的一个较大的子集。例如,假定开发人员使用 IL 汇编语言写程序,就可以使用 CLR/CTS 提供的全部功能。但是,其他大多数语言(比如 C#、Visual Basic 和 Fortran)只向开发人员公开了 CLR/CTS 的一个功能子集。CLS 定义了所有语言都必须支持的
一个最小功能集。
用一种语言定义一个类型时,如果希望在另一种语言中使用该类型,就不要在该类型的 public 和protected 成员中使用位于 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)]这个 attribute16 应用于程序集。这个 attribute 告诉编译器检查pubic 类型,判断是否存在任何不合适的构造,阻止了从其他编程语言中访问该类型。上述代码编译时,C#编译器会报告两条警告消息。第一个警告是因为 Abc 方法返回了一个无符号整数;有一些语言是不能操作无符号整数值的。第二个警告是因为该类型公开了两个 public 方法,这两个方法(Abc 和 abc)只是大小写和返回类型有别。Visual Basic 和其他一些语言无法区分这两个方法。
有趣的是,删除 sealed class SomeLibraryType 之前的 public 字样,然后重新编译,两个警告都会消失。因为这样一来,SomeLibraryType 类型将默认为 internal(而不是 public),将不再向程序集的外部公开。
九.与非托管代码的互操作性
.NET Framework 提供了其他开发平台没有的许多优势。但是,能下定决心重新设计和重新实现其现有的全部代码的公司并不多。Microsoft 也知道这个问题,并通过 CLR 来提供了一些机制,允许在应用程序中同时包含托管和非托管代码。具体地说,CLR 支持三种互操作情形。
l 托管代码能调用 DLL 中的一个非托管函数 托管代码可以采取一种名为 P/Invoke(Platform Invoke 的简称)的机制来调用 DLL 中包含的函数。毕竟,FCL 中定义的许多类型都要在内部调用从Kernel32.dll、User32.dll 等导出的函数。许多编程语言都提供了一个机制,允许托管代码方便地调用包含在 DLL 中的非托管函数。例如,C#应用程序可调用从 Kernel32.dll 导出的 CreateSemaphore函数。
l 托管代码可使用现有的 COM 组件(服务器) 许多公司都已经实现了大量非托管 COM 组件。利用来自这些组件的类型库,可创建一个托管程序集来描述 COM 组件。托管代码可以像访问其他任何托管类型一样访问托管程序集中的类型。这方面的详情可以参见.NET Framework SDK 配套提供的 TlbImp.exe 工具 17 。有的时候,你可能没有一个类型库,或者想对 TlbImp.exe 生成的内容进行更多的控制。在这种情况下,可以在源代码中手动构建一个类型,使 CLR 能用它来实现正确的互操作性。例如,可以从一个 C#应用程序中使用 DirectX COM 组件。
l 非托管代码可使用托管类型(服务器) 许多现有的非托管代码要求提供一个 COM 组件,从而确保代码正确工作。使用托管代码可以更简单地实现这些组件,避免所有代码都不得不和引用计数以及接口打交道。例如,可以使用 C#来创建一个 ActiveX 控件或者一个 shell 扩展。这方面的详情可以参见.NET Framework SDK 配套提供的 TlbExp.exe 和 RegAsm.exe 工具。