为什么我们不能从栈帧直接移动 1 个字节到寄存器?

Why can't we move directly 1 byte from stack's frame to register?

我正在阅读计算机系统:程序员的视角,3/E (CS:APP3e) Randal E. Bryant 和 David R. O'Hallaron,作者说 "Observe that the movl instruction of line 6 reads 4 bytes from memory; the following addb instruction only makes use of the low-order byte"

第6行,他们为什么要用movl?他们为什么不移动 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

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

(此代码 完全 我们从 GCC4.8 和更高版本的 gcc -O1 中获得的那些 C 语句和那些宽度的整数类型。看到它和 clang on the Godbolt compiler explorer 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) is your go-to instruction for loading bytes / words, not movb or movw. It's always safe, and on modern CPUs MOVZX loads are literally as fast as dword mov loads, no extra latency or extra uops; handled right in the load execution unit. (Intel since Core 2 or earlier, AMD since at least Ryzen. https://agner.org/optimize/)。 唯一的成本是 1 个额外字节的代码大小(更大的操作码)。 movsblmovsbq(又名 MOVSX)符号扩展在较新的 CPU 上同样有效,但在某些 AMD(如某些 Bulldozer 系列)上,它们的延迟比 MOVZX 加载高 1 个周期。因此,如果您关心的只是在加载字节时避免部分寄存器恶作剧,那么更喜欢 MOVZX。

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

写入 %dl 会错误地依赖于在某些 CPU 上编写 EDX 的指令(在您的调用程序中),包括当前的 Intel 和所有 AMD。 ()。 Clang 和 ICC 不关心,不管怎样,按照你期望的方式实现功能。

movl 写入完整的 64 位寄存器 (by implicit zero-extension when writing a 32-bit register) 避免了该问题。

但是如果调用者只使用字节存储,从 8(%rsp) 读取一个双字可能会导致存储转发停顿。 如果调用者用push,你很好。但是,如果调用者仅在 call 之前使用 movb 3, (%rsp) 进入已保留的堆栈 space,那么现在您的函数正在从最后一个存储为字节的位置读取一个双字。除非有某种其他的停顿(例如在调用你的函数后的代码提取中),当加载 uop 执行时,字节可能在存储缓冲区中,但加载需要从缓存中加上 3 个字节。或者来自一些仍在存储缓冲区中的早期存储,因此在将存储缓冲区中的字节与缓存中的其他字节合并之前,它还必须扫描存储缓冲区以查找所有可能的匹配项。只有当您加载的所有数据都来自一个商店时,存储转发的快速路径才有效。 (Can modern x86 implementations store-forward from more than one prior store?)

但等等,x86-64 System V 调用约定的未成文 "extension" 意味着没有存储转发停顿的风险

,即使所写的 System V ABI(还?)不需要它。 Clang 生成的代码也依赖于它。这显然包括在内存中传递的参数,正如我们在 Godbolt 上查看调用者所看到的那样。 (我使用 __attribute__((noinline)) 所以我可以在启用优化的情况下进行编译,但仍然没有调用内联和优化。否则我可以只注释掉主体并查看只能看到原型的调用者。

这是 不是 C 的 "default argument promotions" 的一部分,用于调用非原型函数。窄参数的 C 类型仍然是 shortchar。这只是一个调用约定功能,它允许被调用方对 C 对象的对象表示形式 外部 中的寄存器(或内存)中的位进行假设。但是,如果要求高 32 位为零,则它会更有用,因为您仍然不能将它们用作 64 位寻址模式的数组索引。但是你可以在没有 MOVSX 的情况下先做 int_arg += char_arg。因此,当您使用 narrow args 时,它可以使代码更高效,并且它们通过 C 规则隐式提升为 int 二元运算符,如 +.

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

更常见的是(没有-maccumulate-outgoing-args)调用者会在加载之前使用push push %rdi来做一个qword存储,这也可以有效地存储转发到一个dword(或字节)负载。因此,无论哪种方式,arg 将至少写入一个 dword 存储,使 dword 重新加载对性能安全

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

GCC4.7 和更早版本 dochar a4 arg 使用 movzbl (MOVZX) 负载,就像我推荐的一般安全选项一样,但 GCC4.8 及更高版本使用 movl.