【问题标题】:Tiny C Compiler's generated code emits extra (unnecessary?) NOPs and JMPsTiny C 编译器生成的代码发出额外的(不必要的?)NOP 和 JMP
【发布时间】:2018-07-22 02:30:27
【问题描述】:

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

#include <stdio.h>

int main()
{
  return 0;
}

当使用 tcc 编译时,使用 tcc code.c 会产生这个 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 位可执行文件。

【问题讨论】:

  • 其实是优化的反面,因为你没有开启它。编辑:显然你不能用tcc 控制优化。啊。好吧,它应该很小,不会生成高效的代码:)
  • TCC 不(也不应该)用于高度优化的建筑。它没有做太多优化。
  • 小而快的是 tcc,而不是它生成的代码。 Tcc 非常适合编译不会执行足够多次的东西,因此值得花时间生成好的代码。
  • @MichaelPetch v0.9.26
  • main() 通常不是你应该检查这种事情的函数,因为编译器可以/将寻找它并添加额外的东西。对于这些测试,请使用其他一些非标准名称,例如 test 或 fun 或 foo 或 bar。而且 tcc 不是一个真正查看输出的编译器,除了它的功能性之外,它不是高效的。

标签: c assembly x86 compiler-optimization tcc


【解决方案1】:

函数结语之前的多余 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 有两种类型的序言。如果需要的本地堆栈空间

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 个字节。

在需要堆栈空间 NOP 用于将序言填充到 10 个字节。对于需要 >= 4096 字节的情况,在 EAX 中传递字节数,并调用函数 __chkstk 来分配所需的堆栈空间。

【讨论】:

  • 可能值得在您的答案中总结 cmets:TCC 不是优化编译器。 JMP 并不是这里唯一愚蠢的东西,这里几乎所有其余的指令都是荒谬的。例如它甚至不寻找the peephole optimization of xor-zeroing,更不用说无用的sub esp,0,甚至是NOP!显然,它不需要创建/销毁堆栈帧,但即使在优化的代码中也有有效的参数。
  • @PeterCordes 我可以讨论所有这些事情,但这都是所要求的 JMP 指令的次要内容。如果您想在另一个答案中总结其他内容,请成为我的客人。 TCC 实际上确实做了某种程度的优化。通常不在说明中,但它会尝试在同一模块中内联代码等。说它根本没有优化是不正确的。
  • 好的,谢谢。除了这个问题,我没有看过 TCC 或其 asm 输出。
【解决方案2】:

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

这是设计使然:TCC 代表 Tiny C 编译器。编译器本身设计得很简单,因此它故意不包含用于寻找多种优化的代码。注意sub esp, 0:这条无用的指令显然来自于填写函数序言模板,而 TCC 甚至没有寻找偏移量为 0 字节的特殊情况。其他函数需要本地的堆栈空间,或者在任何子函数调用之前对齐堆栈,但是这个 main() 不需要。 TCC不管,盲目发出sub esp,0预留0字节。

(事实上,TCC 确实是一次通过,就像通过 C 语句逐个语句一样布置机器代码。它使用imm32 编码为sub,因此它有空间填写正确的数字(到达函数末尾时),即使函数使用了超过 255 字节的堆栈空间。因此,它不会在内存中构建指令列表以稍后完成汇编,它只记住一个位置来填写 @987654334 @. 这就是为什么它在不需要时不能省略 sub。)


创建一个任何人都会在实践中使用的优秀优化编译器的大部分工作是优化器。与可靠地发出高效的 asm 相比,即使解析现代 C++ 也是小菜一碟(即使不考虑自动向量化,甚至 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 字节,因为它是一个 single-pass 编译器(直到最后它才知道本地人需要多少空间函数,当它返回并填写 imm32 时)。但是 Windows 目标在将 ESP / RSP 修改超过一整页(4096 字节)时需要堆栈探测,并且这种情况的替代序言是 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 生成堆栈帧,但在所有 CPU 上使用 pop ebp 将其拆除将严格优于 leave,对于这种特殊情况,ESP = EBP 所以 mov esp,ebp 的一部分不需要leavepop ebp 仍然是 1 个字节,但它也是现代 CPU 上的单指令,与现代 CPU 上的 leave which is 2 or 3 不同。 (http://agner.org/optimize/,另请参阅 标签 wiki 中的其他性能优化链接。)这就是 gcc 所做的。这是一种相当普遍的情况;如果您在制作堆栈帧之后 推送其他一些寄存器,则必须在pop ebx 或其他任何内容之前将ESP 指向正确的位置。 (或使用mov 恢复它们。)


TCC 关心的基准是编译速度,而不是生成代码的质量(速度或大小)。例如,the TCC web site 有一个以行/秒和 MB/秒(C 源代码)为单位的基准,而 gcc3.2 -O0 在 P4 上的速度要快约 9 倍。

但是,TCC 并非完全脑残:it will apparently do some inlining,正如 Michael 的回答所指出的,最近的一个补丁确实遗漏了 JMP(但仍然不是无用的sub esp, 0)。

【讨论】:

  • NOP 不用于对齐。
  • @MichaelPetch:我没说是。我只是说对齐并不是它存在的合理解释,因为问题是推测这是一个原因。编辑以明确我认为 TCC 也不打算将其用于对齐目的。
  • 使用 Win32 PE/Win64PE 当您需要 >= 4096(一页)堆栈空间时,您需要一次触摸每个堆栈页(为了)以防止堆栈保护页导致错误.
  • 10 个字节的原因是为了说明需要的最大堆栈序言,因此可以在解析实际函数之前预先分配序言指令的空间。 TCC 是单次传递,因此直到需要多少堆栈空间以及将使用哪种分配空间的方法之后才知道。 10 字节是函数序言代码的最大大小。任何短于 10 字节的方法都用 NOP 填充。编译器/代码生成器会返回并使用所需的方法更新序言。
  • 我现在对否决票感到惊讶。这个答案一般都有合理的信息。
猜你喜欢
  • 2015-07-03
  • 1970-01-01
  • 2012-02-09
  • 2014-06-24
  • 1970-01-01
  • 1970-01-01
  • 2011-05-03
  • 1970-01-01
相关资源
最近更新 更多