root.system / 0x08 / data

let x = 42.
Where does it go?

You write a single line, let x = 42 in Rust or int x = 42; in C, and somewhere in your machine four bytes get the pattern 00101010 stamped into them. Where exactly? Why does String behave differently from i32? This page traces a variable from your source line to its actual bytes in RAM.

Beginner// level 01

A variable is a name for an address

When the compiler sees let x = 42, three things happen:

  1. The compiler picks where the variable will live, usually a fixed offset on the function's stack frame. The name x never reaches the binary; it just becomes "the 4 bytes at SP − 12" or whatever offset it chose.
  2. The compiler emits a store instruction that writes the bits of 42 (00101010) into those 4 bytes.
  3. Anywhere your code uses x, the compiler emits a load from the same address.

So a "variable" is just a label the compiler uses to keep track of an address. The CPU doesn't know about variables; it only knows loads and stores.

The OS gave the process the room first

None of this works without the OS. When your program launched, the kernel:

  • Created a virtual address space for the process (one private universe; see the operating system page).
  • Loaded the binary's read-only sections (text, rodata) into low addresses.
  • Loaded the writable globals (data, bss) right after.
  • Reserved a stack region near the top of the address space and pointed the stack-pointer register at it.
  • Left a giant gap in the middle for the heap to grow into on demand.

From there, your variables can land in any of those regions, depending on how they're declared. The compiler picks; the OS just made the regions exist.

Five places a value can live

regionwhat's thereset up bylifetime
textCompiled instructions of your programCompiler & OS at execProcess lifetime (read-only)
rodataconsts, string literals, lookup tablesCompiler & OS at execProcess lifetime (read-only)
data + bssMutable globals / staticsCompiler & OS at execProcess lifetime
StackFunction locals, parameters, return addressesCompiler emits SP-bumps; OS reserved the regionFrom function entry to return
Heapmalloc / Box / Vec / String bodiesAllocator (libc, jemalloc, …) on demandUntil free'd (C) or dropped (Rust)

See it: print where each lives

Rust• • •
// Where does each piece of data live in memory?
// Print the address of one example from every storage class.

const C_CONST: i32 = 999;            // baked into the binary, read-only
static G_INIT: i32 = 100;            // initialised global → DATA
static mut G_BSS: i32 = 0;           // zero-init global  → BSS
static G_LIT: &str = "hello world";  // bytes in RODATA, slice on stack

fn main() {
    let x: i32 = 42;                 // local primitive → STACK
    let v: Vec<i32> = vec![1, 2, 3]; // header on STACK, buffer on HEAP
    let b: Box<i32> = Box::new(7);   // pointer on STACK, value on HEAP

    println!("CONST  @ {:p}", &C_CONST);
    println!("DATA   @ {:p}", &G_INIT);
    println!("BSS    @ {:p}", unsafe { &raw const G_BSS });
    println!("RODATA @ {:p}  (the bytes \"{}\" live here)", G_LIT.as_ptr(), G_LIT);
    println!("STACK  @ {:p}  (x = {})", &x, x);
    println!("HEAP   @ {:p}  (Vec buffer; header @ {:p})", v.as_ptr(), &v);
    println!("HEAP   @ {:p}  (Box payload; pointer @ {:p})", &*b, &b);
}

// Typical output (addresses vary every run on Linux/macOS due to ASLR):
// CONST  @ 0x55b6e9c1d000   ← low addresses, the binary
// DATA   @ 0x55b6e9c1d100   ← right next to CONST
// BSS    @ 0x55b6e9c1d200
// RODATA @ 0x55b6e9c1d420
// STACK  @ 0x7ffe23a4f8d4   ← high addresses
// HEAP   @ 0x55b6ea102b30   ← middle, allocator-managed
C• • •
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

const int C_CONST = 999;        // RODATA: read-only, baked into binary
int       g_init  = 100;        // DATA  : initialised global
int       g_bss;                // BSS   : zero-initialised global

int main(void) {
    int   x   = 42;             // STACK : local primitive
    char  arr[5] = "hi!";       // STACK : the string is *copied* here
    char *lit = "hello world";  // pointer on STACK, bytes in RODATA
    int  *p   = malloc(sizeof *p);
    *p = 7;                     // HEAP  : malloc'd value

    printf("RODATA @ %p   (constant)\n", (void*)&C_CONST);
    printf("DATA   @ %p   (g_init=%d)\n", (void*)&g_init, g_init);
    printf("BSS    @ %p   (g_bss=%d, zero by default)\n",
           (void*)&g_bss, g_bss);
    printf("STACK  @ %p   (x=%d)\n",  (void*)&x, x);
    printf("STACK  @ %p   (local arr=\"%s\")\n", (void*)arr, arr);
    printf("RODATA @ %p   (string literal \"%s\")\n", (void*)lit, lit);
    printf("HEAP   @ %p   (*p=%d)\n", (void*)p, *p);

    free(p);
    return 0;
}
// the address is the variable
&x in either language is just a number: the address the compiler picked for x. Print it ({:p} in Rust, %p in C) and you're literally seeing the chosen storage location. Stack addresses are huge (near the top of the address space); heap is in the middle; binary regions sit at the bottom.
Intermediate// level 02

