x86-64 SysV ABI指定了函数参数如何在寄存器中传递(rdi
中的第一个参数,然后是rsi
等等),以及如何传递整数返回值返回(在rax
然后在rdx
获取非常大的值)。
然而,我找不到的是,当传递小于64位的类型时,参数或返回值寄存器的高位应该是什么。
例如,对于以下功能:
void foo(unsigned x, unsigned y);
... x
将在rdi
和y
rsi
中传递,但它们只有32位。高{32}的rdi
和rsi
需要为零吗?直观地说,我会假设是,但是所有gcc,clang和icc的code generated在开始时都有特定的mov
指令将高位清零,所以看起来编译器会假设其他情况。 / p>
类似地,编译器似乎假设如果返回值小于64位,则返回值rax
的高位可能具有垃圾位。例如,以下代码中的循环:
unsigned gives32();
unsigned short gives16();
long sum32_64() {
long total = 0;
for (int i=1000; i--; ) {
total += gives32();
}
return total;
}
long sum16_64() {
long total = 0;
for (int i=1000; i--; ) {
total += gives16();
}
return total;
}
... compile到clang
中的以下内容(和其他编译器相似):
sum32_64():
...
.LBB0_1:
call gives32()
mov eax, eax
add rbx, rax
inc ebp
jne .LBB0_1
sum16_64():
...
.LBB1_1:
call gives16()
movzx eax, ax
add rbx, rax
inc ebp
jne .LBB1_1
注意调用返回32位后的mov eax, eax
和16位调用后的movzx eax, ax
- 两者都分别将前32或48位清零。所以这种行为有一些成本 - 处理64位返回值的相同循环省略了这条指令。
我非常认真地阅读了x86-64 System V ABI document,但我无法确定标准中是否记录了这种行为。
这样的决定有什么好处?在我看来,有明显的成本:
在处理参数值时,会对callee的实现施加成本。并在处理参数时的功能。当然,这个成本通常是零,因为该函数可以有效地忽略高位,或者归零是免费的,因为可以使用32位操作数大小的指令隐含地将高位置零。
然而,在接受32位参数的函数的情况下,成本通常是非常真实的,并且可以从64位数学中获益。以this function为例:
uint32_t average(uint32_t a, uint32_t b) {
return ((uint64_t)a + b) >> 2;
}
直接使用64位数学计算一个本来必须小心处理溢出的函数(以这种方式转换许多32位函数的能力是64位架构经常被忽视的好处)。这编译为:
average(unsigned int, unsigned int):
mov edi, edi
mov eax, esi
add rax, rdi
shr rax, 2
ret
需要4个指令中的2个(忽略ret
)才能将高位清零。这在移动消除的实践中可能很便宜,但仍然需要付出很大的代价。
另一方面,如果ABI指定高位为零,我无法真正看到调用者的类似相应成本。因为rdi
和rsi
以及其他参数传递寄存器是 scratch (即,可以被调用者覆盖),所以只有几个场景(我们看一下{{ 1}},但用您选择的参数reg替换它:
传递给rdi
中的函数的值在调用后代码中已死(不需要)。在这种情况下,无论上次分配给rdi
的任何指令,都只需要分配给rdi
。这不仅是免费的,如果你避免使用REX前缀,它通常会缩小一个字节。
在函数之后需要传递给edi
中的函数的值。在这种情况下,由于rdi
被调用者保存,因此调用者需要对被调用者保存的寄存器执行值rdi
。您通常可以对其进行组织,以便在被调用者保存的寄存器(例如mov
)中将值启动,然后将其移至rbx
,如edi
,因此需要付费什么都没有。
我看不到很多场景,其中调零会使调用者付出太多代价。一些例子是在分配mov edi, ebx
的最后一条指令中需要64位数学运算。这似乎很少见。
这里的决定似乎更加中立。让callees清除垃圾有一个明确的代码(你有时会看到rdi
指令来执行此操作),但如果允许垃圾,则成本会转移到被调用者。总的来说,调用者似乎更有可能免费清除垃圾,因此允许垃圾似乎并不会对性能造成不利影响。
我认为这种行为的一个有趣的用例是具有不同大小的函数可以共享相同的实现。例如,以下所有功能:
mov eax, eax
实际上可以共享相同的实现 1 :
short sums(short x, short y) {
return x + y;
}
int sumi(int x, int y) {
return x + y;
}
long suml(long x, long y) {
return x + y;
}
1 对于那些获取地址的函数,这种折叠是否实际上是允许的非常open to debate。
答案 0 :(得分:4)
看起来你有两个问题:
第一个问题的答案是不,可能在高位中是垃圾,而且Peter Cordes已经在主题上写了very nice answer
关于第二个问题,我怀疑保留高位未定义总体上更好的性能。一方面,当使用32位操作时,预先零扩展值不会产生额外成本。但另一方面,预先将高位置零并不总是必要的。如果您允许高位垃圾,那么您可以将其留给接收值的代码,以便在实际需要时仅执行零扩展(或符号扩展)。
但我想强调另一个考虑因素:安全
当结果的高位未被清除时,它们可能保留其他信息片段,例如堆栈/堆中的函数指针或地址。如果有机制执行更高权限的函数并在之后检索rax
(或eax
)的完整值,那么这可能会引入信息泄漏。例如,系统调用可能会泄漏从内核到用户空间的指针,从而导致内核ASLR失败。或者IPC机制可能会泄漏有关其他进程的信息。地址空间可以帮助开发sandbox突破。
当然,有人可能认为ABI不负责防止信息泄露;程序员应该正确地实现他们的代码。虽然我同意,强制编译器将高位置零,但仍然可以消除这种特殊形式的信息泄漏。
另一方面,更重要的是,编译器不应盲目相信任何接收到的值都将其高位置零,否则函数可能不会按预期运行,这也可能导致可利用的条件。例如,请考虑以下事项:
unsigned char buf[256];
...
__fastcall void write_index(unsigned char index, unsigned char value) {
buf[index] = value;
}
如果我们被允许假设index
的高位被清零,那么我们可以将上面的代码编译为:
write_index: ;; sil = index, dil = value
mov rax, offset buf
mov [rax+rsi], dil
ret
但是如果我们可以从我们自己的代码中调用这个函数,我们可以在rsi
范围之外提供[0,255]
的值,并写入超出缓冲区范围的内存。
当然,编译器实际上不会生成这样的代码,因为如上所述,被调用者负责对其参数进行零或符号扩展,而不是来电者。我认为,这是一个非常实际的理由,让接收值的代码总是假设高位中有垃圾并明确删除它。