《CLR via C#》学习笔记【1】

时间:2021-12-21 19:28:46

一、CLR的执行模块

1.1 将源代码编译成托管模块

非托管C/C++可对系统进行低级控制,按自己的想法管理内存、VB可以快速生成UI应用程序,并控制COM对象和数据库。

公共语言运行时(Common Language Runtime, CLR)是一个由多种编程语言使用的“运行时”。CLR的核心功能(如内存管理、程序集加载、安全性、异常处理和线程同步)可由面向CLR的所有语言使用。
事实上在运行时,CLR根本不关心开发人员用哪一种语言写源代码,这意味着在选择编程语言时,应选择最容易表达自己意图的语言。
面向CLR的语言编译器:C++/CLI、C#、VB、F#、Iron Python、Iron Ruby以及一个“中间语言”(Intermediate Language, IL)汇编器。除了Microsoft,另一些公司、大学和学院也创建了自己的编译器,也能向CLR生成代码。例如:Ada, APL, Caml, COBOL, Eiffel, Forth, Fortran, Haskell, Lexico, Lisp, LOGO, Lua, Mercury, ML, Mondrian, Oberon, Pascal, Perl, PHP, Prolog, RPG, Scheme, Smalltalk, Tcl/Tk
无论选择哪个编译器,结果都是托管模块(Managed Module)。它们都需要CLR才能执行

表1 托管模块各个部分

组成部分 说明
PE32或PE32+头 标识了文件类型,包括GUI、CUI或者DLL;包含时间标记,如果包含本机CPU代码的模块,会包含本机CPU代码有关的信息
CLR头 头中包含要求的CLR版本,一些标志(Flag),托管模块入口方法的MethodDef元数据Token以及模块的元数据、资源、强名称、一些标识以及其他不太重要的数据项的位置及大小
元数据 主要有两种表:描述源代码中定义的类型和成员,描述源代码引用的类型和成员
IL代码 编译器编译源代码时生成的代码,在运行时,CLR将IL编译成本机的CPU指令

IL代码有时被称为“托管代码(Maneged Code)”,因为CLR管理它的执行。

1.1-1 元数据:

面向CLR的编译器要在每个托管模块中生成完整的元数据(metadata)。一些数据表描述了模块中定义了什么(比如类型以及其成员),另一些描述了模块引用了什么(比如导入的类型及其成员)元数据是一些老技术的超集,这些老技术包括COM的“类型库”(Type Library)和“接口定义语言”(Interface Definition Language, IDL)文件,但CLR元数据远比他们全面。
元数据有多种用途,下面仅举例一部分

  • 避免了编译时对原生C/C++头和库文件的需求,编译器直接从托管模块读取元数据。
  • 智能感知”(IntelliSense)技术会解析元数据,告诉你一个类型提供了哪些方法、属性、事件和字段、乃至方法需要的参数。
  • CLR的代码验证过程使用元数据确保代码只执行“类型安全”的操作。
  • 元数据允许将对象的字段序列化到内存块,将其发送给另一台机器,然后反序列化,在远程机器上重建对象状态。
  • 元数据允许垃圾回收器跟踪对象生存期。垃圾回收器能够判断任何对象的类型,并从源数据知道那个对象中的那些字段引用了其他对象。

为了执行包含托管代码以及/或者托管数据的模块,最终用户必须在自己的计算机上安装好CLR(目前作为.NET Framework的一部分提供)。这类似于为了运行MFC或者VB6.0应用程序,用户必须安装MFC库或者VB DLL。

1.1-2 C++/CLI:

Microsoft C++编译器默认生成包含非托管代码的EXE/DLL模块,并在运行时操纵非托管数据(native内存)。这些模块不需要CLR即可执行。通过指定/CLR命令行开关,C++编译器就能生成包含托管代码的模块。在前面提到的所有Microsoft编译器中,C++编译器是独一无二的,只有它才允许开发人员同时写非托管代码和托管代码,并生成到同一个模块中。他也是唯一允许开发人员在源代码中同时定义托管和非托管数据类型的Microsoft编译器。

1.2 将托管模块合并成程序集

CLR实际不和模块工作,他和程序集工作。程序集(Assembly)是一个抽象概念。首先程序集是一个或多个模块/资源文件的逻辑性分组。其次,程序集是重用、安全性以及版本控制的最小单元。取决于你所选择的编译器或工具,即可生成单文件程序集也可生成多文件程序集。在CLR的世界中,程序集相当于“组件”。

