CIL代码阅读入门

CIL

Posted by SixTeen on December 3, 2018

CIL

CIL是学习C#本质重要的一环。因为C#经过编译器被编译成CIL,在VES上托管执行,VES负责CIL的执行,以及为CIL的执行提供额外的服务。本文主要是搜集网上的一些关于CIL的操作码的解释,帮助看懂CIL代码。

CIL指令

CIL特性

CIL操作码

主要分为5类操作:ld入栈,st出栈,运算,转移,其他。(这里的入栈出栈是针对VES虚拟机的,出栈就是将栈里的内容赋值)

缩写 含义 操作数范围 操作数
ld load,入栈 arg,参数
loc,局部变量
c,常量
fld,字段
null,空值
.0 表示第几个参数
st store, 出栈 与ld相同  
conv convert,值类型转换    
b,br branch,跳转    
call 调用   virt,调用虚函数
newarr 将对新的从零开始的一维数组(其元素属于特定类型)的对象引用推送到计算堆栈上。    
newobj 创建一个值类型的新对象或新实例,并将对象引用(O 类型)推送到计算堆栈上。    
nop no operation, 如果修补操作码,则填充空间。尽管可能消耗处理周期,但未执行任何有意义的操作    
pop 移除当前位于计算堆栈顶部的值。    
……      

代码试读

这里编写一段会出问题的for循环中带lambda表达式的错误实例(没有使用闭包),并分析问题出在哪里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static void Main(string[] args)
{
    func f = null;
    for(int i = 0; i < 5; i++)
    {
        f += () =>
        {
            Console.WriteLine(i);
        };
    }

    f();

    Console.ReadLine();
}

delegate void func();

生成的CIL,阅读CIL代码时候,先画出两个堆栈,一个堆栈就是ld和st使用的堆栈,另一个堆栈就是进入函数的时候通过.locals init生成的固定大小的用于直接通过index索引的调用帧,调用帧存储的类型是固定的。然后根据每一个指令进行入栈出栈,就可以模拟CIL的执行。需要注意的是,指令使用到的值和对象都是从栈中取出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
.method private hidebysig static void  Main(string[] args) cil managed
{
  .entrypoint //程序入口
  // 代码大小       87 (0x57)
  .maxstack  3 //使用的堆栈最大高度是3
  .locals init ([0] class ConsoleApp1.Program/func f,
           [1] class ConsoleApp1.Program/'<>c__DisplayClass0_0' 'CS$<>8__locals0',
           [2] int32 V_2,
           [3] bool V_3)
  //新建一个调用帧
  IL_0000:  nop
  IL_0001:  ldnull //将null压栈
  IL_0002:  stloc.0	//将null出栈赋值给调用栈中的V_0
  IL_0003:  newobj     instance void ConsoleApp1.Program/'<>c__DisplayClass0_0'::.ctor()
  //生成一个新的对象并压入栈顶
  IL_0008:  stloc.1 //将栈顶元素赋值给V_1
  IL_0009:  ldloc.1 //将V_1压入栈顶
  IL_000a:  ldc.i4.0 //将常数0压入栈顶
  IL_000b:  stfld      int32 ConsoleApp1.Program/'<>c__DisplayClass0_0'::i //将栈顶的值给lambda表达式中的field中的i
  IL_0010:  br.s       IL_003c //无条件跳转到对应的指令

  //-----------------这里是循环部分
  IL_0012:  nop
  IL_0013:  ldloc.0    //将v_0压入栈顶
  IL_0014:  ldloc.1    //将v_1压入栈顶
  IL_0015:  ldftn      instance void ConsoleApp1.Program/'<>c__DisplayClass0_0'::'<Main>b__0'()
  //将lambda的函数执行体放入栈顶
  IL_001b:  newobj     instance void ConsoleApp1.Program/func::.ctor(object,native int)
  //生成一个新的委托对象
  IL_0020:  call       class [mscorlib]System.Delegate [mscorlib]System.Delegate::Combine(class [mscorlib]System.Delegate, class [mscorlib]System.Delegate)
  //合并委托生成新的多播委托
  IL_0025:  castclass  ConsoleApp1.Program/func
  //转换成对应的委托类型
  IL_002a:  stloc.0
  //将栈顶赋值到v_0
  IL_002b:  nop
  IL_002c:  ldloc.1 //将v_1放到栈顶
  IL_002d:  ldfld      int32 ConsoleApp1.Program/'<>c__DisplayClass0_0'::i
  //将field i放到栈顶
  IL_0032:  stloc.2
  //赋值到v_2
  IL_0033:  ldloc.1
  //将v_1放到栈顶
  IL_0034:  ldloc.2
  //将v_2放到栈顶
  IL_0035:  ldc.i4.1
  //将常数1放到栈顶
  IL_0036:  add
  //将栈顶2个数字相加再放到栈顶
  IL_0037:  stfld      int32 ConsoleApp1.Program/'<>c__DisplayClass0_0'::i
  //设置到field i
  //----------------------

  IL_003c:  ldloc.1    //将v_1压入栈顶
  IL_003d:  ldfld      int32 ConsoleApp1.Program/'<>c__DisplayClass0_0'::i //将field压入栈顶,这里的field是lambda中使用的i
  IL_0042:  ldc.i4.5	//将常数5压入栈顶
  IL_0043:  clt         //毕竟栈顶2层数字,栈顶小就压0,否则压1
  IL_0045:  stloc.3     //将栈顶数字存到v_3中
  IL_0046:  ldloc.3		//将v_3压入栈顶
  IL_0047:  brtrue.s   IL_0012//如果为true,非零非空就跳转到对应指令
  IL_0049:  ldloc.0    //将v_0放到栈顶
  IL_004a:  callvirt   instance void ConsoleApp1.Program/func::Invoke() //调用虚函数Invoke
  IL_004f:  nop
  IL_0050:  call       string [mscorlib]System.Console::ReadLine()
  IL_0055:  pop        
  IL_0056:  ret
} // end of method Program::Main

所有有用的指令的注释都标明了,再次说明需要注意的是,指令使用到的值和对象都是从栈中取出,调用函数需要的参数也是从栈中取出的,ldfld这种也是需要从栈中取出对象才能执行的。这里的lambda闭包可能会给你造成一些阅读的困扰,这个例子作为CIL代码阅读非常不合适,不过因为这个例子是我在C#本质论,lambda闭包变量,都用到了,所以直接搬过来使用。这里最后的结果是打印了5个5。

FIN 2018.12.03/00.23