在不是地址/指针的值上使用LEA?

14

我试图理解地址计算指令的工作原理,特别是使用 leaq 命令时。但当我看到使用 leaq 执行算术运算的示例时感到困惑。例如下面的C语言代码:

long m12(long x) {
return x*12;
}
在汇编语言中,
leaq (%rdi, %rdi, 2), %rax
salq $2, $rax
如果我理解正确,leaq应该移动任何地址(%rdi,%rdi,2),它应该是2*%rdi +%rdi,并将其计算为%rax 。 我困惑的是,由于值x存储在%rdi中,而%rdi只是内存地址,为什么将%rdi乘以3然后将这个内存地址左移2位等于x乘以12?难道当我们将%rdi乘以3时,我们会跳转到另一个不保存值x的内存地址吗?

@Johan,我将https://dev59.com/AWvXa4cB1Zd3GeqPGjL3关闭为此问题的重复,因为这个问题有更详细的答案来澄清新手对于在非指针情况下使用LEA的困惑。 - Peter Cordes
2
相关:LEA指令有什么用?主要是在讨论leamov的区别,从不同的角度接近了相同的问题。那里的答案都谈到了它用于地址/指针,或者只是说“这是一个愚蠢的名称,用于位移和加法指令”,这些都只讲述了故事的一半。 - Peter Cordes
4个回答

41

lea (参见Intel的指令集手册)是一种使用内存操作数语法和机器编码的移位加法指令。这解释了它的名称,但这不是它唯一的用途。它实际上从未访问过内存,因此就像在C中使用&一样。

例如,请参见如何使用只有2个连续的leal指令在x86上将寄存器乘以37?

在C中,它就像uintptr_t foo = (uintptr_t) &arr[idx]。请注意,这里使用了&来给出arr + idx(由于这是C而不是汇编,因此要缩放arr的对象大小)。在C中,这将是对语言语法和类型的滥用,但在x86汇编中,指针和整数是相同的东西。一切都只是字节,程序需要按正确顺序放置指令才能获得有用的结果。

有效地址是x86中的一个技术术语:它指代seg:off逻辑地址的“偏移量”部分,特别是在需要执行base_reg + index*scale + displacement计算时。例如,在%gs:(%rax,%rcx,4) 寻址模式中的rax + (rcx<<2)。(但EA仍适用于stosb中的%rdi, 或movabs的绝对位移负载/存储,或其他没有ModRM地址模式的情况)。在这种上下文中使用它并不意味着它必须是一个有效/有用的内存地址,而是告诉您该计算不涉及段基址,因此它不会计算一个线性地址。(添加段基址将使它在非平坦内存模型中无法用于实际地址数学计算。)


8086指令集的原始设计师/架构师(Stephen Morse)可能考虑过指针数学作为主要用例,但现代编译器将其视为在指针/整数上进行算术运算的另一个选项,人类也应该这样做。

(请注意,16位寻址模式不包括移位操作,只有[BP | BX] + [SI | DI] + disp8 / disp16,因此在386之前,LEA对于非指针数学来说并不是那么有用。有关32/64位寻址模式的更多信息,请参见this Q&A,尽管该答案使用Intel语法,例如[rax + rdi * 4],而不是本问题中使用的AT&T语法。无论您使用哪种语法创建它,x86机器代码都是相同的。)

也许8086的设计师只是想展示地址计算硬件以供任意使用,因为他们可以在不使用大量额外晶体管的情况下实现。解码器已经必须能够解码寻址模式,CPU的其他部分也必须能够进行地址计算。将结果放入寄存器而不是与段寄存器值一起用于内存访问并不需要太多额外的晶体管。Ross Ridge确认,原始8086上的LEA重用了CPU的有效地址解码和计算硬件。


