CIL - 运算符

2018/06/29

C# 内置有多种常用的运算符,在这篇文章中,我们将从 CIL 的角度重新学习 C# 中的那些运算符。

一元运算符

一元运算符通常有较高的运算优先级,下面这个函数基本涵盖了 C# 中与运算有关的一元运算符。

public void UnaryOperators()
{
    int intA = +12;
    int intB = -intA;
    intB = +intA;
    bool boolC = true;
    bool boolD = !boolC;
    int intE = ~intA;
    intA++;
    intA--;
}

这个函数产生的 CIL 如下所示(或者您可以选择在线查看):

// Methods
.method public hidebysig 
    instance void UnaryOperators () cil managed 
{
    // Method begins at RVA 0x2050
    // Code size 29 (0x1d)
    .maxstack 2
    .locals init (
        [0] int32,
        [1] int32,
        [2] bool,
        [3] bool,
        [4] int32
    )

    IL_0000: nop
    IL_0001: ldc.i4.s 12
    IL_0003: stloc.0
    IL_0004: ldloc.0
    IL_0005: neg
    IL_0006: stloc.1
    IL_0007: ldloc.0
    IL_0008: stloc.1
    IL_0009: ldc.i4.1
    IL_000a: stloc.2
    IL_000b: ldloc.2
    IL_000c: ldc.i4.0
    IL_000d: ceq
    IL_000f: stloc.3
    IL_0010: ldloc.0
    IL_0011: not
    IL_0012: stloc.s 4
    IL_0014: ldloc.0
    IL_0015: ldc.i4.1
    IL_0016: add
    IL_0017: stloc.0
    IL_0018: ldloc.0
    IL_0019: ldc.i4.1
    IL_001a: sub
    IL_001b: stloc.0
    IL_001c: ret
} // end of method Program::UnaryOperators

+ 与 -

首先是我们给变量 intA 赋值为 +12,其中,+ 是一个单元运算符,作用是直接返回操作数的值。可以看到,当我们对一个数值字面量使用单元运算符的时候,编译器会自动的把结果计算出来,所以在 CIL 中并没有体现出来 + 运算符的作用。接下来我们对 intA 取相反数,然后把结果赋值给 intB,对应的 CIL 如下所示:

IL_0004: ldloc.0    // 首先把 intA 的值推到栈上
IL_0005: neg        // 然后使用 neg 操作符对栈上的值就地取反
IL_0006: stloc.1    // 把栈上的值弹出并存储到本地变量列表中的 intB 的位置上

接下来,我们对 intA 使用了 + 运算符,并将其结果存储到 intB 中,前面已经提到过了,+ 运算符只是单纯的将操作数的值返回,所以,这里的对 + 的使用并不会产生额外的 CIL:

IL_0007: ldloc.0    // 把 intA 的值推到栈上
IL_0008: stloc.1    // 把栈上的值弹出并存储到本地变量列表中的 intB 的位置上

! 与 ~

接下来的 ! 逻辑取反运算符就比较有意思了。首先,在我们初始化 bool 类型变量的时候,CIL 是使用 int32 字面量进行初始赋值的,不过最终在使用的时候,会自动截断到单字节(见本系列第一篇文章)。当我们简单的将一个变量按逻辑取反并把结果赋值给另一个变量中的时候,CIL 生成的代码就会有些复杂:

IL_000b: ldloc.2    // 把变量 boolC 的值推到栈上
IL_000c: ldc.i4.0   // 把数值 0 作为 int32 类型推到栈上
IL_000d: ceq        // 弹出并比较栈上的两个值,若相等将 1 推到栈上,否则将 0 推到栈上
IL_000f: stloc.3    // 将栈上的值弹出并存储到本地变量列表中的 boolD 的位置上

可以看到,虽然逻辑取反是一个一元运算符,但是在 CIL 中却使用了 ceq 这个二元操作符,通过将 ! 的操作数与 0 相比较,将结果作为逻辑取反的结果返回。

接下来的按位取反就比较符合我们的直觉了,CIL 直接使用了 not 这个一元操作符实现了对数值按位取反的操作:

IL_0010: ldloc.0    // 把 intA 的值推到栈上
IL_0011: not        // 将栈上的值弹出再把按位取反的结果推到栈上
IL_0012: stloc.s 4  // 将栈上的值弹出并存储到 intE 的位置上

++ 与 --

自增自减运算符是我们比较常用的两个运算符,这两个运算符能够给我们提供原地修改数值的能力,但是在 CIL 中,实现自增自减这两个操作需要使用二元操作符来达到加减的效果:

IL_0014: ldloc.0    // 把 intA 的值推到栈上
IL_0015: ldc.i4.1   // 把数值 1 作为 int32 类型推到栈上
IL_0016: add        // 弹出并将栈上的两个值相加,最终把结果推到栈上
IL_0017: stloc.0    // 把栈上的值弹出并存储到本地变量列表中的 intA 的位置上

对于自增,CIL 会生成 add 操作符,将需要自增的变量与 1 相加,而自减则是使用 sub 操作符,将需要自减的变量与 1 相减。

小结

C# 支持的很多一元运算符有些并不能在 CIL 中找到对应的一元操作符,实现这些操作有时候需要使用二元操作符来模拟。

加减乘除与移位

在 C# 中,乘除法主要由三个运算符来完成:*/%。这三个运算符在 CIL 中分别对应着 muldivrem 这三个二元操作符。加减运算符 +- 在 CIL 中对应着上面已经介绍过的 addsub。向左、向右移位 在 CIL 中对应这 shlshr。CIL 中二元操作符的使用就不再赘述了。

数值大小比较

数值大小比较运算符通常是二元运算符,接受两个数值作为操作数并返回一个 bool 类型的运算结果。这个例子 中列举出了常见的比较运算符的用法。

public void Compare()
{
    int intA = +12;
    int intB = -intA;
    bool c = intA < intB;
    bool eq = intA == intB;
    bool d = intA >= intB;
    bool e = intB != 1;
    bool f = intB != 0;
}

> 和 <

变量赋值语句我们已经非常熟悉了,所以就让我们跳过前两行代码,直接看看对变量 c 的赋值。

IL_0007: ldloc.0
IL_0008: ldloc.1
IL_0009: clt
IL_000b: stloc.2

既然比较运算符是一个二元运算符,所以就需要先使用 ldloc 操作符把操作数加载到栈上,然后使用 CIL 中的 clt 操作符就可以对栈末尾的两个变量进行比较了,如果第一个操作数小于第二个操作数,那么就会返回 1 否则返回 0

clt 是 “compare less than” 的缩写,熟悉 PowerShell 的人可能已经猜到了其他的比较运算符,在 CIL 中,还有 ceqcgt 两个运算符,分别表示 compare equalcompare greate than。所以接下来对 intA == intB 的比较会生成下面的代码:

IL_000c: ldloc.0
IL_000d: ldloc.1
IL_000e: ceq
IL_0010: stloc.3

<= 和 >=

在 PowerShell 中有一个 -ge 运算符,在 C# 里面对应的就是 >=,表示“大于等于”。然而 CIL 中并没有直接对应的操作符,所以编译器会把 a >= b 翻译成 !(a < b)。所以 intA >= intB 就生成的 CIL 代码就是:

IL_0011: ldloc.0
IL_0012: ldloc.1
IL_0013: clt        // a < b
IL_0015: ldc.i4.0
IL_0016: ceq        // 取反
IL_0018: stloc.s 4

类似的,a <= b 运算符也会被编译成 !(a > b)

!= 运算符

接下来就 != 运算符了,从上面的内容我们可以了解到,CIL 中并不包含 cne(compare not nequal) 这样的操作符,所以不等于运算需要被编译成 !(a == b),必须 intA != 1 生成的 CIL 代码就是:

IL_001a: ldloc.1
IL_001b: ldc.i4.1
IL_001c: ceq        // intA == 1
IL_001e: ldc.i4.0
IL_001f: ceq        // 取反
IL_0021: stloc.s 5

可以看到,如果我们使用了 CIL 中不存在的比较运算符,那么生成的 CIL 代码通常会使用两次运算来实现我们需要的操作,不过这里仍存在例外情况,比如对 intB != 0 的判断,根据我们上面的经验,这个比较应该会被编译成 !(intB == 0),然而实际生成的代码却是:

IL_0023: ldloc.0
IL_0024: ldc.i4.0
IL_0025: cgt.un     // 手动高亮
IL_0027: stloc.s 6

这里,编译器使用了一个我还没介绍的比较运算符:cgt.un,类似的还有 clt.un.un 后缀表示这个操作符的操作数作为无符号数值来处理。为什么编译器要这么做,明明是“不等于”怎么就能变成“大于”比较呢?

为了解开这个问题,我们需要先了解一下 CIL 是如何存储带符号整数的,在 ECMA335 Common Language Infrastructure* 这本书的 Ⅰ.12.1 Supported data types 章节中我们可以看到,CIL 是使用补码的形式来存储带符号整数的,如果忽略掉补码的符号位,那么任何非 0 数值都会大于 0 。所以,CIL 为了效率就使用了 cgt.un 操作符。