C#/.NET JIT和IL(MSIL或CIL)实现跨平台

所有 .NET 支持的语言编写出来的程序,在对应的编译器编译之后,会先产出程序集,其主要内容是中间语言 IL 和元数据。

之后,JIT 再将 IL 翻译为机器码(不同机器实现方式不同)。

IL 使得跨平台成为可能,并且统一了各个框架语言编译之后的形式,使得框架实现的代价大大降低了。

比如,.NET 框架有N种语言,那么每种语言都必须有自己的编译器。而 .NET 框架又决定跨 M 种平台,那么,就需要有 M 种 JIT。

如果不存在 IL,则 .NET 框架为了支持 N 种语言跨 M 种平台,需要 MxN 个编译器。

但如果所有 .NET 框架的 N 种语言经过编译之后,都变成相同的形式,那么只需要 M+N 个编译器就可以了。因此,IL 大大降低了跨平台的代价。

什么是 IL(CIL)

在 .NET 的开发过程中,IL 的官方术语是 MSIL 或 CIL(Common Intermediate Language, 公共中间语言)。

因此,IL、MSIL 和 CIL 指的是同一种东西,我们统一使用 IL 进行指代。

使用不同语言(例如 C# 和 VB)经过不同编译器(例如 C# 编译器和 VB 编译器),编译一段功能相似的代码(区别仅仅在于语法),其 IL 也基本相似。

可以通过反编译工具加载任意的 .NET 程序集并分析它的内容,包括它所包含的 IL 代码和元数据,以及反编译之后的 C# 代码。

在 C# 没有开源之前,这项技能是开发者进阶的必备技能,这是因为有些性质是必须通过查看 IL 或反编译才能得知的,例如装箱和拆箱发生了多少次(box 是 IL 指令),using 的本质实际上是一个 try-finally 块,闭包和委托涉及密封类、迭代器和状态机等等。

另外,也可以自己书写 IL 代码,然后使用 .NET 自带的 ilasm. exe 编译为程序集。

初识 IL

IL 虽然比 C# 低级一些,但它实际上也拥有很多助记符和指令,这些指令使得 IL 的可读性没有想象中那么差。

IL 中的关键字可以分为三类:指令、特性和操作码。

IL 指令在语法上使用一个点前缀来表示。

在 ILSpy 中,IL 指令是绿色的。这些指令用来描述代码文件的结构,包括 .namespace.class、.method、.field、.property等等。例如,如果你的代码文件包括了三个 .class,这意味着 C# 源代码包含三个类型。

下面的 IL 代码中 包括四个字段和一个方法:

// Fields
.field private object '<>2_current'
.field private int32 '<>1_state '
.field public class DataStructureLab.People '<>4_this'
.field public int32 '<i>5_1'
// Methods
.method private final hidebysig newslot virtual
instance bool MoveNext () cil managed
{

IL 特性和 IL 指令一起修饰成员。例如,一个 .class 可以被 public 修饰,指定它的可见性,也可以被 extend 修饰,指定它的父类,也可以被 static 或 instance 修饰指定它是静态还是实例成员等等。

IL 操作码提供了可以在 IL 上实现的各种操作。

真正的操作码是无法一眼理解的二进制数据(例如相加的操作码是 0x58),但是,每个操作码都对应一个助记符(例如相加的助记符是 add),助记符的长度设计得较短,这使它们有时会让人难以理解,例如创建一个新的字 符串,需要使用 Idstr 助记符。

1) IL 以栈为基础

IL 实际上是完全以栈为基础的。IL 提供了将变量压入虚拟执行栈中(称为加载,这会使栈的成员增加 1)的操作码,然后,也提供了将栈顶的值拿出来移动到内存中(称为存储,这会使栈的成员减少 1 )的操作码。

初始化局部变量时,必须将它加载入栈,然后再弹岀来赋给本地变量。

因此,初始化完局部变量之后,栈应当是空的。当使用局部变量时,必须将其从栈顶弹出,不能直接访问。

加载的助记符中,最常见的是 ldloc/ldc/ldstr,存储的助记符中,最常见的一个是 stloc。

我们先看一个非常简单的例子:

class Program
{
    static void Main(string[] args)
    {
        int i = 999;
        int j = 888;
        Console.WriteLine( i + j);
    }
}

该段代码对应的未被优化的 IL 代码(存在很多 nop 指令,它是空指令):

.class private auto ansi beforefieldinit AssemblyLab.Program extends [mscorlib] System.Object
{
    // Methods
    .method private hidebysig static void Main (string[] args) cil managed
    {
        //Method begins at RVA 0x207c
        // Code size 15 ( Oxf)
        .maxstack 2
        .entrypoint
        .locals init (
            [0] int32 i,
            [1] int32 j
        )
        IL_0000: nop
        IL_0001: ldc.i4 999
        IL_0006: stloc.0
        IL_0007: ldc.i4 888
        IL_000c: stloc.1
        IL_000d: ldloc.0
        IL_000e: ldloc.1
        IL_000f: add
        IL_0010: call void [mscorlib]System.Console::WriteLine(int32)
        IL_0015: nop
        IL_0016: ret
} // end of method Program::Main
.method publie hidebysig specialname rtspecialname instance void .ctor () cil managed
{
        // Method begins at RVA 0x2 097
        // Code size 7 ( 0x7 )
        maxstack 8
        L_0000: ldarg.0
        L_0001: call instance void [mscorlib]System.Obj ect::.ctor()
        IL_0006: ret
    } // end of method Program::.ctor
} // end of class AssemblyLab. Program

Main 方法的大部分代码都含有一个助记符。其中,nop 是编译器在 Debug 模式下插入的方便我们调试设置断点的空操作,所以,这里我们就忽略 nop。

首先看看方法的定义:

.method private hidebysig static void Main (string[] args) cil managed

IL 指令 .method 指出后面的代码为一个方法。IL 特性 private 指出该方法是私有的 (如果一个方法在 C# 中,没有显式给出可见性关键字,则默认的关键字是 private)。

而 hidebysig 的意思是,这个方法会被隐藏,当且仅当其父类存在同名且同签名 (相同的输入和输出参数个数和类型 ) 的方法 (hide by name and signature)。

后面的 static、void 和 C# 的意思是一样的。Main 方法接受一个字符串数组作为参数,cil managed 顾名思义是表示该方法为托管的。

下面的这一段代码中,我们看到了栈的身影:

.maxstack 2
.entrypoint
.locals init (
    [0] int32 i,
    [1] int32 j
)

由于代码仅仅有两个变量,因此栈的最大空间为2。之后,.entrypoint 指令指示编译器, 代码的入口点在此。

.local init 指令定义两个 int 类型的变量 i 和 j。

使用类之前,如果没有声明构造函数,C# 自动提供一个构造函数,用来调用它的所有父类的构造函数。

Program 类没有显式声明父类那么它的父类就是 System.Object。

Program 的构造函数以 .ctor 作为名称 ( 这是实例构造函数,静态构造函数以 .cctor 作为名称):

.method public hidebysig specialname rtspecialname instance void .ctor () cil managed
{
    //Method begins at RVA 0x2097
    // Code size 7 ( 0x7)
    .maxstack 8
    IL_0000: ldarg.0
    IL_0001: call instance void [mscorlib]System.Object::.ctor()
    IL_0006: ret
} // end of method Program:: . ctor

Program类Main方法的IL代码主体如下:

IL_0000: nop
IL_0001: ldc.i4 999
IL_0006: stloc.0
IL_0007: ldc.i4 888
IL_000c: stloc.1
IL_000d: ldloc.0
IL_000e: ldloc.1
IL_000f: add
IL_0010: call void [mscotlib]System.Console::WriteLine(int32)
IL_0015: nop
IL_0016: ret

0001 行加载了第一个变量(通过 ldc.i4), 其中,i4 代表 int32 类型,而后面的 999 则是变量的值。

0006 行则把刚刚加载的变量从栈中第 0 个位置弹出,并赋值给第 0 个局部变量 i。

0007 和 000c 行与前面的逻辑类似,如下图所示。

C#/.NET JIT和IL(MSIL或CIL)实现跨平台

之后,000d 行使用 ldloc 将第 0 个变量加载进栈,000e 将第 1 个变量加载进栈,000f 将它们加起来,在加完之后,原本的 i 和 j 将消失,值为 888+999 =1887,将它推入栈。

最后,0010 行使用 call 调用指定的方法。0016 行使用 ret 返回,如下图所示。

C#/.NET JIT和IL(MSIL或CIL)实现跨平台

2) 编写 IL 代码并运行

我们可以通过记事本等文本处理工具编写 IL 代码。之后,可通过微软提供的 ilasm 工具将其生成为一个具有 PE 文件头的程序集。

我们建立一个新的文件 helloworld.il,使用 IL 编写 HelloWorld 程序。

该程序需要一个 Main 方法以及一个空的 .assembly 即可。

幸运的是,ilasm 会自动帮我们将 mscorlib.dll 引用加入进来 (或者也可以打开之前生成的 program.exe,将外部程序集 mscorlib 的引用那一段抄过来也行)。

所以,其实下面的代码就足够了:

//没有这段也行
//这是对mscorlib的引用,可以从其他地方直接抄过来
//.assembly extern mscorlib
//{
//    .ver 4:0:0:0
//    .publickeytoken = (B7 7A 5C 56 19 34 E0 89)
//}

.assembly helloWorld
{
}

//程序集的 IL 代码,所有的助记码都应该已经见过了
//其中,IL0001这些行号不是必须的,不影响程序运行
.method static void Main()
{
    .entrypoint
    .maxstack 1

    ldstr "Hello, world!"
    call void [mscorlib]System.Console::WriteLine(string)

    ret
}

注意,我们的 Main 方法不属于任何类,是一个光杆方法,这在 Visual Studio 中是不可能的。

使用 ilasm helloworld.il 命令进行生成,会收到一个警告(没有添加对 mscorlib 的引 用),不过,编译器好心地为我们加上了 mscorlib。

因此,编译可以通过:

Microsoft (R) .NET Framework IL Assembler.  Version 4.6.1055.0
Copyright (c) Microsoft Corporation.  All rights reserved.
Assembling 'helloworld.il'  to EXE –> 'helloworld.exe'
Source file is ANSI

helloworld.il(10) : warning : Reference to undeclared extern assembly 'mscorlib'
. Attempting autodetect
Assembled global method Main
Creating PE file

Emitting classes:

Emitting fields and methods:
Global  Methods: 1;

Emitting events and properties:
Global
Writing PE file
Operation completed successfully

编译结束之后,会生成 helloworld.exe 文件。执行效果如下图所示。

helloworld.exe运行效果

我们看看编译之后的 helloworld.exe 文件,会发现它非常有趣:

].class private auto ansi '<Module>'
{
    // Methods
    .method static privatescope
] void Main$PST06000001 () cil managed
    {
        // Method begins at RVA 0x2050
        // Code size 11 (0xb)
        .maxstack 1
        .entrypoint

        IL_0000: idstr "Hello, world!"
        IL_0005: call void [mscorlib]System.Console::WriteLine(string)
        IL_000a: ret
- } // end of method '<Module>'::Main
-} // end of class <Module>

