将 32 位偏移量添加到 x86-64 ABI 的指针时是否需要符号或零扩展?

Is a sign or zero extension required when adding a 32bit offset to a pointer for the x86-64 ABI?

总结:我正在查看汇编代码以指导我的优化,并在将 int32 添加到指针时看到很多符号或零扩展。

void Test(int *out, int offset)
{
    out[offset] = 1;
}
-------------------------------------
movslq  %esi, %rsi
movl    , (%rdi,%rsi,4)
ret

起初,我认为我的编译器在将 32 位整数添加到 64 位整数时遇到了挑战,但我已经使用 Intel ICC 11、ICC 14 和 GCC 5.3 确认了这种行为。

thread 证实了我的发现,但不清楚是否需要符号或零扩展。此 sign/zero 扩展仅在尚未设置高 32 位的情况下才有必要。但是 x86-64 ABI 不会智能到需要它吗?

我不太愿意将所有指针偏移更改为 ssize_t,因为寄存器溢出会增加代码的缓存占用空间。

由于 EOF 的注释表明编译器不能假定用于传递 32 位参数的 64 位寄存器的高 32 位具有任何特定值。这使得符号或零扩展成为必要。

防止这种情况的唯一方法是为参数使用 64 位类型,但这会将扩展值的要求转移给调用者,这可能不是改进。不过,我不会太担心寄存器溢出的大小,因为你现在这样做的方式很可能在扩展后原始值将失效并且将溢出的是 64 位扩展值.即使它没有死,编译器可能仍然更愿意溢出 64 位值。

如果您真的很关心内存占用并且不需要更大的 64 位地址 space 您可以查看 x32 ABI,它使用 ILP32 类型但支持完整的 64 位指令集。

是的,您必须假设 arg 或 return 值寄存器的高 32 位包含垃圾。另一方面,您可以在给自己打电话或 returning 时在高位 32 中留下垃圾。即负担在接收方忽略高位,而不是在传递方清理高位。

您需要将符号或零扩展到 64 位才能使用 64 位有效地址中的值。在 x32 ABI 中,gcc 经常使用 32 位有效地址,而不是对每条修改用作数组索引的潜在负整数的指令使用 64 位操作数大小。


标准:

x86-64 SysV ABI 只说明寄存器的哪些部分为 _Bool(又名 bool)归零。第 20 页:

When a value of type _Bool is returned or passed in a register or on the stack, bit 0 contains the truth value and bits 1 to 7 shall be zero (footnote 14: Other bits are left unspecified, hence the consumer side of those values can rely on it being 0 or 1 when truncated to 8 bit)

此外,关于 %al 的东西保存可变参数函数的 FP 寄存器参数的数量,而不是整个 %rax

有一个open github issue about this exact question on the github page for the x32 and x86-64 ABI documents

ABI 对保存 args 或 return 值的整数或向量寄存器的高位部分的内容没有任何进一步的要求或保证,因此没有任何要求或保证。我通过 Michael Matz(ABI 维护者之一)的电子邮件确认了这一事实:"Generally, if the ABI doesn't say something is specified, you cannot rely on it."

他还证实,例如clang >= 3.6's use of an addps that could slow down or raise extra FP exceptions with garbage in high elements is a bug(这提醒我应该报告)。他补充说,这曾经是 AMD 实现的 glibc 数学函数的一个问题。当传递标量 doublefloat args 时,普通 C 代码 可以 在向量 regs 的高位元素中留下垃圾。


标准中(尚未)记录的实际行为:

窄函数参数,甚至 _Bool/bool,都是符号或零扩展到 32 位。 clang 甚至生成依赖于此行为的代码 (since 2007, apparently). ICC17 doesn't do it,因此 ICC 和 clang 不兼容 ABI,即使对于 C。不要从 ICC 调用 clang 编译的函数-如果前 6 个整数参数中的任何一个小于 32 位,则为 x86-64 SysV ABI 编译代码。

这不适用于 return 值,仅适用于 args:gcc 和 clang 都假定它们收到的 return 值仅具有不超过类型宽度的有效数据。例如,gcc 将使 returning char 的函数在 %eax 的高 24 位中留下垃圾。

A recent thread on the ABI discussion group 是一项阐明将 8 位和 16 位参数扩展到 32 位的规则的提议,并且可能实际上修改 ABI 以要求这样做。主要编译器(ICC除外)已经这样做了,但这将改变调用者和被调用者之间的契约。

这是一个示例(与其他编译器一起检查或调整代码 on the Godbolt Compiler Explorer,我在其中包含了许多简单的示例,这些示例仅演示了一个难题,而这个演示了很多):

extern short fshort(short a);
extern unsigned fuint(unsigned int a);

extern unsigned short array_us[];
unsigned short lookupu(unsigned short a) {
  unsigned int a_int = a + 1234;
  a_int += fshort(a);                 // NOTE: not the same calls as the signed lookup
  return array_us[a + fuint(a_int)];
}

