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.
You write one line.
let x = 42;
Seems like nothing.
But that single line sets off a chain of events that reaches from your text editor all the way down to transistors on a chip.
The compiler reads it.
Decides where x will live.
Emits a store instruction.
The OS loads your program.
Creates a virtual address space.
Reserves a stack. A heap. Sections for your code.
The CPU executes the store.
The MMU translates the address.
Four bytes in actual RAM get the pattern 00101010 stamped into them.
And then x exists.
Not as a name.
Not as a concept.
As charged capacitors holding a voltage state somewhere on your motherboard.
You already know what those voltage states are.
0 and 1.
A variable is not a named box.
A variable is a binary number at a memory address that the compiler agreed to call x.
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.
The OS creates the virtual address space. Stack near the top of memory. Heap in the middle. Binary sections at the bottom. These are not abstract concepts. They are real memory addresses. Written in hex. 0x7ffe23a4f8d4 for the stack. 0x55b6ea102b30 for the heap. You learned hex on page 1. You learned why addresses are hex on page 6. This is both of those pages made physical. ← see: Number Systems · Memory
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;
}Declare a variable. Watch it land.
&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.
A Rust reference and a C pointer are the same eight bytes in memory. The same binary number. The same memory address. The difference is entirely in what the type system guarantees about that address at compile time. Rust refuses to compile programs where that address might be invalid. C trusts you to never follow an invalid one. This is the compile vs runtime page applied to a single variable type. ← see: Compile vs Runtime · Pointers
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.
Rust lets you control alignment explicitly. #[repr(C)] matches C's layout rules exactly. #[repr(align(64))] aligns to a cache line. When Bitcoin Core's C++ structs cross the FFI boundary into Rust bindings the layout must match exactly. One misaligned field corrupts everything. Silent. No warning. No error. Just wrong data at a wrong address. ← see: Memory
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
Variables in Bitcoin Core
Every data structure Bitcoin Core works with is variables in memory. Laid out according to the same alignment rules this page just described.
Here is the actual Bitcoin block header struct. Exactly as it sits in RAM on every full node:
// Same struct in Rust with explicit layout control.
#[repr(C, packed)]
struct BlockHeader {
version: u32, // 4 bytes - offset 0
prev_hash: [u8; 32], // 32 bytes - offset 4
merkle_root: [u8; 32], // 32 bytes - offset 36
timestamp: u32, // 4 bytes - offset 68
bits: u32, // 4 bytes - offset 72
nonce: u32, // 4 bytes - offset 76
} // total: 80 bytes
// #[repr(C, packed)]:
// C = C-compatible field order and alignment rules
// packed = no padding bytes between fields
//
// Rust would normally feel free to reorder fields
// for better alignment performance.
// repr(C) stops that.
// packed removes all padding.
// Together they guarantee 80 bytes.
const _: () = assert!(
std::mem::size_of::<BlockHeader>() == 80,
"BlockHeader must be exactly 80 bytes"
);
// The compile-time assert catches layout bugs
// before a single block is hashed.
// C's _Static_assert does the same.
// Without it you ship wrong code to production
// and every node rejects your blocks./* Bitcoin block header - 80 bytes exactly.
* Alignment matters: this struct is hashed
* by passing its raw memory to SHA-256.
* Any padding would corrupt the hash. */
struct __attribute__((packed)) BlockHeader {
uint32_t version; /* 4 bytes - offset 0 */
uint8_t prev_hash[32]; /* 32 bytes - offset 4 */
uint8_t merkle_root[32]; /* 32 bytes - offset 36 */
uint32_t timestamp; /* 4 bytes - offset 68 */
uint32_t bits; /* 4 bytes - offset 72 */
uint32_t nonce; /* 4 bytes - offset 76 */
}; /* total: 80 bytes */
/* __attribute__((packed)) tells the compiler:
* no padding. ever.
* sizeof(BlockHeader) must equal exactly 80,
* because SHA-256 hashes these 80 raw bytes.
* if the compiler added padding
* the hash would change.
* the block would be invalid.
* every miner would reject it. */
/* verify at compile time: */
_Static_assert(sizeof(struct BlockHeader) == 80,
"BlockHeader must be exactly 80 bytes");Now the mining variable.
nonce is the variable miners change. One u32. Four bytes. Starts at 0. Increments until the hash meets the target. It has been incremented roughly 10^21 times across all of Bitcoin history. Each increment is a store instruction. Each store writes four bytes to RAM. The same store instruction from the beginner section of this page. Mining is just: increment a u32, hash, check.A single u32. Four bytes. Incrementing.
That is what secures the Bitcoin network.
From the alignment rules on this page to the most valuable computation ever performed. Variables all the way down.
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).
Variables across ScrapyBytes
The same ideas surface all over ScrapyBytes. Here is where this page connects to the rest of the curriculum, and how to follow each thread.
A variable is a named, typed slot in memory. Fixed-size values go on the stack, growable ones on the heap. The memory page is the ground this page builds a name on.
scrapybytes.vercel.app/memory →PointersA reference or a Box is a variable whose value is an address. The pointers page is what happens when a variable points at another.
The type decides how a variable's bits are read. The same 32 bits are an i32 or an f32 depending on the type. The binary page is the raw value under the name.
The compiler fixes a stack variable's offset at compile time and a heap allocation's address at runtime. The variables page is the clearest place to see that split.
scrapybytes.vercel.app/compile-vs-runtime →ArraysAn array variable is a fixed-size block; a Vec is a three-field handle on the stack pointing at the heap. The arrays page is this page made plural.
The fastest variable lives in a register. The CPU page is where let x = 42 stops being a name and becomes silicon holding a value.
A HashMap variable is a small struct on the stack (pointer, length, capacity) pointing at megabytes on the heap. Same shape as Vec. The hashing page reuses this layout.
A variable's address is a hex number like 0x7fff5fbff8d4. The number systems page is why 16 is the readable shorthand for it.
A variable's bits sit in DRAM cells and flip-flops, the same gates as the ALU. The logic gates page is your variable as silicon holding charge.
scrapybytes.vercel.app/logic-gates →Operating SystemThe OS builds the virtual address space and reserves the stack a variable lives in; without it there is no variable. The operating system page makes them possible.
scrapybytes.vercel.app/operating-system →RecursionEvery recursive call mints fresh locals in a new stack frame, and without a base case they fill the stack until the process dies. The recursion page is variables piling up.
scrapybytes.vercel.app/recursion →BlockchainBitcoin's nonce is a single u32 variable miners overwrite billions of times a second. The blockchain page has the most expensive variable assignment in computing.