文章

CLR via C#学习笔记(1)

.NETCLR.NET CoreCoreCLR主体没有太大区别,之前对于这本书的了解程度是在需要的时候去查阅,现在希望能够对其进行一个相对系统,整体的学习

CLR简介

CLR,全名Common Language Runtime ,一般翻译成公共语言运行时,所谓运行时,可以对标Java生态中的JVM,无论是什么编程语言,只要能通过各种编译器编译成托管模块(managed module),就可以通过CLR执行,CLR为其运行提供了环境,其核心功能包括:内存管理、程序集加载、安全性、异常处理和线程管理等

托管模块

托管模块是指通过面向CLR的编译器编译的,最后通过CLR运行的PE(Portable Executable:可移植执行体)文件

托管模块包括几个部分:

  1. PE32或PE32+头,这里标识了托管模块可以运行的操作系统版本,以及文件类型(GUICUIDLL),文件生成时间

  2. CLR头:包含要求的CLR版本,一些flags,托管模块的入口函数(Main函数)的MethodDef元数据token,以及包括模块的元数据、资源、强名称、标志,还有其他一些不太重要的数据项的位置/大小

  3. 元数据(metadata):述源代码中定义的类型和成员,以及引用的类型和成员

  4. IL(中间语言)代码:编译器编译源码产生的代码,在运行时,会被CLR编译成编辑CPU指令

程序集

CLR直接与程序集(assembly)打交道,程序集是一个抽象概念,程序集中包含一个名为清单(manifest)的数据块。清单也是元数据表的集合,这些表描述了构成程序集的文件、程序集中文件所实现的公开导出的类型,与程序集关联的资源或数据文件等。

编译器默认将生成的托管模块转换成程序集,也就是说,C#编译器生成的是含有清单的托管模块。所以,对于只有一个托管模块且无其他资源文件的项目,程序集就是托管模块,生成过程中无需执行任何其他操作的步骤,但是如果希望将一组文件合并到程序集中,就需要程序集链接器和其他命令行选项。

在程序集的模块中,还包含与引用的程序集有关的信息(包括版本号)。这些信息使程序集可以自描述(self-describing)CLR可以通过这些信息判断程序集的直接依赖对象(immediate dependency)是什么,而不需要在注册表或其他地方保存额外的信息,所以和非托管组件相比,程序集更容易部署

加载CLR

Windows执行可执行文件时,先检查其文件头,判断需要32位还是64位地址空间。其中,如果操作系统是64位的,需要运行32位Windows应用程序的话,会通过Wow64(Windows on Windows64)技术运行32位Windows应用程序。

判断完成之后,其会在进程地址空间加载MSCorEE.dll,接着,进程调用MSCorEE.dll中定义的一个方法,这个方法初始化CLR,加载EXE程序集,调用其入口方法(Main),随即,托管应用程序启动并运行。

值得注意的是,如果非托管程序调用LoadLibrary加载托管程序集,Windows会自动加载并初始化CLR。因为此时进程以及你个启动并运行了,所以可能会限制程序集的可用性,例如,64位进程完全无法加载使用文件头为PE32的托管程序集

执行程序集的代码

托管程序集同时包含元素据和ILIL是与CPU无关的机器语言,它比大多数CPU机器语言都高级,可以将其视为一种面向对象的机器语言,其能够访问和操作对象类型,具有创建和初始化对象、调用对象上的虚方法以及直接操作数组元素的指令

高级语言通常只公开了CLR全部功能的一个子集。然而,IL汇编语言允许开发人员访问CLR的全部功能。所以如果你选择的编程语言隐藏了你迫切需要的一个CLR功能,可以换用IL汇编语言或者提供了所需功能的另一种编程语言来写那部分代码

为了执行方法,首先必须把方法的IL转换成本机(native)CPU指令。这是CLR的JIT(just-in-time)编译器的职责。

书里举了一个调用Console.WriteLine函数的例子

其在方法首次被调用时,验证并将IL代码编译成本机CPU指令,本机CPU指令保存到动态分配的内存块中。之后,JITCompiler回到CLR为类型创建的内部数据结构,替换被调用方法对应的那条记录的引用,使其指向内存块(包含了刚才编译号的本机CPU指令)的地址。最后,JITCompiler函数跳转到内存块中的代码,代码执行完毕并返回时,会回到Main中的代码,并像往常一样继续执行

第二次调用WriteLine时,因为已经对WriteLine的代码进行了验证和编译,所以会直接执行内存块中的代码,完全跳过JITCompiler函数。所以,方法仅在首次调用时才会有一些性能损失。以后对该方法的所有调用都以本机代码的形式全速运行,无需重新验证IL并把它编译成本机代码

程序重新启动,或者同时动应用程序的两个实例,JIT编译器都会再次将IL编译成本机指令。相比之下,本机(native)应用程序的只读代码页可由应用程序正在运行的所有实例共享

/optimize/debug这两个编译开关对编译生成的IL代码会有影响,这些编译选项主要是会对程序的调试提供帮助

因为JIT编译器对程序执行环境的认识比非托管编译器更深刻,所以有理由相信,托管应用程序有能力超越非托管应用程序的性能

IL和验证

IL基于栈,并且是无类型(typeless)的。其是对底层CPU的抽象,并且由于将IL编译成本机CPU指令时,CLR会执行一个验证过程,这个过程会检查IL代码,确认代码所作的一切都是安全的,所以其构建的应用程序具有健壮性和安全性。

上面提到的可以验证安全性的代码,被称为安全(safe)代码,Microsoft C#编译器也允许开发人员写不安全的(unsafe)代码。不安全的代码允许直接操作内存地址,并可操作这些地址处的字节。这类包含不安全代码的所有方法都需要用unsafe关键字标记。除此之外,C#编译器要求使用/unsafe编译器开关来编译源代码

本机代码生成器

使用.NET Framework提供的NGen.exe工具,可以在应用程序安装到用户的计算机上时,将IL代码编译成本机代码,其作用是可以提交程序的启动速度以及减少程序运行时独自占用的内存(其将IL编译成本机代码,并保存到单独的文件中。该文件可以通过内存映射的方式,同时映射到多个进程地址空间中,使代码得到了共享,避免每个进程都需要一份单独的代码拷贝)

但是,需要注意的是NGen生成的文件是没有知识产权保护的,因为在运行时CLR要求访问程序集的元数据(用于反射,序列化等功能),所以要求发布包含IL和元数据的程序集,另外,如果因为各种原因,NGen生成的文件失去了同步,也必须要对程序集的IL进行JIT编译,所以IL代码必须处于可用状态。由于编译代码时,NGen无法像JIT编译器那样对执行环境进行许多假定,所以这会造成其造成性能较差的代码

Framework类库

.NET Framework包含Framework类库(Framework Class Library, FCL),这是一组DLL程序集的统称,其中定义了数千个拥有各自功能的类

通用类型系统

CLR的一切围绕类型展开,Microsoft制定了一个正式的规范来描述类型的定义和行为,这就是通用类型系统

本文由作者按照 CC BY 4.0 进行授权