是的,你必须假设一个参数或返回值寄存器的高32位包含垃圾数据。 另一方面,在调用或返回自己时允许在高32位留下垃圾数据。 也就是说,忽略高位的负担在接收方身上,而不是传递方清理高位。
您需要签名或零扩展到64位才能在64位有效地址中使用该值。 在
x32 ABI中,gcc经常使用32位有效地址,而不是为每个修改可能为负的整数用作数组索引的指令使用64位操作数大小。
标准:
x86-64 SysV ABI 只说明了哪些寄存器的部分在 _Bool
(也称为 bool
) 中被清零。第20页:
当以寄存器或堆栈传递或返回 _Bool
类型的值时,位0包含真值,位1到7必须为零(脚注14:其他位未指定,因此消费者端可以依赖其在截断为8位时为0或1)
此外,有关 %al
保存可变参数函数的 FP 寄存器参数数量而不是整个 %rax
的内容。
关于这个确切问题,有一个开放的 GitHub 问题,位于 x32 和 x86-64 ABI 文档的 github 页面。
ABI对于保存参数或返回值的整数或向量寄存器高位的内容没有进一步要求或保证,因此不存在。我通过电子邮件从ABI维护者之一Michael Matz确认了这一事实:“通常情况下,如果ABI没有说明某些内容是指定的,您就不能依赖它。”
他还确认了例如
clang >= 3.6使用一个addps
的bug,可能会在高元素中产生垃圾,从而减慢或引发额外的FP异常(这让我想起我应该报告一下)。他补充说,这曾经是一个AMD实现glibc数学函数的问题。当传递标量
double
或
float
参数时,普通C代码
可以在向量寄存器的高元素中留下垃圾。
实际行为,尚未在标准中记录:
即使是_Bool
/bool
,狭窄的函数参数也会被符号或零扩展为32位。clang甚至制造了依赖于这种行为的代码(自2007年以来,显然)。ICC17没有这样做,因此ICC和clang在C方面甚至不兼容ABI。如果前6个整数参数中有任何一个小于32位,请勿从ICC编译的代码调用x86-64 SysV ABI的clang编译函数。
这并不适用于返回值,仅适用于参数:gcc和clang都假定它们接收到的返回值只有类型宽度范围内的有效数据。例如,gcc将使返回
char
的函数在
%eax
的高24位中留下垃圾。
最近在ABI讨论组上的一个帖子提出了一个建议,即澄清将8位和16位参数扩展为32位的规则,并可能实际修改ABI以要求此操作。主要编译器(除ICC外)已经这样做了,但这将是调用者和被调用者之间合同的变更。
这是一个例子(可以通过其他编译器检查或调整
在Godbolt编译器资源管理器上的代码),其中包括许多简单的示例,仅演示谜题的一部分以及演示很多部分的示例。
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)
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)
是等价的,但不会更小。如果我们可以保证
fuint()
返回值的高位被清零,编译器就可以使用
array_us(%rbx, %rax, 2)
代替使用
add
指令。
性能影响
故意忽略高32位是一个好的设计决策,对于32位操作,忽略高32位是免费的。32位操作自动将结果零扩展到64位,所以只有在64位寻址模式或64位操作中可以直接使用寄存器时,才需要额外的mov edx, edi
或其他指令。
一些函数不会因为参数已经扩展到64位而节省任何指令,因此对于调用者来说总是需要这样做可能是一种潜在的浪费。一些函数以需要与参数符号相反的方式使用它们的参数,因此让被调用者决定该怎么做效果很好。
无论有无符号,将其零扩展为64位对于大多数调用者来说是免费的,而且可能是良好的ABI设计选择。由于arg regs无论如何都会被破坏,如果调用者想在仅传递低32位的调用中保留完整的64位值,则已经需要做一些额外的工作。因此,仅在您需要在调用之前获得64位结果并将截断版本传递给函数时才需要额外付费。在x86-64 SysV中,您可以在RDI中生成结果并使用它,然后调用
call foo
,它只会查看EDI。
16位和8位操作数大小通常会导致错误的依赖性(AMD、P4或Silvermont以及之后的SnB系列),或部分寄存器停顿(SnB之前)或轻微减速(Sandybridge),因此需要将8和16b类型扩展为32b进行参数传递的未记录行为是有道理的。有关这些微体系结构的更多详细信息,请参见为什么GCC不使用部分寄存器?。
这对于实际代码中的代码大小可能不是什么大问题,因为微小的函数应该是静态内联的,并且参数处理指令只是更大函数的一小部分。即使没有内联,跨过调用之间的开销也可以通过函数间优化来消除,只要编译器能看到两个定义。(我不知道编译器在实践中做得如何。)
我不确定将函数签名更改为使用uintptr_t是否会在64位指针上帮助或损害整体性能。对于标量,我不会担心堆栈空间。在大多数函数中,编译器推送/弹出足够数量的保留寄存器(例如%rbx和%rbp),以使其自己的变量保持在寄存器中活动。相比于4B,额外的8B溢出空间微不足道。
就代码大小而言,使用64位值需要在一些指令上加上REX前缀,而本来不需要的指令则不需要。如果在将32位值用作数组索引之前需要进行任何操作,则零扩展到64位是免费的。如果需要符号扩展,则始终需要额外的指令。但编译器可以从一开始就对其进行符号扩展并将其视为64位有符号值来节省指令,代价是需要更多的REX前缀。(有符号溢出是未定义行为,不被定义为环绕,因此编译器通常可以避免在使用
arr[i]
的
int i
循环内重新进行符号扩展。)
现代CPU通常更关注指令计数而不是指令大小,但要合理。热点代码通常会从具有uop缓存的CPU中运行。尽管如此,较小的代码可以提高uop缓存的密度。如果您可以节省代码大小而不使用更多或更慢的指令,则这是一个胜利,但通常不值得为此牺牲任何其他方面,除非节省的代码量非常大。
比如,在后续的十几条指令中添加一个额外的LEA指令,以允许使用[reg + disp8]
寻址,而不是使用disp32
。或者在多个mov [rdi+n], 0
指令之前加上xor eax,eax
,用寄存器源替换imm32=0。(特别是如果这样做可以实现微融合,而使用RIP相对+立即数则无法实现微融合,因为真正重要的是前端uop计数,而不是指令计数。)
int
是一种有符号类型。如果你不想要符号扩展,请使用unsigned
或更好地使用size_t
。 - EOFvoid foo(uint32_t); void bar(uint64_t x){foo(x);}
。 - EOF