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

Tiny C Compiler's generated code emits extra (unnecessary?) NOPs and JMPs

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

#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 位可执行文件。

函数结尾之前多余的 JMP

底部的JMP进入下一条语句,这是TCC的fixed in a commit. Version 0.9.27解决了这个问题:

When 'return' is the last statement of the top-level block (very common and often recommended case) jump is not needed.

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

在右大括号之前的较低范围内具有 return 的代码示例:

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 有两种类型的序言。如果本地堆栈 space 需要 < 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)所需的堆栈数量 space。如果您添加一些局部变量,您将看到 SUB 指令中的 0x0 更改为与局部变量所需的堆栈数量 space 一致。这个序言需要 9 个字节。还有另一个序言处理需要的堆栈 space >= 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 是单通道编译器。编译器不知道函数将使用多少堆栈 space,直到 函数被处理后。为了避免提前不知道这一点,TCC 为序言预分配 10 个字节以适应最大的方法。任何更短的都被填充到 10 个字节。

在堆栈 space 需要 < 4096 字节的情况下,指令总共使用 9 字节。 NOP 用于将序言填充为 10 个字节。对于需要 >= 4096 字节的情况,将字节数传入 EAX 并调用函数 __chkstk 来分配所需的堆栈 space .

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

这是设计使然:TCC 代表 Tiny C 编译器。编译器本身设计得很简单,所以它有意不包含寻找多种优化的代码。请注意 sub esp, 0:这个无用的指令显然来自于填充函数序言模板,TCC 甚至不寻找偏移量为 0 字节的特殊情况。其他函数需要 stack space 用于局部变量,或者在任何子函数调用之前对齐堆栈,但这个 main() 不需要。 TCC 不关心,盲目发出 sub esp,0 以保留 0 个字节。

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


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

上的回答

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

函数中间甚至还有一个NOP。很明显是代码字节和解码/问题前端带宽和乱序window大小的浪费。 (有时在循环外执行 NOP 或对齐重复分支的循环的顶部是值得的,但是在基本块中间的 NOP 基本上是不值得的,所以这不是 TCC 把它放在那里的原因. 如果 NOP 确实有帮助,你可能会通过重新排序指令或选择更大的指令来做同样的事情而不使用 NOP 来做得更好。即使像 gcc/clang/icc 这样的适当优化编译器也不会尝试预测这种微妙的前端效果。)

@MichaelPetch 指出 TCC 总是希望它的函数序言是 10 个字节,因为它是一个 单通道 编译器(它不知道有多少 space 它需要局部变量直到函数结束,当它返回并填充 imm32 时)。但是 Windows 目标在修改 ESP / RSP 超过一整页(4096 字节)时需要堆栈探测,并且这种情况的备用序言是 10 字节,而不是没有 NOP 的正常序言的 9 字节。所以这是另一个有利于编译速度而不是好的 asm 的权衡。


优化编译器会将 EAX 异或零(因为它更小并且至少与 mov eax,0 一样快),并忽略所有其他指令。异或归零是最著名/最常见/最基本的 x86 窥孔优化之一,.

main:
    xor eax,eax
    ret

一些优化编译器可能仍会使用 EBP 生成堆栈帧,但在所有 CPU 上使用 pop ebp 将其拆解会严格优于 leave,对于 ESP = EBP 的这种特殊情况,所以不需要 leavemov esp,ebp 部分。 pop ebp 仍然是 1 个字节,但它也是现代 CPU 上的单 uop 指令,这与 leave which is 2 or 3 on modern CPUs. (http://agner.org/optimize/, and see also other performance optimization links in the 标记 wiki 不同。)这就是 gcc 所做的。这是一种相当普遍的情况;如果你在 之后推送一些其他寄存器 创建堆栈帧,你必须将 ESP 指向 pop ebx 之前的正确位置或其他任何内容。 (或使用 mov 恢复它们。)


TCC 关心的基准是编译速度,而不是结果代码的质量(速度或大小)。例如,the TCC web site 在 lines/sec 和 MB/sec(C 源代码)中有一个基准,与 gcc3.2 -O0 相比,它在 P4 上快约 9 倍。

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