深入探讨 .NET 系列:学习了解 CLR(2. CLR执行程序集代码)

本文是这个系列第二篇,上一篇简单介绍了一下CLR,并且提到了IL、JIT函数、程序集(DLL)等,这篇将简单介绍一下CLR如何执行程序集代码。

在讲述本文的内容之前,补充一下上篇博客关于程序集的一些内容。
程序集类型:

  1. 可执行文件(EXE):
    包含一个程序入口点,可以直接执行的文件,如Windows应用程序。
    特点:
    入口点:EXE文件必须包含一个入口点(Entry Point),操作系统在运行程序时会从这个入口点开始执行。
    独立性:通常情况下,一个EXE文件是一个独立的应用程序单元,可以直接运行在操作系统上。
    部署:EXE文件通常作为独立的应用程序部署和分发,用户可以通过双击启动程序。
  2. 动态链接库(DLL):
    DLL文件是一种包含了可重用代码和资源的文件,可以被多个程序共享和调用。通常用于存放类库、组件、插件等,以提供特定功能的代码和服务。
    特点:
    共享性:DLL文件可以被多个程序共享调用,有助于代码的复用和模块化
    动态链接:DLL文件在运行时被动态链接到调用它的应用程序中,允许应用程序在运行时加载和调用DLL中的函数和服务。
    无入口点:DLL文件本身没有入口点,不能直接执行,必须由其他程序或操作系统调用。

IL

托管程序集同时包含元数据和IL。IL是与CPU无关的机器语言,是Microsoft在请教了外面的几个商业及学术性语言/编译器的作者之后,费尽心思开发出来的。IL比大多数CPU机器语言都高级。IL能访问和操作对象类型,并提供了指令来创建和初始化对象、调用对象上的虚方法以及直接操作数组元素。甚至提供了抛出和捕捉异常的指令来实现错误处理。可将IL视为一种面向对象的机器语言。

摘自《CLR via C# 》第十页

IL代码示例:
测试代码
ildasm查看IL代码

JIT

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

JIT:
JIT(Just-In-Time)编译是CLR(Common Language Runtime)执行.NET代码的重要组成部分。JIT编译将中间语言(IL)代码在程序运行期间将IL代码编译成特定平台的机器代码。它不同于静态编译,静态编译是在程序编译阶段将代码直接编译成机器代码。JIT编译的主要优点是能够在运行时进行优化,并根据运行时环境调整编译策略。

方法的首次调用
就在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中的代码,并像往常一样继续执行。
JIT函数编译后,再次调用方法时

摘自《CLR via C# 》第十一页至十三页

小结:
简单来说,CLR内部有一个数据结构管理对所有引用类型的访问,每个引用类型都有自己的方法表,方法表中每个方法默认对应一个未编档函数(JITCompiler)。当方法首次调用时,会触发JITCompiler函数,JIT函数会找到方法的IL代码,并将IL代码转换为本机CPU指令存储在内存中,然后修改方法表中方法的指针,指向刚才编译好的CPU指令,这个操作是实时的。方法第一次执行完后,由于IL已经被编译成CPU指令保存在内存中了,所以后续执行就不会在触发JIT函数。

另:由于编译的CPU指令是保存在内存中,所以项目停止运行后,编译好的CPU指令会被丢弃。
JIT函数执行非常快,并且每个方法只会执行一次,所以不必担心性能问题。
此外CLR的JIT会对本机代码进行优化,可能会花费更久的时间生成代码,但是生成后性能更佳。具体本文不再展开讲述,感兴趣可以自行去看《CLR via C#》第十三页十四页

方法首次调用
方法解析
JIT编译器编译IL代码
生成机器代码
执行编译后的机器代码
缓存机器代码
后续调用直接执行机器代码

我个人认为,IL最大的优势不是它对底层CPU的抽象,而是应用程序的健壮性安全性。将IL编译成本机CPU指令时,CLR执行一个名为验证(verification)的过程。这个过程会检查高级IL代码,确定代码所做的一切都是安全的。例如,会核实调用的每个方法都有正确数量的参数,传给每个方法的每个参数都有正确的类型,每个方法的返回值都得到了正确的使用,每个方法都有一个返回语句,等等。托管模块的元数据包含验证过程要用到的所有方法及类型信息。

Windows的每个进程都有自己的虚拟地址空间。独立地址空间之所以必要,是因为不能简单地信任一个应用程序的代码。应用程序完全可能读写无效的内存地址(令人遗憾的是,这种情况时有发生)。将每个Windows进程都放到独立的地址空间,将获得健壮性与稳定性: 一个进程干扰不到另一个进程。 然而,通过验证托管代码,可确保代码不会不正确地访问内存,不会干扰到另一个应用程序的代码。这样就可以放心地将多个托管应用程序放到同一个Windows虚拟地址空间运行。 由于Windows进程需要大量操作系统资源,所以进程数量太多,会损害性能并制约可用的资源。用一个进程运行多个应用程序,可减少进程数,从而增强性能,减少所需的资源,健壮性也没有丝毫下降。这是托管代码相较于非托管代码的另一个优势。

事实上,CLR确实提供了在一个操作系统进程中执行多个托管应用程序的能力。每个托管应用程序都在一个AppDomain中执行。每个托管EXE文件默认都在它自己的独立地址空间中运行,这个地址空间只有一个AppDomain。然而,CLR的宿主进程(比如IIS 或者Microsoft SQL Server)可决定在一个进程中运行多个AppDomain。

摘自《CLR via C# 》第十五页至十六页

Microsoft C#编译器默认生成安全(safe)代码,这种代码的安全性可以验证。然而,Microsoft C#编译器也允许开发人员写不安全的(unsafe)代码。不安全的代码允许直接操作内存地址,并可操作这些地址处的字节。这是非常强大的一个功能,通常只有在与非托管代码进行互操作,或者在提升对效率要求极高的一个算法的性能的时候,才需要这样做。
然而,使用不安全的代码存在重大风险:这种代码可能破坏数据结构,危害安全性,甚至造成新的安全漏洞。有鉴于此,C#编译器要求包含不安全代码的所有方法都用unsafe关键字标记。除此之外,C#编译器要求使用/unsafe编译器开关来编译源代码。

摘自《CLR via C# 》十六页

本文总结:
文中描述了CLR在第一次调用程序集方法时,使用JIT函数来实时编译IL代码,转化为CPU指令,并且保存在内存中,下次直接调用。
但实际上程序集方法调用更为复杂,需要经过以下步骤:

启动应用程序
CLR初始化
加载程序集
验证程序集
方法首次调用?
JIT编译IL代码
生成机器代码
执行机器代码
直接执行机器代码
内存管理
安全性管理
异常处理
多线程管理
垃圾回收

本文暂时只讲述了JIT这一块,后续会慢慢讲述程序集的加载验证等。

  • 20
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值