【深入理解CLR 二】CLR的执行模型

上一篇博文我讲到了如何在整个体系中定位CLR,以及CLR的一些作用,这篇博文是我在消化大佬书第一章之后的一些体会和感悟,为了之后技术分享方便,我会在一些结合我们日常开发的地方,用CLR解释一些现象,方便技术分享的时候大家理解的更加深刻。

博文的行文布局和介绍的部分如下规划

  • 首先我将介绍以下几个概念:源代码,托管模块,程序集

  • 其次我将介绍如下两个过程:Windows如何加载CLR,CLR如何执行程序集

  • 最后我将简单介绍几个相关工具与概念:本机代码生成器,Framework类库,CTS与CLS,与非托管代码的互操作

前两部分是本篇博文核心,详细介绍了CLR的执行模型,第三部分做了一些补充介绍,周边概念相关。另外,文中用到的图片部分来自原书。废话不多说,进入正题。

#CLR的执行模型
类似JAVA,半编译半解释型语言,首先不同种类的源代码会通过不同编译器统一编译为CLR可操作的语言,类似对JVM可操作的class文件一样,然后如同将class文件加载到JVM执行一样,生成的中间文件也会加载到CLR模型执行

所以执行模型的步骤如下:

  1. 第一步当然就是将不同种类的语言编译为中间语言(文件),java是class文件,C#是程序集

  2. 第二步则类似二次编译,将中间语言编译为本机的CPU指令。
    ##编译为CLR可执行
    首先,无论是何种语言,只要是面向CLR的C++/CLI,C#,VB,F#,Iron Python,Iron Ruby,LuaPHPScheme),都可以通过各自的编译器生成托管模块,如下图所示:
    这里写图片描述
    但要知道,CLR并不是面向托管模块,而是面向程序集的,它执行的是程序集文件。所以编译后交给CLR使用的应该是程序集,如下图所示:
    这里写图片描述

以上就是要送交CLR执行之前的整个过程,把握好大局之后,我再来详细介绍下各个概念和特性。
###源代码
源代码,顾名思义,其实就是我们再IDE里敲的一行行代码,是最原始的,没有任何作用,只有通过编译之后才能发挥它们的作用,例如:C#的.cs文件,Java的.java文件,C++的.cpp文件
###托管模块
####文件类型
经过编译之后生成的托管模块是标准的32位Microsoft Windows可移植执行体(PE32)文件或者标准的64位Microsoft Windows可移植执行体(PE32+)文件。
####文件结构
托管模块由以下四部分组成:PE32或PE32+头,CLR头,元数据,IL(中间语言)代码
这里写图片描述
1,PE32或PE32+头

主要是文件的一些信息,让我们知道这是一个PE32或PE32+文件。

  • 标识文件格式。若头为PE32格式,则文件可以在win32和win64上运行,若为PE32+,则只能在win64上运行,具体原因和使用我之后会在技术分享实例部分详加解释。
  • 标识文件类型。包括GUI(图形用户界面),CUI(个性化用户界面),DLL(动态链接库文件)
  • 标识文件生成时间
  • 对于包含本机CPU代码模块,该头还包括与本机CPU代码有关信息。

注意,本机代码生成的是面向特定CPU架构(x86,x64,ARM)的代码,也就是非托管代码,往往也是不安全代码,关于这两个概念,我后边会提到

2,CLR头

主要是托管模块的一些信息,让我们知道这是一个托管模块。

  • 使此模块成为托管模块信息
  • 要求的CLR版本
  • 一些标志为flag
  • 托管模块入口方法(Main)的MethodDef元数据token以及模块的元数据,资源,强名称,一些标志和不太重要数据项的位置/大小

3,元数据

元数据类似Java里class文件的表结构,感觉这里用元数据描述更加靠谱,描述数据的数据,描述IL代码的数据

  1. 描述源代码中定义的类型和成员的表
  2. 描述源代码引用的类型和成员的表

