MOVZX将32位寄存器缺失到64位寄存器

9

这里是将无符号寄存器进行复制(转换)的指令:http://www.felixcloutier.com/x86/MOVZX.html

基本上,该指令有 8->16、8->32、8->64、16->32 和 16->64 五种类型。

那么 32->64 的转换在哪里呢?需要使用带符号版本吗?
如果是,如何使用完整的 64 位无符号整数呢?


4
这是因为你从未需要它,32位寄存器加载已将高32位重置为0。详细背景请参见此处。请注意,MOVSX的情况不同,它具有专用的MOVSXD。 - Hans Passant
1
Hans是正确的。在64位代码中,当目标操作数是32位寄存器时,CPU会自动通过64位寄存器的高32位进行零扩展。如果您在64位寄存器的低32位中有一个值,并且想要确保高32位设置为零,则可以使用常规的mov来实现。例如,mov eax,eax将把RAX的高32位设置为零。 - Michael Petch
2
有趣的事实:由于某些编码怪异性,movsxd eax,eax 是一条有效的指令。 - fuz
你不需要一个,因为大多数32位指令将高位清零 - phuclv
@phuclv:我认为有些情况下你可能需要将一个32位寄存器进行零扩展,并将其放入零扩展目标中。就我所知,64位Linux ABI不能保证用作参数的寄存器的高32位将为零。如果你将uint32_t作为参数传递给函数,然后再添加一个uint64_t,那么很可能会有额外的mov指令来确保高32位被清零。 - Michael Petch
@phuclv:Hans已经说过了。而且不仅是大多数,所有写入32位寄存器的指令都是如此。无论如何,这仍然是一个有趣的ISA设计(和/或汇编伪指令)问题,为什么没有一种形式的movzx可以执行32->64。请参见我的答案。 - Peter Cordes
1个回答

21

简短回答

如果您无法保证 RDI 的高位都为零,则使用 mov eax, edi 将 EDI 扩展为 RAX 的零。请参见:为什么在32位寄存器上的x86-64指令将全64位寄存器的上半部分清零?

最好使用不同的源/目标寄存器,因为在英特尔和 AMD CPU 上 mov-elimination无法消除mov eax, eax。当移动到不同的寄存器时,您会遇到零延迟,并且不需要执行单元。(gcc显然不知道这一点,并且通常在原地进行零扩展。)不要花费额外的指令来实现这一点。


长答案

机器码解释为什么没有32位源的movzx编码

总结:每个不同的movzx和movsx源宽度都需要不同的操作码。目标宽度由前缀控制。由于mov可以完成工作,因此为movzx dst,r/m32创建新操作码将是冗余的。

在设计AMD64汇编语法时,AMD选择不使movzx rax,edx作为mov eax,edx的伪指令。这可能是一件好事,因为知道写入32位寄存器会将上部字节清零对于编写x86-64的高效代码非常重要。


AMD64需要一种新的操作码来进行32位源操作数的符号扩展。他们将助记符命名为movsxd,而不是将其作为movsx助记符的第三个操作码,原因未知。英特尔在一个ISA参考手册条目中将它们全部记录在一起。他们在32位模式下重新使用了1字节操作码ARPL,因此从8或16位源(假设您仍然需要REX前缀扩展到64位)到movsxd实际上比movsx短1个字节。
不同的目标大小使用相同的操作码,只是操作数大小不同1。(使用66或REX.W前缀进行16位或64位扩展,而不是默认的32位。)例如,movsx eax,bl和movsx rax,bl只有REX前缀不同;相同的操作码。(movsx ax,bl也是相同的,但是使用66前缀使操作数大小为16位。)
在AMD64之前,没有必要使用读取32位源的操作码,因为最大目标宽度为32位,并且"符号扩展"到相同大小只是一份拷贝。请注意,movsxd eax, eax是合法但不建议使用。您甚至可以使用66前缀对其进行编码,以读取32位源并写入16位目标2

在64位模式下,不建议使用没有REX.W的MOVSXD。应该使用常规MOV而不是使用没有REX.W的MOVSXD。

32->64位符号扩展可以使用cdq将EAX符号扩展为EDX:EAX(例如,在32位idiv之前)。这是x86-64之前唯一的方法(当然也可以复制并使用算术右移来广播符号位)。
但是AMD64已经通过任何写入32位寄存器的指令免费进行了从32位到64位的零扩展。 这避免了乱序执行的错误依赖关系, 这就是为什么AMD打破了8086/386传统,当写入部分寄存器时不保留上半字节的原因。(为什么GCC不使用部分寄存器?)
由于每个源宽度需要不同的操作码,没有前缀可以使任何一个 movzx 操作码读取32位源。

