A stack slot is reused¶
Level: 201 · working knowledge
One line: A released frame is not cleared, it is reissued — the next call gets the same bytes for its own locals — so an address that held a valid Point a moment ago is now somebody else's, which is the concrete reason a reference may not outlive what it borrows.
fn plot(x: u32, y: u32) -> u32 {
let p = Point { x, y }; // lives at some address
p.x * 1000 + p.y
} // ...which is free again here
let first = plot(7, 42);
let second = plot(9, 13); // same address, a different Point
Both calls put their local in the same place. Nothing moved it aside and nothing wiped it: plot returned, the stack pointer went back, and the second call was handed the region the first one had been using.
The verified output¶
Verified output of a_stack_slot_is_reused.rs — regenerated by tools/run_examples.py, never hand-typed.
1. The same call, twice: the same address
call 1 produced 7042, call 2 produced 9013
both locals lived at the same address: true
the first Point was not moved aside. Its region was reissued.
2. A different type, from the same place: the same region
sum_of returned 60, from a u64 rather than a Point
within 256 bytes of where the Points were: true
the bytes that spelled a Point now spell something else entirely
3. Depth is reused too, not just the top
a 21-frame call bottomed out well below the shallow ones: true
and the next shallow call still lands where the others did: true
so the depth a program reached is invisible afterwards --
the stack pointer went back, and nothing was erased on the way
4. Four ways a location stops holding a valid value
(a) the frame was released:
plot() returned, and its Point's slot became free real estate
(b) the value was moved out:
`owned` was moved into `moved`; the old slot holds bytes,
and the compiler will not let you read them
(c) the value was dropped at the end of its scope:
alive inside the block...
[drop] block-scoped is no longer a valid value
(d) the slot was reused by a later call -- parts 1 to 3 above.
5. Why the compiler cares
All four leave an ADDRESS that is still perfectly readable and no
longer means what it meant. C will hand you that pointer. Rust
refuses to build one: a reference may not outlive the thing it
borrows, which is what a lifetime is for.
moved-away was the value that survived all of this:
[drop] moved-away is no longer a valid value
Three observations, in the order the program makes them.
The same function called twice from the same place gets the same address. Not an equivalent address — the same one. It follows from how frames are allocated: the stack pointer was restored to exactly where it had been, so the second call started from exactly the same offset. The example wears #[inline(never)] to keep that observable — inline the calls and there are no two frames to compare — but the mechanism is what matters, and it is the one the released region is subject to either way.
A different function gets the same region. sum_of keeps a u64 where plot kept a Point, a few bytes away in the same few hundred. The bytes that spelled a Point now spell something else, and nothing about the memory records that it changed meaning.
Depth is reused too. A 21-frame call reaches far below the shallow ones and, once it returns, leaves no trace at all — the next shallow call lands exactly where its predecessors did. The deepest point a program reached is invisible afterwards, because nothing was erased on the way back up.
Four ways a location stops holding a valid value¶
The screenshot-friendly version of this page is one sentence: a memory location that previously held a valid value of a certain type may have stopped doing so. There are four routes, and it is worth being able to name which one you are looking at.
| What happened | What is left at that address | |
|---|---|---|
| Frame released | the function returned | bytes that are now unowned, and about to be reissued |
| Value moved out | let b = a; on a non-Copy type |
the old bytes, which the compiler now refuses to read through a |
| Value dropped | the owner's scope ended | whatever Drop left behind — a freed heap pointer among it |
| Slot reused | a later call was given the region | a different value, of a different type, perfectly valid for someone else |
The first and last are two halves of the same event, separated in time. The middle two are moves and drops, which the rest of this section covers.
What all four share is the thing that makes them dangerous: the address stays perfectly readable. No hardware fault, no flag, nothing that distinguishes "this is a Point" from "these bytes were a Point until three instructions ago". A pointer holds an address, and an address alone cannot tell you which of those it is.
What C does with that¶
Reading the old bytes is not something safe Rust can express, which is why the example above stops at comparing addresses. C will do it, so the demonstration lives there:
struct Point *plot(unsigned x, unsigned y) {
struct Point p = { x, y };
return &p; /* the frame ends on the next line */
}
before the region is reissued: x=7 y=42
after: x=3116579248 y=32759
The pointer read correctly the first time. That is the worst possible outcome: the bug was invisible until an unrelated call — reuse(), which does nothing but fill some locals — was inserted between the two reads.
Clang does warn (-Wreturn-stack-address) and still produces a binary. The warning is a syntactic check on the return &p it can see: put that line behind a helper that just hands the pointer back and it goes quiet — measured on clang, which then prints 7 with no diagnostic at all. That is the general shape of the problem — whether a pointer outlives its frame is not a local property of one line, which is why it takes a checker that reasons about the whole function rather than a lint.
What Rust does instead¶
Two refusals, and the difference between them is worth reading carefully. Neither can live in examples/ — that folder is compiled and run by the answer-key tool, and these must fail — so both sit in refusals/ under the names their transcripts print.
error[E0106]: missing lifetime specifier
--> dead_frame.rs:14:20
|
14 | fn plot(x: u32) -> &Point {
| ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
The signature is rejected before the body is even considered: a returned reference must borrow from something the caller already has, and this function has no argument to borrow from. Supply one and the signature becomes writable — at which point the body is checked, and the same bug is caught a second time, by name:
error[E0515]: cannot return reference to local variable `p`
--> dead_frame_named.rs:13:5
|
13 | &p
| ^^ returns a reference to data owned by the current function
"Data owned by the current function" is this page in five words. p lives in the frame, the frame ends when the function returns, and no annotation can make the region survive — which is the thing <'a> does not do: it names a relationship between lifetimes that already exist, it does not extend one.
The fix is never a cleverer lifetime. It is to return the value instead of a reference to it — -> Point, moving it into a slot the caller provided — or to borrow from an argument that outlives the call.
The rule this is the reason for¶
A reference may not outlive the value it borrows.
Stated abstractly, that sounds like a restriction. Stated as this page's mechanism, it is the only rule that could work: the frame is going to be reissued, on the very next call, and there is nothing the borrow checker could add at run time to notice. It has to be settled before the program runs, because afterwards there is no evidence left — the bytes look fine.
That is also why the "clone everything" advice works while you are learning. A clone is an independent value with its own owner, so no question about whose frame it was in can arise.
If you are coming from another language¶
Python. This class of bug does not exist for you, and it is worth knowing what pays for that. Every object is on the heap and reference-counted, so a local that "escapes" its function — returned, captured by a closure, stashed in a list — simply keeps the object alive; the frame going away decrements a count and nothing more. The price is the count itself on every assignment, a collector for the cycles counting cannot free, and no way to say this value lives on the stack and costs nothing. Rust moves the decision to compile time and hands you the counting version explicitly when you want it: Rc is Python's model, opted into per value. The nearest Python analogue to reading a reused slot is holding a memoryview of a buffer that was resized underneath you — BufferError exists precisely because CPython has to forbid at run time what Rust forbids at compile time.
ABAP. The direct counterpart is a field-symbol or data reference that outlives what it points at, and ABAP finds out at run time or not at all. ASSIGN a field-symbol to a local of a FORM, let the FORM return, and <fs> still looks assigned — IS ASSIGNED reports true, because the check is "does this symbol have a target", not "is that target still alive". Touch it and you get GETWA_NOT_ASSIGNED or, worse, a plausible-looking value belonging to whatever ran next, which is exactly the C transcript above with an SAP dump on the end. The same shape appears with LOOP AT ... ASSIGNING <fs> followed by a REFRESH or an INSERT that reallocates the table's memory: the symbol points into the old block. Rust rejects both before the program runs, and the error names the frame — "data owned by the current function" — rather than reporting a symptom in an unrelated routine ten minutes later.
C. The mechanism is the one you already know, described above. What is worth noticing is which tool catches it: not the compiler, which sees one line at a time, but ASan (-fsanitize=address, and specifically its detect_stack_use_after_return mode, off by default) — a run-time instrument that finds the bug only if your test suite happens to exercise the path. Rust's version is a compile-time proof over every path, which is the whole trade the borrow checker asks you to make.
C++. Same as C, and the modern idioms make it easier to hit rather than harder. A std::string_view or a std::span returned from a function is a &str without the check, and a lambda capturing by reference that outlives its scope is a closure Rust would reject. -Wdangling-reference and the clang lifetimebound attribute chase a few cases; neither is a proof. The Rust equivalent of "return a view into my local" is a compile error with a fix suggested in the message.
Practice¶
Prove the reuse, then get the compiler to stop you exploiting it. Comparisons only — never print an address.
- Write a function that makes a local, records its address as a
usize, and returns it. Call it twice and compare the two numbers. Predict the answer first, then explain why it is not a coincidence. - Call a different function, with a differently-typed local, from the same place. Predict whether its local is inside the region the first one used, and check it with a distance rather than an equality.
- Now try to keep a reference instead of an address:
-> &Pointon a function that makes a local one. You will getE0106; add a&u32argument so a lifetime is available, and you will getE0515. Write down what the second message tells you that the first does not. - Then fix it twice — once by returning the value, once by borrowing from an argument — and say which of the two you would actually write.
Solution
a_stack_slot_is_reused_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.
//! Kata solution: prove the reuse, then let the compiler stop you using it.
//!
//! The two refusals cannot be in this file -- an example that does not compile
//! has no answer key and would fail the gate. They are written as comments
//! beside the fixes, and their transcripts are on the lesson page, taken from
//! `dead_frame.rs` and `dead_frame_named.rs` compiled by hand.
//!
//! rustc --edition 2024 a_stack_slot_is_reused_kata.rs -o /tmp/assirk && /tmp/assirk
#[derive(Debug)]
struct Point {
x: u32,
y: u32,
}
/// Part 1. Records where its own local sat, and hands back the address as a
/// plain number -- which is legal, and useless, and exactly the point.
#[inline(never)]
fn address_of_local(x: u32) -> usize {
let p = Point { x, y: 0 };
&p as *const Point as usize
}
/// Part 2. A different local, a different type, called from the same place.
#[inline(never)]
fn address_of_other_local() -> usize {
let running_total: u64 = 0;
&running_total as *const u64 as usize
}
// Part 3, the two refusals. Neither can be written here, so both are quoted.
//
// fn make() -> &Point { let p = Point { .. }; &p }
// error[E0106]: missing lifetime specifier
// ...the SIGNATURE is wrong: a returned reference has to borrow from
// something, and this function has nothing to borrow from.
//
// fn make<'a>(seed: &'a u32) -> &'a Point { let p = Point { .. }; &p }
// error[E0515]: cannot return reference to local variable `p`
// ...the signature is now writable, so the BODY gets checked, and the
// real bug is named: "returns a reference to data owned by the
// current function". The first error asks where the lifetime comes
// from; the second says this local is not it.
/// Part 4, fix one: return the VALUE. It moves into a slot the caller owns.
#[inline(never)]
fn built(x: u32) -> Point {
Point { x, y: 5 }
}
/// Part 4, fix two: borrow from an argument, which outlives the call.
/// Elision fills the lifetime in, so no `'a` has to be written.
#[inline(never)]
fn highest(points: &[Point]) -> &Point {
points.iter().max_by_key(|p| p.y).expect("non-empty")
}
fn main() {
println!("Part 1 — the same call twice.\n");
let first = address_of_local(7);
let second = address_of_local(9);
println!(" two calls, one address: {}", first == second);
println!("\n Not a coincidence of this build. `address_of_local` returned, the");
println!(" stack pointer was restored to where it had been, and the second");
println!(" call started from the same offset. Same frame layout, same slot.");
println!("\nPart 2 — a different function, from the same place.\n");
let other = address_of_other_local();
println!(" a u64 local, within 256 bytes of the Points: {}",
other.abs_diff(first) < 256);
println!("\n Checked as a distance, not an equality: a different function has a");
println!(" different frame layout, so its local sits at a different offset");
println!(" inside the SAME reissued region. Equality would be luck; overlap");
println!(" is the claim worth making.");
println!("\nPart 3 — what the two errors each tell you.\n");
println!(" E0106 is about the SIGNATURE: no lifetime is available.");
println!(" E0515 is about the BODY: the lifetime you supplied is not this");
println!(" local's. Fixing the first only gets you as far as the second,");
println!(" which is the useful part -- naming a lifetime never lengthens");
println!(" one, so there was never a spelling that made the local survive.");
println!("\nPart 4 — the two fixes, both compiling above.\n");
let owned = built(7);
println!(" returned by value: {owned:?}");
println!(" ...moved into a slot main provided, so no frame outlived it");
let points = vec![
Point { x: 1, y: 3 },
Point { x: 2, y: 9 },
Point { x: 3, y: 4 },
];
let top = highest(&points);
println!(" borrowed from an argument: {top:?}");
println!(" ...the highest y is {} at x = {}", top.y, top.x);
println!(" ...the reference borrows `points`, which is main's, so it is");
println!(" alive for as long as the caller keeps it alive");
println!("\n Which would I write? Return the value. Borrowing from an argument");
println!(" is right when the caller already owns the data and you are picking");
println!(" something OUT of it -- `highest` is that shape and a lifetime would");
println!(" be wrong there. When the function CREATED the value, the caller has");
println!(" nothing for it to borrow from, and a reference is the wrong return");
println!(" type rather than an annotation problem.");
}
Verified output of a_stack_slot_is_reused_kata.rs — regenerated by tools/run_examples.py, never hand-typed.
Part 1 — the same call twice.
two calls, one address: true
Not a coincidence of this build. `address_of_local` returned, the
stack pointer was restored to where it had been, and the second
call started from the same offset. Same frame layout, same slot.
Part 2 — a different function, from the same place.
a u64 local, within 256 bytes of the Points: true
Checked as a distance, not an equality: a different function has a
different frame layout, so its local sits at a different offset
inside the SAME reissued region. Equality would be luck; overlap
is the claim worth making.
Part 3 — what the two errors each tell you.
E0106 is about the SIGNATURE: no lifetime is available.
E0515 is about the BODY: the lifetime you supplied is not this
local's. Fixing the first only gets you as far as the second,
which is the useful part -- naming a lifetime never lengthens
one, so there was never a spelling that made the local survive.
Part 4 — the two fixes, both compiling above.
returned by value: Point { x: 7, y: 5 }
...moved into a slot main provided, so no frame outlived it
borrowed from an argument: Point { x: 2, y: 9 }
...the highest y is 9 at x = 2
...the reference borrows `points`, which is main's, so it is
alive for as long as the caller keeps it alive
Which would I write? Return the value. Borrowing from an argument
is right when the caller already owns the data and you are picking
something OUT of it -- `highest` is that shape and a lifetime would
be wrong there. When the function CREATED the value, the caller has
nothing for it to borrow from, and a reference is the wrong return
type rather than an annotation problem.
See also¶
- The call stack — the region that is being reissued, and what a call does to reserve it
- Recursion and the stack — what happens when the reissuing never gets a chance to happen
- Borrowing — the rule this page is the mechanism behind, and where a borrow ends
- Lifetime annotations — why
<'a>cannot rescue any of this - How to learn lifetimes — the scaffold that sidesteps the whole question while it lands
- A shadow does not drop — the neighbouring case, with its own C comparison for the heap version of the same bug
Po polsku¶
Zwolniona ramka nie jest czyszczona — jest wydawana ponownie. Wskaźnik stosu wraca na swoje miejsce, a następne wywołanie dostaje dokładnie ten sam obszar na własne zmienne lokalne. Dwa wywołania tej samej funkcji z tego samego miejsca umieszczą swoją zmienną lokalną pod tym samym adresem, a wywołanie innej funkcji położy tam wartość innego typu.
Cztery drogi do tego, że pod danym adresem nie ma już poprawnej wartości: ramka została zwolniona, wartość została przeniesiona (move), wartość została wypuszczona (drop) na końcu zasięgu, albo obszar został ponownie wykorzystany przez późniejsze wywołanie. Wspólny mianownik jest groźny: adres nadal daje się odczytać. Nic w pamięci nie zapisuje, że bajty zmieniły znaczenie.
W C można taki wskaźnik zwrócić i odczytać — kompilator ostrzega, ale buduje program, a ostrzeżenie znika, gdy ukryjemy return &p za pomocniczą funkcją. Rust odmawia dwa razy: E0106 (brak czasu życia, od czego miałaby pożyczać zwracana referencja?) i E0515 — cannot return reference to local variable — czyli „referencja do danych będących własnością tej funkcji”.
Stąd bierze się cała reguła: referencja nie może przeżyć wartości, którą pożycza. To musi być rozstrzygnięte przed uruchomieniem programu, bo potem nie ma już żadnego śladu — bajty wyglądają poprawnie.
Szukaj po polsku: wiszący wskaźnik · użycie po zwolnieniu · ponowne użycie pamięci stosu · czas życia referencji · rust E0515 · dangling pointer