CLR via C# 1.4 执行程序集代码
托管程序集中包含元数据和IL。IL是独立于CPU的机器语言,比大多数CPU机器语言都要高级得多。IL可以访问和操作对象类型,包含创建和初始化对象、调用虚方法和直接操作数组元素、抛出和捕获异常的指令。可以将IL看成是一门面向对象的机器语言。
通常,程序员使用C#、C++/CLI、VB等高级语言进行开发。这些高级语言的编译器生成IL。和其他机器语言相同的是,IL可以用汇编语言的方式编写。微软提供了IL Assembler(ILAsm.exe)和IL Disassembler(ILDasm.exe)。
任何高级语言大多数情况下提供的都只能是CLR全部功能的一个子集。但是IL汇编语言允许我们访问CLR的全部功能。因此,如果你的程序语言你迫切需要的隐藏了CLR的部分功能,你可以选择使用IL汇编或提供了这部分功能的编程语言来编写这部分代码。
要执行一个方法,它的IL必须首先转换为本地CPU指令。这是CLR的JIT编译器的工作。
下图展示了一个方法第一次被调用时的情况:
在Main方法执行前,CLR检测所有被Main方法中的代码引用的类型。这导致CLR分配一个内部的数据结构,用于管理对所引用到的类型的访问。在上图中,Main方法只引用了Console类型,CLR将会为此分配一个单独的内部结构。Console类型中的每个方法在该内部数据结构中都有一个对应的条目(or入口?entry)。每个entry都拥有一个地址,在这个地址中可以找到方法的实现。初始化这个数据结构时,CLR将把每一个entry设置成内部的没有文档的函数,该函数就是JITComplier。
当Main方法第一次调用WriteLine时,JITComplier函数将被调用。JITComplier函数负责将方法的IL代码编译为本地CPU指令。(注意JIT的编译针对的是方法,在方法第一次被调用的时候编译)由于IL代码时背即时编译的,CLR的这一部分通常被称为JITer或JIT Complier。
JITComplier根据Windows版本(32位or64位)的不同产生不同版本的CPU指令。
当JITComplier函数被调用时,它知道正在调用的是哪个方法,以及该方法是由哪个类型定义的。随后JITComplier函数在被调用方法所定义的程序集中的元数据内搜索被调用方法的IL(图中第2步)。JITComplier接着验证这些IL代码并将其编译为本地CPU指令。本地CPU指令保存在一个动态分配的内存块中(图中第4步)。然后JITComplier将前面内部数据结构中被调用方法的地址替换为包含本地CPU指令的内存块地址(图中第5步)。最后JITComplier函数跳转到内存块的代码上(图中第6步)。这里的代码就是WriteLine方法的实现代码。当该代码返回时,将返回到Main函数中,并继续执行下面的代码。
现在,第二次调用WriteLine方法。这时,WriteLine方法已经被验证和编译了,因此将跳过整个JITComplier函数,直接调用内存块中已有的本地代码。执行完后再次返回到Main中。下图展示了WriteLine方法被第二次调用时的情况:
这样,只有方法在第一次被调用的时候才会产生性能损失。所有对该方法的后续调用都将以本地代码做全速执行,因为不再需要对本地代码进行验证和编译了。
JIT编译器将本地CPU指令存储在动态内存块中,这意味着当应用程序关闭时,编译的本地代码也将被丢弃。因此再次运行该应用程序或者同时运行两个实例时(即在两个完全不同的操作系统进程中),JIT编译器都会再次将IL编译为本地指令。
对于大多数应用程序来说,JIT编译时产生的性能损失是微不足道的。而且大多数应用程序经常反复调用同一个方法。这些方法只会导致一次性能损失。通常方法内部执行所花费的时间要多于调用方法本身所花费的时间。
CLR的JIT编译器对本地代码的优化方式与非托管的C++编译器后端所做的工作类似。生成优化代码可能要花费很多时间,但是代码的性能要明显优于未优化的。
C#编译器有两个影响代码优化的选项:/optimize和/debug。
C#编译器使用/optimize-生成的非优化的IL代码包含非操作(NOP)和分支指令。生成这些指令是为了在调试时使用Visual Studio的“编辑后继续运行(edit-and-continue)”的特性。其他的指令允许在控制流指令(如for、while、do、if、else、try、catch和finally)中设置断点,以易于调试。在生成优化的IL代码时,C#编译器移除了这些无关紧要的NOP和分支指令,优于控制流被优化了,使得代码单步调试变得困难。并且有些在调试器内部执行的功能也无法工作了。但是,IL代码的体积更小了,EXE/DLL文件更小了,IL的可读性也更强了。
此外,如果指定/debug(+/full/pdbonly)选项,编译器将生成程序数据库(PDB,Program Database)文件。PDB文件辅助编译器查找本地变量以及将IL指令映射到源代码。/debug:full选项告诉JIT编译器希望调试程序集,JIT编译器将跟踪what native code came from each IL instruction。这允许你使用Visual Studio的just-in-time debugger特性,将调试器附加到正在运行的进程中,从而轻松地进行调试。不使用/debug:full,JIT编译器将不跟踪IL到本地代码的信息,这会使程序快一些,使用的内存也少。如果启动了一个Visual Studio调试器的进程,这会强制JIT编译器跟踪IL到本地代码的信息(不管是否有/debug选项),除非关闭Visual Studio中的Suppress JIT Optimization On Module Load(Managed Only)选项。
当新建C#项目时,Debug模式的配置为/optimize-和/debug:full,Release模式的配置为/optimize+和debug:pdbonly。
IL和代码验证
IL是基于堆栈的,这意味着它所有的指令都是将操作数(operands)压入执行栈(execution stack)中,或从栈中弹出结果。由于IL没有提供操作寄存器(execution)的指令,因此可以很容易地创建新的产生面向CLR代码的语言和编译器。
IL指令还是无类型的。例如,IL提供了add指令,对堆栈中最后两个操作数进行相加。没有32位和64位指令的区别。当执行add指令时,它判定对战中操作数的类型,然后执行相应的操作。
IL的最大好处不是对底层CPU的抽象,而是对程序健壮性和安全性的改善。在将IL编译为本地CPU指令时,CLR执行一个叫做验证(verification)的过程。verification检查高级别的IL代码,并确保它做的每件事都是安全的。例如,验证过程检查每个被调用的方法的参数数量是否无误、参数类型是否正确、方法的返回值是否可用、每个方法是否都包含return语句,等等。验证过程所使用的所有方法和类型的信息,都存在于托管模块的元数据中。
在Windows中,每个进程都有虚拟的地址空间。你不能完全信任一个应用程序的代码,因此分离的地址空间是必要的。一个程序完全有可能(而且经常)读写一个无效的内存地址。将每个Windows进程放置于分离的内存地址中,一个进程就不可能对另一个进程产生负面影响,从而换来健壮性和稳定性。
通过对托管代码的验证,代码不会访问错误的内存,也不会影响其他应用程序。这意味着可以在一个单独的Windows虚拟地址空间商运行多个托管程序。
由于Windows进程需要大量的操作系统资源,进程过多将影响性能和资源。通过在一个单独的操作系统进程中运行多个应用程序的方法,可以减少进程数量,以改善性能减少资源需求,并且不会影响健壮性。这是托管代码相比非托管代码的又一个优势所在。
事实上,CLR确实提供了一个在单独的进程中执行多个托管程序的功能。每个托管程序运行在一个AppDomain中。默认情况下,每一个托管的EXE文件运行在自己单独的地址空间中(该地址空间仅含有一个AppDomain),但是CLR的宿主进程(IIS或SQL Server)可以决定在一个单独的进程中运行多个AppDomain。
不安全的代码
默认情况下,C#编译器生成的是安全代码,即被验证为安全的代码。其实C#编译器也允许编写非安全的代码。非安全代码允许直接操作内存地址以及这些地址上的字节。
风险是,不安全的代码可以摧毁数据结构,暴露甚至开放安全隐患。因此C#编译器要求所有非安全的代码都要用unsafe关键字进行标记。在编译时,使用/unsafe编译器选项。
当JIT编译器试图编译非安全的代码时,会检查包含该方法的程序集是否通过设置System.Security.Permissions.SecurityPermissionFlag的SkipVerification标记进行System.Security.Permissions.SecurityPermission授权。如果设置了标记,JIT编译器将编译非安全代码并允许其执行。CLR新人该代码,并认为直接访问地址和字节的操作不会带来任何损害。如果没有设置标记,JIT编译器将抛出System.InvalidProgramException或System.Security.VerificationException来组织方法的执行。实际上整个应用程序都将终止,但至少没有损害。
默认情况下,对本地或网络共享的程序集给予完全信任,可以执行非安全代码。对于互联网上的程序集则不能执行非安全代码。如果其包含非安全代码,将抛出上面的异常。管理员/最终用户可以修改这些默认设置。
微软提供了一个叫做PEVerify.exe的工具,来检查程序集中的方法是否含有非安全代码,并进行通知。可以对你要引用的程序集使用PEVerify.exe工具。
需要注意的是,验证过程需要访问所依赖的程序集中的元数据。因此在使用PEVerify检查程序集时,必须定位并加载引用的程序集。由于PEVerify使用CLR来定位所依赖的程序集,那么定位程序集所使用的绑定和探测规则与执行程序集所使用的规则是一样的。
IL与知识产权
IL代码比一般的汇编语言要高级,对IL代码进行反向工程要容易得多。可以使用第三方混淆工具来保护代码。这些工具混淆程序集中元数据的私有符号,使得难以还原这些名称,也就难以理解代码的意图。注意这其实只能起到很小的保护作用,因为IL必须在某一点是可用的,以保证CLR对其进行JIT编译。
如果觉得这还不够,可以在某些非托管模块中实现更敏感的算法,使用本地CPU指令代替IL和元数据。然后使用CLR的互动性(interoperability)特性(假设有充分的许可)在非托管与托管代码之间进行通信。
CLR via C# 1.4 执行程序集代码