Memory layout: C++ and Rust

A struct is a layout, and both languages let the compiler choose it. C++ keeps the members in declaration order and adds padding, while Rust is free to reorder them unless the type says otherwise. The same three fields therefore measure sixteen bytes in one language and twenty-four in the other, and the offsets differ with them.

The task in C++

The struct has a char, a double and a char. The program prints the size and the three offsets, which is the whole layout in one line.

The double must start at a multiple of eight, so seven bytes of padding follow the first character, and the struct is rounded up to its alignment at the end.

#include <cstddef> #include <iostream> struct Record { char tag; double weight; char kind; }; int main() { std::cout << "size " << sizeof(Record) << "\n"; std::cout << "tag " << offsetof(Record, tag) << " weight " << offsetof(Record, weight) << " kind " << offsetof(Record, kind) << "\n"; }

g++ -std=c++23 -Wall -Wextra layout.cpp -o layout
./layout

size 24
tag 0 weight 8 kind 16

The order is the declaration order and the gaps are the alignment. A C compiler is not allowed to move a member, because the offset is part of what the code sees, so the twenty-four bytes are the price of the order.

The same task in Rust

The same three fields in a Rust struct measure sixteen bytes, and the offsets are eight, zero and nine. The compiler moved the double to the front to save the padding, which is legal because a Rust struct has no defined layout until an attribute asks for one.

use std::mem::{align_of, offset_of, size_of}; struct Record { tag: u8, weight: f64, kind: u8, } fn main() { println!("size {} align {}", size_of::<Record>(), align_of::<Record>()); println!( "tag {} weight {} kind {}", offset_of!(Record, tag), offset_of!(Record, weight), offset_of!(Record, kind), ); }

rustc --edition 2024 layout.rs
./layout

size 16 align 8
tag 8 weight 0 kind 9

Sixteen bytes against twenty-four, with the same fields and the same values. Adding #[repr(C)] to the struct brings the number back to twenty-four and the offsets back to zero, eight and sixteen, which is the layout the C++ compiler chose.

use std::mem::{offset_of, size_of}; #[repr(C)] struct Record { tag: u8, weight: f64, kind: u8, } fn main() { let record = Record { tag: b'a', weight: 2.5, kind: b'b' }; println!( "{} {} {}", record.tag as char, record.weight, record.kind as char ); println!("size {}", size_of::<Record>()); println!( "tag {} weight {} kind {}", offset_of!(Record, tag), offset_of!(Record, weight), offset_of!(Record, kind), ); }

rustc --edition 2024 layout_repr.rs
./layout_repr

a 2.5 b
size 24
tag 0 weight 8 kind 16

The values print the same way as in C++, and the three offsets now match the C program exactly. That is the form a struct takes before it is written to a file or passed to a C library.

What differs and why

Who chooses the order: in C++ the programmer does, and the compiler fills the gaps. In Rust the compiler does, unless a repr attribute takes the choice back. The Rust default is smaller for this struct, and the price is that the offsets are an implementation detail that may change between compiler versions.

What the offsets mean: a C++ program may read a struct as bytes and send it over a network, because the layout is part of the language. A Rust program may not, because the layout is not defined; it writes the fields one by one or adds #[repr(C)] and documents the format.

What the attribute costs: nothing at run time. #[repr(C)] only removes the compiler's freedom, and the four saved bytes of the default layout are the only thing given up.

What about the padding bytes: in both languages they hold whatever was in the memory before, so a program that writes a struct as a block of bytes leaks them. C++ offers no help here, and Rust has the same problem until the type is built field by field.

The habit that breaks

The habit is to take a pointer to a member of a packed struct. In C++ a packed struct puts the uint32_t at offset one, and taking its address compiles with a warning; the pointer is misaligned, and on a target that faults on unaligned access the read crashes.

#include <cstdint> #include <iostream> struct __attribute__((packed)) Header { char flag; std::uint32_t value; }; int main() { Header header{'f', 42}; const std::uint32_t *pointer = &header.value; std::cout << *pointer << "\n"; }

g++ -std=c++23 -Wall -Wextra packed.cpp -o packed
./packed

packed.cpp: In function 'int main()':
packed.cpp:12:36: warning: taking address of packed member of 'Header' may result in an unaligned pointer value [-Waddress-of-packed-member]
  12 | const std::uint32_t *pointer = &header.value;
     | ^~~~~~~~~~~~~
42

The program compiles, prints the value and leaves a warning that many builds ignore. The same struct and the same reference in Rust are a compile error, because a reference is a promise of alignment and a packed field cannot keep it.

#[repr(packed)] struct Header { flag: u8, value: u32, } fn main() { let header = Header { flag: b'f', value: 42 }; let pointer = &header.value; println!("{}", pointer); }

rustc --edition 2024 packed.rs

error[E0793]: reference to field of packed struct is unaligned
--> packed.rs:9:19
|
9 | let pointer = &header.value;
| ^^^^^^^^^^^^^
|
= note: this struct is 1-byte aligned, but the type of this field may require higher alignment
= note: creating a misaligned reference is undefined behavior (even if that reference is never dereferenced)
= help: copy the field contents to a local variable, or replace the reference with a raw pointer and use `read_unaligned`/`write_unaligned` (loads and stores via `*p` must be properly aligned even when using raw pointers)

The help names both safe answers: copy the value out, or go through a raw pointer and read_unaligned. The C++ version has the same two answers and prints a warning instead of stopping the build, which is the difference between the two compilers in one paragraph.

The Rust lesson for this ground is size_of, align_of and repr, and the C++ lesson is Stack, heap, alignment and sizeof. The previous page in this set is Testing.

The C++ programs were built with g++ 15.2.0 and the Rust programs with rustc 1.96.1, and both were tested on Alpine Linux 3.24.3 (x86_64) in a Docker container.

Article author: Arthur Isaev