为什么在某些情况下,零大小类型会导致实际分配?

55

我正在尝试处理零大小类型(ZSTs),因为我很好奇它们在底层实际上是如何被实现的。考虑到ZSTs不需要任何内存空间,并且获取原始指针是一项安全操作,我对从不同种类的ZST“分配”中获得的原始指针以及结果对于安全Rust来说有多奇怪感兴趣。

我的第一次尝试(test_stk.rs)是取几个栈上ZST实例的const指针:

struct Empty;
struct EmptyAgain;

fn main() {
    let stk_ptr: *const Empty = &Empty;
    let stk_ptr_again: *const EmptyAgain = &EmptyAgain;
    let nested_stk_ptr = nested_stk();

    println!("Pointer to on-stack Empty:        {:?}", stk_ptr);
    println!("Pointer to on-stack EmptyAgain:   {:?}", stk_ptr_again);
    println!("Pointer to Empty in nested frame: {:?}", nested_stk_ptr);
}

fn nested_stk() -> *const Empty {
    &Empty
}

编译并运行此代码,将产生以下结果:

$ rustc test_stk.rs -o test_stk
$ ./test_stk 
Pointer to on-stack Empty:        0x55ab86fc6000
Pointer to on-stack EmptyAgain:   0x55ab86fc6000
Pointer to Empty in nested frame: 0x55ab86fc6000

对进程内存映射进行简要分析后发现,0x55ab86fc6000实际上不是一个栈分配,而是.rodata节的开头。这似乎是合理的:编译器假装每个ZST都有一个单一的大小为零的值,在编译时已知,并且这些值中的每一个都驻留在.rodata中,就像编译时常量一样。

第二次尝试是使用boxed ZSTs(test_box.rs):

struct Empty;
struct EmptyAgain;

fn main() {
    let ptr = Box::into_raw(Box::new(Empty));
    let ptr_again = Box::into_raw(Box::new(EmptyAgain));
    let nested_ptr = nested_box();

    println!("Pointer to boxed Empty:                 {:?}", ptr);
    println!("Pointer to boxed EmptyAgain:            {:?}", ptr_again);
    println!("Pointer to boxed Empty in nested frame: {:?}", nested_ptr);
}

fn nested_box() -> *mut Empty {
    Box::into_raw(Box::new(Empty))
}

运行此代码段会得到:

$ rustc test_box.rs -o test_box
$ ./test_box 
Pointer to boxed Empty:                 0x1
Pointer to boxed EmptyAgain:            0x1
Pointer to boxed Empty in nested frame: 0x1

经过快速调试,发现这是零大小类型在 Rust 的 liballoc/alloc.rs 中的分配器工作方式:

unsafe fn exchange_malloc(size: usize, align: usize) -> *mut u8 {
    if size == 0 {
        align as *mut u8
    } else {
        // ...
    }
}

根据 Nomicon,最小可能的对齐方式为 1,因此对于 ZSTs,box 操作符调用 exchange_malloc(0, 1),并得到的地址为 0x1

注意到 into_raw() 返回可变指针后,我决定使用可变指针 (test_stk_mut.rs) 重新尝试之前的测试(在堆栈上):

struct Empty;
struct EmptyAgain;

fn main() {
    let stk_ptr: *mut Empty = &mut Empty;
    let stk_ptr_again: *mut EmptyAgain = &mut EmptyAgain;
    let nested_stk_ptr = nested_stk();

    println!("Pointer to on-stack Empty:        {:?}", stk_ptr);
    println!("Pointer to on-stack EmptyAgain:   {:?}", stk_ptr_again);
    println!("Pointer to Empty in nested frame: {:?}", nested_stk_ptr);
}

fn nested_stk() -> *mut Empty {
    &mut Empty
}

运行此命令会输出以下内容:

$ rustc test_stk_mut.rs -o test_stk_mut
$ ./test_stk_mut 
Pointer to on-stack Empty:        0x7ffc3817b5e0
Pointer to on-stack EmptyAgain:   0x7ffc3817b5f0
Pointer to Empty in nested frame: 0x7ffc3817b580

原来这一次我有了真正的栈分配值,每个值都有自己的地址!当我尝试顺序声明它们 (test_stk_seq.rs) 时,我发现每个值占用了个字节:

struct Empty;

fn main() {
    let mut stk1 = Empty;
    let mut stk2 = Empty;
    let mut stk3 = Empty;
    let mut stk4 = Empty;
    let mut stk5 = Empty;

    let stk_ptr1: *mut Empty = &mut stk1;
    let stk_ptr2: *mut Empty = &mut stk2;
    let stk_ptr3: *mut Empty = &mut stk3;
    let stk_ptr4: *mut Empty = &mut stk4;
    let stk_ptr5: *mut Empty = &mut stk5;

    println!("Pointer to on-stack Empty: {:?}", stk_ptr1);
    println!("Pointer to on-stack Empty: {:?}", stk_ptr2);
    println!("Pointer to on-stack Empty: {:?}", stk_ptr3);
    println!("Pointer to on-stack Empty: {:?}", stk_ptr4);
    println!("Pointer to on-stack Empty: {:?}", stk_ptr5);
}

运行:

$ rustc test_stk_seq.rs -o test_stk_seq
$ ./test_stk_seq 
Pointer to on-stack Empty: 0x7ffdba303840
Pointer to on-stack Empty: 0x7ffdba303848
Pointer to on-stack Empty: 0x7ffdba303850
Pointer to on-stack Empty: 0x7ffdba303858
Pointer to on-stack Empty: 0x7ffdba303860

所以,我无法理解以下几点:

  1. 为什么盒装ZST分配使用愚蠢的0x1地址而不是像“堆栈上”值一样更有意义的东西?

  2. 为什么需要为堆栈上的ZST值分配真实空间,当有可变的原始指针可以指向它们?

  3. 为什么可变堆栈分配只使用了八个字节?我应该将这个大小视为“实际类型大小的0字节+8字节的对齐方式”吗?


23
关于你的第二个问题:实际上并没有必要,而且Rust的beta版和夜间版本不再为ZST(零大小类型)创建堆栈分配 - Sven Marnach
关于第一个问题,该值与NonNull::dangling()相同,但这里有一些解释。 - rodrigo
1
最后一个示例在发布模式下不再进行堆栈分配 - 可能需要一份完整的回答,以“大多数这些现在已经被捕获,但优化器并非全知全能”的调子。 - c-x-berger
2个回答

3
重要提示:请记住,以下几乎没有任何保证。这只是当前的工作方式。
为什么盒式ZST分配会使用愚蠢的0x1地址,而不是像“在堆栈上”的值一样更有意义的东西?
对于ZST,没有任何地址是有意义的。编译器只是使用最简单的方法。特别是,可变指针的堆栈上的地址和共享地址中的.rodata都不是ZST的特殊属性,而是任何类型的一般属性,我稍后会解释。相反,Box需要特殊处理ZST。它通过返回可能的第一个虚假地址来实现这一点。
为什么需要为在堆栈上的ZST值分配真实空间,当有可变原始指针指向它们时?
问题不是为什么我们需要为ZST分配真正的堆栈空间,而是为什么不这样做。每个变量和临时变量都分配在堆栈上。没有理由特别处理ZST。
如果你问,“但我看到那些共享引用它们被分配在.rodata中!”试试下面的方法:
struct Empty;
struct EmptyAgain;

fn main() {
    let empty = Empty;
    let empty_again = EmptyAgain;
    let stk_ptr: *const Empty = ∅
    let stk_ptr_again: *const EmptyAgain = &empty_again;
    let nested_stk_ptr = nested_stk();

    println!("Pointer to on-stack Empty:        {:?}", stk_ptr);
    println!("Pointer to on-stack EmptyAgain:   {:?}", stk_ptr_again);
    println!("Pointer to Empty in nested frame: {:?}", nested_stk_ptr);
}

