CIL - 声明变量与初始化

2018/06/29

在 C# 中我们可通过 类型 变量名 [= 初始值] 这样的语句来定义变量,如果变量被定义在一个函数中,那么,这些变量就被称为本地变量(local variable)。为了简单起见,让我们先从最常见的几种类型的变量的声明与初始化来看 IL 是如何达成这个简单的任务的。

一些简单的变量声明与初始化

public void Test()
{
    int a = 10;
    var b = 3;
    int c = 666;
    int d = 2333;
    ushort e = 16;
    var str = "";
    long l;
}

上面是一段非常简单的 C# 代码,将其反编译后将会得到下面的 CIL

.method public hidebysig instance void
    Test() cil managed
  {
    .maxstack 1
    .locals init (
      [0] int32 a,
      [1] int32 b,
      [2] int32 c,
      [3] int32 d,
      [4] unsigned int16 e,
      [5] string str,
      [6] int64 l
    )

    IL_0000: nop

    IL_0001: ldc.i4.s     10 // 0x0a
    IL_0003: stloc.0      // a

    IL_0004: ldc.i4.3
    IL_0006: stloc.1      // b

    IL_0007: ldc.i4       666 // 0x0000029a
    IL_000c: stloc.2      // c

    IL_000d: ldc.i4       2333 // 0x0000091d
    IL_0012: stloc.3      // d

    IL_0013: ldc.i4.s     123 // 0x7b
    IL_0015: stloc.s      e

    IL_0017: ldstr        ""
    IL_001c: stloc.s      str

    IL_001e: ret

  } // end of method Program::Test

这段 CIL 的结构大致上跟我们的 C# 代码类似,都是先定义了函数的签名,然后在一个花括号中定义了函数体。与我们的 C# 代码最明显的一个区别就是,CIL 会先把所有的本地变量声明出来并存放在本地变量列表(local variable list)中。

nop 是我们见到的第一个 CIL 操作符,这个操作符没有任何含义,他不会对其他东西产生任何影响。那为什么需要这样一个操作符呢?

  1. 在调试模式中,为 Debugger 在一些特殊的地方提供断点位置。
  2. 帮助 CIL 字节对齐,更高效的使用内存空间。

ldc.i4.s 10,表示把 10 这个值作为 int32 类型推入栈中。 一般以 ld 开头的操作符都表示把一个值推入栈尾: [...] -> [..., num]。而紧跟着的下一行是 stloc.0,表示把栈尾的值弹出并存入本地变量列表中第 0 的位置。这两行的 CIL 就实现了用 10 初始化 a 的功能。

举一反三,那 var b = 3 肯定就是下面这样:

ldc.i4.s 3 // 把 3 推入栈中
stloc.1 // 把栈尾的值弹出并存入本地变量列表中第 1 的位置

然而事实与我们的猜想有些偏差:ldc.i4.3 代替了 ldc.i4.s 10。像 ldc.i4.3 这样的操作符还有 8 个,他们是 ldc.i4.0 ~ ldc.i4.8。表示把 0 ~ 8 推入栈尾。知道了这个规则之后,那接下来的 int c = 666,我们就可以大概确定了:

ldc.i4.s 666
stloc.2

然而偏差还是出现了,实际上生成的 CIL 是 ldc.i4 666。比之前少了个 s,通过查阅 MSDN 可知,之前我们遇到的 s 表示 short,当一个值被赋予给一个 int32 变量的时候,如果这个值可以用 int8 来表示,但又大于 8,CIL 就会产生 ldc.i4.s 这样的操作符把一个 int8 类型的值作为 int32 类型推入栈中,这样会更加高效。

在有了上面经验之后,int d = 2333 生成的 CIL 我们就可以准确的推测出来了:

ldc.i4 2333 // 把 2333 推入栈尾
stloc.3 // 把栈尾的值弹出并存储到本地变量列表的第 3 个位置

为不同数值类型变量初始化

接下来的 ushort e = 123 又是一个有意思的特例。ushort 对应的 FCL (Framework Class Library)类型是 UInt16,但我们对一个 Uint16 类型的变量进行赋值还是使用的跟对 Int32 类型变量进行赋值的操作符一样。所以,CIL 在进行赋值的时候,选用何种操作符跟值的范围有关。还有一个需要注意的地方,ReSharper(或者是 dotPeek)为了方便我们阅读会自动把 stloc.sstloc 这两个操作符的参数使用变量名来命名,实际上的参数其实是这个变量在本地变量列表的索引。

为字符串类型变量初始化

上面我们都是对数值类型进行赋值操作,接下来试试给一个字符串赋值:

IL_0017: ldstr        ""
IL_001c: stloc.s      str

首先使用 ldstr 操作符来把一个字符串字面量的引用推到栈上,其参数是字符串字面量在程序集元数据中的引用。字符串常量在编译出来的文件中被存储到程序集的元数据表中并被分配一个 token ,在运行时通过 ldstr <元数据 Token>,来加载字符串字面量并为其分配内存。ReSharper 为了方便人类阅读,自动的根据把 token 替换成了存储在程序集中的字面量。

接下来就是对本地变量赋值了,因为 str 还只是第 5 个变量,所以还是使用 stloc.s 来进行赋值。当变量在本地变量列表的位置超过 255 的时候,CIL 才会使用 stloc 这个操作符来进行赋值。

最后,我们只声明了一个 long 类型的变量,但是没有为他赋值,所以,除了会把这个变量计入本地变量列表之外不会产生额外的操作符。最后一条 ret 操作符表示函数结束,调用栈返回。

总结

初始化变量是一个非常常见的操作,在 CIL 中初始化本地变量主要有一下几个步骤:

  1. 把初始值推入栈中。

  2. 根据变量在本地变量列表中的位置,使用恰当的操作符把栈尾的值弹出并存储到对应的位置。

通常 ldxxx 表示把一个字面值推入栈中,stxxx 表示把栈尾的值存储到本地变量列表的对应位置。具体采用何种操作符跟字面值类型与字面值范围有关。