内联汇编破坏红区

25
我正在编写一个加密程序,其中核心部分(一个宽乘例程)是用x86-64汇编语言编写的,旨在提高速度,并且因为它广泛使用像adc这样不易从C中访问的指令。我不想内联此函数,因为它很大,并且在内部循环中被调用多次。
理想情况下,我还想为此函数定义自定义调用约定,因为它在内部使用所有寄存器(除了rsp),不会破坏其参数,并在寄存器中返回。现在,它适应于C调用约定,但是这当然使其变慢(约10%)。
为了避免这种情况,我可以使用 asm("call %Pn" : ... : my_function... : "cc", 所有寄存器); 调用它 ,但是有没有一种方法告诉GCC这个call指令会干扰堆栈?否则,GCC将把所有这些寄存器放在红区中,而顶部寄存器将被破坏。我可以使用-mno-red-zone编译整个模块,但我更喜欢一种方法来告诉GCC,例如,红区的顶部8字节将被破坏,以便它不会放置任何内容在那里。

只是一个未经测试的想法,但你不能指定一个额外的虚拟输入吗?这样GCC就会将其放在红区并且它会被(无害地)破坏掉。 - Tony Delroy
3
嗯,可能不可靠。我发现控制GCC什么时候在栈上溢出数据是相当困难的。在我写过的其他加密代码中,我尝试过抑制GCC倾向于无缘由地将整个密钥表等数据写入栈中,但取得了有限的成功。 - Mike Hamburg
添加 sp 作为破坏项?添加内存破坏项? - tc.
1
将加密例程定义为宏(在文件顶部使用顶层asm)怎么样?然后通过扩展的asm从C代码中的多个位置调用它(而不是使用call),这样稍微好一些(尽管它会使可执行文件膨胀)。您仍然可以破坏所有寄存器,但堆栈不受影响。顺便问一下,加密如何知道要加密什么?通过内联访问全局变量可能会很棘手。此外,破坏sp没有任何效果。 - David Wohlferd
5个回答

5

从您最初的问题中,我没有意识到gcc将红区使用限制在叶子函数上。我认为这不是x86_64 ABI所要求的,但对于编译器来说这是一个合理的简化假设。在这种情况下,您只需要使调用您汇编例程的函数成为非叶子函数以进行编译:

int global;

was_leaf()
{
    if (global) other();
}

GCC无法确定global是否为真,因此无法优化调用other(),因此was_leaf()不再是叶子函数。我编译了这个程序(包括触发堆栈使用的更多代码),并观察到作为叶子时它没有移动%rsp,而使用所示修改后则移动了。
我还尝试在叶子函数中分配超过128字节(只需char buf[150]),但惊讶地发现它只进行了部分减法:
    pushq   %rbp
    movq    %rsp, %rbp
    subq    $40, %rsp
    movb    $7, -155(%rbp)

如果我把去叶代码放回去,它会变成subq $160, %rsp


3
__attribute__(leaf),但遗憾的是没有类似于__attribute__(nonleaf)的东西。 - Ben Voigt
我并不觉得gcc在需要保留一些堆栈空间时不放弃红区是令人震惊的:红区的好处之一是能够使用disp8位移访问更多内存,因此将rsp放在局部变量中间意味着它可以使用[rsp-128..+127]寻址模式访问所有局部变量。这是一个很好的优化。(如果你使用了-O3 + volatile char buf[150]来获得RSP相对寻址模式而不是-155(%rbp),那么它本来会更好) - Peter Cordes