有时需要使用指令将某些内容零扩展。在编译器输出小函数时很常见,因为x86-64 SysV和Windows x64调用约定允许参数和返回值中存在高垃圾。

通常情况下,如果您想知道如何在汇编语言中执行某些操作,请向编译器询问,特别是当您找不到所需指令时。我省略了每个函数末尾的ret指令。

从Godbolt编译器资源中获取的asm源码,适用于System V调用约定(参数在RDI、RSI、RDX等寄存器中):

#include <stdint.h>

uint64_t zext(uint32_t a) { return a; }
uint64_t extract_low(uint64_t a) { return a & 0xFFFFFFFF; }
    # both compile to
    mov     eax, edi

int use_as_index(int *p, unsigned a) { return p[a]; }
   # gcc
    mov     esi, esi         # missed optimization: mov same,same can't be eliminated on Intel
    mov     eax, DWORD PTR [rdi+rsi*4]

   # clang
    mov     eax, esi         # with signed int a, we'd get movsxd
    mov     eax, dword ptr [rdi + 4*rax]


uint64_t zext_load(uint32_t *p) { return *p; }
    mov     eax, DWORD PTR [rdi]

uint64_t zext_add_result(unsigned a, unsigned b) { return a+b; }
    lea     eax, [rdi+rsi]

在x86-64中,默认地址大小为64位。高垃圾不会影响加法的低位,因此与lea eax, [edi+esi]相比,这可以节省一个字节,后者需要一个67地址大小前缀,但对于每个输入都会产生相同的结果。当然,add edi, esi会在RDI中产生零扩展的结果。
uint64_t zext_mul_result(unsigned a, unsigned b) { return a*b; }
   # gcc8.1
    mov     eax, edi
    imul    eax, esi

   # clang6.0
    imul    edi, esi
    mov     rax, rdi    # silly: mov eax,edi would save a byte here

Intel建议在有选择的情况下立即销毁mov的结果,释放mov-消除所占用的微架构资源,并提高mov-消除的成功率(在Sandybridge系列上不像AMD Ryzen那样达到100%)。 GCC选择mov/imul是最好的选择。
此外,在没有mov消除的CPU上,如果另一个输入还没有准备好(即关键路径通过未得到mov的输入),则在imul之前的mov可能不在关键路径上。但是,imul之后的mov取决于两个输入,因此始终在关键路径上。
当然,当这些函数内联时,编译器通常会知道寄存器的完整状态,除非它们来自函数返回值。而且它也不需要在特定寄存器(RAX返回值)中产生结果。但是,如果您的源代码混合使用unsignedsize_tuint64_t,则编译器可能被迫发出指令来截断64位值。(查看编译器汇编输出是捕捉此类问题并找出如何调整源代码以让编译器节省指令的好方法。)

