【问题标题】:"PUSH" "POP" Or "MOVE"?“PUSH”“POP”还是“MOVE”?
【发布时间】:2017-01-10 17:53:20
【问题描述】:

当涉及在寄存器中临时存储现有值时,所有现代编译器(至少我所经历的编译器)都会执行 PUSH 和 POP 指令。但是,如果数据可用,为什么不将数据存储在另一个寄存器中呢?

那么,现有值的临时存储应该放在哪里?堆栈或注册?

考虑以下第一条代码:

MOV ECX,16
LOOP:
PUSH ECX    ;Value saved to stack       
...     ;Assume that here's some code that must uses ECX register
POP ECX     ;Value released from stack  
SUB ECX,1
JNZ LOOP

现在考虑第二个代码:

MOV ECX,16
LOOP:
MOV ESI,ECX ;Value saved to ESI register    
...     ;Assume that here's some code that must uses ECX register
MOV ECX,ESI ;Value returned to ECX register
SUB ECX,1
JNZ LOOP

毕竟,上面的代码哪一个更好,为什么?

我个人认为第一个代码的大小更好,因为 PUSH 和 POP 只占用 1 个字节,而 MOV 占用 2 个字节;第二个代码在速度上更好,因为寄存器之间的数据移动比内存访问快。

【问题讨论】:

  • 您通常在堆栈中使用push 值,以便您可以使用它们占用的寄存器。他们为什么不将它们移到其他寄存器?可能是因为某些值也需要其他寄存器。
  • 如果 ESI 寄存器在循环中是空闲的,你最好将计数器放在 ESI 中,而不是随意移动它。如果你的编译器很聪明,它就会知道这一点。结论:要么你有愚蠢的编译器,要么它知道 ESI 在循环中也不是空闲的,也没有其他空闲寄存器。在这种情况下,PUSH/POP 组合并不可怕。
  • “所有现代编译器(至少我经历过的编译器)都执行 PUSH 和 POP 指令” ...这是非常虚假的说法,试试 gcc 或 clang ,他们不会那样做(除非他们在循环中用完了多余的寄存器,那么我敢打赌他们宁愿使用[ebp-ofs]/[esp+ofs] 局部变量)。我很想看到一些 C/C++ 源代码使用这两个生成 PUSH/POP。再说一遍,这两个基本上也是唯一的现代编译器,所以我不确定你检查了什么。
  • 实际上我试图生成一些源代码,它会将计数器 for 循环从gcc 中的寄存器中取出,并且完全优化非常困难(创建一些未优化到某个常数的计算并且需要许多寄存器)。我没有设法实现干净的“regs 中的循环变量,计数器输出”状态,但是当我用尽了足够的寄存器(已经以[esp-ofs] 本地堆栈内存完成了一些计算变量)时,计数器确实也进入了[esp-ofs] 本地,所以循环代码是:sub [esp-0x34],1jnz .L2 ... 甚至没有任何 PUSH/POP 可能性的迹象。
  • “毕竟,上面的代码哪一个更好,为什么?” .. 两者都不是最理想的,足以改变你的编译器。

标签: gcc assembly optimization nasm inline-assembly


【解决方案1】:

使用寄存器会快一点,但需要您跟踪哪些寄存器可用,并且您可能会用完寄存器。此外,此方法不能递归使用。此外,如果您使用 INT 或 CALL 调用子程序,一些寄存器将被丢弃。

可以根据需要多次使用堆栈(POP 和 PUSH)(只要您没有耗尽堆栈空间),此外它还支持递归逻辑。您可以通过 INT 或 CALL 安全地使用堆栈,因为按照惯例,任何子程序都应该保留自己的堆栈部分,并且必须将其恢复到之前的状态(否则 RET 指令将失败)。