是的,仅仅三行就结束了。而且,最重要的是,我们的 IL 代码中,Main 方法不属于一个类,也就是说我们的程序没有类,只有一个方法。

所以,编译器自己给我们生成了一个类 <Module>,而且,它不继承自 System.Object,也没有构造函数。

实际上,如果直接写 IL 代码,你的程序可以变得十分无法无天:

  • 将上面的 IL 代码中的方法名由 Main 改为其他名称,程序仍然可以运行,实际上,IL 只会关心是否有且仅有一个方法带有 .entrypoint 指令,根本不在乎方法的名称。
  • 定义一个不属于任何类型的全局方法。
  • 阻止类型自动从 Object 类继承。
  • 使用非 0 开始的数组。
  • 允许两个相同名字、相同输入类型及个数的参数,但输出类型不同的方法存在。

类似毁三观的事情还有很多,这些都是 IL 允许但 C# 不允许的,因为正如上所说,C# 只实现了 CTS 的一部分功能。

而且,还通过 csc.exe 做了手脚,强制为我们加上了一些性质,例如类型(除了接口)必须继承自 System.Object。

3) 加载与存储

IL 中所有的变量都需要加载到栈上才可以使用,这里的使用包括赋值和运算(赋值也是一种运算)。

通过自己编写 IL 可以更好地理解 IL 中的加载与存储。IL 中常用的加载命令有:

  • ldloc.x:将索引x处的局部变量加载到栈上。
  • Idstr:推送一个字符串。
  • ldc.xx.y :将类型为 xx 的值加载到栈上。

