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

ale*_*stx 7 c x86 assembly tcc compiler-optimization

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

#include <stdio.h>

int main()
{
  return 0;
}
Run Code Online (Sandbox Code Playgroud)

当使用tcc使用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
Run Code Online (Sandbox Code Playgroud)

我猜可能是

00401009  |.  90   NOP
Run Code Online (Sandbox Code Playgroud)

也许有一些内存对齐,但是怎么样

0040100F  |.  E9 00000000     JMP fmt_vuln1.00401014
00401014  |.  C9              LEAVE
Run Code Online (Sandbox Code Playgroud)

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

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

Mic*_*tch 10

功能结局之前的多余JMP

底部的JMP转到下一个语句,这是在提交修复的.TCC 版本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;
}
Run Code Online (Sandbox Code Playgroud)

生成的代码如下所示:

  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
Run Code Online (Sandbox Code Playgroud)

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


功能序言后的NOP

NOP不是对齐.由于Windows 为堆栈实现保护页面的方式(可执行程序格式的程序),TCC有两种类型的序言.如果所需的本地堆栈空间<4096(小于单个页面),那么您会看到生成的这种代码:

401000:       55                      push   ebp
401001:       89 e5                   mov    ebp,esp
401003:       81 ec 00 00 00 00       sub    esp,0x0
Run Code Online (Sandbox Code Playgroud)

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

char somearray[4096] 
Run Code Online (Sandbox Code Playgroud)

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

401000:       b8 00 10 00 00          mov    eax,0x1000
401005:       e8 d6 00 00 00          call   0x4010e0
Run Code Online (Sandbox Code Playgroud)

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

在堆栈空间需要<4096字节的情况下,指令总共使用9个字节.的NOP被用于衬垫的序幕10个字节.对于需要> = 4096字节的情况,在EAX中传递字节数,并__chkstk调用该函数来分配所需的堆栈空间.


Pet*_*des 6

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

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

(实际上,TCC 是真正的一次传递,像通过 C 语句一个语句一样布置机器代码。它使用imm32for 编码,sub因此它有空间填充正确的数字(到达函数末尾时)甚至如果事实证明该函数使用了超过 255 字节的堆栈空间。因此,它不会在内存中构造一个指令列表以稍后完成组装,而是只记住一个位置来填充 a uint32_t。这就是为什么它不能省略 sub 的原因事实证明不需要。)


创建一个任何人都会在实践中使用的良好优化编译器的大部分工作是优化器。与可靠地发出高效的 asm 相比,即使解析现代 C++ 也是小菜一碟(即使不考虑自动向量化,gcc / clang / icc 也不能一直这样做)。与优化相比,仅生成有效但效率低下的 asm 很容易;gcc 的大部分代码库是优化,而不是解析。请参阅 Basile 关于为什么 C 编译器如此之少的回答


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 窥孔优化之一,并且在一些现代 x86 微体系结构上具有除代码大小之外的几个优点

main:
    xor eax,eax
    ret
Run Code Online (Sandbox Code Playgroud)

一些优化编译器可能仍然使用 EBP 制作堆栈帧,但将其拆除pop ebp会比leave在所有 CPU 上更好,对于 ESP = EBP 的这种特殊情况,因此不需要mov esp,ebp部分leavepop ebp仍然是 1 个字节,但它也是现代 CPU 上的单 uop 指令,与现代 CPUleave 上的 2 或 3不同。(http://agner.org/optimize/,另请参阅标签 wiki中的其他性能优化链接。)这就是 gcc 所做的。这是一种相当普遍的情况;如果创建堆栈帧推送其他一些寄存器,则必须在之前pop ebx或其他任何地方将 ESP 指向正确的位置。(或用于mov恢复它们。)


TCC 关心的基准是编译速度,而不是结果代码的质量(速度或大小)。例如,TCC 网站在行/秒和 MB/秒(C 源代码)与gcc3.2 -O0.

然而,TCC 并不是完全脑残:它显然会做一些内联,正如迈克尔的回答所指出的那样,最近的补丁确实遗漏了 JMP(但仍然不是无用的sub esp, 0)。

  • 我现在对否决票感到惊讶。这个答案中一般都有合理的信息。 (2认同)