Skip to content

The Rust Engineering Handbook / Chapter 26

Layout, Alignment, Niches, and Representation

Separate Rust's documented layout and ABI guarantees from target facts, compiler observations, and explicit serialization formats.

Two correct sizes, two different contracts

The first wireview layout run prints two facts:

WireHeader: LayoutObservation { size: 8, alignment: 4, kind_offset: 0, flags_offset: 1, payload_len_offset: 4 }
WireHeader object size is 8, encoded size is 6

Both numbers are correct. They answer different questions. Eight bytes is the object size produced for a repr(C) header on the recorded target. Six bytes is the protocol encoding chosen by the program. Treating one as the other would put padding, native representation, and possibly native byte order onto the wire.

Parts I–IV developed contracts about evaluation, ownership, states, and abstraction. Part V adds a physical lens: where values may be stored, how many bytes successive values occupy, which offsets a representation fixes, and which apparent efficiencies are only observations. The first habit is to classify every layout statement before relying on it.

Evidence class Example What review may conclude
language or representation guarantee repr(C) struct fields follow declaration order with specified padding rules code may rely on it within all stated preconditions
target ABI fact C uint32_t alignment and calling convention on a named target boundary may rely on it only for supported toolchain/target pairs
compiler observation a default Rust enum occupies four bytes in Rust 1.97.0 on x86-64 measure and regression-test it, but do not promise it generally
application format the header is six big-endian bytes stable only because the encoder/decoder specification says so

Representation contract: Rely only on guarantees supplied by the language, an explicit repr, the supported target ABI, or an application format you define. Treat every other size, offset, discriminant encoding, and niche use as a scoped observation.

An eight-byte C-compatible header places one-byte kind and flags fields before two hatched padding bytes and a four-byte payload length, while a separate six-byte serialized form omits padding and an enum panel distinguishes explicit tags from observational niche reuse.

Size includes the stride required by alignment

Every Rust value has a size and an alignment. Alignment n means valid storage addresses must be multiples of n; alignments are powers of two and at least one. For a sized type T, size_of::<T>() is the offset between adjacent elements in [T; 2]. It therefore includes trailing padding needed to align the next element, and size is a multiple of alignment.

This distinction explains the header:

#[repr(C)]
pub struct WireHeader {
    pub kind: u8,
    pub flags: u8,
    pub payload_len: u32,
}

On the recorded x86_64-unknown-linux-gnu target, u32 has four-byte alignment. kind occupies offset 0 and flags offset 1. Two padding bytes advance the next offset to 4; payload_len occupies offsets 4–7. The maximum field alignment is four, so the struct alignment is four and its size is already a multiple of four: eight bytes.

Padding is not an extra field and need not contain initialized application data. Code must not read padding as though it were a stable byte sequence. Padding can vary across construction, moves, compiler decisions, and types. Comparing, hashing, serializing, or authenticating arbitrary object bytes risks nondeterminism and, in unsafe implementations, reading bytes that are not initialized for such access.

Use size_of, align_of, and offset_of! to inspect a sized type. Use size_of_val and align_of_val when the value may be dynamically sized. These tools answer the current compilation; the type’s representation determines which results are promises.

Default Rust representation optimizes freedom

User-defined structs and enums use the Rust representation unless an attribute selects another. For a default-representation struct, Rust guarantees that each field is properly aligned, fields do not overlap, and the type alignment is at least the maximum alignment of its fields. It does not guarantee declaration-order layout. Zero-sized fields may even share addresses because non-overlap does not imply distinct addresses for zero-size regions.

That freedom lets implementations reorder or otherwise lay out fields to serve optimization and soundness constraints. It also means this is invalid API reasoning:

The first source field is the first bytes of every RustHeader.

The fixture prints offsets for RustHeader as an observation but deliberately does not assert them. It asserts only weak facts consistent with the fields. A profiler may use observed offsets to investigate cache behavior in one build. An FFI header, persistent file, network protocol, shared-memory schema, or cryptographic transcript may not treat them as stable.

Generic parameters do not dynamically choose the representation attribute. A representation applies to the nominal item. Its fields may themselves have representations whose details remain independent: putting an ordinary Rust-layout type inside a repr(C) outer struct does not convert the inner type into C layout.