常用的存储命令有:

  • stloc.x:弹出栈顶的值,并存储于索引 X 处的局部变量列表中。

下面举例说明,C# 目标代码如下:

static void Main(string[] args)
{
    int i = 999;
    int j = 888;
    Console.WriteLine(i+j);
}

因此,我们总共有 2 个变量,最大栈容量为 2:

//方法不一定要使用Main命名
.method static void Add()
{
    .entrypoint
    .maxstack 2
    //做加法
    //调用 WriteLine 的 int32 重载
    call void [mscorlib]System.Console::WriteLine(int32)

    ret
}

在使用变量之前,要先声明局部变量(名字可以随便起):

//局部变量
.locals init (int32 num1,int32 num2)

下面开始做加法。这需要加载 2 个 int 变量,然后将它们存储在局部变量中:

//Idc.i4 加载一个 int32 变量进栈,值为888
ldc.i4 888

// stloc将栈顶的变量弹出,并赋给num1 (写成 stloc . 0也可,0对应num1 )
stloc num1 // stloc . 0

ldc.i4 999
stloc num2 // stloc . 1

注意,stloc.x 不意味着使用栈上第 X 个元素,而是将栈顶的元素拿出来,赋给第 X 个变量。

当加载、存储都完成之后,现在要使用变量了。使用变量时,必须也将变量压入栈中,然后才可以使用:

//将两个变量压入栈
ldloc num1 //ldloc.0
ldloc num2 //ldloc.1

//add会将栈上最顶的两个数相加,然后弹出结果
add

完整的IL代码如下:

.assembly helloWorld
{
}

//方法不一定要使用Main命名
.method static void Add()
{
    .entrypoint
    .maxstack 2
  //局部变量
    .locals init (int32 num1,
                  int32 num2)
     
//ldc.i4加载一个int32变量进栈,值为888
ldc.i4 888

//stloc将栈顶的变量弹出,并赋给num1(写成stloc.0也可,0对应num1)
stloc num1 //stloc.0
ldc.i4 999
    stloc num2 //stloc.1

//将两个变量压入栈
ldloc num1 //ldloc.0
ldloc num2 //ldloc.1

//add会将栈上最顶的两个数相加,然后弹出结果
add

//调用WriteLine的int32重载
    call void [mscorlib]System.Console::WriteLine(int32)

    ret
}

将上面的代码保存在 add.il 文件中,编译并运行,可以得到 1887 的正确结果:

Microsoft (R) .NET Framework IL Assembler.  Version 4.6.1055.0
Copyright (c) Microsoft Corporation.  All rights reserved.
Assembling 'add.il'  to EXE –> 'add.exe'
Source file is ANSI

add.il(23) : warning : Reference to undeclared extern assembly 'mscorlib'. Attem
pting autodetect
Assembled global method Add
Creating PE file

Emitting classes:

Emitting fields and methods:
Global  Methods: 1;

Emitting events and properties:
Global
Writing PE file
Operation completed successfully


add.exe运行效果

4) Call 与 Callvirt 的第一次讨论

调用函数的 IL 助记符有 Call 与 Callvirt 两种形式。顾名思义,Callvirt 可以调用虚方法,不过它也可以调用实例方法,但不能调用静态方法;而 Call 可以调用所有方法。

那么,Call 与 Callvirt 有什么区别呢?我们先写岀下面的代码:

namespace Callvirt
{
    class Program
    {
        static void Main(string[] args)
        {
            A nullObject = null;
            nullObject.Print();

            Console.ReadKey();
        }
    }

    class A
    {
        public void Print()
        {
            Console.WriteLine("A");
        }
    }

    class B : A
    {
        public void Print()
        {
            Console.WriteLine("B");
        }
    }
}

代码很简单,显然它可以编译通过,但却会在运行时报错,因为我们试图在一个 null 对象上调用方法。

我们看看对应的 IL 代码(这里只显示了 Main方法):

IL_0000:  nop
IL_0001:  ldnull
IL_0002:  stloc.0
IL_0003:  ldloc.0
IL_0004:  callvirt   instance void code_2.A::Print()
IL_0009:  nop
IL_000a:  call       valuetype [mscorlib]System.ConsoleKeyInfo [mscorlib]System.Console::ReadKey()
IL_000f:  pop
IL_0010:  ret