特别注意,元数据是一些老数据的超集,可以这么理解,元数据超级全面(也只有这么全面的元数据才能描述各种类型吧,个人认为感觉它之所i这么全面就是为了方便CTS使用,这点以及CTS概念后边我会提到。),还有就是,元数据和它所描述的IL代码永远紧密绑定,它们永远不分离,最终被嵌入托管模块。

4,IL(中间语言)代码

中间语言代码,类似java的字节码指令。 编译器编译源代码时生成的代码,运行时被编译为本机CPU指令

####元数据优点
为什么要使用元数据,元数据有哪些优点呢。

  1. 元数据包含了有关引用类型/成员的全部信息,编译器可以直接从托管模块读取元数据。
  2. vs可以通过元数据使用智能感知帮助写代码,代码提示
  3. 元数据允许将对象序列化–传输—反序列化重新生成对象
  4. 元数据允许垃圾回收器跟踪对象生存周期

###程序集
CLR实际上不和模块儿工作,它和程序集工作。
####基本概念
事实上程序集是一个抽象的概念。
1,程序集是一个或多个模块/资源文件的逻辑性分组,特别注意,不是物理分组哦。
2,程序集是重用,安全性以及版本控制的最小单元
####组成结构
下图左边的一些托管模块交由工具处理,工具生成代表逻辑分组一个PE32(+)文件。
**注意:**这里说的的是逻辑意义上分组,概念性东西,一组文件可以作为一个单独实体来对待。
这里写图片描述
程序集由三部分组成:清单数据块,文件或文件集,自描述信息
1,清单数据块
清单也是元数据表的集合,这些表描述了

  • 构成程序集的文件
  • 程序集中文件所实现的公开导出类型(public)
  • 与程序集关联的资源或数据文件

2,文件或文件集
这里的文件或文件集就是清单指出的的构成程序集的文件。为什么有个或呢?

  • 清单指出程序集只由一个文件组成
  • 对于只有一个托管模块,没有资源文件的项目,程序集就是托管模块.
  • 如果需要将一组文件合并到程序集中可以使用程序集链接器(AL.exe)以及其他的一些命令行选项.

3,自描述信息
在程序集的模块中,还包含与引用的程序集有关的信息(版本号、描述等等)。这些信息使程序集能够自描述.也就是说CLR能判断为了执行程序集中的代码,程序集的直接依赖对象是什么.不需要在注册表或者Active Directory Domain Services(ADDS)中注册额外的信息.由于无需额外的信息,所以和非托管组件相比,程序集更容易部署。这也就解释了为什么我们可以通过编译器查看依赖项
这里写图片描述

####特性与优点
程序集逻辑表示和物理表示区分的好处在于:将很少用到的类型和文件放到单独的文件中,并将这些文件作为程序集的一部分,如果运行时需要,则去下载,这样不仅节省了磁盘空间,还节省了安装时间.通过程序集,可以在不同的地方部署,同时仍然将所有的文件当作一个整体来看待.