repr(C) gives layout rules, not universal FFI safety

For structs, repr(C) fixes field order to declaration order and defines alignment/padding using the fields’ own sizes and alignments. This is useful for C interoperation and for layout-dependent operations whose full preconditions are established.

It does not make every field C-compatible. A String, Vec<T>, Rust trait object, closure, reference with Rust validity requirements, default-representation nested enum, or Rust-specific destructor does not acquire a portable C contract merely because the outer struct says repr(C). The boundary must also settle:

  • exact corresponding types in the foreign declaration;
  • target ABI and compiler flags;
  • ownership and allocator pairing;
  • pointer nullability, provenance, alignment, length, and validity;
  • enum value validity;
  • error representation and partial initialization;
  • thread affinity and callback lifetime;
  • panic/unwind containment;
  • versioning and symbol compatibility.

For fieldless enums, repr(C) follows the target C ABI’s enum representation, but C enum objects may admit integer values that are invalid for the corresponding Rust enum. Modeling C flags or open-ended status codes with a Rust enum can therefore be unsound at an unchecked boundary. An integer plus checked conversion is often safer.

repr(C) is necessary for many FFI records. It is never the entire safety case.

Transparent representation preserves one field’s ABI

repr(transparent) is designed for a struct or single-variant enum with exactly one non-zero-sized field; other fields must be zero-sized with suitable alignment constraints. The transparent type has the layout and ABI of that field. The fixture uses:

#[repr(transparent)]
pub struct FrameLength(pub u32);

This lets the program create a distinct Rust type while preserving u32 representation at a boundary. The newtype can enforce constructors and methods without adding a wrapper layer in memory.

The guarantee does not choose byte order, semantic range, or ownership policy. If FrameLength is serialized, the encoder must still specify endianness. If only values below a limit are valid, the public constructor must check that invariant. Transparent representation answers layout and ABI questions; it does not define the domain.

Visibility also matters to the public ABI promise. A library should document when transparent layout is part of its supported external contract rather than exposing it accidentally.

Integer representations make discriminants explicit

A fieldless enum has logical discriminants. With repr(u8), repr(i32), or another primitive integer representation, its size and alignment match that primitive type and its valid discriminants must fit:

#[repr(u8)]
pub enum FrameKind {
    Data = 1,
    Ack = 2,
}

The representation fixes how the tag is stored, but it does not make every u8 a valid FrameKind. Constructing a Rust enum containing an undefined discriminant violates its validity requirements. Decode an integer, match recognized values, and preserve unknown values separately when forward compatibility requires them.

For enums with fields, repr(C) and primitive representations define tagged-union-like layouts described by the Reference. These layouts are more involved than “one byte followed by the largest payload,” because each variant payload has its own alignment and the selected representation controls the tag/union arrangement. Generate matching foreign definitions and test them on every supported target rather than reconstructing the algorithm from memory.

Discriminants are semantic; storage is representational

mem::discriminant lets code compare which variant values inhabit without revealing the numeric representation. Explicit discriminant values and casts are permitted only in defined enum forms. For default data-carrying enums, the compiler is free to choose an encoding consistent with validity and layout guarantees.

Keep three questions separate:

  1. Which variant is this value?
  2. Does the type expose an explicit numeric discriminant?
  3. Which bits encode the tag in this build?

Application logic normally needs the first. Protocols need a separately specified numeric code. Low-level tools may inspect the third, but an observation is not automatically a contract.

A niche may be guaranteed or merely observed

A niche is an invalid bit pattern or value range in a field that can encode another state. A NonZeroU32 cannot contain zero. For this exact relationship, the standard library guarantees that Option<NonZeroU32> has the same size and alignment as NonZeroU32, and that the option is compatible with u32, including at FFI boundaries. The fixture verifies the guaranteed size relationship:

size_of::<NonZeroU32>() == 4
size_of::<Option<NonZeroU32>>() == 4

That guarantee is narrow. The standard library documents a specific set of types for which Option<T> shares T’s size, alignment, and function-call ABI. Beyond those cases, do not generalize from one successful size_of assertion to all enums, wrappers, platforms, or compiler versions. A private performance-sensitive type may regression-test an observed compact layout and revisit it when the assertion fails. A public ABI, persisted format, or FFI declaration needs a documented guarantee or an explicit representation.