其中,IL_0004 使用了 callvirt。我们将整个 IL 代码保存在 callvirt.il 文件中,然后使用 ilasm 编译并运行,程序出错,这符合我们的预期:

Microsoft (R) .NET Framework IL Assembler.  Version 4.6.1055.0
Copyright (c) Microsoft Corporation.  All rights reserved.
Assembling 'callvirt.il'  to EXE –> 'callvirt.exe'
Source file is ANSI

callvirt.il(7) : warning : Reference to undeclared extern assembly 'mscorlib'. A
ttempting autodetect
Assembled method Callvirt.A::Print
Assembled method Callvirt.A::.ctor
Assembled method Callvirt.B::Print
Assembled method Callvirt.B::.ctor
Assembled method Callvirt.Program::Main
Assembled method Callvirt.Program::.ctor
Creating PE file

Emitting classes:
Class 1:        Callvirt.A
Class 2:        Callvirt.B
Class 3:        Callvirt.Program

Emitting fields and methods:
Global
Class 1 Methods: 2;
Class 2 Methods: 2;
Class 3 Methods: 2;
Resolving local member refs: 2 -> 2 defs, 0 refs, 0 unresolved

Emitting events and properties:
Global
Class 1
Class 2
Class 3
Resolving local member refs: 0 -> 0 defs, 0 refs, 0 unresolved
Writing PE file
Operation completed successfully

运行结果如下图所示。

Callvirt.exe运行结果

现在我们将代码中的这一句 ( 位于 Main 函数中):

callvirt instance void Callvirt.A::Print()

中的 callvirt 替换为 call,然后再次使用 ilasm 编译并运行,结果如下图所示。

C#/.NET JIT和IL(MSIL或CIL)实现跨平台

程序不会报错,并确实执行了 Print 方法,打印出 A。也就是说,当使用 Callvirt 时,会检查实例对象是否为 null,而 Call 就可以直接进行调用。

Syste itlR eflection.Emit

除了可以使用记事本写 IL 代码之外,微软还提供了 System.Reflection.Emit,使得我们可以用 C# 写 IL。

有了 IL 的基本知识,我们现在就看看动态程序集的作用。

顾名思义,动态程序集是在运行时才加载的程序集,它们可以绑定通过 System.Reflection.Emit 在运行时创建的新程序集。

创建新程序集也必须遵循程序集的构成:创建程序集,托管模块、类型,最后才是方法,因为程序集必须至少要有一个托管模块。

System.Reflection.Emit 提供了 ILGenerator 为方法注入 IL 代码。

ILGenerator 提供了 Emit 方法,方法可以传入 IL 助记码,这使得建立 IL 代码片断变得十分简单:只需要先写 C# 代码,再编译,使用 ILSpy 获得 IL 代码,然后再对照 IL 代码即可。

下面演示了一个动态程序集的例子:

static void Main(string[] args)
{
    //创建程序集
    EmitLab();

    //加载动态程序集
    var asm = Assembly.Load("HelloWorldAssembly");

    //获得HelloWorld类
    var type = asm.GetType("HelloWorld");

    //获得Print方法
    var m = type.GetMethod("Print");

    //创建一个HelloWorld类的实例,并调用方法
    var instance = Activator.CreateInstance(type);
    m.Invoke(instance, null);

    Console.ReadKey();
}
static void EmitLab()
{
    //得到当前线程所在的应用程序域
    var appdomain = Thread.GetDomain();

    //必须在应用程序域中才能创建程序集
    var assemblyName = new AssemblyName
    {
        Name = "HelloWorldAssembly"
    };
    var assembly = appdomain.DefineDynamicAssembly(assemblyName, AssemblyBuilderAccess.Save);

    //在程序集中定义模块,如果程序集只有一个模块,可以将程序集名设置为主模块的名字
    var module = assembly.DefineDynamicModule("MainModule", "HelloWorldAssembly.dll");

    //定义一个公共类
    var classHelloWorld = module.DefineType("HelloWorld", TypeAttributes.Public);

    //定义一个名为Print的方法,没有参数,什么也不返回
    var method = classHelloWorld.DefineMethod("Print", MethodAttributes.Public, null, null);
    var methodIL = method.GetILGenerator();

    //为方法加入输出
    methodIL.EmitWriteLine("Hello world!");

    //这里调用了助记符ret返回
    methodIL.Emit(OpCodes.Ret);

    //在做好所有工作之后,创建HelloWorld类
    classHelloWorld.CreateType();

    //保存程序集
    assembly.Save("HelloWorldAssembly.dll");
}

