x86-64对局部变量使用堆栈的情况很奇怪

时间:2019-03-15 18:55:01

标签: assembly x86-64 nasm

我正在学习x86-64,并且正在使用一些我最了解的编译器生成的汇编代码。它是一个递归阶乘程序,该程序将调用自己直到达到基数,其中将1放入rax中,然后依次与每个先前递减的计数值相乘。我了解在变量访问的情况下的对齐方式,其中访问未对齐的数据会付出巨大的代价,并且我认为要对齐的文本段几乎相同。

在程序中,我发现有两个标记点使我感到困惑,第一个是在 rdi 寄存器的递减中使用了三个堆栈分配的局部变量空间之一,该寄存器保存了用户提供的数字计算阶乘。为什么不直接使用rax代替:

mov qword [rbp + - 16] 

使用

mov rdi, rax?. 

第二个是在执行每个阶乘时使用其他两个堆栈局部变量,然后执行似乎是多余的操作,其中乘法结果从rax移到局部变量,然后再移回到rax之前函数返回。

mov qword [rbp + -24], rax                                                                                                                             
mov rax, rdi                                                                                                                                                      
imul rax, qword [rbp + -24]                                                                                                                                   
mov qword [rbp + -8], rax                                                                                                     
mov rax, qword [rbp + -8]   

利用任何未经修改的通用寄存器并省略这些堆栈局部变量,或者这些操作是否是16字节对齐的一部分,这些计算会不会快得多?

rec:
  push rbp                                                                                                                                                                      
  mov rbp, rsp                                                                                                                                                              
  sub rsp, 24                                                                                                                 
  push rbx                                                                                                                                                                           
  push r12
  push r13
  push r14
  push r15
.sec0:
  mov qword [rbp + -8], 1                                                                                                                              
  test rdi, rdi                                                                                                                               
  je .sec1                                                                                                                                                          
.sec2:
  mov rax, rdi                                                                                                                                                                  
  sub rax, 1                                                                                                                                                              
  mov qword [rbp + -16], rax  ;; point 1.0                                                                                                                                               
  push rcx                                                                                                                                                                       
  push rdx
  push rsi
  push rdi
  push r8
  push r9
  push r10
  push r11
  mov rdi, qword [rbp + -16]  ;; point 1.1                                                                                                                  
  call rec                                                                                                                                                           
  pop r11
  pop r10
  pop r9
  pop r8
  pop rdi
  pop rsi
  pop rdx
  pop rcx
  mov qword [rbp + -24], rax   ;; point 2.0                                                                                                                           
  mov rax, rdi                                                                                                                                                    
  imul rax, qword [rbp + -24]  ;; point 2.1                                                                                                                                   
  mov qword [rbp + -8], rax    ;; point 2.2
  mov rax, qword [rbp + -8]    ;; point 2.3                                                                                   
  pop r15
  pop r14
  pop r13
  pop r12
  pop rbx
  leave
  ret
.sec1:
  mov rax, qword [rbp + -8]
  pop r15
  pop r14
  pop r13
  pop r12
  pop rbx
  leave
  ret

1 个答案:

答案 0 :(得分:1)

您不会说该示例是从哪个代码生成的,或者是在哪个编译器上生成的,但它必须非常原始,甚至可能是Undergrad编译器类中的某些玩具编译器。没错,那是非常次优的。即使我测试过的最旧版本的gcc,在不进行所有优化的情况下,也不会产生糟糕的代码。让我们看一下使用几种不同的编译器进行编译时得到的结果。比较的一种好方法是在godbolt结束。

我测试了以下代码:

unsigned long long factorial(const unsigned long long n)
{
  return (n <= 1) ? 1
                  : n*(factorial(n-1));
}

factorial()函数是您描述的简单的单行递归实现。我还写了factorial_tail(),它是带有累加器的尾递归版本,以使某些编译器更容易注意到该函数为tail-recursive modulo an associative operation,因此可以自动转换为紧密循环。

但是,现代编译器通常对此非常聪明。

除了-fomit-frame-pointer以外,没有其他优化方法(以抑制保存和恢复堆栈帧),这就是gcc 8.2的作用:

factorial:
        sub     rsp, 24
        mov     QWORD PTR [rsp+8], rdi
        cmp     QWORD PTR [rsp+8], 1
        jbe     .L2
        mov     rax, QWORD PTR [rsp+8]
        sub     rax, 1
        mov     rdi, rax
        call    factorial
        imul    rax, QWORD PTR [rsp+8]
        jmp     .L4