Niche use can change when a field is wrapped, when variants change, when a destructor is added, or when a compiler changes. Even if two types have the same size, they need not have the same set of valid bit patterns or the same calling convention. Size equality never proves that transmutation is valid.

This is also why an “optimized enum” benchmark must measure the actual workload. A smaller type may improve cache density, but representation tricks may complicate decoding, reduce portability, or freeze an API. Confirm that size is a bottleneck before spending compatibility budget.

Zero-sized types occupy no bytes but remain types

The unit type, empty structs, and marker types can have size zero. The fixture’s Parsed marker and even [Parsed; 1024] have size zero on the guaranteed Rust model used here. A ZST still has alignment, participates in type checking and drop semantics, and can carry state distinctions at compile time.

Zero size does not mean “does not exist” semantically. Iterators over ZSTs still have a length; destructors, if present, must run the required number of times; pointers used by library implementations must satisfy their own validity and alignment conditions. Multiple zero-sized fields may have the same address. Code must not use their addresses as stable unique identities.

ZSTs work well for typestate, allocators, policies, and capabilities that affect code generation without per-value storage. As Chapter 25 showed, a zero-sized marker can still affect variance, auto traits, and drop checking. Runtime size and type-system influence are separate dimensions.

Layout inspection is evidence, not a serialization strategy

The wireview-layout-lab intentionally reports both guaranteed and observed facts. A defensible inspection record includes:

  • complete type definition and representation attributes;
  • rustc -Vv or equivalent compiler identity;
  • target triple and relevant target features;
  • sizes, alignments, and offsets;
  • whether each fact is guaranteed or merely observed;
  • matching C declarations and compiler settings for FFI;
  • regression commands and supported configuration matrix.

size_of alone is too little. Two structs can share a size while fields have different offsets. Two types can share offsets yet differ in validity. Two matching object layouts can still use different function-call ABIs. A layout report should serve a question: validating generated bindings, estimating cache density, checking a shared-memory schema, or auditing a representation-dependent unsafe block.

Tools that print compiler layouts or generated LLVM types can deepen an investigation, but their formats and results are implementation details. Keep such evidence versioned and reproducible rather than quoting it as timeless language behavior.

Serialization defines bytes deliberately

The fixture’s encoder makes the protocol independent of padding and native object representation:

pub const fn encode_header(header: WireHeader) -> [u8; 6] {
    let length = header.payload_len.to_be_bytes();
    [
        header.kind,
        header.flags,
        length[0], length[1], length[2], length[3],
    ]
}

The wire format is six bytes, uses big-endian length encoding, and contains no padding. A decoder can validate kind, reserved flag bits, payload limits, and exact input length before constructing domain values. This remains correct even if the in-memory application type changes, provided the protocol contract remains stable.

Copying a struct’s memory to disk or the network is usually wrong because:

  • padding is not application data;
  • endianness may vary;
  • default layout may change;
  • pointer fields are process-local addresses, not referents;
  • enum and boolean validity may not match arbitrary bytes;
  • compiler/target ABI is not a versioned storage schema;
  • schema evolution needs explicit field and unknown-value policy.

Even repr(C) does not select a serialization format. C layout is an ABI-oriented representation whose padding and primitive representation remain target-dependent. Use explicit encoding or a format with a documented schema and compatibility policy.

A four-column FFI review

Suppose a C library wants to receive WireHeader by value and Rust also persists it in a capture file. Review each claim in the correct column:

Claim Classification Evidence required Decision
fields appear as kind, flags, payload_len repr(C) guarantee Rust Reference plus matching C declaration rely on it
offsets are 0, 1, and 4; size is 8 target/layout fact derived from field ABIs offset_of!, C offsetof, size/alignment assertions on every target rely only on tested targets
passing by value uses the same calling convention target ABI fact, not implied by equal layout alone generated bindings or ABI/toolchain verification verify separately
padding bytes are zero unsupported assumption none reject
the object can be written as an eight-byte file record application-format claim explicit schema absent reject; encode six bytes
kind = 7 can inhabit FrameKind Rust validity claim no declared variant reject; validate as integer first
Option<NonZeroU32> remains four bytes standard-library representation guarantee official Option and NonZeroU32 documentation rely on this exact relationship; do not generalize it