在这个例子中,我们沿着应用程序域→程序集托管模块→类型→方法的顺序建立程序集。

使用这种方法建立程序集和直接书写 IL 代码相比,可以说各有优缺点。书写 IL 代码建立程序集,托管模块和类型非常简单,也可以一目了然地得知类型有多少方法。但使用 Emit,不需要计算栈的最大值,也不需要手动使用 ilasm 编译。

在反射效率较差的时代,Emit 曾用来改善程序的性能。当出现表达式树(内部使用了 Emit)之后,很少会有直接使用 Emit 编程的情景了,除非你需要在运行时动态创建程序集,或者对性能有非常苛刻的需求,通常来说,对需要大量调用的方法进行优化是比较有意义的选择。

即时编译(JIT)

为了使程序真正运行起来,IL 代码需要变成机器码,从而使得机器能够理解。

CLR 把这一步交给 JIT 编译器去做。JIT 编译器会使用即时编译,对不同架构的机器生成不同的代码,所以大部分的代码优化都在这里完成。

JIT 只有在运行时才会工作,当生成(Build)项目时,JIT 不会工作。

即时编译(Just-in-time compilation, JIT)是动态编译的一种形式,是一种提高程序运行效率的方法。

通常,程序有两种运行方式:预先编译(AOT)与动态编译。

预先编译的程序在执行前全部被翻译为机器码,而动态编译执行的则是一句句,边运行边翻译。

即时编译则混合了这二者,一句句编译源代码,但是会将翻译过的代码缓存起来以降低性能损耗。

相对于预先编译代码,即时编译的代码可以处理延迟绑定并增强安全性。

当运行程序时,CLR 先会调用类加载器加载需要的类型,加载完成之后,就创建了类型对象,包括方法表。

假设类型已经加载完成了,则此时的情况如下图所示。

JIT之前

现在,试图首次调用该类型的 X 方法。由于其没有对应的机器码(除非它预先编译好 了,例如,Console 类中的方法是预先编译好的),所以 CLR 会在调用时遭遇 jmp 指令(目标为 JIT 编译器)。

它负责将方法的 IL 代码转换为机器码。编译过程中,会做运行时的类型验证。

编译完成之后,将机器码存储在缓存中,并将缓存地址放在 jmp 指令的后面,代替之前的 JIT 编译器地址。

以后对该方法的所有调用都不需要再次 JIT 编译,如图所示。

JIT之后

JIT 会将机器码储存在内存中,当程序结束后,这些机器码就会消失。所以每次程序运行都伴随着即时编译。

不过,这个现象带来的性能损耗仅仅会在方法第一次调用时体现,大部分程序都会调用方法不止一次。

如果你十分在乎机器码的重建带来的性能损失,你可以使用 NGen.exe 工具将 IL 预先编译为本地代码,并将本地代码存入磁盘持久化,不过,本地代码的大小可比 IL 大多了,而且,对完全没有调用过的方法,预先编译也会将其转换为本地代码,但 JIT 则是按需编译。因此,预先编译可能未必有听上去那么好。

运行时的验证

在 JIT 的编译过程中会执行验证,通过将代码和元数据中的定义进行比对,确定代码的类型安全性。

JIT 会扫描 IL 代码,并对其中引用的方法探测对应的程序集,例如:

callvirt instance class [mscorlxb]System.Type [mscorlib]System.Object::GetType()

这里第一次调用了方法 GetType,它属于类型 System.Type,而 System.Type 的元数据又指明它位于 mscorlib。

因此,CLR 会定位并加载这个程序集。解析一个类型时,如果类型本身便位于该 IL 代码文件中,或者虽然不位于该文件中,但位于同一个程序集,则不需要加载其他程序集。

如果类型位于不同的程序集,则需要先通过元数据找到程序集的名称(例 如它的名称为 X),再通过当前程序集的清单,确定X确实在清单中。

最后,根据 X 为强名称程序集(去 GAC 寻找)或非强名称程序集(使用探测,配合 App.config 的设置)。

如果还是找不到,就会引发异常。

普通情况下,每个程序都有自己的进程和独立的内存空间,这保证了程序的稳定,使得程序不能读取自己的内存空间之外的内存地址(很可能是无效的地址)。