《CLR via C#》学习笔记【1】
图1有助于理解程序集,图中一些托管模块和资源(或数据)文件准备交由一个工具处理。工具生成代表文件逻辑分组的一个PE32(+)文件。这个PE32(+)文件包含一个名为清单(manifest)的数据模块。清单也是元数据表的集合。这些表描述了构成程序集的文件、程序集中的文件所实现的公开导出的类型 以及与程序及关联的资源或数据文件。

①译者注:所谓公开导出的类型,就是程序集中定义的Public类型,它们在程序集内部外部均可见

C#编译器生成的是含有清单的托管模块。对于只有一个托管模块而没有资源或数据文件的项目,程序集就是托管模块,生成过程中无需执行额外步骤,但是如果希望将一组文件合并到程序集中,就必须掌握更多的工具(比如程序集链接器AL.exe)以及其他命令行选项。
在程序集的模块中,还包含与引用的程序集有关的信息(包括他们的版本号)。这些信息使程序既能够自描述(self-describing)。CLR能判断为了执行程序集中的代码,程序集的直接依赖对象(Immediate dependency)是什么。相比COM而言不需要注册表或Active Directory Domain Services(ADDS)

1.3 加载公共语言运行时

C#编译器提供了一个/platform命令行开关选项,这个开关允许指定最终生成的程序集只能运行在32位Windows版本的x86的机器上使用。或者在32位windows RT的ARM机器上使用,不指定具体平台的话,默认选项就是anycpu。VS用户想要设置目标平台,可以打开项目的属性页,从“生成”选项卡的“目标平台”列表选择一个选项,如图2
《CLR via C#》学习笔记【1】
取决于/platform开关选项,C#编译器生成的程序集包含的要么是PE32头,要么是PE32+头。PE32文件在32位或64位地址空间中均可运行,PE32+文件则需要64位地址空间。Windows的64位版本通过WoW64(Windows on Windows64)技术运行32位windows应用程序。

表2 /plantform开关选项对生成的模块的影响以及在运行时的影响

/plantform开关 生成的托管模块 X86 Windows X64 Windows ARM Windows RT
anycpu(默认) PE32/任意cpu架构 作为32位应用程序运行 作为64位应用程序运行 作为32位应用程序运行
Anycpu 32bit preferred PE32/任意cpu架构 作为32位应用程序运行 作为WoW64位应用程序运行 作为32位应用程序运行
X86 PE32/x86 作为32位应用程序运行 作为WoW64应用程序运行 不运行
X64 PE32+/x64 不运行 作为64位应用程序运行 不运行
ARM PE32/ARM 不运行 不运行 作为32位应用程序运行

Windows检查EXE文件头,决定进程为64或32位后,加载相应的MSCroEE.dll文件,然后进程的主线程调用该dll中的一个方法。这个方法初始化CLR,加载EXE程序集,在调用其入口方法(Main)。
如果非托管程序调用LoadLibrary加载托管程序集,Windows会自动加载并初始化CLR,以处理程序集中的代码,但由于此时进程已经启动并运行,则可能影响程序集可用性,如:64位进程无法加载使用/plantform:x86开关编译的托管程序集,但该托管程序集确实可以在64位内存空间中以WoW64方式运行。

1.4 执行程序集的代码

IL是与CPU无关的机器语言,IL比大多数CPU语言都高级,IL也能使用汇编语言编写,Microsoft甚至专门提供了名为ILAsm.exe的IL汇编器和名为IDLasm.exe的IL反汇编器。
高级语言通常只公开了CLR全部功能的一个子集,IL汇编语言允许开发人员访问CLR的全部功能。

1.4-1 JIT(just-in-time 运行时编译技术):

为了执行方法,首先必须将方法的IL转换成本机(natice)CPU指令。这是CLR的JIT编译器的职责。
《CLR via C#》学习笔记【1】

