assembly 如何在最新的64位英特尔CPU上交换栈顶寄存器而不使用隐式锁定?

rqdpfwrv  于 2022-11-13  发布在  其他
关注(0)|答案(2)|浏览(107)

x64调用约定使用的寄存器最多为前4个参数(rcxrdxr8r9),并传递堆栈上的其余参数。在这种情况下,处理asm过程中的补充参数的明显方法如下:

procedure example(
  param1, //rcx
  param2, //rdx
  param3, //r8
  param4, //r9
  param5,
  param6
);
asm
  xchg param5, r14 // non-volatile registers, should be preserved
  xchg param6, r15 // non-volatile registers, should be preserved

  // ... procedure body, use r14–r15 for param5–param6

  mov r15, param6
  mov r14, param5  
end;

但这里有一个巨大的问题:如果涉及内存操作,Intel CPU中的XCHG指令具有隐含的LOCK,这也意味着巨大的性能损失;也就是说,在最坏的情况下,总线将被锁定数百个时钟周期。(顺便说一句,我不能真正理解这个隐含的LOCK是否具有真正可用的和智能的互锁指令,如XADDCMPXCHGBTS/BTR等;如果我需要线程同步,naked XCHG将是我的最后一个选择。)那么,如果我希望在寄存器中使用/保存/恢复params 5和params 6,我应该在这里做什么?是否有一种方法可以防止XCHG指令的总线锁定?通常,这种情况下广泛使用的标准方法是什么?

l2osamch

l2osamch1#

正如Ross的回答所解释的,标准的广泛使用的方法是溢出(然后重新加载)其他内容来释放tmp寄存器。

如果你把所有的东西都先加载到寄存器中,而不是根据需要加载,那就是搬起石头砸自己的脚。有时候你甚至可以使用一个arg作为内存源操作数,而根本不需要单独的mov加载。

但为了回答标题问题