3
最大性能的方法可能是将整个内部循环都用汇编语言编写(包括call指令,如果完全展开会导致太多的uop-cache缺失,那么这种方法肯定是可行的)。无论如何,让C调用一个包含优化循环的汇编函数即可。
顺便说一下,破坏所有寄存器会使gcc难以生成一个非常好的循环,因此您可能会从自己优化整个循环中获得更好的结果。(例如,可以在寄存器中保留一个指针,并将结束指针存储在内存中,因为cmp mem,reg仍然相当高效。)
看一下在gcc/clang中修改数组元素的asm语句的代码(在
void testloop(long *p, long count) {
  for (long i = 0 ; i < count ; i++) {
    asm("  #    XXX  asm operand in %0"
    : "+r" (p[i])
    :
    : // "rax",
     "rbx", "rcx", "rdx", "rdi", "rsi", "rbp",
      "r8", "r9", "r10", "r11", "r12","r13","r14","r15"
    );
  }
}

#gcc7.2 -O3 -march=haswell

    push registers and other function-intro stuff
    lea     rcx, [rdi+rsi*8]      ; end-pointer
    mov     rax, rdi
   
    mov     QWORD PTR [rsp-8], rcx    ; store the end-pointer
    mov     QWORD PTR [rsp-16], rdi   ; and the start-pointer

.L6:
    # rax holds the current-position pointer on loop entry
    # also stored in [rsp-16]
    mov     rdx, QWORD PTR [rax]
    mov     rax, rdx                 # looks like a missed optimization vs. mov rax, [rax], because the asm clobbers rdx

         XXX  asm operand in rax

    mov     rbx, QWORD PTR [rsp-16]   # reload the pointer
    mov     QWORD PTR [rbx], rax
    mov     rax, rbx            # another weird missed-optimization (lea rax, [rbx+8])
    add     rax, 8
    mov     QWORD PTR [rsp-16], rax
    cmp     QWORD PTR [rsp-8], rax
    jne     .L6

  # cleanup omitted.

clang使用load / add -1 / store而不是内存目标add [mem], -1 / jnz,向零计数一个单独的计数器。如果在热循环中留下这部分代码交给编译器处理,你可能会比这做得更好,如果你自己用汇编写整个循环的话。如果可能的话,考虑使用一些XMM寄存器进行整数运算,以减少整数寄存器的压力。在Intel CPU上,GP和XMM寄存器之间的移动只需要1个ALU uop和1个时钟周期的延迟。(在AMD上仍然是1个uop,但延迟更高,特别是在Bulldozer系列上)。在XMM寄存器中执行标量整数操作并不会更差,如果总的uop吞吐量是瓶颈,或者它节省的溢出/重载比它花费的多,那么这样做可能是值得的。但当然,对于循环计数器(paddd/pcmpeq/pmovmskb/cmp/jccpsubd/ptest/jcc)、指针或扩展精度算术来说,XMM并不是非常可行(即使在32位模式下没有64位整数寄存器,手动使用比较和另一个paddq进行进位的方法也很糟糕)。如果你不是在load/store uops上瓶颈,那么将变量spill/reload到内存中通常比XMM寄存器更好。
如果你还需要从循环外部调用该函数(清理或其他操作),请编写一个包装器或使用add $-128, %rsp ; call ; sub $-128, %rsp来保留这些版本中的red-zone。(注意,-128可以编码为imm8,但+128不能)。在C函数中包含实际的函数调用并不一定意味着可以安全地假设red-zone未被使用。在(编译器可见的)函数调用之间的任何spill/reload都可能使用red-zone,因此在asm语句中破坏所有寄存器很可能触发这种行为。
// a non-leaf function that still uses the red-zone with gcc
void bar(void) {
  //cryptofunc(1);  // gcc/clang don't use the redzone after this (not future-proof)

  volatile int tmp = 1;
  (void)tmp;
  cryptofunc(1);  // but gcc will use the redzone before a tailcall
}

# gcc7.2 -O3 output
    mov     edi, 1
    mov     DWORD PTR [rsp-12], 1
    mov     eax, DWORD PTR [rsp-12]
    jmp     cryptofunc(long)

如果要依赖编译器特定的行为,您可以在热循环之前调用(使用常规C)一个非内联函数。在当前的gcc / clang中,这将使它们保留足够的堆栈空间,因为它们必须进行堆栈调整(以在调用之前对齐 rsp )。这完全不具备未来性,但应该能够正常工作。
GNU C具有
__attribute__((target("options"))) x86函数属性,但无法用于任意选项,并且 -mno-red-zone 不是可以在每个函数或在编译单元内使用#pragma GCC target(“ options”)切换的选项之一。
您可以使用类似以下的东西:
__attribute__(( target("sse4.1,arch=core2") ))
void penryn_version(void) {
  ...
}

但不包括 __attribute__(( target("mno-red-zone") ))

有一个#pragma GCC optimize和一个optimize函数属性(两者都不适用于生产代码),但是#pragma GCC optimize ("-mno-red-zone")也不起作用。 我认为这个想法是让一些重要的函数即使在调试构建中也能被优化为-O2。 您可以设置-f选项或-O

您可以将该函数放入一个单独的文件中,并使用-mno-red-zone编译该编译单元。 (希望LTO不会出问题...)


2

你能否修改汇编函数,通过在进入函数时将栈指针向后移动128字节来满足x86-64 ABI信号的要求?

或者如果你指的是返回指针本身,可以将偏移量放入你的调用宏中(如sub %rsp; call...)。


3
无法在函数内部执行此操作,因为call使用堆栈并且会自己造成问题。 sub $128, %rsp; call...; add $128, %rsp 可以工作,但不是最理想的选择。 我觉得总体来说最好让我的函数符合ABI规范。 - Mike Hamburg

0

我不确定,但看了一下GCC函数属性文档,我发现stdcall函数属性可能会有用。

我仍然在想你对asm调用版本有什么问题。如果只是美观上的问题,你可以将其转换为宏或内联函数。


3
call 指令会将当前指针推入栈中。如果在栈下面(即“红区”)没有任何东西,这是可以的,但在 x86-64 上,ABI 允许编译器在叶函数(即不调用任何函数的函数)中放置一些东西。然而,GCC 不认为这个 call 是一个函数调用,因为它被隐藏在内联汇编中。因此,在红区中可能放置一些内容,并且会被该调用清除。这不仅是理论上的可能性,而且实际上确实会发生,并且在我的代码中导致了错误。此外,stdcall 不会这样做。 - Mike Hamburg
具体来说,stdcall 的问题在于它仅适用于实际的、非内联函数。但是为了为我的函数定义自定义调用约定,我正在尝试通过内联汇编调用它。因此,GCC根本没有意识到这是一个函数调用(这就是问题所在),因此我无法将属性附加到它上面。 - Mike Hamburg

0

那么创建一个虚拟函数,用C语言编写,什么也不做,只是调用内联汇编怎么样?


将该函数标记为__attribute__((noinline))?使用-O0选项,编译器仍可能将函数参数溢出到红区。 - Peter Cordes

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接