Skip to main content

Memory Layout and repr in Rust: FFI Safety, Alignment, and Performance

Published: April 24, 2026 Updated: August 28, 2026 Larry Qu 8 min read

Memory layout determines how data is arranged in RAM — which bytes correspond to which fields, how much space a struct takes, and whether C code can read your Rust structs. Understanding layout is essential for FFI correctness, zero-copy serialization, cache optimization, and unsafe code soundness.

The Basics: Size, Alignment, and Padding

Every type has a size (bytes it occupies) and an alignment (the address must be a multiple of this value). The CPU requires types to be aligned — a u64 must start at a multiple-of-8 address. To satisfy this, the compiler inserts padding bytes between fields:

use std::mem::{size_of, align_of, offset_of};

struct Naive {
    a: u8,    // 1 byte
    b: u64,   // 8 bytes
    c: u32,   // 4 bytes
}

struct Optimized {
    b: u64,   // 8 bytes
    c: u32,   // 4 bytes
    a: u8,    // 1 byte
    // 3 bytes padding to make size multiple of alignment (8)
}

fn main() {
    println!("Naive:     size={}, align={}", size_of::<Naive>(), align_of::<Naive>());
    // Naive:     size=24, align=8  (7 bytes padding after a, 4 after c)

    println!("Optimized: size={}, align={}", size_of::<Optimized>(), align_of::<Optimized>());
    // Optimized: size=16, align=8  (3 bytes padding at end)
}

Naive has 24 bytes: [a:1][padding:7][b:8][c:4][padding:4]

Optimized has 16 bytes (same data, just reordered): [b:8][c:4][a:1][padding:3]

Rule of thumb for reducing padding: order fields from largest alignment to smallest.

repr(Rust) — Default Layout

By default, Rust can reorder fields, add padding however it likes, and change the layout between compiler versions. This is great for optimization but means you cannot make assumptions about field offsets:

struct MyStruct {
    x: u8,
    y: i64,
    z: u16,
}

// Rust may reorder to: y, z, x for optimal packing
// You cannot assume x is at offset 0

Use the default for all purely internal Rust types. Never assume C compatibility.

repr(C) — C-Compatible Layout

#[repr(C)] forces the struct to use C’s layout rules: fields appear in declaration order, alignment matches C’s rules for the target platform. This is required for FFI:

// This struct can be safely passed to C code
#[repr(C)]
pub struct NetworkPacketHeader {
    pub version:  u8,
    pub flags:    u8,
    pub length:   u16,
    pub checksum: u32,
    pub src_ip:   [u8; 4],
    pub dst_ip:   [u8; 4],
}

fn main() {
    use std::mem::{size_of, offset_of};
    println!("size={}", size_of::<NetworkPacketHeader>()); // 16

    // Offsets are predictable with repr(C)
    // version: 0, flags: 1, length: 2, checksum: 4, src_ip: 8, dst_ip: 12
}

Using repr(C) with FFI

// Rust side
#[repr(C)]
pub struct Point {
    pub x: f64,
    pub y: f64,
}

extern "C" {
    fn distance(a: *const Point, b: *const Point) -> f64;
}

fn main() {
    let a = Point { x: 0.0, y: 0.0 };
    let b = Point { x: 3.0, y: 4.0 };
    let d = unsafe { distance(&a, &b) };
    println!("{}", d); // 5.0
}
// C side (mylib.c)
#include <math.h>
typedef struct { double x; double y; } Point;
double distance(const Point* a, const Point* b) {
    double dx = a->x - b->x, dy = a->y - b->y;
    return sqrt(dx*dx + dy*dy);
}

repr(C) Enums

// C-compatible enum — integer underneath
#[repr(C)]
pub enum Status {
    Active   = 0,
    Inactive = 1,
    Pending  = 2,
}

// You can specify the discriminant type with repr(C, u8) etc.
#[repr(C, u8)]
pub enum SmallEnum {
    A = 0,
    B = 1,
    C = 255,
}

repr(transparent) — Single-Field Wrappers