具体程序集的工作流程和原理在下一篇博客中我会提到。
##CLR调用执行
上一部分我用较长的篇幅描述了代码如何从源代码变为可由CLR执行的代码。这一部分我会详细说明CLR是如何来面向程序集工作的。结合VS的一些东西来说明问题:
这里写图片描述
包括我接下来要提到的调试配置(优化代码),平台架构,不安全代码。这些实例设置结合底层的设置来帮助我们了解为什么要做这些设置,后果又是什么,所谓知其然,知其所以然。
###Windows加载CLR
/platform开关选项对于生成的模块影响以及在运行时的影响,下图为对应关系,之后详细解释:
这里写图片描述
当然,要想用CLR来执行程序集代码,首先得有CLR,CLR由windows加载而来,不同版本的操作系统也需要不同版本的CLR
####创建PE32(+)可执行文件
当我们在编译的时候,可以在vs里平台的地方选择,选择好对应架构会生成对应文件。可以观察上图的左边两列:
这里写图片描述
选择不同的开关,可以生成对应的托管模块。目前VS好像不提供ARM
这里写图片描述
####运行可执行文件过程
可执行文件运行的时候分成以下4步:

  1. Windows检查文件头,判断需要32位还是64位地址空间(也就是判断文件是PE32还是PE32+),PE32在32和64位中均可运行,只不过在64位中作为WoW64应用程序运行。PE32+只能在64位版本上运行。这也解释了为什么有的32位程序可以在64位系统上跑了,根源在这儿
    这里解释下:WOW64 (Windows-on-Windows 64-bit)是一个Windows操作系统的子系统, 它为现有的32 位应用程序提供了32 位的模拟,可以使大多数32 位应用程序在无需修改的情况下运行在Windows 64 位版本上

  2. Windows检查头中嵌入的CPU架构信息(x86/x64/ARM),确保当前计算机CPU符合要求。比如说选择ARM开关也生成PE32文件,但它确实不符合32位架构,所以也不能运行
    这里写图片描述

  3. 确定好本机CPU架构和程序作为何种应用程序(32/64)运行后。作出创建何种进程的决定,会在进程空间地址加载对应MSCorEE.dll。

  4. 然后进程的主线程调用MSCorEE.dll定义的一个方法,初始化CLR,加载EXE程序集,调用入口方法,随后托管应用程序启动并允许。所以说当我们点了exe之后,是CLR开启了整个活动。
    这里写图片描述

###CLR执行程序集代码
按照上一小节的流程,我们已经初始化完了CLR,也就是说CLR就位,待使用的托管程序集就位,接下里就是执行过程,也是重头戏。按照书中内容举例,简单如下代码的执行流程:
这里写图片描述
####执行流程
对接上一节CLR从应用程序的入口程序MAIN方法来执行。我总结了如下流程

CLR初始化类型,做准备工作。

  1. CLR检测Main代码里引用的所有类型。每个引用类型分配一个内部结构,此处为Console类型。
  2. Console类型定义的每个方法都有一个对应的记录项每个记录项都有一个地址,初始化时,每个记录项都被设置为指向CLR内部的一个未编档函数—JITCompiler