在Main方法执行之前,CLR会检测出Main的代码引用的所有类型。这导致CLR分配一个内部数据结构来管理对引用类型的访问。如图3,Main方法引用了一个Console类型,在这个内部数据结构中,Console类型定义每个方法都有一个对应的记录项(entry)。每一个记录项都有一个地址。这个地址最初指向JITCompiler,因此在Main首次调用WriteLine()方法时,JITCompiler会被调用并将该方法的IL代码编译成本机的CPU指令,随后本机CPU指令会被保存到动态分配的内存块中,然后JITCompiler回到Console结构中,修改最初对JITConpiler的引用,并使其指向刚刚开辟的内存块。
现在,Main要第二次调用WriteLine时,就会直接调用之前由JITCompiler生成的本机CPU代码,直到进程终止释放该内存块。
《CLR via C#》学习笔记【1】

JIT将本机CPU指令保存到动态内存中,这意味着一旦应用程序终止,编译好的代码也会被丢弃,因此再次运行应用程序,或者同时启动应用程序的两个实例,JIT编译器都必须再次执行编译。
另一方面JIT会对本机代码进行优化,可能花较多时间生成优化代码,但和没有优化时相比,代码优化后性能更佳。

编译器开关设置 C# LI代码质量 JIT本机代码质量
/optimize-/debug- 未优化 经优化
/optimize-/debug(+/full/pdbonly) 未优化 未优化
/optimize+/debug(-/+/full/pdbonly) 经优化 经优化

/debug:full开关告诉编译器你打算调试程序,JIT编译器会记录每一条IL指令所生成的本机代码
/debug-下则使得JIT运行的稍快,并且用的内存也稍少。
在Visual Studio中新建C#项目时,”调试“(Debug)配置指定的是/optimize-/debug:full,而”发布“(release)配置指定的是/optimize+/debug:pdbonly

虽然你可能很难相信,但许多人(包括本书作者)都认为托管应用程序的性能实际上超越了非托管应用程序。

托管应用程序较非托管应用程序的几大优势

  1. JIT能够针对本机CPU为IL代码生成指令,以利用本机指定CPU的任何特殊指令进行编译。相反,非托管应用程序通常是针对具有最小功能集合的CPU编译的。
  2. JIT编译器能够判断一个特定的测试在它运行的机器上是否总是失败,例如,假定有一个方法包含以下代码
if(numberofCPUs>1){
    ...//Do something
}

如果主机只有一个CPU,JIT编译器不会为上述代码生成任何CPU指令。

1.4-2 IL和验证

作者认为IL最大的优势在于其产出的应用程序具有出色的健壮性和安全性,将IL编译成本机CPU指令时,CLR执行一个名为验证(verification)的过程,确认代码的行为是安全的,确认传给每个方法的每个参数都有正确的类型,每个方法的返回值都得到了正确的使用。

1.4-3 不安全的代码

MS C#编译器默认生成安全(safe)代码,这种代码的安全性可以得到验证。C#也允许开发人员写不安全(unsafe)的代码。C#编译器要求包含不安全代码的所有方法都用unsafe关键字标记,并使用/unsafe编译器开关来编译源代码。