注1: 有趣的事实:AT&T语法(使用不同的助记符,如movswl(符号扩展字 -> 长字(dword)或movzbl)可以从寄存器推断出目标大小,例如movzb %al,%ecx,但即使没有歧义,它也不会汇编movz %al,%ecx。因此,它将movzb视为自己的助记符,带有可以推断或显式的通常操作数大小后缀。这意味着AT&T语法中每个不同的操作码都有自己的助记符。

另请参见assembly cltq and movslq difference,了解CDQE和MOVSXD之间的冗余历史教训,前者用于EAX->RAX,后者用于任何寄存器。请参见What does cltq do in assembly?the GAS docs,了解AT&T vs. Intel menmonics的零/符号扩展。

注2: 使用movsxd ax,[rsi]的愚蠢计算机技巧:

汇编器拒绝汇编movsxd eax, eaxmovsxd ax, eax,但手动编码是可能的。 ndisasm甚至不会反汇编它(只有db 0x63),但GNU objdump会。实际的CPU也会解码它。我为了确保尝试了Skylake:
 ; NASM source                           ; register value after stepi in GDB
mov     rdx, 0x8081828384858687
movsxd  rax, edx                         ; RAX = 0xffffffff84858687
db 0x63, 0xc2        ;movsxd  eax, edx   ; RAX = 0x0000000084858687
xor     eax,eax                          ; RAX = 0
db 0x66, 0x63, 0xc2  ;movsxd  ax, edx    ; RAX = 0x0000000000008687

那么CPU在内部如何处理它呢?它是否实际读取32位然后截断为操作数大小?事实证明,Intel的ISA参考手册将16位形式文档化为63 /r MOVSXD r16,r/m16,因此movsxd ax,[unmapped_page-2]不会出错。 (但它错误地将非REX形式记录为兼容/遗留模式下有效;实际上,0x63在那里解码为ARPL。这不是Intel手册中的第一个错误。)
这是很有道理的:当没有REX.W前缀时,硬件可以简单地将其解码为与mov r16,r/m16或mov r32,r/m32相同的uop。或者不是!Skylake的movsxd eax, edx(但不是movsxd rax, edx)在目标寄存器上具有输出依赖性,就像它正在合并到目标中一样!循环使用times 4 db 0x63,0xc2; movsx eax,edx以每次迭代4个时钟运行(每个movsxd一个周期延迟),因此uop相当均匀地分布在所有4个整数ALU执行端口上。使用movsxd eax,edx / movsxd ebx,edx / 2个其他目标的循环以每次迭代约1.4个时钟运行(仅略差于使用普通4x mov eax,edx或4x movsxd rax,edx的1.25个时钟每次迭代的前端瓶颈)。在i7-6700k上使用Linux上的perf进行定时。
我们知道movsxd eax, edx会将RAX的高位清零,因此实际上它没有使用等待目标寄存器中的任何位,但是假设在内部类似地处理16位和32位可以简化解码,并简化处理这个角落情况的编码,尽管没有人应该使用它。 16位形式总是必须实际合并到目标中,因此确实对输出寄存器有真正的依赖关系。(Skylake不会单独重命名16位寄存器和完整寄存器。)
GNU binutils将其反汇编不正确:gdb和objdump显示源操作数为32位,例如:
  4000c8:       66 63 c2                movsxd ax,edx
  4000cb:       66 63 06                movsxd ax,DWORD PTR [rsi]

当应该是

时,它应该是


  4000c8:       66 63 c2                movsxd ax,dx
  4000cb:       66 63 06                movsxd ax,WORD PTR [rsi]

在AT&T语法中,objdump有趣的是仍然使用movslq。所以我猜它将其视为整个助记符,而不是带有q操作数大小的movsl指令。或者这只是gas不会汇编的特殊情况无人关心的结果(它拒绝movsll,并检查movslq的寄存器宽度)。在查阅手册之前,我实际上在Skylake上使用NASM进行了测试,看看加载是否会出错。当然,它没有:
section .bss
    align 4096
    resb 4096
unmapped_page: 
 ; When built into a static executable, this page is followed by an unmapped page on my system,
 ; so I didn't have to do anything more complicated like call mmap

 ...
_start:
    lea     rsi, [unmapped_page-2]
    db 0x66, 0x63, 0x06  ;movsxd  ax, [rsi].  Runs without faulting on Skylake!  Hardware only does a 2-byte load

    o16 movsxd  rax, dword [rsi]  ; REX.W prefix takes precedence over o16 (0x66 prefix); this faults
    mov      eax, [rsi]            ; definitely faults if [rsi+2] isn't readable

请注意,movsx al, ax 不可能:字节操作数大小需要单独的操作码。前缀只选择32位(默认),16位(0x66),在长模式下为64位(REX.W)。自386以来,movs/zx ax, word [mem] 就一直可用,但读取源比目标宽的情况是x86-64中的一个特例,仅适用于符号扩展。(并且事实证明,16位目标编码实际上只读取16位源。)

AMD没有选择的其他ISA设计可能性:

顺便说一句,AMD本来可以(但没有)在32位寄存器写入时设计AMD64始终进行符号扩展而不是零扩展。在大多数情况下,这会给软件带来更不方便,并且可能需要额外的晶体管,但它仍然可以避免在寄存器中固有的旧值上出现假依赖性。这可能会在某个地方添加额外的门延迟,因为结果的高位取决于低位,而不像零扩展那样只依赖于它是32位操作。(但这可能不重要)。

如果 AMD以这种方式设计它,他们将需要一个 movzxd 来代替 movsxd。我认为这种设计的主要缺点是,当将位字段打包到更宽的寄存器中时需要使用额外的指令。例如,在写入edxeaxrdtsc 之后执行 shl rax,32 / or rax,rdx,免费的零扩展对于此类操作很方便。如果是符号扩展,则需要一个指令来清零rdx的上半字节,然后再执行or


其他ISA做出了不同的选择:MIPS III(约在1995年)将体系结构扩展到64位,而没有引入新模式。与x86非常不同的是,在固定宽度32位指令字格式中剩余了足够的未使用操作码空间。
MIPS最初是一个32位体系结构,并且从其16位8086遗产以及8086对AX = AH:AL部分寄存器的8位操作数大小的完全支持中从未有任何遗留的部分寄存器问题,以便轻松移植8080源代码
MIPS 32位算术指令(例如64位CPU上的addu)要求其输入正确地进行符号扩展,并产生符号扩展输出。 (运行不知道更宽寄存器的遗留32位代码时,一切都可以正常工作,因为移位是特殊的。)

ADDU rd, rs, rt (from the MIPS III manual, page A-31)

Restrictions:
On 64-bit processors, if either GPR rt or GPR rs do not contain sign-extended 32-bit values (bits 63..31 equal), then the result of the operation is undefined.

Operation:

  if (NotWordValue(GPR[rs]) or NotWordValue(GPR[rt])) then UndefinedResult() endif
  temp ←GPR[rs] + GPR[rt]
  GPR[rd]← sign_extend(temp31..0)
(请注意,在addu中,U表示无符号,这实际上是一个误称,正如手册所指出的那样。您也可以在有符号算术中使用它,除非您真的希望add在有符号溢出时触发。)

有一个DADDU指令用于双字ADDU,它会执行您所期望的操作。类似地,还有DDIV/DMULT/DSUBU、DSLL和其他移位操作。

按位操作保持不变:现有的AND操作码变成了64位AND;没有必要进行64位AND,但也没有免费的32位AND结果符号扩展。

MIPS 32位移位是特殊的(SLL是32位移位。DSLL是一个单独的指令)。

SLL Shift Word Left Logical

Operation:

s ← sa
temp ← GPR[rt] (31-s)..0 || 0 s
GPR[rd]← sign_extend(temp)

Programming Notes:
Unlike nearly all other word operations the input operand does not have to be a properly sign-extended word value to produce a valid sign-extended 32-bit result. The result word is always sign extended into a 64-bit destination register; this instruction with a zero shift amount truncates a 64-bit value to 32 bits and sign extends it.


我认为SPARC64和PowerPC64与MIPS64类似,可以保持窄结果的符号扩展。对于int a,(a & 0x80000000) +- 12315的代码生成(使用-fwrapv,这样编译器不能假设由于有符号溢出UB而使a非负)显示PowerPC64的clang保持或重新进行符号扩展,而clang -target sparc64则是AND然后OR来确保仅在低32位中设置正确的位,同样保持符号扩展。将返回类型或arg类型更改为long或在AND掩码常量上添加L后缀会导致MIPS64、PowerPC64和有时SPARC64的代码差异;也许只有MIPS64实际上会在输入没有正确符号扩展的32位指令上发生故障,而在其他情况下,它只是软件调用约定要求。
但是AArch64采用的方法更像x86-64,其中w0..31寄存器是x0..31的低半部分,并且指令有两种操作数大小可用。
这整个关于MIPS的部分与x86-64无关,但它是一个有趣的比较,可以看出AMD64所做的不同(我认为更好的)设计决策。
我在上面的Godbolt链接中包含了MIPS64编译器输出,针对那些示例函数。(还有一些其他内容,告诉我们更多关于调用约定和编译器的信息。)它经常需要使用dext从32位扩展到64位;但是该指令直到mips64r2才被添加。使用-march=mips3,对于无符号的areturn p[a]必须使用两个双字移位(先左移然后右移32位)进行零扩展!它还需要额外的指令来将加法结果零扩展,即实现从无符号到uint64_t的强制转换。
因此,我认为我们应该高兴地看到x86-64是设计为具有自由零扩展而不仅仅为某些事物提供64位操作数大小。(就像我说的,x86的遗产非常不同;它已经使用前缀为相同的操作码提供了可变的操作数大小。)当然,更好的位域指令会很好。一些其他的ISA,如ARM和PowerPC,在高效的位域插入/提取方面对x86构成了威胁。

2
我只是假设那些踩了这个答案的人不喜欢CPU架构/设计,并错过了我直接回答问题的第一段。难道我需要为此部分的答案使用更大的字体,以使其与movzx/movsx机器编码的详细信息区分开来吗? - Peter Cordes
3
哇,这简直就是纯金啊。谢谢 Peter。 - Johan
@Johan:感谢您的编辑,标记“使用此代码”部分结束的想法很好。 - Peter Cordes
在每个平台上,movsxd ax, dx 是否真的会对 rax=dx 进行零扩展操作? - l4m2
db 0x66, 0x63, 0xc2 ;movsxd ax, edx ; RAX = 0x0000000000008687 isn't what sign extend behave. I wrote movsxd ax,dx just because document claim it MOVSXD r16, r/m16 - l4m2
显示剩余3条评论

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