When you wrap a type in a single-field newtype for type safety, repr(transparent) guarantees the wrapper has the exact same ABI as the inner type:

#[repr(transparent)]
pub struct UserId(pub u64);

#[repr(transparent)]
pub struct NonNullPtr<T>(*mut T);

// Without repr(transparent), the wrapper might have different layout
// than the inner type, breaking FFI expectations.

extern "C" {
    fn get_user_data(id: u64) -> *mut u8;
}

// With repr(transparent), UserId has the same ABI as u64
// This is safe to call:
fn get_user(id: UserId) -> *mut u8 {
    unsafe { get_user_data(id.0) }
}

Requirements for repr(transparent):

  • Exactly one non-zero-sized field
  • All zero-sized fields must be repr(transparent) compatible (like PhantomData<T>)

repr(packed) — Remove Padding

repr(packed) eliminates padding bytes. Useful for network packets and binary formats, but creates unaligned access which is undefined behavior on many architectures when you take references to fields:

#[repr(packed)]
struct UdpHeader {
    src_port:  u16,
    dst_port:  u16,
    length:    u16,
    checksum:  u16,
}

fn main() {
    println!("size={}", std::mem::size_of::<UdpHeader>()); // 8 (no padding)
}

The danger with packed structs:

#[repr(packed)]
struct Packed { a: u8, b: u32 }

fn main() {
    let p = Packed { a: 1, b: 2 };

    // UNSAFE — b might not be at a 4-byte aligned address
    // let r = &p.b; // Compiler warning/error in recent Rust

    // SAFE — copy the value first
    let b = p.b; // Loads unaligned, but as a value not reference
    println!("{}", b);
}

Safe pattern for packed structs — use ptr::read_unaligned:

use std::ptr;

#[repr(packed, C)]
struct PacketHeader {
    kind: u8,
    length: u32,
}

fn read_length(header: &PacketHeader) -> u32 {
    // SAFETY: We read via unaligned accessor — always safe regardless of alignment
    unsafe { ptr::read_unaligned(ptr::addr_of!(header.length)) }
}

repr(align(N)) — Custom Alignment

Force a minimum alignment for cache efficiency, SIMD, or lock-free data structures:

// Cache-line aligned struct (64 bytes on x86_64)
// Prevents false sharing between CPU cores
#[repr(align(64))]
struct CacheAlignedCounter {
    value: std::sync::atomic::AtomicU64,
    _pad: [u8; 56], // Fill the rest of the cache line
}

// SIMD-aligned buffer for vectorized operations
#[repr(align(32))]
struct Avx2Buffer {
    data: [f32; 8], // 8 x f32 = 32 bytes = one AVX2 register
}

fn main() {
    println!("{}", std::mem::align_of::<CacheAlignedCounter>()); // 64
    println!("{}", std::mem::align_of::<Avx2Buffer>());           // 32

    // Useful for lock-free concurrent counters
    // Two CacheAlignedCounters will be on different cache lines
    // → no false sharing between threads updating different counters
}

False Sharing Example

use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::thread;

// BAD: Both counters fit in one cache line → false sharing
struct BadCounters {
    a: AtomicU64,
    b: AtomicU64,
}

// GOOD: Each counter on its own cache line
#[repr(align(64))]
struct PaddedCounter(AtomicU64);

struct GoodCounters {
    a: PaddedCounter,
    b: PaddedCounter,
}

Enum Layout and Niche Optimization

Rust is clever about enum layout. For Option<&T>, the null pointer value serves as None — no extra discriminant byte needed:

fn main() {
    use std::mem::size_of;

    // Reference is non-null, so None can be represented as null
    println!("{}", size_of::<&i32>());             // 8
    println!("{}", size_of::<Option<&i32>>());     // 8 (same! niche optimization)

    // For types with no niche, discriminant adds size
    println!("{}", size_of::<Option<u64>>());      // 16 (8 + 8 for discriminant)

    // Box also has niche optimization (non-null pointer)
    println!("{}", size_of::<Option<Box<i32>>>()); // 8
}

For FFI, never rely on Rust’s niche optimization — use explicit discriminants with repr(C).