(以博主C++转来的浅薄的见识看来,如果可能的话,unsafe代码可能还是单独隔离出来写到C/C++中比较好,一方面感觉C#中的指针操作和内存模型不如C++好用,另一方面可能分开用也方便管理(看着顺心)一些,毕竟微软也提供了很方便的工具,C+/CLI也好,COM访问也好。不知这个想法对不对)(笑)


1.4-4 IL和知识产权保护

IL反汇编器可以较为轻松的对CLR托管模块进行逆向工程,如果担心分发出去的程序集,可以从第三方厂商购买“混淆器”(obfuscator)实用程序。它能够打乱程序集元数据中的所有私有符号的名称,但它们能提供的保护是有限的。如果觉得混淆器不能提供自己需要的知识产权保护等级,可以考虑在非托管模块中实现你想保密的算法。

1.5 本机代码生成器:NGen.exe

NGen.exe能将IL代码便宜成本及代码,使用它的好处主要有以下几点:

  • 提高应用程序启动速度:因为代码已经编译成本机代码,运行时不再花费时间便宜。
  • 减小应用程序的工具集

同时,它生成的文件也具有以下缺点

  • 没有知识产权保护
  • NGen生成的文件可能失去同步:当前执行环境与生成文件时有任何特征不匹配,NGen生成的文件将无法使用
    • CLR版本
    • CPU类型
    • Windows操作系统版本
    • 程序集的标志模块版本ID:重新编译后改变
    • 引用程序集的版本ID:重新编译被引用模块后改变
    • 安全性:包括声明性继承,声明性链接时等
  • 较差的执行时性能:NGen无法像JIT编译器那样对执行环境进行许多假定,不能优化地使用特定CPU指令,静态字段只能间接访问,还会到处插入代码调用类构造器,因为它不知道运行时的代码执行顺序。相较于JIT编译的版本,NGen生成的某些应用程序在执行时反而要慢5%左右。

所以使用NGen的时候应当谨慎。

1.6 Framework类库

.NET Framework包括Framework类库(Framework Class Library FCL)。FCL是一组DLL程序集的总称,其中含有数千种类型的定义,Microsoft还公开了其他的库,如:Windows Azure SDK和DirectX SDK

Visual Studio允许创建“可移植类库”项目,能用于多种应用程序类型,包括.NET Framework, Silverlight, Windows Phone, Windows Store应用和Xbox 360。

表3 部分常规FCL命名空间

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

1.7 通用类型系统

CLR的一切都围绕类型展开,由于类型是CLR的根本,所以Microsoft制订了一个正式的规范来描述类型的定义和行为,这就是“通用类型系统”(Common Type System, CTS)。
Microsoft事实上已经将CTS和.NET Framework的其他组件——包括文件格式、元数据、中间语言以及对底层平台的方位P/invoke——提交给ECMA已完成标准化的工作。最后形成的标准称为“公共语言基础结构”(Common Language Infrastructure, CLI)
我们最好区别对待“代码的语言”和“代码的行为”,使用的语言不通,固然有不同的类型定义语法,但无论使用哪一种语言,类型的行为都完全一致,因为最终是由CLR的CTS来定义类型的行为。
下面是另一条CTS规则:所有类型最终必须从预定义的System.Object类型继承。该类型允许做下面这些事情:

namespace testspace
{
class test
{
    void testfun()
    {
        object obj = 0, tar = 0;
        if (obj.Equals(tar)) ;                        //比较两个实例的相等性
        int hash = obj.GetHashCode();                 //获取实例的哈希码
        obj.GetType();                                //查询一个实例的真正类型
        object other = this.MemberwiseClone();        //执行实例的浅(按位/bitwise)拷贝
        string str = obj.ToString();                  //获取实例对象当前状态的字符串表示
    }
}
}

1.8公共语言规范

不同语言创建的对象可以通过COM互相通信。CLR则集成了所有语言,用一种语言创建对象在另一种语言中也具有完全一致的行为和特征。
语言集成是一件非常棘手的事情,要创建很容易从不同语言访问的类型,只能从各个语言中挑选其他语言都支持的功能。Microsoft定义了“公共语言规范”(Common Language Specification CLS),它详细定义了一个最小功能集。任何编译器只有支持这个功能集,生成的类型才能兼由其他符合CLS、面向CLR的语言生成的组件。
CLR/CTS支持的功能比CLS定义的多得多。具体的说,如果是开放式(对外可见)的类型,就必须遵守CLS定义的规则。但如果是封闭式的,或者说只需要在某一程序集内部访问,CLS规则就不再适用。
《CLR via C#》学习笔记【1】

关于符合CLS的类型,以下给出原书中的示例代码以作补充:

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;}
    }
}

第一个警告是因为Abc方法返回了无符号整数,一些语言是不能操作无符号整数值的
第二个警告是因为该类型公开了两个public方法,这两个方法只是大小写和返回类型有别,VB和其他一些语言无法区分这两个方法。

1.9 与非托管代码的互操作性

  • 托管代码能调用DLL中的非托管函数:托管代码通过P/invoke机制调用DLL中的函数
  • 托管代码可以使用现有的COM组件:详情可参考.NET Framework SDK提供的TlbImp.exe
  • 非托管代码可以使用托管类型:可用C#创建ActiveX控件或Shell扩展。详情可以参考.NET Framework SDK提供的TlbExp.exe和RegAsm.exe工具。

Microsoft随同Windows 8引入了称为Windows Runtime(WinRT)的新Windows API。该API内部通过COM组件来实现。但通过.NET Framework团队创建的元数据ECMA标准描述其API,好处是用一种.NET语言写的代码能与WinRT API无缝对接。第25章将对此详细叙述。