The call stack: what a call does to memory¶
Level: 101 → 201 · working knowledge
One line: Calling a function reserves a region of the stack for its parameters and locals, moves the arguments into it, and releases the whole region on return — so the address of a local means something only between those two moments.
struct Counter(u32);
fn report(x: Counter) -> u32 {
x.0 // `x` is a local variable of THIS call
}
let a = Counter(1);
let n = report(a); // the value moves into x's slot; `a` is gone
Nothing in that code says stack. It is what report(a) does anyway: a region appears, a's value is moved into it, x uses it for the length of the call, and the region goes away when report returns.
A call is a region, not a jump¶
| The call does | Which means |
|---|---|
| reserves space for the parameters and locals | the callee gets somewhere to keep x, and it is not the caller's space |
| moves each argument into that space | the value stops belonging to the caller; the caller's name goes dead |
| runs the body | x behaves like any other local — mutable if declared mut, dropped at the end unless it leaves |
| returns, releasing the region in one step | there is no per-variable cleanup and no list to walk; the stack pointer moves back |
The last row is why stack allocation costs nothing worth measuring. Freeing a frame is not a search for what to free — the order is guaranteed, so it is one arithmetic instruction. That is also why the region is a region: everything in it dies together, and nothing in it can be kept.
The argument becomes a local¶
fn report(x: Counter) takes its argument by value, so x is not a view of the caller's a. The value was moved in, a is unusable afterwards, and it is report that will free it — which the example below shows by putting a Drop on the type and watching where the free happens.
Two consequences worth having:
xcan be mutated if you writefn bump(mut x: Counter). Themutis on the local, not on the caller's variable, and the caller sees nothing.xcan leave. Returning it moves the value into a slot the caller provided, so it outlives the frame it was standing in. A value is not tied to the region it happened to be in; the region is just where it was.
Nesting: frames stack up, and come back down in order¶
Calling from inside a call reserves another region below the first. Four nested calls make four frames, all live at once, and all released as the outermost returns.
The verified output¶
Verified output of the_call_stack.rs — regenerated by tools/run_examples.py, never hand-typed.
1. A call reserves a region, and it is not the caller's
inside report: x = Counter(1)
my frame is below the caller's? true
x is a local of THIS call, and drops here unless it leaves
[drop] Counter(1) freed
report returned 1; `a` is gone from main, because it went IN
and the drop line above ran inside report, before it returned
2. Nesting: every further call is another region, below the last
four nested calls, four distinct frames: 4
each one below the frame that called it: true
the whole chain fits in under 4 KiB: true
...and all four are released by the time level_1 returns.
3. A parameter is a local, so it can leave by the return path
bump returned Counter(107) and freed nothing on the way out
the VALUE outlived the frame it was standing in; the frame did not
4. A block is not a frame, but it ends a name just the same
c = Counter(9), alive inside this block
[drop] Counter(9) freed
past the brace the name is gone, and the value went with it
5. Why `&x` is what forces a slot to exist at all
small = 3, and size_of::<u8>() = 1
a byte can live entirely in a register and never touch the stack;
asking for its address is what obliges the compiler to give it one.
`&small` landed inside main's own frame: true
so `&u8` costs 8 bytes to hold a value that is 1.
main ends here, and `bumped` drops last:
[drop] Counter(107) freed
The four booleans are the whole model. Frames are distinct, each nested one sits below its caller, the four together cost a few hundred bytes, and the chain unwinds in reverse.
Why the program prints comparisons and never an address. A raw address differs on every run — the OS randomizes where the stack starts — so 0x7ffee3a4b8 is not a fact about Rust, it is a fact about one run of one process. What is stable is the relationship: this frame is below that one, these two are the same, that one is within a few hundred bytes. Those are the claims a recorded answer key can hold.
The footnote everyone skips¶
"Memory for parameters is reserved on the stack" is the mental model, and it is not quite what the machine does. A small value may be passed in a register and never touch memory at all; the calling convention decides, and it differs between platforms. Taking &x is what obliges the compiler to give the value an address, which is why the example above anchors every frame with a borrow of a local u8.
The optimizer goes further and can delete the frames entirely. Same four functions, once as the lesson compiles them and once with -O:
$ rustc --edition 2024 inlined_away.rs && ./inlined_away
descending? true
main -> deepest: 304 bytes
level_1 -> level_2: 32 bytes
level_2 -> level_3: 32 bytes
level_3 -> level_4: 32 bytes
$ rustc --edition 2024 -O inlined_away.rs && ./inlined_away
descending? false
main -> deepest: 103 bytes
level_1 -> level_2: 8 bytes
level_2 -> level_3: 16 bytes
level_3 -> level_4: 64 bytes
At -O the four calls are inlined into one frame, the addresses stop descending, and "the frame of level_3" names nothing. The lesson's example wears #[inline(never)] for exactly this reason — the structure it demonstrates is real, but it is not guaranteed, and a demonstration that quietly depends on a debug build should say so rather than be lucky.
None of that changes the model. A value's lifetime is a fact about the program; where the compiler chose to put it is a fact about one build. Everything the borrow checker enforces is on the first list.
If you are coming from another language¶
Python. You have the same mechanism and almost none of the consequences. def report(x) creates a frame object with its own local namespace, exactly as here, and x is a local name that rebinding cannot reach out of. What differs is what the name holds: Python passes a reference to a heap object, so report(a) leaves a perfectly usable and a mutation through x is visible to the caller. That sharing is one half of the famous mutable-default-argument surprise; the other is that a default is evaluated once, when def runs, so def log(item, bucket=[]) hands every call that omits bucket the same list — log(1) returns [1], then log(2) returns [1, 2]. Rust's report(a) moves, so a is gone, and the two things Python gives you at once are two signatures here: report(&a) keeps a usable but refuses a write through x (E0594), and only report(&mut a) makes a write the caller sees — refused in turn while another borrow of a is still in use (E0502). The signature says which one you meant. Two other things transfer: sys.setrecursionlimit is Python's software counterpart to a real stack bound (CPython counts frames rather than bytes, which is why the limit is 1,000 and not "8 MiB"), and a RecursionError is a catchable exception where Rust's overflow is not — the next page is about that difference. And locals() has no Rust equivalent, because a frame here is not an object; it is an offset from a register.
ABAP. A FORM/METHOD call builds a stack entry with its own local DATA, and the ABAP debugger's call stack is showing you exactly the structure this page is about. The split Rust spells x: Counter versus x: &mut Counter is VALUE( ) in ABAP, not USING versus CHANGING: a subroutine parameter written without VALUE( ) is passed by reference whichever keyword introduced it, and a by-reference USING parameter is handled exactly like a CHANGING one, so a write to it changes the caller's variable (FORM ↗). The keyword records intent, which the syntax check backs only with a warning, and only in a subroutine that also has CHANGING parameters. Methods keep the default — a bare name means REFERENCE( ) — and make a by-reference IMPORTING parameter read-only (METHODS parameters ↗):
| ABAP | Rust |
|---|---|
USING x or CHANGING x on a FORM; EXPORTING x or CHANGING x on a method |
x: &mut Counter, without the rule that nothing else may reach a meanwhile |
IMPORTING x on a method |
x: &Counter |
USING VALUE(x), IMPORTING VALUE(x) |
x: Counter called with a.clone() — a local copy, and the caller's variable keeps its value |
CHANGING VALUE(x) on a FORM |
fn bump(x: Counter) -> Counter called as a = bump(a) — copied in, and copied back at ENDFORM, RETURN, CHECK or EXIT, but not when a message or an exception ends the subroutine |
Two differences worth knowing. A VALUE( ) parameter is a copy, where Rust's by-value moves: the caller's name goes dead, and the compiler tells you rather than letting you read a stale copy. And nothing at compile time checks how long an ABAP reference is kept: a data reference to a local of a FORM that has since returned is the bug on the third page of this arc, and ABAP's answer comes at run time — the reference becomes invalid when the procedure ends, and IS BOUND is false for it (Heap References and Stack References ↗). LOCAL has no counterpart here: it saves a data object and restores it when the procedure ends, and Rust has no globals you save and restore, which is most of why its call stack is simpler to reason about than ABAP's.
C. The mechanism is identical, down to the register conventions, and one habit has to go. struct Counter b; report(b); copies the struct into the callee's frame and both copies remain valid; in Rust the same line moves it, and the caller's is statically dead. So the C reflex of "pass a pointer to avoid the copy" becomes "pass a reference to avoid the move", and the reason changes: not performance, but who is responsible for freeing. The other habit worth unlearning is that return &local is a warning you can build past in C and is rejected outright here.
C++. Everything about frames is the same, and the default is inverted. report(a) copies in C++ and moves in Rust, so the expensive thing is the one you must ask for here and the one you must suppress there. Counter&& and std::move have no counterpart because moving is not a special overload — it is what = and argument passing already do. What C++ calls a dangling reference to a local is the same bug as ever; the difference is only that here it does not compile.
Practice¶
Three predictions, written down before you run anything. Each one is a comparison, never an address — a printed address teaches nothing, because it is different next time.
- A function takes a
Stringby value. Predict whether the header's address inside the callee differs from the caller's, and whether the heap buffer moved. Then print both as booleans. One of the two answers is the reason moves are cheap. - Give a type a
Dropthat prints. Pass one into a function by value and do not return it; then change the function to return it. Predict, for each version, whether the free happens before or after the caller's next line. - Nest four calls and collect one address from each frame. Predict the ordering, then check it — and then compile the same file with
-Oand predict what changes. Say which of your three claims was about Rust and which was about one build.
Solution
the_call_stack_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.
//! Kata solution: three predictions about what a call does to memory.
//!
//! Every answer is a comparison. A printed address is different next run and
//! could not be an answer key -- and predicting one would teach nothing.
//!
//! rustc --edition 2024 the_call_stack_kata.rs -o /tmp/tcsk && /tmp/tcsk
/// Part 1. Takes the String BY VALUE, and reports where it found it.
#[inline(never)]
fn receive(s: String) -> (usize, usize) {
let header_at = &s as *const String as usize;
let bytes_at = s.as_ptr() as usize;
(header_at, bytes_at)
}
/// Part 2. A value that says when it is freed.
struct Marker(&'static str);
impl Drop for Marker {
fn drop(&mut self) {
println!(" [drop] {} freed", self.0);
}
}
#[inline(never)]
fn consume(r: Marker) {
println!(" consume() has it: {}", r.0);
} // <- freed HERE
#[inline(never)]
fn pass_through(r: Marker) -> Marker {
println!(" pass_through() has it: {}", r.0);
r
} // <- freed by whoever takes it
/// Part 3.
#[inline(never)]
fn here(anchor: &u8) -> usize {
anchor as *const u8 as usize
}
#[inline(never)]
fn d4(f: &mut Vec<usize>) {
let a = 0u8;
f.push(here(&a));
}
#[inline(never)]
fn d3(f: &mut Vec<usize>) {
let a = 0u8;
f.push(here(&a));
d4(f);
}
#[inline(never)]
fn d2(f: &mut Vec<usize>) {
let a = 0u8;
f.push(here(&a));
d3(f);
}
#[inline(never)]
fn d1(f: &mut Vec<usize>) {
let a = 0u8;
f.push(here(&a));
d2(f);
}
fn main() {
println!("Part 1 — a String passed by value.\n");
let text = String::from("the quick brown fox jumps over it");
let header_before = &text as *const String as usize;
let bytes_before = text.as_ptr() as usize;
let (header_inside, bytes_inside) = receive(text);
println!(" header at a different address inside the callee? {}",
header_before != header_inside);
println!(" heap buffer relocated? {}",
bytes_before != bytes_inside);
println!("\n Predicted right if you said yes and no. The 24-byte header was");
println!(" copied into the callee's own slot; the text never moved, however");
println!(" long it is. That asymmetry is why Rust can afford to move by");
println!(" default -- a move is a fixed, small cost that does not grow with");
println!(" the data. And `text` is unusable here now: it went IN.");
println!("\nPart 2 — where the free happens.\n");
println!(" (a) passed in, not returned:");
consume(Marker("consumed"));
println!(" ...and the free already happened, inside consume()");
println!("\n (b) passed in and returned:");
let returned = pass_through(Marker("returned"));
println!(" ...and nothing was freed: the value moved out through");
println!(" the return slot, into `returned`, which now owns it");
println!("\n The frame is a place; the value is a thing. Ending a frame frees");
println!(" whatever is still standing in it, and a returned value is not.");
println!("\nPart 3 — four nested frames.\n");
let anchor = 0u8;
let main_frame = here(&anchor);
let mut frames = Vec::new();
d1(&mut frames);
let mut distinct = frames.clone();
distinct.sort_unstable();
distinct.dedup();
println!(" four distinct frames? {}", distinct.len() == 4);
println!(" each below the frame that called it? {}",
frames.windows(2).all(|w| w[1] < w[0]));
println!(" the whole chain under 4 KiB from main? {}",
main_frame - frames[3] < 4096);
println!("\n Which of those three is a claim about RUST?");
println!(" None of them. All three are claims about this build.");
println!(" Compile the same functions without #[inline(never)] and at -O");
println!(" and the descending answer flips to false -- the four calls were");
println!(" folded into one frame, so there is no `d3` frame to be below");
println!(" `d2`. What survives optimization is the part the borrow checker");
println!(" actually enforces: `d3`'s local cannot outlive `d3`, whether or");
println!(" not `d3` still exists as a frame.");
println!("\n `returned` is still alive until here:");
drop(returned);
}
Verified output of the_call_stack_kata.rs — regenerated by tools/run_examples.py, never hand-typed.
Part 1 — a String passed by value.
header at a different address inside the callee? true
heap buffer relocated? false
Predicted right if you said yes and no. The 24-byte header was
copied into the callee's own slot; the text never moved, however
long it is. That asymmetry is why Rust can afford to move by
default -- a move is a fixed, small cost that does not grow with
the data. And `text` is unusable here now: it went IN.
Part 2 — where the free happens.
(a) passed in, not returned:
consume() has it: consumed
[drop] consumed freed
...and the free already happened, inside consume()
(b) passed in and returned:
pass_through() has it: returned
...and nothing was freed: the value moved out through
the return slot, into `returned`, which now owns it
The frame is a place; the value is a thing. Ending a frame frees
whatever is still standing in it, and a returned value is not.
Part 3 — four nested frames.
four distinct frames? true
each below the frame that called it? true
the whole chain under 4 KiB from main? true
Which of those three is a claim about RUST?
None of them. All three are claims about this build.
Compile the same functions without #[inline(never)] and at -O
and the descending answer flips to false -- the four calls were
folded into one frame, so there is no `d3` frame to be below
`d2`. What survives optimization is the part the borrow checker
actually enforces: `d3`'s local cannot outlive `d3`, whether or
not `d3` still exists as a frame.
`returned` is still alive until here:
[drop] returned freed
See also¶
- Stack and heap — which region a value lives in, decided by its type rather than by a keyword
- Recursion and the stack — the same frames, stacked until they run out
- A stack slot is reused — what happens to a released region, and why a pointer into one is a bug
- Ownership and moves — what "the value moved in" transfers, which is responsibility
- What an address shows — why
&xchanges on a move without a byte relocating - Scope is about names, not values — the three different questions "goes out of scope" is asked
Po polsku¶
Wywołanie funkcji rezerwuje ramkę stosu (stack frame) — obszar na parametry i zmienne lokalne tej konkretnej instancji wywołania. Argumenty są do niej przenoszone (nie kopiowane, jak w C), ciało funkcji z niej korzysta, a przy powrocie cały obszar znika naraz: wskaźnik stosu wraca na swoje miejsce i to jest całe zwalnianie pamięci.
Trzy rzeczy, które warto zapamiętać. Po pierwsze, parametr jest zmienną lokalną — fn report(x: Counter) nie daje podglądu na zmienną wywołującego, tylko przejmuje wartość; po wywołaniu nazwa a w funkcji wywołującej jest martwa. Po drugie, wartość może opuścić ramkę przez return — wtedy nie jest zwalniana, tylko przenoszona do miejsca przygotowanego przez wywołującego. Po trzecie, ramka to miejsce, a nie wartość: wartość żyje dopóki ktoś jest za nią odpowiedzialny, a ramka to tylko obszar, w którym akurat stała.
Uwaga na przypis, który wszyscy pomijają: to, że coś „leży na stosie”, jest cechą jednej kompilacji, a nie języka. Mała wartość może w całości zmieścić się w rejestrze i nigdy nie dotknąć pamięci, a optymalizator potrafi wkompilować cztery wywołania w jedną ramkę (-O w przykładzie wyżej). Model myślowy zostaje ten sam — czas życia wartości jest własnością programu, a miejsce jej przechowywania własnością buildu.
Szukaj po polsku: ramka stosu · stos wywołań · przekazywanie przez wartość i referencję · zmienne lokalne w Ruscie · rust call stack · stack frame