为什么这个非常简单的C#方法会产生如此不合逻辑的CIL代码?

17

最近我在深入研究IL,发现C#编译器有一些奇怪的行为。以下方法是一个非常简单且可验证的应用程序,它将立即以退出代码1退出:

static int Main(string[] args)
{
    return 1;
}

当我使用Visual Studio Community 2015编译此代码时,会生成以下的IL代码(已添加注释):

.method private hidebysig static int32 Main(string[] args) cil managed
{
  .entrypoint
  .maxstack  1
  .locals init ([0] int32 V_0)     // Local variable init
  IL_0000:  nop                    // Do nothing
  IL_0001:  ldc.i4.1               // Push '1' to stack
  IL_0002:  stloc.0                // Pop stack to local variable 0
  IL_0003:  br.s       IL_0005     // Jump to next instruction
  IL_0005:  ldloc.0                // Load local variable 0 onto stack
  IL_0006:  ret                    // Return
}
如果我要手写这个方法,似乎可以通过以下的IL实现相同的结果:
.method static int32 Main()
{
  .entrypoint
  ldc.i4.1               // Push '1' to stack
  ret                    // Return
}

这种行为是否有我不知道的潜在原因?

还是只是已组装的IL对象代码在后续进行了进一步优化,因此C#编译器不需要担心优化问题?


3
你是使用调试模式还是发布模式编译? - Verendus
3个回答

23
你展示的输出是调试版本生成的。使用发布版本(或者基本上启用了优化),C#编译器会生成你手写的同样的IL代码。
我强烈怀疑这一切都是为了让调试器更容易工作,基本上是为了使中断操作更简单,并在返回值返回之前看到它。
教训:当你想运行优化的代码时,请确保不要要求编译器生成针对调试的代码 :)

2
这非常有道理,谢谢!当在发布模式下编译时,IL与预期完全一致。 - lpmitchell
6
Jon的怀疑当然是正确的。@lpmitchell:在未经优化的代码中,你会经常看到一些“短暂”的值——也就是说,只是被推送到评估堆栈上,然后在使用完之后就被弹出——被存储并从特定的本地变量堆栈槽中读取。这对数字1没有任何作用,但是想象一下如果有一个对象引用;将其存储和检索会使GC较少意外地回收该对象,这有助于调试。 - Eric Lippert
@EricLippert 本地变量的作用很清晰,但是 br.s 指令有任何理由存在,还是只是出于发射器代码的方便?我猜如果编译器想要在那里插入中断点占位符,它可以只发射一个 nop - Lucas Trzesniewski
@LucasTrzesniewski:我已经发布了一个回答,跟进你的问题。 - Eric Lippert

11

琼的回答当然是正确的;此回答是对这个评论的跟进:

@EricLippert 当地变量是很有道理的,但那个br.s指令是否有任何理由存在,还是它只是方便存在于发射器代码中?我猜如果编译器想要在那里插入断点占位符,它可以只生成一个nop...

看一个更复杂的程序片段,那个看似毫无意义的分支就变得更有意义了:

public int M(bool b) {
    if (b) 
      return 1; 
    else 
      return 2;
}

未经优化的 IL 是什么?

    IL_0000: nop
    IL_0001: ldarg.1
    IL_0002: stloc.0
    IL_0003: ldloc.0
    IL_0004: brfalse.s IL_000a
    IL_0006: ldc.i4.1
    IL_0007: stloc.1
    IL_0008: br.s IL_000e
    IL_000a: ldc.i4.2
    IL_000b: stloc.1
    IL_000c: br.s IL_000e
    IL_000e: ldloc.1
    IL_000f: ret

请注意,有两个return语句,但只有一个ret指令。在未经优化的IL中,生成简单返回语句的代码模式是:

  • 将要返回的值装入堆栈插槽
  • 跳转/离开到方法的结尾
  • 在方法的结尾,从插槽中读取值并返回

也就是说,未经优化的代码使用了单一返回点形式。

在这种情况和原始帖子所显示的简单情况中,这种模式会导致生成“跳转到下一个”情况。当生成未经优化的代码时,“删除任何跳转到下一个”的优化器不会运行,因此它仍然存在。


-4
我即将要写的内容并不是特定于.NET,而是一般性的。我不知道.NET在生成CIL时识别和使用的优化方法。语法树(以及语法分析器本身)识别带有以下词素的返回语句:
returnStatement ::= RETURN expr ;
其中returnStatement和expr是非终结符,RETURN是终结符(return token)。因此,当访问常量节点1时,解析器会像它是表达式的一部分一样行为。为了进一步说明我的意思,下面是代码: return 1 + 1; 对于使用表达式堆栈的(虚拟)机器,代码看起来会像这样:
push const_1 // Pushes numerical value '1' to expression stack
push const_1 // Pushes numerical value '1' to expression stack
add          // result = pop() + pop(); push(result)
return       // pops the value on the top of the stack and returns it as the function result
exit         

你忘记了一个非常常见的优化,叫做常量折叠。当编译器看到1+1时,它知道这总是会得到2,无论如何。因此,编译器不会在运行时让程序执行1加1的操作,而是在编译期间进行一次加法运算。因此,在你的伪代码中,两个push const_1行将被替换为单个push const_2行。 - flarn2006

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接