.L2:
        mov     eax, 1
.L4:
        add     rsp, 24
        ret

您仍然可以看到函数将中间结果保存在堆栈中紧靠8字节返回地址的上方,并在堆栈中进行了一些不必要的复制。这样做的目的是,在调试时,临时值存在于离散的内存位置,并且可以对其进行监视,检查和修改。

您问:“利用任何未修改的通用寄存器并省略这些堆栈局部变量[...],这些计算会不会快得多吗?”确实会!您不能将所有阶乘因子都保存在不同的寄存器中,因为可能会有数十亿。但是您可以自动重构代码,直到只需要恒定的暂存空间。

在生产代码中,您将打开优化功能。出于学习目的,针对空间进行优化的代码比针对速度进行完全优化的代码更容易理解,后者通常更长,更复杂。使用gcc -std=c11 -g -Os -mavx,我们得到的是:

factorial:
        mov     eax, 1
.L3:
        cmp     rdi, 1
        jbe     .L1
        imul    rax, rdi
        dec     rdi
        jmp     .L3
.L1:
        ret

GCC很聪明,可以得出because multiplication is associative and has an identity,(4×(3×(2×1)))= 1×4×3×2×1。因此,它可以保持运行总计从左到右依次乘以(4,然后是12,然后是24),然后完全消除call。该代码只是一个紧密的循环,几乎与使用高级语言编写for循环所得到的结果相同。

如果您优化时间而不是使用-O3进行空间调整,则GCC会尝试对循环进行矢量化处理,具体取决于您是否为其指定了标志-mavx。其他进行最大程度优化的编译器会展开循环,但不使用向量指令。

Clang 7.0.0产生的一条指令稍长一些,带有相同标志的代码会稍快一些,因为它知道足以检查是否在结尾处终止循环,而不是跳回然后在起始处进行检查。我希望此代码比GCC的代码略微。

factorial:                              # @factorial
        mov     eax, 1
        cmp     rdi, 2
        jb      .LBB0_2
.LBB0_1:                                # =>This Inner Loop Header: Depth=1
        imul    rax, rdi
        dec     rdi
        cmp     rdi, 1
        ja      .LBB0_1
.LBB0_2:
        ret

MSVC 19.0无法解决将转换应用于该代码的问题,仍然使用call生成递归代码,但是我们可以通过重构并添加一个显式的累加器参数来提示它:

unsigned long long factorial_tail(const unsigned long long n,
                                  const unsigned long long p)
/* The n parameter is the current value counted down, and the p parameter
 * is the accumulating product.  Call this function with an initial value
 * of p = 1.
 */
{
  return (n <= 1) ? p
                  : factorial_tail( n-1, n*p );
}

此版本明确地是尾递归的,每个现代编译器都知道尾调用消除。这会用/Ox /arch:avx编译为:

factorial_tail PROC
        mov     rax, rdx
        cmp     rcx, 1
        jbe     SHORT $LN4@factorial_
        mov     rdx, rcx
        imul    rdx, rax
        dec     rcx
        jmp     factorial_tail
$LN4@factorial_:
        ret     0

您在不同的代码清单中观察到,“似乎是多余的操作,其中乘法的结果从rax移到局部变量,然后在函数返回之前移回rax。”确实,在每个函数中循环的迭代。并没有意识到,已经将运行中的产品放到rax上了,它可以并且应该将其保留在那里。

Intel的19.0.1编译器也无法告知它可以将factorial()转换为循环,但是可以使用factorial_tail()。使用-std=c11 -g -avT -Os,它产生的代码比MSVC更好,并且与clang非常相似:

factorial_tail:
        cmp       rdi, 1                                        #14.16
        jbe       ..B2.5        # Prob 12%                      #14.16
..B2.3:                         # Preds ..B2.1 ..B2.3
        imul      rsi, rdi                                      #15.44
        dec       rdi                                           #15.39
        cmp       rdi, 1                                        #14.16
        ja        ..B2.3        # Prob 88%                      #14.16
..B2.5:                         # Preds ..B2.3 ..B2.1
        mov       rax, rsi                                      #14.16
        ret       

它意识到应该避免将值从一个寄存器复制到另一个寄存器并在循环迭代之间复制回来。相反,它选择将其保留在其初始位置rsi(第二个函数参数)中,并在最后仅将返回值移动到rax一次。