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.
A variable is a name for an address
When the compiler sees let x = 42, three things happen:
- The compiler picks where the variable will live, usually a fixed offset on the function's stack frame. The name
xnever reaches the binary; it just becomes "the 4 bytes at SP − 12" or whatever offset it chose. - The compiler emits a store instruction that writes the bits of
42(00101010) into those 4 bytes. - 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
| region | what's there | set up by | lifetime |
|---|---|---|---|
text | Compiled instructions of your program | Compiler & OS at exec | Process lifetime (read-only) |
rodata | consts, string literals, lookup tables | Compiler & OS at exec | Process lifetime (read-only) |
data + bss | Mutable globals / statics | Compiler & OS at exec | Process lifetime |
| Stack | Function locals, parameters, return addresses | Compiler emits SP-bumps; OS reserved the region | From function entry to return |
| Heap | malloc / Box / Vec / String bodies | Allocator (libc, jemalloc, …) on demand | Until free'd (C) or dropped (Rust) |
See it: print where each lives
// 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#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;
}&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.Primitive vs dynamic data
The split that drives almost every memory-layout decision:
- A type whose size is known at compile time: every
i32is 4 bytes, everyf64is 8, everyPoint { 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).
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
// 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());
}#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
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.
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.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.
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.
// 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.#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
Globals, statics, constants: what are they really?
| declaration | where it lives | writable? | lifetime |
|---|---|---|---|
Rust const X: i32 = … | rodata (or inlined into instructions) | no | process |
Rust static X: i32 = … | data (initialised) / bss (zero) | only via static mut + unsafe | process |
Rust &'static str literal | bytes in rodata; the slice header is anywhere | no | process (the bytes); local (the slice) |
C const int X = … | rodata | no | process |
C int g = 5; at file scope | data | yes | process |
C int g; at file scope | bss (auto-zeroed at exec) | yes | process |
C static int x inside fn | data / bss (visibility-scoped, lifetime is process-wide) | yes | process |
What the OS sees, end to end
- You write
let x = 42in source. - 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. - The compiler emits machine code that executes a store of the bits of 42 into that address.
- At program launch, the OS creates the process's virtual address space and lays out the binary's regions.
- 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).
- The bits land in actual transistor-level state on a DRAM chip somewhere on your motherboard.
- 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 binaryon 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).