DEV Community

Cover image for [Advanced Rust] 1.13. Memory Types Pt.1 - Alignment, Layout, and the Repr Attribute
SomeB1oody
SomeB1oody

Posted on

[Advanced Rust] 1.13. Memory Types Pt.1 - Alignment, Layout, and the Repr Attribute

1.13.1. The Basic Responsibility of Types

Every Rust value has a type, and the responsibility of that type is to tell you how to interpret the bits in memory.

For example, the bit pattern 0b10111101 has no meaning by itself, but:

  • Interpreted as u8, it becomes the number 189
  • Interpreted as i8, it becomes the number -67

When you define a custom type, the compiler decides where each part of that type is placed in memory.

1.13.2. Alignment

Alignment determines where a type’s bytes may be stored.

Once a type’s representation is determined, you might think it can be stored anywhere in memory. In theory that is possible, but in practice computer hardware places constraints on where a given type can live.

The most typical example is a pointer. A pointer points to bytes, not bits; one byte equals 8 bits. In other words, it does not point to an individual bit. So if a value of some type were placed at bit index 4 in memory, you would not be able to address it, because pointers address bytes rather than specific bits. That is why alignment is done at the byte level — that is, at 8-bit boundaries.

For this reason, all values, regardless of type, must begin on a byte boundary. All types must be at least byte-aligned. In other words, the storage address must be a multiple of 8 bits.

1.13.3. Stricter Alignment Rules

Some types have alignment requirements stricter than byte alignment. In CPU and memory systems, memory is often accessed in blocks larger than a single byte.

For example, on a 64-bit CPU, most values are accessed in 8-byte blocks, and each operation begins at an address that is 8-byte aligned. This is also called the CPU word size.

Of course, CPUs can also handle reads and writes of smaller values, as well as values that cross block boundaries. But as developers, we should try our best to ensure that hardware operates at its native alignment.

For example, if the i64 value you want to read begins in the middle of an 8-byte block, then reading it requires at least two reads. Because i64 is 8 bytes wide, beginning in the middle of two 8-byte blocks means it must span both blocks. So when reading it, the engine must read from both blocks: the first block provides the first part of the i64, the second block provides the remaining part, and then the pieces must be merged.

That is very inefficient and slows down program execution, so we should try to keep hardware operations aligned to their native boundaries whenever possible.

1.13.4. Misaligned Access

When a CPU accesses memory and the data address does not follow the alignment required by the architecture, it is called a "misaligned access". This can lead to poor performance and concurrency issues.

Many CPUs require, or strongly recommend, that their parameters be naturally aligned. A naturally aligned value has alignment that matches its size.

For example, if you want to load 8 bytes, the provided address should be 8-byte aligned.

1.13.5. The Compiler Tries to Use Alignment as Much as Possible

Based on the contents a type includes, the compiler computes an alignment for that type (or, in other words, assigns it an alignment scheme):

  • For primitive values, alignment usually matches their size. For example, u8 is aligned to 1 byte, u16 to 2 bytes, u32 to 4 bytes, and u64 to 8 bytes.

  • For compound types (types that contain other types), the alignment is usually the maximum alignment of the contained types. For example, if a type contains fields of u8, u16, and u32, then the type should be 4-byte aligned (u32 has the largest alignment, which is 4 bytes).

1.13.6. Layout

The layout of a type is how the compiler decides to represent that type in memory.

The Rust compiler does not provide many guarantees about how types are laid out.

Rust provides the repr attribute: it can be added to a type definition to request a specific representation.

1.13.7. repr(C)

One of the most common repr attributes is repr(C). The C in the name indicates that it is related to C.

repr(C) layout is compatible with the layout used by C/C++ compilers for the same type. This is useful for Rust code that interacts with other languages through FFI (Foreign Function Interface).

When using FFI to interact with other languages, Rust generates a layout that matches what the other language’s compiler expects. Because C layout is predictable and unlikely to change, repr(C) is very useful in unsafe contexts.

For example, you can use it when working with raw pointers to that type or when converting between two types with the same fields.

1.13.8. repr(transparent)

The transparent in repr(transparent) means transparent. It is used on newtype-style wrappers and guarantees that the outer type has the same layout as its single non-zero-sized field. (Other fields are allowed only if they are zero-sized types, such as () or PhantomData.)

This is very useful when combined with the newtype pattern.

Let’s briefly revisit the newtype pattern here: you use a tuple struct to create a new local type, which is essentially a thin wrapper.

For example, if you want to operate on the memory representation of struct A and struct NewA(A), then after using repr(transparent), the two memory representations should be the same. Without it, the Rust compiler cannot guarantee that.

1.13.9. An Example of Using repr

Let’s look at an example:

Code Field Type Size Default Representation Padding Final Alignment
#[repr(C)]
struct Foo {
tiny: bool, 1 byte 1-byte aligned 3 bytes
normal: u32, 4 bytes 4-byte aligned (tiny + normal) 8 bytes
small: u8, 1 byte 1-byte aligned 7 bytes 8 bytes
long: u64, 8 bytes 8-byte aligned 8 bytes
short: u16, 2 bytes 2-byte aligned 6 bytes 8 bytes
}
Total 32 bytes

This table shows the memory alignment and padding of a Rust struct under #[repr(C)]:

  • The code is in the leftmost column and uses the repr(C) annotation. The struct contains several fields

  • The Rust compiler first sees that the tiny field is of type bool, which occupies 1 byte in memory, so it is aligned to 1 byte

  • The compiler then sees that the normal field is of type u32, which occupies 4 bytes, so it only needs 4-byte alignment. At this point Rust notices that tiny is aligned to 1 byte, so the compiler inserts 3 bytes of padding to make tiny occupy 4 bytes

  • Since this field now occupies exactly 8 bytes, which is a multiple of 4 bytes, it is already aligned

  • The small field is of type u8, which occupies 1 byte and is aligned to 1 byte. Because the previous two fields are already aligned, Rust will decide how much padding to add based on the following bytes. At this point the compiler still has to wait and see

  • long is of type u64, which occupies 8 bytes and is naturally 8-byte aligned. Since its field is 8 bytes or larger, we now see that tiny and normal together form an 8-byte-aligned region, and long is also 8-byte aligned. Rust understands that the structure should now be aligned to 8 bytes. Therefore the compiler has to add 7 bytes of padding to small to make it 8-byte aligned

  • short is of type u16, which occupies 2 bytes. Since the structure should now be 8-byte aligned, the compiler adds 6 bytes so that it becomes 8-byte aligned

The process can be represented in a table like this:

Field Type Size Required Alignment Padding Notes
tiny: bool 1 byte 1 byte 3 bytes To align the next u32
normal: u32 4 bytes 4 bytes none Aligned as u32
small: u8 1 byte 1 byte 7 bytes To align the next u64
long: u64 8 bytes 8 bytes none 8-byte aligned
short: u16 2 bytes 2 bytes 6 bytes Structure aligned to 8 bytes

Top comments (0)