我正在使用零大小类型(ZST),因为我很好奇它们实际上是如何在后台实现的。鉴于ZST不需要内存中的任何空间,并且采用原始指针是一种安全的操作,因此我很感兴趣,我将从不同种类的ZST“分配”中获得什么原始指针,以及结果的奇怪程度(对于安全的Rust)。
我的第一个尝试(test_stk.rs)是使用const指针指向ZST的一些堆栈实例:
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,就像编译时常量一样。
第二次尝试是使用带框的ZST(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
快速调试表明,这是ZST(Rust的liballoc/alloc.rs)分配器的工作方式:
unsafe fn exchange_malloc(size: usize, align: usize) -> *mut u8 {
    if size == 0 {
        align as *mut u8
    } else {
        // ...
    }
}
最小可能的对齐方式是1(根据Nomicon),因此对于ZST,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;
    // <4 times more>
    let stk_ptr1: *mut Empty = &mut stk1;
    // <4 times more>
    println!("Pointer to on-stack Empty:        {:?}", stk_ptr1);
    // <4 times more>
}
跑:
$ 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
因此,以下是我无法理解的事情:
为什么装箱的ZST分配使用哑0x1地址而不是更有意义的地址(如“堆栈”值)?
当存在指向它们的可变原始指针时,为什么需要为堆栈ZST值分配实际空间?
为什么恰好八个字节用于可变的堆栈分配?我应该将此大小视为“实际类型大小的0字节+对齐的8字节”吗?
重要提示:请记住,以下几乎没有一个是可以保证的。这就是现在的运作方式。
为什么盒装 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;
作为s,它是否static存储在、ZST 中。.rodata
为什么恰好八个字节用于可变堆栈分配?我应该将此大小视为“实际类型大小的 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 个字节。
如果你尝试使用eg,struct Empty(u8);或者struct Empty(u8, u8);你会看到它分别使用1或2的堆栈空间,而不是8。这是因为这些结构(Scalar和ScalarPair布局,因为它们在rustc中被称为)不表示为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);
}