尽管有这个问题的标题,但我对swapping 2 registers in 8086 assembly language(16 bits)的回答确实有效地解决了寄存器与内存的交换问题,避免了xchg,因为lock前缀是隐式的。溢出(然后重新加载)一个tmp reg,或者在最坏的情况下,在reg和mem之间进行XOR交换。这是 * 可怕的 *,基本上说明了为什么你的整个方法会导致效率低下的实现。
(As Ross说,你可能还不能比编译器更高效地编写asm。一旦你了解如何创建高效的asm(Agner Fog的优化指南和微架构指南:https://agner.org/optimize/https://stackoverflow.com/tags/x86/info中的其他链接),并且可以在优化的编译器输出中发现实际的低效率,那么如果您愿意,您 * 有时可以 * 手动编写更好的asm。(通常以编译器输出作为起点)。但通常您只会利用这些经验来调整您的C源代码,以便在可能的情况下从编译器中获得更好的asm。因为从长远来看,它更有用/更便于移植,而且它很少重要到值得手写asm。
此时,您更有可能通过查看gcc -O3的输出来学习如何创建更高效的asm。但是,错过优化的情况并不罕见,如果您发现了一些,您可能会在GCC的bugzilla中报告它们。)

xchg的隐式-lock语义来自386。lock前缀从8086年就存在了,用于add/or/and/etc [mem], reg or immediate之类的指令。包括lock xchg、它显然没有隐含的lock行为(即使没有前缀)直到386。或者可能直到那时才有文档?IDK为什么英特尔做出了这样的改变。也许是为了原始的SMP 386系统。
**您提到的其他指令是后来添加的:**386中的bts/btr/btc(但并非仅用于共享内存,因此隐式的lock没有意义)。

xadd出现在486,而cmpxchg直到Pentium才出现(486有一个未公开的cmpxchg操作码,参见an old version of the NASM appendix A的注解)。这些CPU的设计时间晚于386,大概是在对原始SMP系统有了一些初步经验之后。
正如你所说,英特尔明智地选择了 * 不 * 使lock隐式用于这些新指令,即使主要用例是用于多线程代码中的原子操作。SMP x86机器开始成为486和奔腾的一种东西,但UP机器上线程之间的同步不需要lock。这是一个与x86 CMPXCHG是原子的相反的问题。如果是,为什么需要锁定?

8086是单处理器机器,因此对于软件线程之间的同步,普通add [mem], reg相对于中断和上下文切换已经是原子的。(而且不可能同时执行多个线程)。文档中提到的遗留#LOCK外部信号只对DMA观察者有影响,或者将MMIO连接到设备上的I/O寄存器(而不是连接到普通DRAM)。

(On对于现代CPU,未跨缓存行边界拆分得可缓存内存上得xchg [mem], reg只执行一个缓存锁,确保缓存行从加载阅读L1 d到提交到L1 d得存储期间保持MESI独占或已修改状态.)
我不知道为什么8086架构师(主要是莫尔斯设计的指令集)选择不使非原子的xchg具有可用的内存。(更正一下,我想他做到了,只是386改变了它;这个答案最初是在我知道这是一个386变化之前写的。)也许在8086上,让CPU在执行存储+加载事务的同时Assert#LOCK并不慢多少?但是,对于x86的其余部分,我们却被这些语义所困。x86设计很少有非常前瞻性的思考,并且如果xchg的主要用例是用于原子I/O,则它节省了代码大小以使lock隐式化。

无法禁用xchg [mem], reg中的隐式锁定

您需要使用多个不同的指令。异或交换是可能的,但效率很低。可能还没有xchg那么差。取决于微体系结构和周围的代码(在执行任何后续加载之前,等待所有先前的存储执行并提交到L1 d缓存的时间有多长)。例如,某些运行中的缓存未命中存储可能会使其与内存相比成本非常高-目的地xor,其可以将数据留在存储缓冲器中。

编译器基本上从不使用xchg,即使在寄存器之间也是如此(因为it's not cheaper than 3 mov instructions on Intel,所以它通常不是一个有用的窥视孔优化),它们只使用它来实现seq_cst存储顺序的std::atomic存储(因为在大多数uarch上,它比mov + mfence更有效:Why does a std::atomic store with sequential consistency use XCHG?),并实现std::atomic::exchange.但not std::swap带有reg或内存。
如果x86有一个非原子的2或3 uop swap reg,mem,它偶尔会有用,但它没有。没有这样的指令。
但是特别是x86-64有16个寄存器,你遇到这个问题只是因为你自己创建了它,给自己留一些暂存寄存器用于计算。

gt0wga4j

gt0wga4j2#

只要做编译器所做的事情。当你需要的时候,把参数从栈中加载到寄存器中,当需要释放寄存器的时候,把寄存器溢出到栈中它们自己的位置。这是一个标准的和广泛使用的方法,如果不是很优雅的话,用于处理需要比可用寄存器更多的问题。
另请注意,Windows x64调用约定要求“非易失性”(被调用方保存的)寄存器必须仅保存在序言中。(尽管您可以使用链接的展开信息在一个函数中拥有多个“序言”。)
因此,假设您需要使用所有被调用方保存的寄存器,并严格遵循Windows x64调用约定,则需要执行以下操作:

example PROC    FRAME

_stack_alloc =  8   ; total stack allocation for local variables
                    ; must be MOD 16 = 8, so the stack is aligned properly;
_push_regs =    32  ; total size in bytes of the callee-saved registers
                    ; pushed on the stack

_param_adj =    _stack_alloc + _push_regs

; location of the parameters relative to RSP, including the incoming
; slots reserved for spilling parameters passed in registers

param1  =   _param_adj + 8h
param2  =   _param_adj + 10h
param3  =   _param_adj + 18h
param4  =   _param_adj + 20h
param5  =   _param_adj + 28h
param6  =   _param_adj + 30h

; location of local variables relative to RSP

temp1   =   0

    ; Save some of the callee-preserved registers
    push    rbp
    .PUSHREG rbp
    push    rbx
    .PUSHREG rbx
    push    rsi
    .PUSHREG rsi
    push    rdi
    .PUSHREG rdi

    ; Align stack and allocate space for temporary variables
    sub rsp, _stack_alloc
    .ALLOCSTACK 8

    ; Save what callee-preserved registers we can in the incoming
    ; stack slots reserved for arguments passed in registers under the
    ; assumption there's no need to save the later registers

    mov [rsp + param1], r12
    .SAVEREG r12, param1
    mov [rsp + param2], r13
    .SAVEREG r13, param2
    mov [rsp + param3], r14
    .SAVEREG r14, param3
    mov [rsp + param4], r15
    .SAVEREG r15, param4

    .ENDPROLOG

    ; ...

    ; lets say we need to access param5 and param6, but R14 
    ; is the only register available at the moment.  

    mov r14, [rsp + param5]
    mov [rsp + temp1], rax  ; spill RAX 
    mov rax, [rsp + param6]

    ; ...

    mov rax, [rsp + temp1]  ; restore RAX

    ; ...

    ; start of the "unofficial" prologue

    ; restore called-preserved registers that weren't pushed

    mov r12, [rsp + param1]
    mov r13, [rsp + param2]
    mov r14, [rsp + param3]
    mov r15, [rsp + param4]

    ; start of the "official" prologue
    ; instructions in this part are very constrained. 

    add rsp, _stack_alloc
    pop rdi
    pop rsi
    pop rbx
    pop rbp
    ret

example ENDP

现在,希望你问自己是否真的需要做这些,答案是肯定和否定的。没有什么可以简化汇编代码的。如果你不关心异常处理,你就不需要unwind info指令,但是如果你想让你的代码像编译器一样高效,同时保持相对容易维护,你仍然需要其他的东西。
但是有一种方法可以避免所有这些,那就是使用C/C编译器。现在对汇编的需求并不多。你不可能写得比编译器更快,你可以使用内部函数来访问你想使用的任何特殊汇编指令。编译器可以考虑堆栈上的东西,它可以很好地完成寄存器分配。最小化所需的寄存器节省和溢出量。
(Microsoft C/C
编译器甚至可以生成我前面提到的链接展开信息,以便仅在必要时才保存被调用方保存的寄存器。)

相关问题