为什么 Rust 栈帧这么大?

Why are Rust stack frames so big?

我遇到了意外的早期堆栈溢出并创建了以下程序来测试该问题:

#![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,但似乎函数确实 return 堆栈指针。

我惊讶地发现,在调试模式下,连续打印的两个 rsp 之间的差异是 192(!),即使在发布模式下也是 128。 据我所知,每次调用 useless_function 需要存储的只是一个 usize 和一个 return 地址,所以我希望每个堆栈帧大约为 16 个字节大。

我 运行 在 64 位 Windows 机器上使用 rustc 1.46.0

我的结果在机器上是否一致?这怎么解释?


看来使用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);
    }
}

我相信这些结构消耗了 space 的大部分。为了证明这一点,我打印了 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]);
        }
    }
}

就是说,您可以创建函数 public 并查看它的程序集(稍微清理一下):

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    , %rbx
    cmpq    %rbx, %r15
    jne .LBB6_2

.LBB6_3:
    popq    %rbx
    popq    %r14
    popq    %r15
    retq

but in debug mode each frame still takes 80 bytes

比较未优化的程序集:

playground::useless_function:
    subq    4, %rsp
    movq    %rdi, 80(%rsp)
    movq    %rsi, 88(%rsp)
    movq    %rdx, 96(%rsp)
    cmpq    [=14=], %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    , %rdx
    setb    %sil
    testb   , %sil
    movq    %rdx, 16(%rsp)                  # 8-byte Spill
    jne .LBB44_9
    movq    , 72(%rsp)
    movq    72(%rsp), %rdx
    movq    48(%rsp), %rdi                  # 8-byte Reload
    movq    40(%rsp), %rsi                  # 8-byte Reload
    callq   core::slice::<impl [T]>::get_unchecked_mut
    movq    %rax, 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    4, %rsp
    retq

.LBB44_9:
    leaq    str.0(%rip), %rdi
    leaq    .L__unnamed_7(%rip), %rdx
    movq    core::panicking::panic@GOTPCREL(%rip), %rax
    movl    , %esi
    callq   *%rax
    ud2

这个答案显示了它在 un-optimized C++ 版本的 asm 中是如何工作的。

这可能不会像我对 Rust 的想法那样告诉我们; Rust 使用它自己的 ABI / 调用约定,因此它不会有“影子 space” 使它的堆栈帧在 Windows 上变得更大。我的答案的第一个版本猜测,当目标 Windows 时,它会遵循 Windows 调用约定来调用其他 Rust 函数。我已经调整了措辞,但我没有删除它,即使它可能与 Rust 无关。

经过进一步研究,至少在 2016 年 Rust 的 ABI 恰好符合 Windows x64 上的平台调用约定,至少如果反汇编 debug-build二进制在this random tutorial代表什么。反汇编中的 heap::allocate::h80a36d45ddaa4ae3Lca 显然在 RCX 和 RDX 中获取参数(将它们溢出并重新加载到堆栈),然后使用这些参数调用另一个函数。在调用之前保留 0x20 字节的 space 未使用在 RSP 之上,即阴影 space.

如果自 2016 年以来没有任何变化(很可能),我认为这个答案确实反映了 Rust 在为 Windows.

编译时所做的一些事情

The recursion gets optimised away in release mode, but in debug mode each frame still takes 80 bytes which is way more than I anticipated. Is this just the way stack frames work on x86? Do other languages do better?

是的,C 和 C++ 做得更好:Windows 上每个堆栈帧 48 或 64 字节,Linux 上 32 个字节。

Windowsx64 调用约定要求调用者保留 32 字节的影子space(在return地址之上基本未使用stack-argspace)供被调用者使用。但看起来 un-optimized clang 构建可能无法利用阴影 space,分配额外的 space 来溢出本地变量。

此外,return 地址占用 8 个字节,re-aligning 在另一个调用占用另外 8 个字节之前,re-aligning 堆栈 16,所以你可以希望的最小值是 [=75] 上的 48 个字节=](除非你启用优化,那么正如你所说,tail-recursion很容易优化成一个循环)。 GCC 编译该代码的 C 或 C++ 版本确实实现了这一点。

为 Linux 或任何其他使用 x86-64 System V ABI 的 x86-64 目标编译,gcc 和 clang 为 C 或 C++ 版本管理每帧 32 字节。只需 ret addr,保存 RBP 和另外 16 个字节以保持对齐,同时腾出空间溢出 8 个字节 x。 (编译为 C 或 C++ 对 asm 没有影响)。


我使用 the Godbolt compiler explorer 上的 Windows 调用约定在 un-optimized C++ 版本上尝试了 GCC 和 clang。只看 useless_function 的 asm,不需要写 mainget_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/LLVMun-optimized做push rbp/sub rsp, 48,所以每帧总共64字节(包括return地址)。正如预测的那样,GCC 确实推送 / sub rsp,32,每帧总共只有 48 个字节。

显然 un-optimized LLVM 确实分配了“不需要的”space,因为它无法使用调用者分配的影子 space。如果 Rust 使用 shadow space,这可能解释了为什么你的 debug-mode Rust 版本可能使用比我们预期更多的堆栈 space,即使在递归函数之外完成打印。 (打印使用大量 space 用于本地人)。

但部分解释还必须包括一些当地人需要更多 space,例如也许是指针局部变量或边界检查? C 和 C++ 非常直接地映射到 asm,访问全局变量不需要任何额外的堆栈 space。 (或者甚至是额外的寄存器,当可以假定全局数组位于虚拟地址的低 2GiB space 中时,它的地址可用作与其他寄存器组合的 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()
...

堆栈上使用的局部变量的唯一 space 是 x,没有发明临时变量作为数组访问的一部分。它只是重新加载 x 然后 mov qword ptr [8*rcx + RSPS], rax 来存储函数调用 return 值。

# GCC10.2 -O0, for Windows x64
useless_function(unsigned long):
        push    rbp
        mov     rbp, 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.