Tiny C Compiler生成的代码会发出额外的(不必要的?)NOP和JMP

时间:2018-02-11 23:47:19

标签: c assembly x86 compiler-optimization tcc

有人可以解释为什么这段代码:

#include <stdio.h>

int main()
{
  return 0;
}

使用 tcc code.c 使用tcc编译时生成此asm:

00401000  |.  55               PUSH EBP
00401001  |.  89E5             MOV EBP,ESP
00401003  |.  81EC 00000000    SUB ESP,0
00401009  |.  90               NOP
0040100A  |.  B8 00000000      MOV EAX,0
0040100F  |.  E9 00000000      JMP fmt_vuln1.00401014
00401014  |.  C9               LEAVE
00401015  |.  C3               RETN

我想那个

00401009  |.  90   NOP

可能存在一些内存对齐,但是

0040100F  |.  E9 00000000     JMP fmt_vuln1.00401014
00401014  |.  C9              LEAVE

我的意思是为什么编译器会插入跳转到 next 指令的近跳转,LEAVE会执行呢?

我在64位Windows上使用TCC 0.9.26生成32位可执行文件。

2 个答案:

答案 0 :(得分:10)

功能结局前的多余JMP

底部的 JMP 转到下一个语句,这是fixed in a commit。 TCC的Version 0.9.27解决了这个问题:

  

当'return'是顶级块的最后一个语句时   (非常常见并经常推荐的情况)不需要跳转。

至于它首先存在的原因?这个想法是每个函数都有一个可能的共同出口点。如果底部有一个带有返回值的代码块,则 JMP 会进入一个公共出口点,在此处完成堆栈清理并执行ret。最初代码生成器也会在函数结束时错误地发出 JMP 指令,如果它出现在最后的}(右大括号)之前。该修复程序检查是否有一个return语句后跟一个在该函数顶层的右括号。如果有,则省略 JMP

在结束括号之前在较低范围内返回的代码示例:

int main(int argc, char *argv[])
{
  if (argc == 3) {
      argc++;
      return argc;
  }
  argc += 3;
  return argc;
}

生成的代码如下所示:

  401000:       55                      push   ebp
  401001:       89 e5                   mov    ebp,esp
  401003:       81 ec 00 00 00 00       sub    esp,0x0
  401009:       90                      nop
  40100a:       8b 45 08                mov    eax,DWORD PTR [ebp+0x8]
  40100d:       83 f8 03                cmp    eax,0x3
  401010:       0f 85 11 00 00 00       jne    0x401027
  401016:       8b 45 08                mov    eax,DWORD PTR [ebp+0x8]
  401019:       89 c1                   mov    ecx,eax
  40101b:       40                      inc    eax
  40101c:       89 45 08                mov    DWORD PTR [ebp+0x8],eax
  40101f:       8b 45 08                mov    eax,DWORD PTR [ebp+0x8]

  ; Jump to common function exit point. This is the `return argc` inside the if statement
  401022:       e9 11 00 00 00          jmp    0x401038

  401027:       8b 45 08                mov    eax,DWORD PTR [ebp+0x8]
  40102a:       83 c0 03                add    eax,0x3
  40102d:       89 45 08                mov    DWORD PTR [ebp+0x8],eax
  401030:       8b 45 08                mov    eax,DWORD PTR [ebp+0x8]

  ; Jump to common function exit point. This is the `return argc` at end of the function 
  401033:       e9 00 00 00 00          jmp    0x401038

  ; Common function exit point
  401038:       c9                      leave
  401039:       c3                      ret

之前到0.9.27的版本中,if语句中的return argc将跳转到公共出口点(函数结尾)。同样,函数底部的return argc也会跳转到函数的同一个公共出口点。问题是函数的公共出口恰好位于顶级return argc之后,因此副作用是恰好是下一条指令的额外JMP。

功能序言后的NOP

NOP 不用于对齐。由于Windows实现guard pages for the stack的方式(可移植可执行格式的程序),TCC有两种类型的序言。如果需要本地堆栈空间&lt; 4096(小于一页)然后你会看到这种代码生成:

401000:       55                      push   ebp
401001:       89 e5                   mov    ebp,esp
401003:       81 ec 00 00 00 00       sub    esp,0x0

sub esp,0未优化。它是局部变量所需的堆栈空间量(在本例中为0)。如果添加一些局部变量,您将看到 SUB 指令中的0x0更改为与局部变量所需的堆栈空间量一致。这个序言需要9个字节。还有另一个序言来处理所需的堆栈空间> = 4096字节的情况。如果添加4096字节的数组,例如:

char somearray[4096] 

