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:
Why do boxed ZST allocations use the dumb
0x1address instead of something more meaningful, like in case of "on-stack" values?Why is there need to allocate real space for on-stack ZST values when there are mutable raw pointers to them?
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"?