Skip to content

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:

  • x can be mutated if you write fn bump(mut x: Counter). The mut is on the local, not on the caller's variable, and the caller sees nothing.
  • x can 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:

Measured by hand — optimized/inlined_away.rs, rustc 1.98.0, x86_64
$ 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.

  1. A function takes a String by 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.
  2. Give a type a Drop that 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.
  3. Nest four calls and collect one address from each frame. Predict the ordering, then check it — and then compile the same file with -O and 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

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