Primitive vs dynamic data

The split that drives almost every memory-layout decision:

  • A type whose size is known at compile time: every i32 is 4 bytes, every f64 is 8, every Point { x: f64, y: f64 } is 16. The compiler can reserve exactly the right space on the stack.
  • A type whose size is decided at runtime: a string the user types, a vector that grows. The compiler doesn't know how many bytes you'll need, so the bytes can't live on the stack.

The fix is universal: split the type into a small, fixed-size header (which lives on the stack) and a variable-size buffer (which lives on the heap, addressed by a pointer in the header).

PRIMITIVE: Point { x: f64, y: f64 }STACKx = 3.14y = 2.71all 16 bytes on the stack: no allocator, no pointer chaseDYNAMIC: String "hello, world"STACKptrlen = 12cap = 16HEAPhello,·world24-byte header on stack; buffer lives elsewhere

This is the same pattern in every language. Rust calls them Vec, String, Box. C doesn't bake them in; you build the same shape with a struct and malloc. Java hides it behind a reference; Python hides it behind everything. But the layout is identical underneath: a fixed-size header that points at a heap-allocated body.

Same data, two layouts

Rust• • •
// Two structs storing the same logical data, laid out
// completely differently in memory.

#[repr(C)]
struct Point {       // primitive: fixed size known at compile time
    x: f64,
    y: f64,
}                    // sizeof(Point) = 16 bytes; one contiguous block.

fn main() {
    let p = Point { x: 3.14, y: 2.71 };
    let s: String = String::from("hello, world");

    println!("--- primitive ---");
    println!("sizeof Point         = {} bytes",
             std::mem::size_of::<Point>());      // 16
    println!("p lives @            {:p}", &p);   // STACK
    println!("p.x & p.y are right next to each other:");
    println!("  &p.x = {:p}", &p.x);
    println!("  &p.y = {:p}  (16 bytes after p, technically 8)", &p.y);

    println!("--- dynamic ---");
    println!("sizeof String header = {} bytes",
             std::mem::size_of::<String>());     // 24: (ptr, len, cap)
    println!("s header lives @     {:p}  (STACK)", &s);
    println!("s.as_ptr()       =   {:p}  (HEAP, the actual bytes)",
             s.as_ptr());
    println!("len/capacity     =   {}/{}", s.len(), s.capacity());
}
C• • •
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef struct {
    double x;
    double y;
} Point;

// "Dynamic string": a (ptr, len, cap) header, just like Rust's String.
typedef struct {
    char   *data;
    size_t  len;
    size_t  cap;
} String;

String string_from(const char *s) {
    size_t n = strlen(s);
    String out = { malloc(n + 1), n, n + 1 };
    memcpy(out.data, s, n + 1);
    return out;
}

int main(void) {
    Point  p = {3.14, 2.71};
    String s = string_from("hello, world");

    puts("--- primitive ---");
    printf("sizeof(Point)        = %zu bytes\n", sizeof p);     // 16
    printf("p lives @            %p   (STACK)\n", (void*)&p);
    printf("  &p.x = %p\n", (void*)&p.x);
    printf("  &p.y = %p   (8 bytes later, contiguous)\n", (void*)&p.y);

    puts("--- dynamic ---");
    printf("sizeof(String)       = %zu bytes\n", sizeof s);     // 24
    printf("s header lives @     %p   (STACK)\n", (void*)&s);
    printf("s.data           =   %p   (HEAP, the bytes)\n", (void*)s.data);
    printf("len/cap          =   %zu/%zu\n", s.len, s.cap);

    free(s.data);
    return 0;
}

Why this is a tradeoff, not a problem

stack-only (primitive)
Fast, fixed
One register move to allocate, automatic to free. Cache-friendly. But the size is locked in at compile time, with no growing and no user-determined dimensions.
heap-backed (dynamic)
Flexible, slower
Allocator call to create, allocator call to free. Pointer chases on every read. But the size is whatever you need at runtime, and the data outlives the function that made it.

Pointers, references, smart pointers

Whenever a variable's data lives somewhere else, what's stored locally is a pointer: an address. Different languages dress this idea differently:

  • C raw pointer: int *p. A bare address. You manage everything.
  • Rust reference: &T. A pointer the compiler statically guarantees is valid for some scope.
  • Rust Box<T>: owned heap pointer. Frees on drop.
  • Rust Rc<T> / Arc<T>: reference-counted heap pointer. Frees when count hits zero.

All of them are, in memory, the same 8 bytes (on a 64-bit system) holding an address. The differences are entirely about what guarantees the type system makes about that address.