Field Reordering for Cache Performance

Accessing a struct’s hot fields brings the entire cache line (64 bytes) into cache. Group frequently accessed fields together:

// BAD: hot fields scattered across cache lines
struct ScatteredHotFields {
    id:          u64,   // accessed every request
    created_at:  u64,   // rarely accessed
    updated_at:  u64,   // rarely accessed
    deleted:     bool,  // accessed every request
    name:        String, // rarely accessed in hot path
    count:       u32,   // accessed every request
}

// GOOD: hot fields in first cache line
struct CacheOptimized {
    // Hot path data (fits in one 64-byte cache line: 8+1+3pad+4 = 16 bytes)
    id:    u64,
    deleted: bool,
    // 3 bytes implicit padding
    count: u32,

    // Cold data (second cache line, only loaded when needed)
    created_at: u64,
    updated_at: u64,
    name: String, // 24 bytes (ptr+len+cap)
}

Measuring Layout with std::mem

use std::mem::{size_of, align_of};

#[derive(Debug)]
struct Example {
    a: bool,
    b: i32,
    c: u8,
    d: i64,
}

fn main() {
    println!("Size:  {}", size_of::<Example>());  // 16
    println!("Align: {}", align_of::<Example>()); // 8

    // Verify with repr(C)
    #[repr(C)]
    struct ExampleC { a: bool, b: i32, c: u8, d: i64 }
    println!("C Size: {}", size_of::<ExampleC>()); // 24 (C rules — stricter padding)

    // Check common types
    println!("bool:    size={} align={}", size_of::<bool>(), align_of::<bool>());
    println!("char:    size={} align={}", size_of::<char>(), align_of::<char>());
    println!("String:  size={} align={}", size_of::<String>(), align_of::<String>());
    println!("Vec<u8>: size={} align={}", size_of::<Vec<u8>>(), align_of::<Vec<u8>>());
    println!("&str:    size={} align={}", size_of::<&str>(), align_of::<&str>());
}

FFI Complete Checklist

When exposing Rust types to C/C++:

// ✅ Use repr(C) for all shared structs
#[repr(C)]
pub struct Config { ... }

// ✅ Use repr(transparent) for newtype wrappers at FFI boundary
#[repr(transparent)]
pub struct Handle(u64);

// ✅ Use C-compatible types only (no String, Vec, references)
#[repr(C)]
pub struct CConfig {
    pub name: *const std::os::raw::c_char, // not String
    pub values: *const i32,                // not Vec<i32>
    pub values_len: usize,
    pub timeout_ms: u32,
}

// ✅ Export with C calling convention
#[no_mangle]
pub extern "C" fn process_config(config: *const CConfig) -> i32 {
    if config.is_null() { return -1; }
    let config = unsafe { &*config };
    // ...
    0
}

// ✅ Document who allocates and frees memory
/// Caller must free the returned pointer with `free_result()`
#[no_mangle]
pub extern "C" fn create_result() -> *mut CResult {
    Box::into_raw(Box::new(CResult { ... }))
}

#[no_mangle]
pub extern "C" fn free_result(ptr: *mut CResult) {
    if !ptr.is_null() {
        unsafe { drop(Box::from_raw(ptr)); }
    }
}

Summary

repr Layout Use when
repr(Rust) (default) Compiler-optimized Pure Rust internal types
repr(C) C ABI order + alignment FFI, interop with C/C++
repr(transparent) Same as inner field Newtype wrappers at FFI boundary
repr(packed) No padding Wire formats, binary protocols (careful!)
repr(align(N)) Minimum alignment N SIMD, cache optimization, lock-free
repr(C, u8) C enum with explicit discriminant C-compatible enums

Key rules:

  • Never assume default Rust layout is C-compatible
  • Always use repr(C) for types crossing language boundaries
  • Reorder fields largest-to-smallest to reduce padding in pure Rust structs
  • Use repr(align(64)) to prevent false sharing in concurrent data structures
  • Measure with size_of and align_of — don’t guess

Resources

Comments

👍 Was this article helpful?