【讨论】:

    【解决方案2】:

    在考虑速度时,您必须始终牢记分寸。

    如果正在编译的函数调用其他函数, 那些push 和pop 指令可能微不足道, 与它们之间执行的指令数相比。

    编译器编写者知道,在这种很常见的情况下,不应该是penny-wise and pound-foolish。

    【讨论】:

      【解决方案3】:

      通过使用 PUSH 和 POP,您可以保存至少一个寄存器。如果您使用有限的可用寄存器,这将非常重要。另一方面,是的,有时使用 MOV 速度更快,但您还必须记住哪个寄存器用作临时存储。如果您想存储多个需要稍后处理的值,这将很难

      【讨论】:

        【解决方案4】:

        这样做确实很有意义。但我认为最简单的答案是正在使用所有其他寄存器。为了使用其他一些寄存器,您需要将其压入堆栈。

        编译器足够聪明。跟踪编译器寄存器中的内容有些微不足道,这不是问题。一般来说,不一定是 x86 特定的,尤其是当您有更多寄存器(比 x86)时,您将有一些用于输入的寄存器(在您的调用约定中),一些您可以丢弃,这可能与输入与否,有些你不能丢弃,你必须先保存它们。有些指令集有特殊的寄存器,必须用这个来自动递增,那个用来间接寄存器,等等。

        如果不是微不足道的话,你肯定会让编译器为 arm 生成代码,例如输入和可回收寄存器是同一组的情况,但这意味着如果你调用另一个函数并正确创建调用函数返回后需要保存一些东西使用:

        unsigned int more_fun ( unsigned int );
        unsigned int fun ( unsigned int x )
        {
            return(more_fun(x)+x);
        }
        00000000 <fun>:
           0:   e92d4010    push    {r4, lr}
           4:   e1a04000    mov r4, r0
           8:   ebfffffe    bl  0 <more_fun>
           c:   e0840000    add r0, r4, r0
          10:   e8bd4010    pop {r4, lr}
          14:   e12fff1e    bx  lr
        

        我告诉过你这是微不足道的。现在要向后使用您的论点,为什么他们不只是将 r0 压入堆栈并稍后将其弹出,为什么要压入 r4?不是 r0-r3 用于输入并且是易失性的,r0 是适合时的返回寄存器,r4 几乎一直要保留(我认为是一个例外)。

        因此假定 r4 被调用者或某些调用者使用,调用约定规定你不能丢弃它,你必须保留它,所以你必须假设它被使用。您可以丢弃 r0-r3,但您不能使用其中之一,因为被调用者也可以丢弃它们,因此在这种情况下,我们需要获取传入的值 x 并使用它(传递它)并在返回后保留它所以他们两者都做了,“使用另一个寄存器移动”但为了做到这一点,他们保留了另一个寄存器。

        为什么在这种情况下将 r4 保存到堆栈中很明显,您可以将其与返回地址一起保存,特别是 arm 希望您始终以 64 位块的形式使用堆栈,因此理想情况下一次使用两个寄存器或至少保持它在 64 位边界上对齐,所以无论如何你都必须保存 lr,所以即使他们没有,他们也会推送其他东西,在这种情况下,保存 r4 是免费的,因为他们需要保存 r0 并同时使用它。 r4 或 r5 或以上是不错的选择。

        顺便说一句,上面的 x86 编译器看起来很像。

        0000000000000000 <fun>:
           0:   53                      push   %rbx
           1:   89 fb                   mov    %edi,%ebx
           3:   e8 00 00 00 00          callq  8 <fun+0x8>
           8:   01 d8                   add    %ebx,%eax
           a:   5b                      pop    %rbx
           b:   c3                      retq 
        

        展示他们推动不需要保留的东西:

        unsigned int more_fun ( unsigned int );
        unsigned int fun ( unsigned int x )
        {
            return(more_fun(x)+1);
        }
        00000000 <fun>:
           0:   e92d4010    push    {r4, lr}
           4:   ebfffffe    bl  0 <more_fun>
           8:   e8bd4010    pop {r4, lr}
           c:   e2800001    add r0, r0, #1
          10:   e12fff1e    bx  lr
        

        没有理由保存 r4,他们只是需要一些寄存器来使堆栈对齐,所以在这种情况下选择了 r4,这个编译器的某些版本你会看到使用了 r3 或其他一些寄存器。

        记住人类(仍然)编写编译器和优化器等。所以他们为什么会这样以及为什么这对那个人或那些人来说真的是一个问题,我们无法真正告诉你他们在想什么。这肯定不是一项简单的任务,但不难采用合理大小的函数和/或项目并找到机会手动调整编译器输出以改进它。当然,美在旁观者的眼中,一种对改善的定义是另一种对恶化的定义。一种指令组合可能使用较少的总指令字节,因此按照程序大小标准“更好”,另一种可能使用或可能不使用更多指令或字节,但执行速度更快,一个可能具有较少的内存访问,但以理想执行的指令为代价更快,等等。

        有数百个通用寄存器的架构,但我们每天接触产品的大多数都没有那么多,所以你通常可以在一个函数中创建一个函数或一些代码,其中有很多变量在运行,你必须开始保存到堆栈中间函数。所以你不能总是在函数的开头和结尾保存几个寄存器来给你更多的工作寄存器中间函数,如果你需要的中间函数的工作寄存器数量比你拥有的寄存器多。实际上需要一些练习才能编写不需要太多寄存器的优化代码,但是一旦您开始通过检查编译器的输出来了解编译器的工作原理,您就可以编写像上面那样的琐碎函数来防止优化或强制保存寄存器中间函数等

        归根结底,要让编译器有点理智,它需要一个调用约定,它可以防止作者发疯,编译器也不会成为编码和管理的噩梦。调用约定非常清楚地定义了任何易失性寄存器的输入和输出寄存器,以及必须保留的寄存器。

        unsigned int fun ( unsigned int x, unsigned int y, unsigned int z )
        {
            unsigned int a;
        
            a=x<<y;
            a+=(y<<z);
            a+=x+y+z;
            return(a);
        }
        00000000 <fun>:
           0:   e0813002    add r3, r1, r2
           4:   e0833000    add r3, r3, r0
           8:   e0832211    add r2, r3, r1, lsl r2
           c:   e0820110    add r0, r2, r0, lsl r1
          10:   e12fff1e    bx  lr
        

        只花了几秒钟,但本可以更加努力。我没有推过总共四个寄存器,因为我有四个变量。而且我没有调用任何函数,因此编译器可以在依赖关系解决时根据需要随意丢弃 r0-r3。所以我不必为了创建临时存储而保存 r4,它不必使用堆栈它只是优化了执行顺序以例如释放 r2,z 变量,以便以后它可以使用 r2 作为中间变量, a 的实例之一等于某事。将其减少到四个寄存器,而不是烧掉第五个。

        如果我的代码更有创意并且我添加了对函数的调用,我可以让它烧掉更多的寄存器,你会看到即使在最后一种情况下,编译器也没有任何问题可以跟踪什么是哪里,当您使用编译器时,您会看到没有理由他们必须在同一寄存器中保持您的高级语言变量完整无缺,更不用说按照您编写代码的相同顺序执行(只要它是合法),但它们仍然受调用约定的支配,如果只有一些寄存器被认为是易失性的,并且如果您在代码中的某个时间从您的函数调用一个函数,那么您必须保留该内容所以你不能将它们用作长期存储,并且那些非易失性的已经被认为已被消耗,因此必须保留它们才能使用它们,然后它部分成为性能问题,它是否成本更高(大小,速度等)以即时保存到堆栈中,或者我可以预先以一种可能减少指令或不可见和/或通过更大的传输而不是单独的、效率较低的中间功能传输而消耗更少时钟的方式预先提供服务?

        我已经说过七次了,但最重要的是该编译器(版本)和目标(以及命令行选项/默认值)的调用约定。如果您有易失性寄存器(通用寄存器的任意调用约定事物,而不是硬件/ISA 事物)并且您没有调用任何其他函数,那么它们很容易使用并为您节省昂贵的堆栈(内存)事务。如果您正在呼叫某人,那么他们可能会被他们丢弃,因此他们可能不再是免费的,这取决于您的代码。非易失性寄存器被认为是调用者消耗的,所以你必须烧掉堆栈操作才能使用它们,它们不是免费使用的。然后它变成了关于何时何地使用堆栈、push、pop 和 mov 的性能。即使使用相同的约定,也不会期望两个编译器生成相同的代码,但是您可以在上面看到,制作测试函数,编译它们并检查输出,在这里和那里调整以浏览和绕过它有点微不足道(编译器、版本和目标以及约定和命令行选项)优化器。

        【讨论】:

        • “记住人类编写优化器”:对。当他们通过寻找最佳配置的算法(例如寄存器分配器)来实现时,可能连他们自己也无法预测结果。
        • 我的意思是没有人编写代码来选择复制到寄存器,编译器不能/不会复制到寄存器。如果没有人类对此的了解/思考并实施它,上述任何一种优化都不会发生。现在确定实现可能是一种算法,它根据一些参数选择是否应该尝试,以及使用什么寄存器。
        • 你会发现不同人编写的不同编译器往往会使用不同的指令混合,部分原因是算法不同,部分原因是人不同。在测试处理器时,我发现一个编译器没有使用某些指令的代码,除了可能是内联汇编之外,永远不会生成它们。其他人确实有案例可以使用它们。有些序列永远不会在一个编译器中发生,而在其他编译器中会发生(通过简单地切换具有相同测试代码的编译器来发现芯片错误)。
        【解决方案5】:

        相信基于数十年代码生成专家工作的优化编译器的工作。

        它们填充尽可能多的可用寄存器,并在需要时扩展到堆栈,比较不同的选项。他们还关心存储值以供以后重用与重新计算值之间的权衡。

        没有单一的规则“寄存器与堆栈”,这是一个全局优化问题,考虑到处理器的特殊性。一般来说,没有单一的“最佳解决方案”,因为它取决于您的“最佳”标准。

        除非可以找到非常有创意的变通方法(或利用只有您知道的数据属性),否则您无法击败编译器。

        【讨论】:

          猜你喜欢
          • 2017-04-23
          • 1970-01-01
          • 2010-09-30
          • 1970-01-01
          • 1970-01-01
          • 2011-02-17
          • 2014-01-30
          • 2014-06-22
          • 1970-01-01
          相关资源
          最近更新 更多