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