并查看结果指令,您将看到函数序言更改为10个字节的序言:

401000:       b8 00 10 00 00          mov    eax,0x1000
401005:       e8 d6 00 00 00          call   0x4010e0

TCC的代码生成器假设在定位WinPE时函数序言总是10个字节。这主要是因为TCC是单通道编译器。在处理函数 之后,编译器不知道函数将使用多少堆栈空间。为了避免提前知道这一点,TCC预先为序言分配10个字节以适应最大的方法。任何较短的内容都填充为10个字节。

在需要堆栈空间的情况下&lt;指令使用的4096字节总共9个字节。 NOP 用于将序言填充到10个字节。对于需要&gt; = 4096字节的情况,在 EAX 中传递字节数,并调用函数__chkstk来分配所需的堆栈空间。

答案 1 :(得分:3)

TCC 不是优化编译器,至少不是真的。它为main发出的每条指令都是次优的或根本不需要,ret除外。 IDK为什么你认为JMP是唯一可能对性能没有意义的指令。

这是设计的:TCC代表Tiny C Compiler。编译器本身设计得很简单,因此它故意不包含寻找多种优化的代码。请注意sub esp, 0:这个无用的指令显然来自填写函数序言模板,而TCC甚至没有查找偏移量为0字节的特殊情况。其他函数需要本地的堆栈空间,或者在任何子函数调用之前对齐堆栈,但是这个main()没有。 TCC不关心,盲目地发出sub esp,0来保留0个字节。注意(来自Michaels的回答)它使用imm32编码,因此它甚至没有使用imm8编码的优化汇编程序。相反,它硬编码功能 - 序言模板,只填充该32位字段。

创建良好优化编译器的大部分工作都是优化器,任何人都会在实践中使用它。即使解析现代C ++也是花生,相比之下,可靠地发射高效的asm(即使不考虑自动向量化,甚至gcc / clang / icc也不能一直这样做)。与优化相比,仅仅生成工作但效率低的asm很容易;大多数gcc的代码库都是优化,而不是解析。请参阅Basile在Why are there so few C compilers?

上的回答

JMP(正如您从@ MichaelPetch的回答中看到的)有一个类似的解释:TCC(直到最近)没有优化函数只有一个返回路径的情况,并且不需要JMP到一个公共结语

在功能中间甚至还有一个NOP。这显然是浪费代码字节和解码/发出前端带宽和无序窗口大小。 (有时候在循环之外执行一个NOP或者某些东西值得对齐循环的顶部,这个循环是重复分支的,但是基本块中间的NOP基本上是不值得的,所以这不是TCC把它放在那里的原因如果一个NOP确实提供了帮助,你可能会通过重新排序指令或选择更大的指令来做更好的事情而不用NOP做同样的事情。即使是适当的优化编译器如gcc / clang / icc也不会试图预测这种微妙的前端效应。)

@MichaelPetch指出TCC总是希望它的函数序言是10个字节,因为它是一个单通道编译器(并且它不知道本地人需要多少空间直到结束功能,当它回来并填写imm32)。但是,当ESP / RSP修改超过整页(4096字节)时,Windows目标需要堆栈探测,并且该情况的替代序言是10字节,而不是没有NOP的正常情况下的9。所以这是另一个有利于编译速度超过良好asm的权衡。

优化编译器会将EAX归零(因为它更小并且至少与mov eax,0一样快),并省略所有其他指令。 xor-zeroing是最着名/常见/基本的x86窥孔优化之一,has several advantages other than code-size on some modern x86 microarchitectures

main:
    xor eax,eax
    ret

一些优化编译器可能仍然使用EBP制作堆栈帧,但是使用pop ebp将其删除将严格优于所有CPU上的leave,对于这种ESP = EBP的特殊情况,所以{ {1}}不需要mov esp,ebp的一部分。 leave仍然是1个字节,但它也是现代CPU上的单uop指令,与pop ebp不同,它至少为2或3.(http://agner.org/optimize/,另请参阅其他性能优化链接在标签维基中。)这就是gcc所做的。这是一种相当普遍的情况;如果在制作堆栈帧之后推送其他一些寄存器,则必须将ESP指向leave之前的正确位置或其他任何位置。

TCC关注的基准是编译速度,而不是结果代码的质量(速度或大小)。例如,the TCC web site具有行/秒和MB /秒(C源代码)与pop ebx的基准,其中P4的速度快〜9倍。

然而,TCC并非完全是脑死亡:it will apparently do some inlining,正如迈克尔的回答所指出的那样,最近的补丁确实遗漏了JMP(但仍然没有用gcc3.2 -O0)。