The review should also decide ownership. An all-integer header has no allocator transfer, but payload pointers, callbacks, and returned buffers would require a transfer table. It should decide compatibility: adding a Rust field changes a repr(C) record; adding an enum variant may break foreign switches; changing a transparent field changes ABI. It should decide failure: malformed foreign values must be rejected before constructing Rust types with stricter validity.

Alternatives when the ABI is not worth freezing

  • Pass opaque handles and accessor functions, keeping Rust representation private.
  • Pass an explicit byte buffer and decode a versioned protocol.
  • Generate bindings from one schema and validate both sides in CI.
  • Use a process boundary when crash containment, independent upgrades, or language runtime constraints outweigh call overhead.

Direct shared structs minimize translation cost but maximize representation coupling. Opaque APIs preserve implementation freedom but require lifecycle functions and error conventions. Byte protocols make compatibility explicit but add encoding cost. The correct choice follows update cadence, performance evidence, failure containment, and organizational ownership—not repr(C) availability alone.

Worked change review: adding a timestamp

Assume version 1 of the C boundary has shipped with the eight-byte WireHeader. A new requirement asks for a 64-bit capture timestamp. Adding the field appears simple:

#[repr(C)]
struct WireHeaderV2 {
    kind: u8,
    flags: u8,
    payload_len: u32,
    captured_at_ns: u64,
}

On a target where u64 alignment is eight, the new field begins at offset 8 and the struct size becomes 16. On a target where u64 alignment is four, offsets or call ABI behavior may differ. Even if both sides recompile to matching definitions, existing binaries that pass the old value by size cannot call the new function safely. Capture files containing eight raw bytes have no self-describing version and cannot be reinterpreted as version 2.

A robust review separates three migrations.

ABI migration

Do not mutate a widely deployed by-value parameter in place. Introduce a versioned symbol or a size-tagged input contract, for example an opaque pointer plus explicit structure version and length. Generate or hand-maintain the matching C declaration from one source of truth. Assert sizeof, _Alignof, and offsetof in C and size_of, align_of, and offset_of! in Rust for every supported target.

A size field is not sufficient validation by itself. The callee must avoid reading fields beyond the supplied size, define required minimum alignment, reject unsupported versions, and decide whether unknown trailing fields are ignored. A pointer-based ABI must also state whether the callee borrows for the call or retains the record.

Protocol migration

The six-byte wire format can evolve independently. A versioned encoder might emit a one-byte version, existing fields, then an eight-byte big-endian timestamp. The decoder selects a schema by version, validates exact or bounded lengths, and gives old messages an explicit timestamp policy. It does not serialize either WireHeader object representation.

This format may be 15 bytes rather than the in-memory type’s 16 or 24 bytes. That difference is healthy: format size follows semantic fields, not alignment padding. If compactness matters, choose a documented integer encoding and benchmark decode cost. Never inherit native endianness merely because producer and consumer currently share a host.

Domain migration

The application should decide what the timestamp means: wall-clock Unix nanoseconds, monotonic ticks, capture-device time, or an optional value. Layout cannot answer epoch, precision, validity range, clock reset, or absence policy. A transparent newtype can distinguish nanoseconds from other u64 values in Rust, while the ABI and protocol still document the underlying representation.

This review reveals a recurring boundary rule: representation attributes preserve selected physical relationships; they do not supply versioning or semantics. Evolve ABI, serialization, and domain models as related but distinct contracts.

Alignment modifiers and packed data require a separate safety decision

Rust supports alignment modifiers such as repr(align(N)) and repr(packed(N)). Raised alignment can support hardware, cache-line, or foreign ABI requirements, though it may increase padding and allocation constraints. Packed representation lowers field alignment and can reduce inter-field padding, but it does not turn unaligned access into ordinary aligned access.

Creating a reference to a field that is not properly aligned for its type is invalid. For packed input, copy a field value when that is supported, or use raw pointers with read_unaligned/write_unaligned inside a carefully justified unsafe boundary. Formatting macros and method calls may implicitly borrow a field, so apparently innocent code can attempt to create an unaligned reference.