不过,通过 CLR 的验证,可以确保代码不会访问一个无效的内存地址,这使得将多个托管的程序放到一个 Windows 进程中运行成为了可能。

CLR 提供了一个“轻型”的进程机制——应用程序域,为程序和进程之间增加了一层。

程序实际运行在应用程序域中,而多个应用程序域可以运行于一个进程之中。

应用程序域减少了系统的总进程数,也减少了上下文切换的速度,从而提升了系统的性能。

上面的验证全部发生在运行时,在程序刚开始运行的时候,并不是所有的程序集都会被加载,程序集是根据需要被加载的。

Visual Studio 的编译模式与本地代码的优化

Visual Studio 提供了两种编译模式:调试(debug)和发布(release)。

其中,调试模式指定的编译参数为 /optimize- 和 /debug:full,它会带来以下的结果:

  • 禁止 CLR 对 IL 和 JIT 的编译中,对本地代码进行优化的行为。
  • 生成一个 PDB 文件。

这两个结果联合起来,使得单步调试成为可能。

首先,未优化的 IL 代码将包含许多 nop 指令,它是一个空的操作,方便你设置端点时使得代码可以停在那里。

例如,我们生成的 A.netmodule 托管模块中的 IL:

IL_0000: ldarg.0
IL_0001: call instance void [mscorlib] System.Objject: : .ctor()
IL_0006: nop
IL_0007: nop
IL_0008: ldstr "A 被创建"
IL_000d: call void [mscorlib]System.Console::WriteLine(string)
IL_0012: nop
IL_0013: ret

整个 IL 只有 8 句话,竟包含了 3个 nop。如果使用发布模式进行编译,这些 nop 都将消失不见,使得程序集变得更小。

另外,PDB 文件和调试器一起工作,使得开发者可以在调试时查找局部变量,甚至运行一些短小的方法。

发布模式指定的编译参数为 /optimize+ 和 /debug:pdbonly,它会带来以下的后果:

  • 允许 CLR 对 IL 和 JIT 的编译中,对本地代码进行优化的行为。
  • 禁止生成 PDB 文件。

CLR 的 JIT 编译器会对本地代码进行优化,例如字符串驻留中对常量字符串相加的优化,或者一个永远不会进入的 for 循环(比如,i=100, i<10, i++)。

和没有优化相比,优化之后的代码将获得更出色的性能,程序集也更小。

而IL编译器(C#的csc.exe)也会优化你的 C# 代码,例如,会把一个没有赋值也没有使用过的局部变量直接忽略掉。

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

上面说的所有内容都是 CLR 编译的 IL 或机器码,它们也叫做托管代码。

但实际上,很多托管代码都要和非托管代码打交道,例如 Winform 程序背后是由 Windows 消息机制在支撑。

因此,即使一个简单的按钮点击,都会牵扯到给 Windows 系统发送消息,而这需要非托管代码来实现。

CLR支持三种互操作的方式:

1) 使用平台调用(Platform Invoke, PInvoke)

通过 PInvoke 可以调用一个外部 dll 中的函数。C# 源码中,很多 FCL 的库都要继续调用 Windows API ( Kemel32. dll、User32.dll等dll)实现需要的功能。

2) 使用COM组件

如果你希望在 C# 中调用其他微软系列的产品(例如 Excel.Word 等),就需要使用 COM 组件。

Excel.Word 等软件直接和 COM 库进行交互,如下图所示,因此,在 C# 中要想使用 Excek Word 等软件产生表格或文档,只能暂时离开 CLR,去到更底层的 COM 中。

3) 非托管代码使用托管类型

例如可以使用 C# 创建一个 ActiveX 控件或者一个 shell 扩展。

虽然可以调用非托管代码,但非托管代码运行是和 CLR 没关系的,也不会经过 JIT 编译和验证,所以,访问无效内存是完全可能的。

编写非托管代码时,你的对象没有 CLR 精心呵护,可能会让你觉得面目狰狞,所以,一定要管理好对象。

原创文章,作者:奋斗,如若转载,请注明出处:https://blog.ytso.com/tech/pnotes/22351.html

(0)
上一篇 2021年7月20日 10:36
下一篇 2021年7月20日 10:36

相关推荐

发表回复

登录后才能评论