所有 .NET 支持的语言编写出来的程序,在对应的编译器编译之后,会先产出程序集,其主要内容是中间语言 IL 和元数据。
之后,JIT 再将 IL 翻译为机器码(不同机器实现方式不同)。
IL 使得跨平台成为可能,并且统一了各个框架语言编译之后的形式,使得框架实现的代价大大降低了。
比如,.NET 框架有N种语言,那么每种语言都必须有自己的编译器。而 .NET 框架又决定跨 M 种平台,那么,就需要有 M 种 JIT。
如果不存在 IL,则 .NET 框架为了支持 N 种语言跨 M 种平台,需要 MxN 个编译器。
但如果所有 .NET 框架的 N 种语言经过编译之后,都变成相同的形式,那么只需要 M+N 个编译器就可以了。因此,IL 大大降低了跨平台的代价。
在 .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 虽然比 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 助记符。
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 行与前面的逻辑类似,如下图所示。