Do not reach for packed simply to make size_of match a file or wire record. Explicit decoding is usually clearer and can select endianness, validate values, and avoid unaligned references. Packed structs may be appropriate for exact hardware or foreign layouts, but they enlarge the safety case and can generate slower accesses on some architectures.

Raised alignment also deserves evidence. Aligning every queue slot to a cache line may reduce false sharing while multiplying memory consumption. Allocators and foreign callers must honor the greater alignment. Measure contention and footprint; document target assumptions; keep the modifier near the data whose physical isolation is part of the supported design.

Portability is a matrix, not a footnote

The fixture records one target. A production layout claim may vary across:

  • 32- and 64-bit pointer widths;
  • primitive alignment rules, especially 64- and 128-bit values;
  • endianness;
  • C compiler and ABI family;
  • calling convention and aggregate passing thresholds;
  • enabled target features;
  • panic/unwind model at function boundaries;
  • Rust/compiler versions when behavior is observational.

Choose supported rows explicitly. A Linux x86-64 service with no foreign callers may use observed layout only for profiling. A library shipped to Windows, macOS, Linux, ARM, and embedded targets needs CI or generated evidence across that matrix. An on-disk format should avoid target dependence entirely.

Cross-compilation can verify sizes and compile-time assertions but may not run behavior tests. Pair it with native CI, emulator evidence, or foreign-side tests appropriate to risk. Store generated headers and layout reports as build artifacts so reviewers can compare a change rather than trusting prose.

When a target is not supported, fail clearly at build or binding generation. Silent fallback to an assumed layout is worse than a deliberate portability limit.

Representation choice changes operational behavior

Layout decisions reach beyond FFI correctness.

Performance and capacity

Type size affects array stride, cache density, queue capacity, network-buffer batching, and copy volume. Alignment can prevent or create false sharing. Indirection may shrink hot records while adding allocation and cache misses. Niche optimization can reduce size, but only measurement under representative access patterns establishes value.

Report both per-value size and system scale. Saving eight bytes is negligible for a dozen configuration records and material for hundreds of millions of table entries. Include fragmentation and container overhead rather than celebrating the isolated struct.

Security and confidentiality

Copying object representations can leak padding or stale storage into files, packets, logs, hashes, or signatures. Unchecked discriminants and lengths can create invalid Rust values or out-of-bounds operations inside unsafe decoders. FFI ownership mismatches can lead to double frees or allocator corruption.

Explicit serialization narrows this surface: it writes named fields, normalizes byte order, checks bounds, and omits addresses and padding. Zero buffers only when the threat model requires it; the stronger rule is not to transmit bytes without semantic ownership.

Observability and incident response

Record the schema or ABI version in diagnostics. A crash report should identify target, compiler/build, binding version, and received lengths before dumping raw bytes. Layout regressions become diagnosable when CI preserves reports and production logs distinguish decode rejection from internal invariant failure.

Do not log raw payloads merely because the boundary is under investigation. They may contain secrets or personal data. Log sizes, versions, validated field summaries, and stable hashes according to policy.

Maintenance

Every public representation freezes some implementation freedom. A private repr(C) used only for one audited syscall can be inexpensive. A public struct embedded by downstream C consumers, persisted verbatim, and mirrored in several languages is a long-lived migration program. Prefer opaque handles or explicit formats when multiple teams upgrade independently.

Add a representation attribute because a boundary contract requires it, not to make debugger output predictable. Document who owns matching declarations, compatibility tests, and target additions.

A representation decision table

Need Preferred starting point Main evidence Common wrong turn
ordinary private Rust data default Rust representation semantic tests and performance measurement relying on source field order
C record boundary repr(C) with boundary-safe fields matching declarations, target offset/ABI tests assuming the outer attribute fixes nested Rust types
domain newtype with field ABI repr(transparent) documented field contract and validation API assuming it chooses serialization
explicit enum tag width primitive repr plus checked decoding valid-value tests and foreign mapping accepting every integer bit pattern
versioned network/disk data explicit encoder/decoder golden vectors, compatibility/property tests copying object bytes
packed hardware/foreign record exact representation plus isolated unaligned access target manual, safety case, dynamic tests borrowing packed fields normally
cache/layout optimization private layout experiment profiles, size/offset reports, regression thresholds exporting an observation as ABI
independently upgraded languages opaque API or byte protocol lifecycle/version contract and integration tests sharing compiler-native structs