# clang-3.8 -O3  for x86-64.    arg in %rdi.  (Actually in %di, zero-extended to %edi by our caller)
lookupu(unsigned short):
    pushq   %rbx                      # save a call-preserved reg for out own use.  (Also aligns the stack for another call)
    movl    %edi, %ebx                # If we didn't assume our arg was already zero-extended, this would be a movzwl (aka movzx)
    movswl  %bx, %edi                 # sign-extend to call a function that takes signed short instead of unsigned short.
    callq   fshort(short)
    cwtl                              # Don't trust the upper bits of the return value.  (This is cdqe, Intel syntax.  eax = sign_extend(ax))
    leal    1234(%rbx,%rax), %edi     # this is the point where we'd get a wrong answer if our arg wasn't zero-extended.  gcc doesn't assume this, but clang does.
    callq   fuint(unsigned int)
    addl    %ebx, %eax                # zero-extends eax to 64bits
    movzwl  array_us(%rax,%rax), %eax # This zero-extension (instead of just writing ax) is *not* for correctness, just for performance: avoid partial-register slowdowns if the caller reads eax
    popq    %rbx
    retq

注意:movzwl array_us(,%rax,2) 是等效的,但不会更小。如果我们可以依赖 %rax 的高位在 fuint() 的 return 值中归零,编译器可以使用 array_us(%rbx, %rax, 2) 而不是 add insn.


性能影响

未定义 high32 是有意为之,我认为这是一个很好的设计决定。

在执行 32 位操作时忽略高 32 是免费的。 A 32-bit operation zero-extends its result to 64-bit for free,所以如果您可以在 64 位寻址模式或 64 位操作中直接使用 reg,则只需要一个额外的 mov edx, edi 或其他东西。

有些函数不会保存任何 insn,因为它们的参数已经扩展到 64 位,所以调用者总是必须这样做是一种潜在的浪费。某些函数以需要与 arg 的符号性相反的扩展的方式使用它们的 args,因此由被调用者决定要做什么效果很好。

尽管如此,无论签名如何,零扩展到 64 位对于大多数调用者来说都是免费的,并且可能是一个不错的 ABI 设计选择。由于 arg regs 无论如何都被破坏了,如果调用者想要在调用中保留完整的 64 位值,它只传递低 32 位,那么调用者已经需要做一些额外的事情。因此通常只有在需要 64 位时才需要额外的费用在调用之前获取结果,然后将截断的版本传递给函数。在 x86-64 SysV 中,您可以在 RDI 中生成结果并使用它,然后 call foo 只会查看 EDI。

16 位和 8 位操作数大小通常会导致错误的依赖关系(AMD、P4 或 Silvermont,以及后来的 SnB 系列),或部分寄存器停顿(SnB 之前)或轻微的减速(Sandybridge) ,因此要求将 8 和 16b 类型扩展到 32b 以进行 arg 传递的未记录行为是有道理的。有关这些微体系结构的更多详细信息,请参阅


这对于实际代码中的代码大小来说可能不是什么大问题,因为微小的函数是/应该是 static inline,并且 arg-handling insn 是更大函数的一小部分。当编译器可以看到两个定义时,即使没有内联,过程间优化也可以消除调用之间的开销。 (不知道编译器在实践中的表现如何。)

我不确定将函数签名更改为使用 uintptr_t 是有助于还是损害 64 位指针的整体性能。我不会担心标量的堆栈 space。在大多数函数中,编译器 pushes/pops 足够的调用保留寄存器(如 %rbx%rbp)来保持其自己的变量存在于寄存器中。 8B 溢出而不是 4B 的一点点额外 space 可以忽略不计。

就代码大小而言,使用 64 位值需要在某些 insn 上使用 REX 前缀,而这些 insn 本来不需要。如果 32 位值在用作数组索引之前需要任何操作,则零扩展到 64 位是免费的。如果需要,符号扩展总是需要额外的指令。但是编译器可以对它进行符号扩展并将其作为 64 位带符号值从一开始就使用它来保存指令,但代价是需要更多的 REX 前缀。 (有符号溢出是 UB,未定义为回绕,因此编译器通常可以避免在使用 arr[i]int i 循环内重做符号扩展。)

在合理的范围内,现代 CPU 通常更关心 insn 数量而不是 insn 大小。热代码通常来自具有它们的 CPU 中的 uop 缓存 运行。不过,较小的代码可以提高 uop 缓存中的密度。如果您可以在不使用更多或更慢的 insns 的情况下节省代码大小,那么这是一个胜利,但通常不值得牺牲任何其他东西,除非它是 lot 的代码大小。

就像一个额外的 LEA 指令允许 [reg + disp8] 寻址十几个以后的指令,而不是 disp32。或者在多个 mov [rdi+n], 0 指令之前 xor eax,eax 将 imm32=0 替换为寄存器源。 (特别是如果这允许微融合,而 RIP 相关 + 立即数是不可能的,因为真正重要的是前端 uop 数,而不是指令数。)