fn nested_stk() -> *const Empty {
    let empty = Empty;
    &empty
}

你可以看到它们被分配在栈上。
如果你问“但是,当在同一语句中获取地址时(let stk_ptr = &Empty;),为什么对于共享引用会在.rodata上给出一个地址,在可变引用的情况下会在堆栈上给出一个地址!”答案是可变情况是正常情况,而共享引用是由于静态提升而特殊处理的。这意味着与正常情况相反,在可变引用和函数调用等其他事物中,以下内容:
let v1 = &mut Foo;

let v2 = &foo();

被翻译成:

let mut __v1_storage = Foo;
let v1 = &mut __v1_storage;

let __v2_storage = foo();
let v2 = &__v2_storage;

有些表达式,特别是结构体字面量,在翻译时会有所不同:
let v = &Foo { ... };

// Translated into:

static __V_STORAGE: Foo = Foo { ... };
let v = &__V_STORAGE;

作为静态变量,它存储在.rodata、ZST或其他地方。对于可变的栈分配,为什么恰好使用8个字节?我应该将此大小视为“0字节的实际类型大小+8字节的对齐方式”吗?更像是“1个字节的实际大小+7个字节的填充以进行对齐”。但在Rust中,ZST的大小(显然)为零,(默认)对齐方式为1,那么这里会发生什么呢?好的,rustc将ZST降级为一个空的LLVM结构体(%Empty = type { })。LLVM中的结构体使用指定对齐方式(在处理它们的指令中)和目标的首选对齐方式中的最大值。x86-64的首选对齐方式为8字节,因此max(1, 8) = 8。关于大小,LLVM不处理零大小的堆栈分配。当正在alloca一个空结构体时,LLVM将其舍入到大小为1。因此,我们得到了大小为1,对齐为8,我们为每个分配填充对齐的倍数-8字节。
如果您尝试使用例如struct Empty(u8);struct Empty(u8, u8);,您会发现它们分别使用1或2个堆栈空间,而不是8个。这是因为这些结构体(在rustc中称为ScalarScalarPair布局)不是表示为LLVM结构体,而是表示为LLVM原语:i8{i8,i8}。它们不使用首选对齐方式。但是,如果您使用三个字段,则会发现其宽度也为8个字节:
struct Empty(u8, u8, u8);

fn main() {
    let mut stk1 = Empty(0, 0, 0);
    let mut stk2 = Empty(0, 0, 0);
    let mut stk3 = Empty(0, 0, 0);
    let mut stk4 = Empty(0, 0, 0);
    let mut stk5 = Empty(0, 0, 0);

    let stk_ptr1: *mut Empty = &mut stk1;
    let stk_ptr2: *mut Empty = &mut stk2;
    let stk_ptr3: *mut Empty = &mut stk3;
    let stk_ptr4: *mut Empty = &mut stk4;
    let stk_ptr5: *mut Empty = &mut stk5;

    println!("Pointer to on-stack Empty: {:?}", stk_ptr1);
    println!("Pointer to on-stack Empty: {:?}", stk_ptr2);
    println!("Pointer to on-stack Empty: {:?}", stk_ptr3);
    println!("Pointer to on-stack Empty: {:?}", stk_ptr4);
    println!("Pointer to on-stack Empty: {:?}", stk_ptr5);
}

非常感谢你详尽的解释! - Danila Kiver

0
所以,如果分配器API不支持零大小的分配,那么我们要存储什么作为我们的分配呢?当然是NonNull::dangling()!几乎每个与ZST相关的操作都是无操作,因为ZST只有一个值,因此不需要考虑任何状态来存储或加载它们。这实际上还扩展到了ptr::read和ptr::write:它们实际上根本不会查看指针。因此,我们永远不需要更改指针。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接