JITCompiler函数开始工作(首次调用该方法的时候)

  1. Main方法首次调用WriteLine方法的时候,该JITCompiler函数从实现类型(Console)的程序集的元数据中查找被调用的方法(WriteLine)
  2. 从元数据中查找到被调用方法的IL代码(并且要验证IL代码
  3. 分配内存
  4. 将IL代码编译为本机CPU指令,存储到3中内存------这里将存储本机代码
  5. 在Type表中修改与方法对应的条目,使它指向3分配的内存块
  6. 跳转到内存块中的本机代码

以下为我绘制的流程草图,详细执行流程还得之后学习后再说,这里是个大概流程。
这里写图片描述

跳过执行 JITCompiler函数(二次调用该方法的时候)

第二次调用该方法的时候,不需要再次编译,直接跳过JIT编译
这里写图片描述

编译好的代码被丢弃

因为分配的内存是动态内存,所以在以下两种情况的时候,编译好的代码会被丢弃

  • 程序终止,重新运行应用程序
  • 同时启动应用程序的两个实例(使用两个不同的操作系统进程)

这里提一下我的猜测,不一定准确:

  • 一个应用程序就是一个程序集,一个进程可以运行多个程序集(应用程序),一个应用程序也可以被多个进程同时运行。(果然不是很准确,一个应用不一定是一个程序集,一个程序集可以被多个应用使用—2018-5-17)
  • IL代码是进程隔离的,不同进程即使运行同一个应用程序也会导致重新编译。因为不同进程互相不可见对方代码。但是本机CPU指令是各个进程共享的,所以之后提到的NGen.exe对于提高性能很有用
    ####CLR对代码的优化
    上小节介绍了执行流程,大概有个了解之后,我们发现Jit编译可能会造成性能损耗,那么就有两种手段来给其执行加速:

1,给调试器进行对应设置。
2,提前编译为本机代码(NGen.exe会提到),该方法不推荐

我们常见的编译器开关有两个,一个是optimize(对应IL代码质量优化),一个是debug(对应于本机代码质量优化)。
这里写图片描述
猜测:第二行表示optimize-并且 debug:full也就是后边提到的Debug状态,第三行表示optimize+并且 debug:pdonly也就是后边提到的Release状态
#####optimize开关
/optimize- 表示不优化IL代码,也就是不优化以下两部分内容:

  • IL代码包含许多NOP(空操作)指令以及许多分支指令利用这些指令,VS在调试期间才能提供“编辑并继续”功能。“编辑并继续”是一种省时的功能,能够在程序处于中断模式时更改源代码。 当通过选择一条类似 Continue 或 Step 的执行命令继续执行程序时,“编辑并继续”有限制地自动应用代码更改。 这允许在调试会话期间更改代码,而不是停止程序,重新编译整个程序,再重新启动调试会话

设置详见这篇博客:https://blog.csdn.net/xiaoxian8023/article/details/7220590

  • 利用以上额外指令,还可在控制流程(for,while,do,if,else,try,catch,finally)指令上设置断点。只有这样,VS在代码调试时就可以对代码进行单步调试,如果被优化掉,一些函数求值可能无法执行。

当然优化后(optimize+),IL代码会变的更小,结果EXE/DLL文件也变小。但不能编辑并继续和单步调试还是很痛苦的,所以调试阶段最好关了,发布阶段可以打开。
#####debug开关
/debug(+/full/pdbonly)表示一定生成PDB文件,full(附加到进程),pdbonly(不附加到进程)
PDB文件帮助调试器查找局部变量并将IL指令映射到源代码,也就是IL指令和源代码之间的映射文件。

PDB 文件的全称是 Program Database,用于存放一些 IL 和 二进制文件之间的映射,主要为对应的文件名、行号等一些符号。该文件由 Visual Studio 自动生成,主要为调试程序提供便利。VS默认是打开的,当然也可以关闭,关闭之后就不能从异常定位错误了

PDB相关博客http://blog.chenxu.me/post/detail?id=f4301bf3-3709-4c8f-8a45-9b9015909ce2

debug:full表示记录IL指令与本机代码的联系。这样就能将调试器附加到进程调试

总结如下:如果没有PDB,就没有源代码与IL代码联系,无法定位错误,有PDB,但只是pdbOnly,只能看堆栈信息,不能附加到进程,debug:full,可以附加到进程调试。

VS启动时默认debug:fullReleas模式下,optimize+, debug:pdbonly, 也就是尽可能优化,不调试了,Debug模式下,optimize-,debug:full,也就是性能降到最低,但可以附加到进程调试。

若想修改启动时默认debug:full,下图中位置不打勾就是了。
这里写图片描述
####JIT编译器的优势
其实也就是介绍托管代码的优势。因为只有托管代码运行在CLR之上,非托管代码是针对一些具体CPU平台编译的,一旦调用,就能执行。这也是为什么虽然大多数时候我们选择anycpu,有时候也要指定cpu架构。

  • JIT可以适配运行本机,做一些提升性能的优化
  • JIT能判断一个特定测试是否总会失败,如果是,那么只执行一遍,不傻瓜式执行
  • JIT能进行分支预测,CLR自己评估每次的代码执行(厉害了,有点儿像AI)
  • 还提供NGen.exe,进行预编译,避免运行时编译(作用有限,之后提到)

#####IL和验证
首先IL是基于栈的无类型的,然后CLR可以通过一定方法对IL进行验证,验证后可确保代码不会不正确的访问内存。此前为了防止代码出错,一个应用程序会启一个进程,因为进程隔离,所以不会互相影响,但会启动很多进程,占用资源,现在有了验证功能,一个进程可以同时执行很多应用程序了!
#####不安全的代码
其实不安全的代码就是非托管代码:

  1. 未经CLR验证安全性
  2. 它可以直接操作内存地址

这么做是相当危险的,那么为什么还要有不安全代码呢?在如下情况可以:

  1. 与非托管模块进行互操作(注意,只有C++编译器才能将非托管代码和托管代码生成到一个模块中)
  2. 提升对效率要求极高的一个算法性能时候,需要用到此类代码

体现在VS里就是:
这里写图片描述

编译器打勾之后,所有包含不安全代码的方法还都要用unsafe来修饰如果遇到不安全代码,就会报错,我们常常看到的包含不安全代码执行的应用程序警告都来源于此,通常是从Internet下载一些程序之类的时候

#周边工具及相关概念
##本机代码生成器NGen.exe
这个工具的工作非常简单,**就是预编译IL为本机代码,防止运行时编译。**通常有以下两方面作用:

  1. 提高应用程序的启动速度,这个好理解,都编译好了,当然快。
  2. 减小应用程序工作集,这个如前所述,**本机代码是进程不隔离的,这样提前编译好可以给各个进程复用啦。**当然小。

就像之前我提到的鸡肋,这个功能一般不用,那为什么鸡肋呢?

  1. 因为没有原始IL代码,所以不能享受IL的知识产权保护
  2. NGen生成的文件可能失去同步,之前生成的环境和当前运行环境可能不匹配,例如操作系统版本,CLR版本,CPU类型
  3. 较差的执行性能,JIT编译器的三个优点(平台适配,判断,分支预测)一个都使不上,因为没有使用JIT编译器,有时候甚至比JIT现场编译更慢。

##FCL类库
类库在上一篇博文里有过介绍,这里不再赘述,贴一个常用命名空间表:
这里写图片描述
##CTS系统
为了使一种编程语言写的代码能与用另一种编程语言写的代码沟通,CLR必须能支持,而类型是CLR的根本,那么类型必须有个正式规范,也就是CTS。定义代码的行为

还有六种访问类型:
private---------------------------同一个类
family and assembly--------自己和同一个程序集里的子类,这个C#没有
family---------------------------自己和自己的子类,C#(protected)
assembly----------------------自己和同一程序集里的,C#(internal) 这个java也没有哦
family or assembly----------自己和自己的子类和同一程序集里的 C#(protected internal)
public---------------------------任何程序集里任何代码

举例说,CTS对待继承的态度是单继承,那么C++里多继承的部分就不适用。感觉这系统很鸡肋,可能没到那个层次吧。微软自家语言在上边互相通信,一般还用不上多语言功能
##CLS规范
CLS 定义了公共语言的最小集合:
这里写图片描述

要想使用其它语言类型,就不要用public和protected修饰,这两个一修饰,整个方法就暴露到外边,其它语言看到了方法中有不适合自己的类型,就要报警告。当然如果代码仅仅是自己内部使用,就没必要关CLS了。
##与非托管代码的互操作性

  • 托管代码能调用DLL中非托管函数
  • 托管代码可以使用现有COM组件
  • 非托管代码可以使用托管类型

#技术分享实例

可以就以下方面重点分享:

1, CLR整体执行流程介绍(主线):编译为IL-------加载CLR-----执行IL过程

2, 挑选一些实例来分享,解释原理:

  • win32和win64不同平台
  • JIT优化,对应于VS面板:调试开关,平台开关,安全代码开关

从学习到汇总用时3天,总算将CLR的大致执行流程汇总为本文,自我感觉还是很不错的。因为有毕设的事情,所以时间用的比较琐碎,感觉理解的还算透彻,再接再厉!

  • 3
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

存在morning

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值