x86-64 SysV ABI 中的参数和返回值寄存器的高位是否允许垃圾?
- 2024-11-04 08:43:00
- admin 原创
- 47
问题描述:
除其他事项外,x86-64 SysV ABI 指定了如何在寄存器中传递函数参数(第一个参数在rdi
,然后rsi
等等),以及如何传回整数返回值(对于非常大的值,在rax
然后rdx
)。
然而,我找不到在传递小于 64 位的类型时参数或返回值寄存器的高位应该是什么。
例如,对于以下函数:
void foo(unsigned x, unsigned y);
...x
将传入rdi
和,但它们只有 32 位。和y
的rsi
高 32 位是否需要为零?直观地讲,我认为是的,但是gcc、clang 和 icc生成的代码在开始时都有特定指令来将高位清零,因此似乎编译器的假设并非如此。rdi
`rsi`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;
}
...编译为以下内容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
请注意mov eax, eax
,在返回 32 位的调用之后,以及movzx eax, ax
在返回 16 位的调用之后,两者都分别具有将前 32 位或 48 位清零的效果。因此,这种行为有一定的成本 - 处理 64 位返回值的相同循环会省略此指令。
我已经非常仔细地阅读了x86-64 System V ABI 文档,但我找不到标准中是否记录了这种行为。
这样的决定有什么好处呢?在我看来,这样做的代价是显而易见的:
参数成本
处理参数值时,调用方的实现会产生成本。处理参数时,函数的实现也会产生成本。当然,这个成本通常为零,因为函数可以有效地忽略高位,或者归零是免费的,因为可以使用 32 位操作数大小指令来隐式地将高位归零。
然而,对于接受 32 位参数并执行一些可从 64 位数学中受益的数学运算的函数,成本通常非常高。以这个函数为例:
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
)即可将高位清零。使用 mov 消除法,这在实践中可能很便宜,但似乎成本仍然很高。
另一方面,如果 ABI 指定高位为零,我实际上看不到调用者有类似的相应成本。由于rdi
和rsi
和其他参数传递寄存器是临时的(即,可以被调用者覆盖),因此您只有几种情况(我们查看rdi
,但将其替换为您选择的参数寄存器):
传递给函数的值在
rdi
调用后代码中是无效的(不需要)。在这种情况下,最后分配给的指令rdi
只需分配给edi
即可。这不仅是免费的,而且如果避免使用 REX 前缀,它通常会小一个字节。传递给函数的值在函数执行后需要。在这种情况下,由于
rdi
是rdi
调用者保存的,因此调用者mov
无论如何都需要将值的 移至被调用者保存的寄存器。通常可以组织它,以便值从被调用者保存的寄存器(例如)开始rbx
,然后移动到edi
,mov edi, ebx
因此它没有任何成本。
我看不到很多情况下归零会给调用者带来太多成本。一些例子是,如果在最后一条分配的指令中需要 64 位数学运算rdi
。不过这种情况似乎很少见。
退货价值成本
在这里,这个决定似乎更加中立。让被调用者清除垃圾有明确的代码(您有时会看到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;
}
实际上可以共享相同的实现1:
sum:
lea rax, [rdi+rsi]
ret
1对于地址已被占用的函数,是否实际上允许进行这样的折叠,这仍是一个有待商榷的问题。
解决方案 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;
}
如果我们可以假设它index
的高位被清零,那么我们可以将上述内容编译为:
write_index: ;; sil = index, dil = value
; movzx esi, sil ; skipped based on assumptions
mov [buf + rsi], dil
ret
rsi
但是如果我们可以从自己的代码中调用此函数,我们可以提供超出范围的值[0,255]
并写入超出缓冲区边界的内存。
当然,编译器实际上不会生成这样的代码,因为如上所述,将其参数零扩展或符号扩展是被调用者的责任,而不是调用者的责任。我认为这是一个非常实际的原因,让接收值的代码始终假定高位有垃圾并明确删除它。
(对于 Intel IvyBridge 及更高版本(mov 消除),编译器希望将零扩展到不同的寄存器,以至少避免指令的延迟,如果不是前端吞吐量成本的话movzx
。)
- 2024年20款好用的项目管理软件推荐,项目管理提效的20个工具和技巧
- 2024年开源项目管理软件有哪些?推荐5款好用的项目管理工具
- 项目管理软件有哪些?推荐7款超好用的项目管理工具
- 项目管理软件哪个最好用?盘点推荐5款好用的项目管理工具
- 项目管理软件有哪些最好用?推荐6款好用的项目管理工具
- 项目管理软件有哪些,盘点推荐国内外超好用的7款项目管理工具
- 2024项目管理软件排行榜(10类常用的项目管理工具全推荐)
- 项目管理软件排行榜:2024年项目经理必备5款开源项目管理软件汇总
- 2024年常用的项目管理软件有哪些?推荐这10款国内外好用的项目管理工具
- 项目管理必备:盘点2024年13款好用的项目管理软件