请注意,大多数现代CPU在与普通加法和移位指令相同的ALU上运行LEA。它们有专用的AGU(地址生成单元),但仅将其用于实际的内存操作数。顺序Atom是一个例外;LEA在管道中比ALU更早地运行:输入必须更早准备好,但输出也更早准备好。乱序执行的CPU(所有现代x86)不希望LEA干扰实际的加载/存储,因此将其运行在ALU上。
LEA具有良好的延迟和吞吐量,但在大多数CPU上,吞吐量不如ADD或MOV R32,IMM32,因此只有在可以使用LEA代替ADD来节省指令时才使用LEA。(参见 Agner Fog的x86微架构指南和asm优化手册和{{link2:https://uops.info/}})Ice Lake为英特尔改进了这一点,现在能够在所有四个ALU端口上运行LEA。
不同微架构下,哪些类型的LEA算法属于“复杂”范畴,只能在较少的端口上运行,规则各不相同。例如,在SnB系列中,3个组件(两个加操作)是速度较慢的情况,而在Ice Lake中,具有缩放指数是低吞吐量的情况。 Alder Lake E-cores(Gracemont)每个时钟周期可处理4个,但只要有索引,就变成了每个时钟周期处理1个,如果有索引和位移(无论是否有基址寄存器),则延迟为2个周期。当存在缩放指数或3个组件时,Zen速度较慢(从每个时钟周期处理4个和1个降至每个时钟周期处理2个和2个周期延迟)。

内部实现是无关紧要的,但可以肯定的是,将操作数解码为LEA与为任何其他指令解码寻址模式共享晶体管。(因此,即使在不执行AGU上的lea的现代CPU上也存在硬件重用/共享。)暴露多输入移位相加指令的任何其他方法都需要对操作数进行特殊编码。

因此,在扩展地址模式以包括缩放索引时,386获得了免费的移位相加ALU指令,并且能够在寻址模式中使用任何寄存器也使LEA更易用于非指针。

通过添加RIP相对寻址模式,x86-64可以便宜地访问程序计数器(而不需要读取call推送的内容)。这使得在x86-64位置无关代码中访问静态数据比32位PIC便宜得多。(RIP相对需要LEA处理的ALU和处理实际加载/存储地址的单独AGU的特殊支持。但不需要新的指令。)


它不仅适用于指针,也适用于任意算术运算,因此现在将其视为指针的意图是错误的。在汇编语言中,一切都是整数,因此将其用于非指针并不是“滥用”或“技巧”。它的吞吐量比“add”低,但几乎所有时间都可以使用它来节省至少一个指令。但它最多可以节省三个指令:
;; Intel syntax.
lea  eax, [rdi + rsi*4 - 8]   ; 3 cycle latency on Intel SnB-family
                              ; 2-component LEA is only 1c latency

 ;;; without LEA:
mov  eax, esi             ; maybe 0 cycle latency, otherwise 1
shl  eax, 2               ; 1 cycle latency
add  eax, edi             ; 1 cycle latency
sub  eax, 8               ; 1 cycle latency

在一些AMD CPU上,即使是复杂的LEA指令也只有2个周期的延迟,但4条指令序列需要4个周期的延迟,从esi准备好到最终eax准备好。无论哪种方式,这都可以节省3个uops的前端解码和发射,并且占用重排序缓冲区中的空间,直到退休。 lea指令有几个主要优点,特别是在32/64位代码中,其中寻址模式可以使用任何寄存器并且可以进行移位:
  • 非破坏性:输出在不是输入之一的寄存器中。有时候像lea 1(%rdi), %eax或者lea (%rdx, %rbp), %ecx这样的复制和加法操作很有用。
  • 可以在一条指令中执行3或4个操作(见上文)。
  • 数学运算不修改EFLAGS,在cmovcc之前的测试中可能会很方便。或者在具有部分标志延迟的CPU上进行带进位循环。
  • x86-64:位置无关代码可以使用RIP相对LEA来获取静态数据的指针。

7字节的lea foo(%rip), %rdimov $foo, %edi(5字节)略大且速度较慢,因此在符号位于虚拟地址空间低32位的操作系统(如Linux)上,建议使用mov r32, imm32来进行位置相关代码。您可能需要disable the default PIE setting in gcc才能使用此功能。

在32位代码中,mov edi, OFFSET symbollea edi, [symbol]相比同样更短更快。 (在NASM语法中省略OFFSET。)由于RIP相对地址不可用且地址适合32位立即数,因此如果您需要将静态符号地址加载到寄存器中,则无需考虑使用lea而不是mov r32, imm32

除了x86-64模式下的RIP相对LEA之外,所有这些都同样适用于计算指针与计算非指针整数加法/移位。

另请参阅 <!--> 标签维基,获取汇编指南/手册和性能信息。

x86-64中的操作数大小与地址大小

另请参见哪些2的补码整数运算可以在只需要结果的低位时不清零输入的高位?。64位地址大小和32位操作数大小是最紧凑的编码(没有额外的前缀),因此在可能的情况下,优先选择lea (%rdx,%rbp),%ecx而不是64位lea (%rdx,%rbp),%rcx或32位lea (%edx,%ebp),%ecx

x86-64 lea (%edx,%ebp),%ecx总是浪费一个地址大小前缀,相对于lea (%rdx,%rbp),%ecx,但显然需要64位地址/操作数大小才能进行64位数学运算。(Agner Fog的objconv反汇编器甚至会警告LEA带有32位操作数大小的无用地址大小前缀。)

除了可能在Ryzen上,Agner Fog报告说,在64位模式下,32位操作数大小的lea具有额外的延迟周期。我不知道如果覆盖地址大小为32位是否可以加快LEA在64位模式下的速度,如果您需要将其截断为32位。
这个问题与LEA指令的目的是什么?非常相似,但大多数答案都是从实际指针数据的地址计算角度进行解释。那只是其中一种用法。

你认为在你提供的LEA手册页面中,以下文本(在“操作”下)的第二个注释是复制/粘贴错误吗? ELSE IF OperandSize = 32 and AddressSize = 64 THEN temp ← EffectiveAddress(SRC); (* 64-bit address ) DEST ← temp[0:31]; ( 16-bit address *) FI; 因为 temp[0:31] 是一个32位地址,而不是一个16位地址? - Peter - Reinstate Monica
是的,应该是“32位地址”。此外,在列举所有其他组合时,似乎缺少32位地址大小、64位操作数大小的情况,比如lea rax,[edi-1]。 (那个例子完全没用,因为它与lea eax,[rdi-1]相同(不需要任何前缀),因为两者都是零扩展而不是符号扩展。) - Peter Cordes
好的,谢谢。这有点尴尬,因为宽度是最重要的,即使只是注释。我刚刚检查了一下——这段话是从英特尔手册中逐字复制的(链接太长无法在评论中提供,但可以在https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html上找到)。错误也存在于那里,出现在2A卷,第3-581页。 - Peter - Reinstate Monica
@Peter-ReinstateMonica:是的,http://felixcloutier.com/x86/ 和类似的网站如 https://github.com/HJLebbink/asm-dude/wiki 都是从英特尔的 vol.2 PDF 手册中提取的。(这两个都使用了相同版本的脚本。)这并不是第一个在英特尔手册中被发现的错误,也不是最严重的错误。 - Peter Cordes
更新:8086 ISA的架构师Stephen Morse写了一本书《8086 Primer》,在其中解释了一些指令的预期用途。他将LEA描述为旨在生成实际地址,并未提及类似于“lea ax,[bx + 1]”的复制和添加。https://stevemorse.org/8086/. 特别是随着386使寻址模式更加灵活,我认为将LEA视为数学指令更有用,实际寻址只是其用途之一。 - Peter Cordes

17
leaq 并非只能操作内存地址,它可以计算地址,实际上并不会读取结果,在 mov 或类似指令使用前,它只是一种奇怪的方法来进行加法运算,一个数字加上另一个数字乘以 1、2、4 或 8 次(或在这种情况下相同次数)。正如您所见,它经常被用于数学目的并被“滥用”。2*%rdi+%rdi 就是 3 * %rdi,所以它计算了不涉及 CPU 的乘法器单元的 x * 3
同样地,对于整数,左移每位(每添加一个零)就将值加倍,这要归功于二进制数的工作原理(十进制数也是这样,右边加零乘以 10)。
因此,这个例子中滥用了 leaq 指令来完成乘法运算,然后将结果左移以实现进一步的乘法运算。最终结果是通过不使用乘法指令而实现 12 倍乘法,它认为这样做可能跑得更慢,并且据我所知,这个想法可能是正确的;反复推测编译器通常是一场失败的游戏。
要明确,这里的“滥用”并不是指“误用”,而是指使用了一种不符合其名称暗示目的的方式。以这种方式使用它完全没有问题。

如果我们将x作为1传入。假设寄存器是4位,%rdi将是0001或0x1吗?(如果我们忽略long类型) - PassingBy
3
我认为这不是滥用LEA指令,复制和添加是通过lea指令暴露CPU地址生成能力的预期目的之一。请参见我的回答。 - Peter Cordes
1
@ZhiyuanRuan 是的,像 int/short/long/... 这样的类型在常见的 x86-64 ABIs 中是按值传递的,当以符合ABI的方式调用某些函数时,该值本身就在寄存器中。在编译器生成的原始汇编代码中没有涉及任何内存地址。 - Ped7g
@PeterCordes: “滥用”主要与用于描述指令(load effective address)的术语相关;它被设计用于地址生成,但寄存器就是寄存器,无论哪种方式,数学都是相同的。我并不是说使用lea不好的,只是这个指令的名称所暗示的目的并非如此。 - ShadowRanger
2
这就是我不同意的地方。我认为它被设计成暴露硬件的地址生成功能,以供任意目的使用。编译器就是这样考虑的,人类也应该如此。命名只是与其使用寻址模式语法和机器编码有关,而不是“预期”的目的。(正如我在答案中所说的那样,我真的不知道英特尔当初的想法,但我认为用这种方式向初学者解释LEA的使用方式会让它听起来很正常,因为它确实很正常。这就是为什么我不喜欢“滥用”这个术语,但这是使用它的一个公正理由。) - Peter Cordes


0

我认为混淆是因为第一个操作数 (%rdi, %rdi, 2) 看起来像是内存引用。

根据 Randal Bryant 和 David O'Hallaron 的书籍《计算机系统:程序员的视角》关于 leaq 的描述:

它的第一个操作数似乎是一个内存引用,但是指令不是从指定位置读取数据,而是将有效地址复制到目标中。

以下是相关部分:

该指令可用于生成后续内存引用的指针。此外,它还可以用于简洁地描述常见的算术运算。例如,如果寄存器 rdx 包含值 x,那么指令 leaq 7(%rdx,%rdx, 4) , %rax 将把寄存器 %rax 设置为 5x+7。编译器经常发现与有效地址计算无关的 leaq 的巧妙用法。


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