// the rule
Fixed size known to the compiler → stack (or DATA, if it's static). Size only known at runtime, or needs to outlive its creating function → heap. The pointer to the heap thing is itself a fixed-size primitive that goes on the stack.
Advanced// level 03

Alignment, lifetime & ownership

Alignment and padding

The compiler doesn't pack fields as tightly as it might. CPUs prefer multi-byte values to start at addresses that are multiples of their size: a 4-byte i32 at an address divisible by 4, an 8-byte f64 at one divisible by 8. This is alignment, and the compiler enforces it by inserting invisible padding bytes between fields. Sometimes a struct is bigger than the sum of its parts, and the order you write the fields in matters.

struct Bad  { u8 a; u32 b; u8 c; }  →  12 bytesoffset01234567891011a···bbbbc···1 byte a, 3 bytes padding, 4 bytes b, 1 byte c, 3 bytes paddingstruct Good { u32 b; u8 a; u8 c; }  →  8 bytesoffset01234567bbbbac··4 bytes b, 1 byte a, 1 byte c, 2 bytes paddingsame fields, smaller footprint; order matters

Same fields, two byte-counts. In hot loops on millions of objects, this matters. Rust's #[repr(C)] turns off field reordering for FFI compatibility; default Rust is free to reorder for size.

Lifetime: when does the memory stop being valid?

Stack memory has a hard rule: the moment its function returns, those bytes are reclaimed. Anything on the stack (local variables, function parameters, the function's view of the world) is gone. Any pointer to that memory becomes a dangling pointer the instant the function exits.

Heap memory is the opposite: it stays valid until something explicitly releases it. The question is who that something is, and that's the entire memory-safety question.

Rust• • •
// Lifetime in code: where each piece of data is born and dies.
fn make_string() -> String {
    let s = String::from("created here");
    s   // ← OWNERSHIP transferred to the caller. The HEAP buffer
        //   survives; the local stack frame's String header is moved.
}       //   No copy; just three pointer-sized fields handed off.

fn main() {
    let outer = make_string();
    println!("{outer}");
}   // ← outer goes out of scope here. Its Drop impl runs:
    //   1) free the HEAP buffer (calls into the allocator)
    //   2) the stack header is dropped automatically.
    //
    // No GC, no manual free. The compiler inserted both steps
    // by reading the lifetime. That's what "ownership" buys you.
C• • •
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

// Same shape, but C makes you remember to free.
char *make_string(void) {
    char *s = malloc(13);
    memcpy(s, "created here", 13);
    return s;          // pointer is returned; HEAP buffer lives on.
}

int main(void) {
    char *outer = make_string();
    printf("%s\n", outer);
    free(outer);       // ← forget this and you have a leak.
                       //   call it twice and you have a double-free.
                       //   the C compiler will say nothing either way.
    return 0;
}

Three philosophies for variable lifetime

01 / manual
C, C++
You wrote the malloc, you write the free. Forget it: leak. Free it twice: corruption. Free it then read: undefined behaviour. Maximum control, zero safety net.
02 / runtime
Java, Go, Python
A garbage collector traces what's still reachable from live references and frees the rest. You never call free. Safe and ergonomic, but you pay in pause times and lose precise control over layout.
03 / compile-time
Rust
The compiler reads ownership and lifetime annotations, refuses programs that could free memory while a reference is still alive. No GC. No manual free. The check is in the type system.

Globals, statics, constants: what are they really?

declarationwhere it liveswritable?lifetime
Rust const X: i32 = …rodata (or inlined into instructions)noprocess
Rust static X: i32 = …data (initialised) / bss (zero)only via static mut + unsafeprocess
Rust &'static str literalbytes in rodata; the slice header is anywherenoprocess (the bytes); local (the slice)
C const int X = …rodatanoprocess
C int g = 5; at file scopedatayesprocess
C int g; at file scopebss (auto-zeroed at exec)yesprocess
C static int x inside fndata / bss (visibility-scoped, lifetime is process-wide)yesprocess

What the OS sees, end to end

// from let x = 42 to bits in RAM
  1. You write let x = 42 in source.
  2. The compiler picks an address for x: a stack offset for a local, or a fixed RODATA/DATA/BSS address for a const or static.
  3. The compiler emits machine code that executes a store of the bits of 42 into that address.
  4. At program launch, the OS creates the process's virtual address space and lays out the binary's regions.
  5. When your function runs, the CPU executes the store. The MMU translates the virtual address to physical RAM (with help from the kernel's page tables).
  6. The bits land in actual transistor-level state on a DRAM chip somewhere on your motherboard.
  7. And on the read, the whole chain reverses, in a few nanoseconds.

Where to dig in next

You now have the through-line: source → compiler → binary regions → OS-managed virtual memory → MMU → DRAM. A few good directions:

  • Compiler Explorer (godbolt.org): paste your code, see exactly which addresses the compiler picks and which load/store instructions it emits.
  • Reading readelf -S binary on Linux: list every region of an executable (TEXT, DATA, BSS, RODATA) and where each one will be loaded.
  • The Rustonomicon, chapter on data representation: exhaustive on layout, repr, niches, and how Rust packs enums.
  • Hennessy & Patterson, Computer Architecture, for the hardware end of the chain (caches, prefetchers, write buffers).
next up / 0x09
A number that means somewhere: pointers, in C and Rust.
pointers