7

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

#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

我的意思是为什么编译器会插入这个跳转到下一条指令的近跳转,LEAVE 无论如何都会执行?

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

4

2 回答 2

10

功能结语之前的多余 JMP

底部的JMP转到下一条语句,这已在 commit 中修复TCC 0.9.27 版解决了这个问题:

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

至于它最初存在的原因?这个想法是每个函数都有一个可能的共同退出点。如果在底部有一个带有返回的代码块,则JMP会转到一个公共退出点,在此完成堆栈清理并ret执行 。最初,如果 JMP 指令出现在最后(右大括号)之前,代码生成器也会在函数末尾错误地发出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之前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

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

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

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

于 2018-02-12T02:24:05.377 回答
6

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

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

(事实上​​,TCC 确实是一次通过,就像通过 C 语句逐个语句一样排列机器代码。它使用imm32编码 forsub所以它有空间填写正确的数字(到达函数末尾时),甚至如果结果证明该函数使用了超过 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 中)。但是 Windows 目标在将 ESP / RSP 修改超过一整页(4096 字节)时需要堆栈探测,并且这种情况的替代序言是 10 字节,而不是没有 NOP 的正常的 9 字节。所以这是另一个有利于编译速度而不是好的 asm 的权衡。


优化编译器将对 EAX 进行异或零(因为它更小并且至少与 一样快mov eax,0),并省略所有其他指令。Xor-zeroing 是最知名/常见/基本的 x86 窥视孔优化之一,并且在某些现代 x86 微架构上除了代码大小之外还有几个优点

main:
    xor eax,eax
    ret

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


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

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

于 2018-02-12T06:42:26.760 回答