The table is a starting point, not a substitute for a safety or compatibility review. A system may combine forms—for example, a transparent integer newtype inside a C record that is explicitly serialized into a different network schema—as long as each boundary names its own contract.

Failure modes to reject in review

  • Assuming source field order defines default Rust layout.
  • Treating one size_of result as a cross-target guarantee.
  • Reordering a repr(C) struct for compactness without updating the foreign declaration.
  • Exposing a nested Rust-layout type inside an alleged C ABI.
  • Constructing an enum from an unchecked integer discriminant.
  • Treating every compact Option<T> as a guaranteed niche optimization.
  • Inferring transmute validity from equal size.
  • Taking references to potentially unaligned packed fields.
  • Using ZST addresses as unique object identities.
  • Copying padding, pointers, or native integers into a wire format.
  • Assuming repr(transparent) defines domain validation or endianness.
  • Recording observed layout without compiler, target, and feature context.

Exercise: approve or reject the boundary

Audit a proposed telemetry ABI:

#[repr(C)]
pub struct Event {
    pub version: u8,
    pub state: State,
    pub payload: Vec<u8>,
}

pub enum State {
    Open,
    Closed,
}

The proposal says C may allocate the struct, fill it, pass it by value, and persist its bytes for later replay.

Produce an FFI decision record that:

  1. Classifies every field’s layout, ABI, validity, ownership, and destruction contract.
  2. Explains why the outer repr(C) does not stabilize State or make Vec<u8> a C type.
  3. Replaces Vec<u8> with an ownership-safe buffer/length contract or an opaque handle, including allocator pairing and failure cleanup.
  4. Chooses an integer representation or checked integer conversion for state, including unknown future values.
  5. Generates matching Rust/C size, alignment, and offset assertions for each supported target.
  6. Defines an explicit replay format with byte order, versioning, length limits, and unknown-field policy rather than copying memory.
  7. Separates guaranteed facts from compiler observations in the report.
  8. Compares direct ABI, opaque accessor API, and byte-message alternatives for call cost, upgrade independence, and safety-case size.

Approval requires executable evidence on both sides of the boundary and an explicit serializer. A matching screenshot of one debugger session is not sufficient.

Part V review card

When memory representation enters a design, ask:

  • Which facts are language guarantees, representation guarantees, target ABI facts, observations, or application-format rules?
  • Are size and alignment both recorded? Are relevant field offsets recorded?
  • Does padding exist, and is any operation incorrectly treating it as data?
  • Is field order actually guaranteed for this representation?
  • Are enum discriminants logical values, explicit integer representations, or inferred storage?
  • Is a niche documented for this exact type relationship or merely observed?
  • Does a ZST still influence drop, iteration, identity assumptions, or type relationships?
  • Does repr(C) contain only boundary-safe fields with matching foreign definitions?
  • Does repr(transparent) preserve the intended field while validation remains explicit?
  • Are layout and function-call ABI being verified separately?
  • Is serialization defined independently of memory layout and native endianness?
  • Can the implementation change without breaking persisted data or foreign callers?

What engineers may rely on

Rust documents size and alignment rules for primitives and aggregate representations, but it intentionally leaves ordinary Rust layout broad. repr(C) supplies C-oriented ordering and padding rules; repr(transparent) follows one non-zero-sized field; primitive enum representations expose a chosen tag type. None of these erase validity, ownership, target ABI, or schema obligations.

Niches and compact enums can be excellent implementation results. Measure them and record the environment. Promote them to public contracts only when official documentation guarantees the exact case or an explicit representation supplies the necessary rule. Zero-sized types demonstrate the converse lesson: consuming no bytes does not mean having no semantic effect.

Most importantly, serialization is a separate design. Encode fields, byte order, validation, and evolution explicitly. The next chapters examine pointers, slices, strings, collections, and zero-copy views; their costs and safety can be reasoned about only after this guarantee ledger is kept intact.

Sources and version notes