Jump to content

Inside Rust’s Memory Layout: The Secrets Behind repr(C) and repr(transparent)

From JOHNWICK

When I first started working with Rust FFI, I made a rookie mistake: I assumed my struct would look in memory the same way it looked in code.
It didn’t. My C library read garbage bytes, segfaulted, and made me question all my life choices. That’s when I discovered Rust’s representation attributes — repr(C), repr(transparent), and the wild, undocumented world of how the compiler decides where and how your data lives in memory. Let’s go deep into the real architecture of Rust’s memory layout — the parts that make FFI work (or break), how fields actually align, and why the compiler isn’t doing what you think it is. The Hidden Contract: Rust vs the CPU When you define a struct like this in Rust: struct Point {

   x: i32,
   y: i32,

} you might imagine this layout in memory: | 0x00 | x (4 bytes) | | 0x04 | y (4 bytes) | That’s true — sometimes. But the Rust compiler is allowed to reorder fields, insert padding, and align data however it sees fit for optimization.
That means your struct’s memory layout isn’t guaranteed. Why?
Because Rust prioritizes performance and safety over binary layout predictability.
The compiler can legally rearrange fields to reduce padding or improve CPU cache performance. Example: struct Weird {

   a: u8,
   b: u64,
   c: u8,

} You might expect this to be 10 bytes (1 + 8 + 1).
But it’s actually 24 bytes due to alignment and padding. | a | pad | pad | pad | pad | pad | pad | pad | b (8 bytes) | c | pad... | Rust aligns b to an 8-byte boundary and pads the rest. Architecture: How Rust Lays Out Data Let’s visualize the flow of how your struct is represented at the compiler level: Source Code

AST (Abstract Syntax Tree)

HIR (High-Level IR)

MIR (Mid-Level IR)

LLVM IR

Machine Code → Memory Layout Memory layout decisions happen before lowering to LLVM IR — but LLVM has the final say on field alignment and padding strategy based on your target platform’s ABI (Application Binary Interface). repr(Rust) — The Default Chaos By default, all structs are repr(Rust) — meaning: The compiler may change the layout at any time. This is great for Rust-only code but disastrous for interoperability. So when you pass a struct to C code, the layout must match what C expects — field order, padding, and alignment all included. That’s where repr(C) comes in. repr(C) — The Bridge Between Worlds When you write:

  1. [repr(C)]

struct Point {

   x: i32,
   y: i32,

} you’re telling Rust: “Lay this out exactly like C would on this platform.” That means:

  • No field reordering.
  • Padding follows C’s ABI rules.
  • The alignment and size are predictable.

Now, this struct can safely cross the FFI boundary:

  1. [repr(C)]

struct Point {

   x: i32,
   y: i32,

}

extern "C" {

   fn print_point(p: *const Point);

}

fn main() {

   let p = Point { x: 5, y: 10 };
   unsafe { print_point(&p) };

} In C: typedef struct {

   int x;
   int y;

} Point;


void print_point(const Point* p) {

   printf("Point: (%d, %d)\n", p->x, p->y);

} This works perfectly because both sides agree on the same memory layout. The Weird Cousin: repr(transparent) Then there’s this little gem:

  1. [repr(transparent)]

struct Wrapper(i32); This tells the compiler: “Make this struct have the exact same representation as its single field.” So Wrapper(i32) behaves like an i32 in FFI calls — identical size, alignment, and ABI. Why does this exist?
Because you often want to create type-safe wrappers without changing binary compatibility. Example:

  1. [repr(transparent)]

struct UserId(u64);

extern "C" {

   fn lookup_user(id: UserId);

} In C: void lookup_user(uint64_t id); UserId is a distinct type in Rust — but to C, it’s just a uint64_t. Perfect for safe abstractions with zero runtime cost. Visual: Comparing Layouts Let’s visualize repr(Rust) vs repr(C) vs repr(transparent): repr(Rust): +----+----+----+----+ | b | a | pad| pad| <-- compiler may reorder or pad arbitrarily +----+----+----+----+

repr(C): +----+----+----+----+ | a | b | pad| pad| <-- follows C ABI rules strictly +----+----+----+----+

repr(transparent): +---------+ | field_0 | <-- identical to its inner type +---------+ Gotcha: Nested Layouts When you nest repr(C) structs, everything must match across the hierarchy:

  1. [repr(C)]

struct Inner {

   x: u8,
   y: u8,

}


  1. [repr(C)]

struct Outer {

   inner: Inner,
   z: u32,

} Outer will pad Inner up to a 4-byte boundary for z. Memory diagram: | x | y | pad | pad | z (4 bytes) | Total size = 8 bytes. If you mix repr(Rust) and repr(C) in nesting — things get unpredictable. The Subtle Power of repr(align(N)) and repr(packed) Rust also gives you fine-grained control:

  1. [repr(C, align(16))]

struct Aligned {

   data: [u8; 16],

}


  1. [repr(C, packed)]

struct Packed {

   a: u8,
   b: u32,

}

  • align(16) forces the struct to start on a 16-byte boundary — useful for SIMD.
  • packed removes padding — but beware: unaligned access can crash CPUs on some architectures.

Example of a packed access crash: let p = Packed { a: 1, b: 0x12345678 }; let ptr = &p.b as *const u32; unsafe { println!("{}", *ptr); } // ❌ may crash on ARM or MIPS That’s why packed is a dangerous optimization — it sacrifices safety for byte-level control. Code Flow: From Definition to Memory Here’s how it really flows: Struct Definition

Attribute Parsing (`repr(...)`)

LLVM Data Layout Engine

Target ABI (C, SystemV, Windows)

Binary Layout + Padding + Alignment

Memory Representation at Runtime Rust → LLVM → CPU.
Each layer has its own say in how things end up in memory. That’s why subtle platform differences (like ARM vs x86) can change struct size even with the same code. The Real Reason Behind repr(C) and repr(transparent) At its core, these attributes exist for trust boundaries.
Rust’s default layout is optimized for speed and safety within Rust.
But when you cross into other worlds — C, C++, hardware, or OS syscalls — you need predictability. repr(C) and repr(transparent) are that handshake between safety and reality. They let you play nice with the rest of the stack, without giving up Rust’s type system. Final Thoughts Rust’s memory layout story isn’t just about alignment or ABI — it’s about control. You can write

  • Safe code at the system boundary.
  • Predictable layouts across C, Rust, and assembly.
  • Wrappers that carry zero overhead but still protect your types.

But you also learn humility:
Memory layout isn’t something the compiler hides from you — it’s something you earn the right to control. repr(C) gives you safety across languages.
repr(transparent) gives you zero-cost wrappers.
And repr(Rust) — the default — gives the compiler freedom to optimize. Choose wisely. Because when it comes to FFI and memory, layout is destiny.

Read the full article here: https://medium.com/@theopinionatedev/inside-rusts-memory-layout-the-secrets-behind-repr-c-and-repr-transparent-ccfc6e6ca377