如何解决为什么Rust堆栈框架这么大?
我遇到了意外的早期堆栈溢出,并创建了以下程序来测试该问题:
#![feature(asm)]
#[inline(never)]
fn get_rsp() -> usize {
let rsp: usize;
unsafe {
asm! {
"mov {},rsp",out(reg) rsp
}
}
rsp
}
fn useless_function(x: usize) {
if x > 0 {
println!("{:x}",get_rsp());
useless_function(x - 1);
}
}
fn main() {
useless_function(10);
}
这是get_rsp
的反汇编(根据cargo-asm):
tests::get_rsp:
push rax
#APP
mov rax,rsp
#NO_APP
pop rcx
ret
我不确定#APP
和#NO_APP
的作用或为什么将rax
推入然后弹出rcx
的原因,但是函数似乎确实返回了堆栈指针
我很惊讶地发现在调试模式下,两个连续打印的rsp
之间的差是192(!),甚至在发布模式下,两者之间的差是128。
据我了解,每次对useless_function
的调用都需要存储的是一个usize
和一个返回地址,因此,我希望每个堆栈帧的大小大约为16个字节。>
我正在rustc 1.46.0
上运行于64位Windows计算机上。
我的结果在计算机上是否一致?如何解释?
使用println!
似乎有很大的作用。为了避免这种情况,我更改了程序(感谢@Shepmaster),将值存储在静态数组中:
static mut RSPS: [usize; 10] = [0; 10];
#[inline(never)]
fn useless_function(x: usize) {
unsafe { RSPS[x] = get_rsp() };
if x == 0 {
return;
}
useless_function(x - 1);
}
fn main() {
useless_function(9);
println!("{:?}",unsafe { RSPS });
}
递归在发布模式下得到了优化,但是在调试模式下,每帧仍占用80个字节,这比我预期的要多。这就是x86上堆栈框架的工作方式吗?其他语言做得更好吗?这似乎效率低下。
解决方法
使用println!
之类的格式化工具会在堆栈上创建许多东西。扩展代码中使用的宏:
fn useless_function(x: usize) {
if x > 0 {
{
::std::io::_print(::core::fmt::Arguments::new_v1(
&["","\n"],&match (&get_rsp(),) {
(arg0,) => [::core::fmt::ArgumentV1::new(
arg0,::core::fmt::LowerHex::fmt,)],},));
};
useless_function(x - 1);
}
}
我相信这些结构会占用大部分空间。为了证明这一点,我打印了format_args
创建的值的大小,该值由println!
使用:
let sz = std::mem::size_of_val(&format_args!("{:x}",get_rsp()));
println!("{}",sz);
这表明它是48个字节。
另请参阅:
类似的事情应该从方程式中删除打印内容,但是编译器/优化器会忽略此处的inline(never)
提示,并且无论如何都将其内联,从而导致顺序值都相同。
/// SAFETY:
/// The length of `rsp` and the value of `x` must always match
#[inline(never)]
unsafe fn useless_function(x: usize,rsp: &mut [usize]) {
if x > 0 {
*rsp.get_unchecked_mut(0) = get_rsp();
useless_function(x - 1,rsp.get_unchecked_mut(1..));
}
}
fn main() {
unsafe {
let mut rsp = [0; 10];
useless_function(rsp.len(),&mut rsp);
for w in rsp.windows(2) {
println!("{}",w[0] - w[1]);
}
}
}
也就是说,您可以将函数公开,并且无论如何都要看一下它的程序集(轻扫):
playground::useless_function:
pushq %r15
pushq %r14
pushq %rbx
testq %rdi,%rdi
je .LBB6_3
movq %rsi,%r14
movq %rdi,%r15
xorl %ebx,%ebx
.LBB6_2:
callq playground::get_rsp
movq %rax,(%r14,%rbx,8)
addq $1,%rbx
cmpq %rbx,%r15
jne .LBB6_2
.LBB6_3:
popq %rbx
popq %r14
popq %r15
retq
但是在调试模式下,每帧仍然占用80字节
比较未优化的程序集:
playground::useless_function:
subq $104,%rsp
movq %rdi,80(%rsp)
movq %rsi,88(%rsp)
movq %rdx,96(%rsp)
cmpq $0,%rdi
movq %rdi,56(%rsp) # 8-byte Spill
movq %rsi,48(%rsp) # 8-byte Spill
movq %rdx,40(%rsp) # 8-byte Spill
ja .LBB44_2
jmp .LBB44_8
.LBB44_2:
callq playground::get_rsp
movq %rax,32(%rsp) # 8-byte Spill
xorl %eax,%eax
movl %eax,%edx
movq 48(%rsp),%rdi # 8-byte Reload
movq 40(%rsp),%rsi # 8-byte Reload
callq core::slice::<impl [T]>::get_unchecked_mut
movq %rax,24(%rsp) # 8-byte Spill
movq 24(%rsp),%rax # 8-byte Reload
movq 32(%rsp),%rcx # 8-byte Reload
movq %rcx,(%rax)
movq 56(%rsp),%rdx # 8-byte Reload
subq $1,%rdx
setb %sil
testb $1,%sil
movq %rdx,16(%rsp) # 8-byte Spill
jne .LBB44_9
movq $1,72(%rsp)
movq 72(%rsp),%rdx
movq 48(%rsp),8(%rsp) # 8-byte Spill
movq %rdx,(%rsp) # 8-byte Spill
movq 16(%rsp),%rdi # 8-byte Reload
movq 8(%rsp),%rsi # 8-byte Reload
movq (%rsp),%rdx # 8-byte Reload
callq playground::useless_function
jmp .LBB44_8
.LBB44_8:
addq $104,%rsp
retq
.LBB44_9:
leaq str.0(%rip),%rdi
leaq .L__unnamed_7(%rip),%rdx
movq core::panicking::panic@GOTPCREL(%rip),%rax
movl $33,%esi
callq *%rax
ud2
,
此答案显示了未优化的C ++版本在asm中的工作原理。
这可能不会像我对Rust的了解那么多。 apparently Rust使用其自己的ABI /调用约定,因此在Windows上不会具有“影子空间”,从而使其堆栈框架更大。我的答案的第一个版本猜测在针对Windows时,它将遵循Windows调用约定来调用其他Rust函数。我已经调整了措词,但是即使它与Rust可能无关,也没有删除它。
经过进一步研究,至少在2016年Rust的ABI恰好与Windows x64上的平台调用约定相匹配,至少在this random tutorial中对debug-build二进制文件的反汇编代表了任何东西。反汇编中的heap::allocate::h80a36d45ddaa4ae3Lca
显然会在RCX和RDX中使用args(溢出并重新加载到堆栈中),然后使用这些args调用另一个函数。调用前在RSP上方保留0x20字节未使用的空间,即影子空间。
如果自2016年以来没有发生任何变化(很可能),我认为这个答案确实反映了Rust在为Windows编译时所做的一些事情。
递归在发布模式下得到了优化,但是在调试模式下,每帧仍占用80个字节,这比我预期的要多。这就是x86上堆栈框架的工作方式吗?其他语言做得更好吗?
是的,C和C ++的性能更好:Windows上每个堆栈帧48或64字节,Linux上32字节。
Windows x64调用约定要求调用者保留32字节的影子空间(返回地址上方基本上未使用的stack-arg空间),以供被调用者使用。但似乎未优化的clang构建可能未利用该影子空间,而是分配了额外的空间来溢出本地var。
此外,返回地址占用8个字节,然后在另一个调用占用另外8个字节之前将堆栈重新对齐16个,因此在Windows上,您希望的最小值是48个字节(除非启用优化,然后再说,则尾递归很容易优化成一个循环)。 GCC编译该代码的C或C ++版本确实实现了这一目标。
针对Linux或使用x86-64 System V ABI,gcc和clang的任何其他x86-64目标进行编译,对于C或C ++版本,每帧管理32个字节。只需ret addr,保存RBP和另外16个字节即可保持对齐,同时还可以腾出空间来溢出8字节x
。 (以C或C ++进行编译与asm无关)。
我使用the Godbolt compiler explorer上的Windows调用约定在未优化的C ++版本上尝试了GCC和clang。仅查看useless_function
的组件,就无需编写main
或get_rsp
。
#include <stdlib.h>
#define MS_ABI __attribute__((ms_abi)) // for GNU C compilers. Godbolt link has an ifdeffed version of this
void * RSPS[10] = {0};
MS_ABI void *get_rsp(void);
MS_ABI void useless_function(size_t x) {
RSPS[x] = get_rsp();
if (x == 0) {
return;
}
useless_function(x - 1);
}
未优化的clang / LLVM执行push rbp
/ sub rsp,48
,因此每帧总共64个字节(包括返回地址)。按照预测,GCC确实会推送/ sub rsp,32
,每帧总共只有48个字节。
因此,显然,未经优化的LLVM确实分配了“不需要的”空间,因为它无法使用调用方分配的影子空间。如果Rust使用了阴影空间,这可能可以解释某些原因,即使在递归函数外部进行打印的情况下,调试模式的Rust版本使用的堆栈空间也可能比我们预期的要多。 (打印对本地人使用很多空间。)
但该解释的一部分还必须包括让一些本地人占用更多空间,例如也许用于指针局部或边界检查? C和C ++非常直接地映射到asm,无需访问任何额外的堆栈空间即可访问全局变量。 (或者甚至是额外的寄存器,当可以将全局数组假定为虚拟地址空间的低2GiB时,因此它的地址可以与其他寄存器一起用作32位带符号移位。)
# clang 10.0.1 -O0,for Windows x64
useless_function(unsigned long):
push rbp
mov rbp,rsp # set up a legacy frame pointer.
sub rsp,48 # reserve enough for shadow space (32) + 16,maintaining stack alignment.
mov qword ptr [rbp - 8],rcx # spill incoming arg to newly reserved space above the shadow space
call get_rsp()
...
在堆栈上使用的唯一本地空间用于x
,没有作为数组访问一部分的临时对象。只需重新加载x
然后重新加载mov qword ptr [8*rcx + RSPS],rax
即可存储函数调用返回值。
# GCC10.2 -O0,rsp
sub rsp,32 # just reserve enough for shadow space for callee
mov QWORD PTR [rbp+16],rcx # spill incoming arg to our own shadow space
call get_rsp()
...
没有ms_abi
属性,GCC和clang都使用sub rsp,16
。
版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 dio@foxmail.com 举报,一经查实,本站将立刻删除。