Why do zero-sized types cause real allocations in some cases?

Viewed 1338

I was playing with zero-sized types (ZSTs) as I was curious about how they are actually implemented under the hood. Given that ZSTs do not require any space in memory and taking a raw pointer is a safe operation, I was interested what raw pointers I would get from different kinds of ZST "allocations" and how weird (for safe Rust) the results would be.

My first attempt (test_stk.rs) was to take const pointers to a few on-stack instances of ZSTs:

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
}

Compiling and running this produced the following result:

$ 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

A short analysis of the process memory map showed that 0x55ab86fc6000 was actually not a stack allocation, but the very beginning of the .rodata section. This seems logical: the compiler pretends that there is a single zero-sized value for each ZST, known at compile time, and each of these values resides in .rodata, as compile-time constants do.

The second attempt was with 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))
}

Running this snippet gave:

$ 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

Quick debugging showed that this is how the allocator works for ZSTs (Rust's liballoc/alloc.rs):

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

The minimum possible alignment is 1 (as per the Nomicon), so for ZSTs the box operator calls exchange_malloc(0, 1) and the resulting address is 0x1.

After noticing that into_raw() returns a mutable pointer, I decided to retry the previous test (on-stack) with mutable pointers (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
}

And running this printed the following:

$ 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

It turns out that this time I had real stack-allocated values, each having its own address! When I tried to declare them sequentially (test_stk_seq.rs), I discovered that each of these values occupied eight bytes:

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);
}

Run:

$ 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

So, here are the things I cannot understand:

  1. Why do boxed ZST allocations use the dumb 0x1 address instead of something more meaningful, like in case of "on-stack" values?

  2. Why is there need to allocate real space for on-stack ZST values when there are mutable raw pointers to them?

  3. Why are exactly eight bytes used for mutable on-stack allocations? Should I treat this size as "0 bytes of actual type size + 8 bytes of alignment"?

0 Answers
Related