首页 > 解决方案 > 为什么我们不能直接从栈帧移动 1 个字节到寄存器?

问题描述

我正在阅读 Computer Systems: A Programmer's Perspective, 3/E (CS:APP3e) Randal E. Bryant 和 David R. O'Hallaron,作者说“观察第 6 行的 movl 指令从内存中读取 4 个字节;以下 addb 指令仅使用低位字节"

第 6 行,他们为什么使用 movl?他们为什么不 movb 8(%rsp), %dl?

void proc(a1, a1p, a2, a2p, a3, a3p, a4, a4p)
Arguments passed as follows:
  a1 in %rdi (64 bits)
  a1p in %rsi (64 bits)
  a2 in %edx (32 bits)
  a2p in %rcx (64 bits)
  a3 in %r8w (16 bits)
  a3p in %r9 (64 bits)
  a4 at %rsp+8 ( 8 bits)
  a4p at %rsp+16 (64 bits)
1   proc:
2   movq    16(%rsp), %rax  Fetch a4p (64 bits)
3   addq    %rdi, (%rsi)    *a1p += a1 (64 bits)
4   addl    %edx, (%rcx)    *a2p += a2 (32 bits)
5   addw    %r8w, (%r9) *a3p += a3 (16 bits)
6   movl    8(%rsp), %edx   Fetch a4 (8 bits)
7   addb    %dl, (%rax) *a4p += a4 (8 bits)
8   ret         Return

标签: gccassemblyx86-64calling-conventionfunction-parameter

解决方案


TL:DR:你可以,GCC 只是选择不,与普通字节加载相比,节省 1 个字节的代码大小,movzbl并避免movb加载+合并造成的任何部分寄存器惩罚。但是由于不明原因,这不会在加载函数 arg 时导致存储转发停止。

(这段代码正是我们从 GCC4.8 和更高版本中得到的,gcc -O1带有那些 C 语句和这些宽度的整数类型。看到它并在 Godbolt 编译器资源管理器上发出叮当声, GCC之前-O3安排了movl一条指令。)


这样做没有正确的理由,只有可能的性能。您是正确的,字节加载也可以正常工作。(我省略了多余的操作数大小后缀,因为它们是由寄存器操作数隐含的)。

    mov     8(%rsp), %dl        # byte load, merging into RDX
    add     %dl, (%rax)

您可能从 C 编译器获得的是零扩展的字节加载。(例如 GCC4.7 和更早的版本会这样做)

    movzbl  8(%rsp), %edx       # byte load zero-extended into RDX
    add     %dl,  (%rax)

movzbl(又名MOVZX in Intel syntax)是您加载字节/单词的首选指令,而movb不是movw. 它总是安全的,并且在现代 CPU 上,MOVZX 加载几乎与 dwordmov加载一样快,没有额外的延迟或额外的微指令;在加载执行单元中处理。(英特尔从 Core 2 或更早版本开始,AMD 至少从 Ryzen 开始 。https: //agner.org/optimize/ )。唯一的成本是 1 个额外字节的代码大小(更大的操作码)。 movsblmovsbq(又名 MOVSX)符号扩展在较新的 CPU 上同样有效,但在某些 AMD(如某些 Bulldozer 系列)上,它们的延迟比 MOVZX 负载高 1 个周期。因此,如果您只关心在加载字节时避免部分寄存器恶作剧,那么更喜欢 MOVZX。

如果您特别想合并到现有 64 位寄存器的低字节或字中,通常只使用movbmovw(与寄存器目标一起使用) 。 字节/字存储在 x86 上非常好,我只是在谈论 mov mem-to-reg 或 reg-to-reg。这条规则有例外;有时,如果您小心并了解您关心代码在其上有效运行的微架构,有时您可以安全地使用字节操作数大小而不会出现问题。请注意,通过写入字节 reg 然后读取更大的 reg 来故意合并可能会导致某些 CPU 上的部分寄存器合并停止。

写入%dl将对在某些 CPU(包括当前的 Intel 和所有 AMD)上编写 EDX 的指令(在您的调用者中)产生错误的依赖。(为什么 GCC 不使用部分寄存器?)。Clang 和 ICC 不在乎,无论如何都要做,按照您期望的方式实现功能。

movl写入完整的 64 位寄存器(在写入 32 位寄存器时通过隐式零扩展)避免该问题。

8(%rsp)但是,如果调用者只使用字节存储,则读取 dword可能会引入存储转发停顿。 如果调用者用 a 写了那个内存push,你就没事了。但是如果调用者只movb $123, (%rsp)call进入已经保留的堆栈空间,现在您的函数正在从最后一个存储是一个字节的位置读取一个 dword。除非有某种其他的停顿(例如,在调用你的函数后的代码获取中),当加载 uop 执行时,字节可能在存储缓冲区中,但加载需要加上缓存中的 3 个字节。或者来自仍然在存储缓冲区中的一些较早的存储,因此在将存储缓冲区中的字节与缓存中的其他字节合并之前,它还必须扫描存储缓冲区以查找所有潜在的匹配项。仅当您加载的所有数据都来自一个商店时,存储转发的快速路径才有效。(现代 x86 实现可以从多个先前的存储中存储转发吗?

但是等等,x86-64 System V 调用约定的不成文“扩展”意味着没有存储转发停止的风险

clang/gcc 将窄 args 符号或零扩展为 32-bit,即使编写的 System V ABI (还没有?)需要它。Clang 生成的代码也依赖于它。这显然包括在内存中传递的参数,正如我们从 Godbolt 上的调用者中看到的那样。(我使用__attribute__((noinline))了这样我可以在启用优化的情况下进行编译,但仍然没有调用内联并优化掉。否则我可能只是注释掉主体并查看只能看到原型的调用者。

不是C 调用非原型函数的“默认参数提升”的一部分。窄 args 的 C 类型仍然是shortor char。这只是一个调用约定功能,它允许被调用者对 C 对象的对象表示之外的寄存器(或内存)中的位进行假设。但是,如果要求高 32 位为零会更有用,因为您仍然不能将它们用作 64 位寻址模式的数组索引。但是您可以int_arg += char_arg先不使用 MOVSX。int因此,当您使用窄参数时,它可以使代码更高效,并且它们被 C 规则隐式提升为二进制运算符(如+.

gcc -O3 -maccumulate-outgoing-args通过用(或-O0或)编译调用者-O1,我让 GCC 保留堆栈空间,sub然后在调用你的函数movl $4, (%rsp)之前使用。call proc使用 gcc 会更有效(更小的代码大小)movb,但它选择使用movl带有 32 位立即数的 a。我认为这是因为它在调用约定中实现了这条不成文的规则,而不是其他原因。

更常见(没有-maccumulate-outgoing-args)调用者将在加载之前使用push $4push %rdi执行 qword 存储,这也可以有效地存储转发到 dword(或字节)加载。因此,无论哪种方式,arg 都将至少使用 dword 存储来编写,从而使 dword 重新加载对性能安全

dwordmov加载的代码大小比movzbl加载小 1 个字节,并且避免了 MOVSX 或 MOVZX 可能的额外成本(在旧的 AMD CPU 和极其旧的 Intel CPU (P5) 上)。所以我认为这是最优的。

GCC4.7 及更早版本确实使用movzbl(MOVZX) 加载char a4arg 就像我推荐的一般安全选项,但 GCC4.8 及更高版本使用movl.


推荐阅读