Worked examples — Return address stack
This is a worked-examples deep dive for the Return Address Stack (RAS). The parent note built the machine: a tiny hardware stack that pushes a return address on every CALL and pops it on every RET. Here we drive that machine through every situation it can meet and watch what each control field does, one cycle at a time.
Before any example, let us re-earn the three numbers the RAS carries, because every scenario below is really a story about how these three change.
The scenario matrix
Every situation the RAS can face is one of these cells. The examples below are tagged with the cell they cover, and together they hit all of them.
| # | Cell (case class) | What makes it distinct | Covered by |
|---|---|---|---|
| C1 | Balanced call/return, no wrap | count never reaches ; textbook LIFO |
Ex 1 |
| C2 | Interleaved deep + shallow nesting | Multiple pushes then partial pops then more pushes | Ex 2 |
| C3 | Overflow (push when count = N) |
Oldest live entry evicted; count saturates | Ex 3 |
| C4 | Underflow (pop when count = 0) |
Degenerate: must return no-prediction | Ex 4 |
| C5 | Wrap-around of TOS pointer | TOS crosses the seam while count stays healthy | Ex 5 |
| C6 | Speculation mis-predict → restore | Snapshot of (TOS, count) rolled back on flush | Ex 6 |
| C7 | Wrong tool (non-return indirect branch) | RAS should not fire; degenerate misuse | Ex 7 |
| C8 | Real-world word problem | Turn a C call chain into RAS depth demand | Ex 8 |
| C9 | Exam twist: accuracy from depth | Compute expected hit-rate for a workload | Ex 9 |
Example 1 — Balanced chain, no wrap (cell C1)
Forecast: three pops in reverse order → 0x3004, then 0x2004, then 0x1004.
Figure 1 below is a three-panel filmstrip of the same ring. Left panel: after all three pushes, holes 0,1,2 are green (live) holding 0x1004,0x2004,0x3004, and the yellow TOS arrow sits at hole 3 (the next free hole), count=3. Middle panel: after the first pop, hole 2 has gone dim (dead) and the arrow has stepped back to it — the popped value 0x3004 came from there, count=2. Right panel: all holes dim, arrow back at hole 0, count=0 — the ring is empty. Watch the yellow arrow climb on pushes and descend on pops; watch green fade to dim as count falls.

- Push
0x1004(return point aftermain's call). Why this step? EveryCALLrecords where control must come back to, which is . Nowslot[0]=0x1004,TOS=1,count=1. - Push
0x2004. Why? Nested call — the ring must rememberfoo's continuation too.slot[1]=0x2004,TOS=2,count=2. - Push
0x3004. Why? Deepest call.slot[2]=0x3004,TOS=3,count=3. (This is the left panel of Figure 1.) bazRET → pop. Why? LIFO: the most recent pusher (barcallingbaz) is the first to be answered.TOS: 3→2, readslot[2]=0x3004,count=2. ✔ predicted0x3004. (Middle panel.)barRET → pop. Why? Next-most-recent open call answered next.TOS: 2→1, readslot[1]=0x2004,count=1. ✔0x2004.fooRET → pop. Why? Oldest remaining open call answered last.TOS: 1→0, readslot[0]=0x1004,count=0. ✔0x1004. Ring empty again (right panel).
Verify: each popped value equals the return point printed in the setup; count returned exactly to (as many pops as pushes). All three predictions correct → zero pipeline flushes for these returns.
Example 2 — Interleaved nesting (cell C2)
Forecast: r2, then r3, then r1.
- push
r1→slot[0]=r1,TOS=1,count=1. Why? First call. - push
r2→slot[1]=r2,TOS=2,count=2. Why? Nested call while inside first. - POP →
TOS:2→1, readslot[1]=r2,count=1. ✔r2. Why? Inner function returned first. - push
r3→slot[1]=r3(reuses the freed hole!),TOS=2,count=2. Why this step? The hole vacated byr2is now free real estate — a new sibling call reuses it.r1atslot[0]is untouched. - POP →
TOS:2→1, readslot[1]=r3,count=1. ✔r3. Why? The most recent live push wasr3(the sibling call), so LIFO answers it first — even though it physically occupies the same holer2used earlier. - POP →
TOS:1→0, readslot[0]=r1,count=0. ✔r1. Empty. Why? Onlyr1(the original outer call) is still open, so it pops last and the ring drains tocount=0.
Verify: interleaving never disturbed r1 at the bottom — it waited patiently and popped last, exactly as LIFO promises. Reused slot proves the ring recycles space without corruption when count is honored.
Example 3 — Overflow (cell C3)
Forecast: the last return (to A) mis-predicts, because got evicted.
Figure 2 below is a three-panel story of the eviction. Left panel: after four pushes the ring is completely green (count = N = 4) holding rA,rB,rC,rD, and the yellow TOS arrow has wrapped all the way around back to hole 0. Middle panel: the fifth push writes rE into hole 0 on top of rA (shown in red to flag the overwrite); count stays pinned at 4. Right panel: after everything is popped, all holes are dim and count=0, and a red marker shows A's return finding nothing live — the mispredict. The teaching point: red hole 0 = the sacrificed oldest entry.

- push → holes fill; after the 4th push
TOS: 3→0(wraps via ),count=4= . Why? Four pushes exactly fill the ring; TOS lands back at hole 0. (Left panel of Figure 2.) - push →
slot[0] \leftarrow r_Eoverwriting , thenTOS: 0→1,count = min(4+1,4)=4. Why this step? Ring is full. Themin(count+1,N)rule caps count while TOS still advances, so the oldest live entry (hole 0 = ) is silently sacrificed. This eviction is intended on overflow. (Middle panel.) - pop (E ret) →
TOS: 1→0(via ), readslot[0]=r_E,count=3. ✔ . Why the arrow moves back to 0? Pop always steps TOS one hole backward to reach the most-recently-written live slot. - pop (D ret) →
TOS: 0→3(wraps via ), readslot[3]=r_D,count=2. ✔ . Why? The seam crossing is normal ring arithmetic;count=3>0proved the slot live. - pop (C ret) →
TOS: 3→2, readslot[2]=r_C,count=1. ✔ . Why? Continue stepping back down the live entries. - pop (B ret) →
TOS: 2→1, readslot[1]=r_B,count=0. ✔ . Why? Last live entry consumed; count hits 0. - pop (A ret) →
countis already , so TOS is not moved. Why this matters? The referee blocks it: no live entry exists for A. Prediction = no-prediction / mis-predict → one fetch redirect penalty. (Right panel of Figure 2.)
Verify: exactly one mis-prediction, and it is A's return (the evicted one). Four correct, one wrong. Push count 5 > ⇒ overflow guaranteed one loss.
Example 4 — Underflow (degenerate) (cell C4)
Forecast: "no prediction" — not garbage.
- Fetch
RET, attempt pop. Why check first? Themax(count-1, 0)guard and thecount==0test exist for precisely this moment. - Read count.
count = 0. Why? Nothing was ever pushed. - Refuse the pop. TOS is not decremented;
countstays clamped at by . Output = no valid target. Why? A bare would step to hole and hand back whatever ancient bits live there — a spurious prediction pointing at stale data.
Verify: with the guard, the front end falls back to the BTB or stalls rather than fetching from a random address. Without the guard: (0-1) mod 8 = 7, so it would read slot[7] — pure garbage. The guard converts a wrong prediction into an honest "I don't know."
Example 5 — Wrap-around of TOS with healthy count (cell C5)
Forecast: w, z, y, x.
- push →
slot[0]=z,TOS: 0→1,count=3. Why? The next-write hole is 0 (where the arrow points), so the new return address lands there and the arrow advances. - push →
slot[1]=w,TOS: 1→2,count=4. Why this step? Hole 1 was also empty, sowfills the last free hole;countreaches without any overwrite — every push landed in a genuinely empty hole, so this is a full-but-not-overflowing ring. - pop →
TOS: 2→1, readslot[1]=w,count=3. ✔w. Why?wwas the most recently pushed live entry, so LIFO answers it first; the arrow steps back to hole 1 to read it. - pop →
TOS: 1→0, readslot[0]=z,count=2. ✔z. Why? Next-most-recent live push waszin hole 0; the arrow steps back one more hole. - pop →
TOS: 0→3(wrap across the seam via ), readslot[3]=y,count=1. ✔y. Why this step? The two newest entries are gone, so the next live one isyin hole 3 — reached by rolling the arrow from hole 0 back to hole 3 with no special case. - pop →
TOS: 3→2, readslot[2]=x,count=0. ✔x. Why? Onlyx(the oldest live entry) remains; it pops last and drains the ring tocount=0.
Verify: the seam crossing (0 → 3) is transparent because count (2 at that moment) proved the entry live. Wrap ≠ overflow: here count never exceeded while overwriting, so no data lost. All four correct.
Example 6 — Speculative mis-predict, then restore (cell C6)
Forecast: back to exactly TOS=2, count=2, holes 0,1 = — as if the speculation never happened.
- Snapshot
(TOS=2, count=2)at the speculative branch. Why? We may have to undo everything downstream if the branch was wrong. - Speculative push (into ) →
slot[2]=s,TOS: 2→3,count=3. Why traced? Speculation modifies the speculative RAS eagerly for prediction speed. - Speculative pop →
TOS: 3→2, readslot[2]=s,count=2. Why this step? The speculative path contained aRET, and while we are guessing we must feed the front end some target — so the eager RAS dutifully pops its most recent entry () and predicts it. This pop is only as trustworthy as the guess that led here; if the guess was wrong, this popped target was wrong too. - Flush — restore both fields from the snapshot:
TOS ← 2,count ← 2. Formally . Why restore BOTH? Restoring only TOS would leavecountdesynced; restoring only count would leave the arrow pointing at the wrong hole. The pair (TOS, count) is the complete stack identity.
Verify: post-flush (TOS, count) = (2, 2), identical to the snapshot. The stray s in slot[2] is now above the count line, so it is dead — the guard will never hand it out. Speculation left no trace. This is why RAS checkpointing stores the pointer+counter pair, not the slot contents.
Example 7 — Wrong tool: a non-return indirect branch (cell C7)
Forecast: double disaster — the switch is mis-predicted and the RAS is corrupted for the real return later.
- Switch executes, wrongly pops.
TOS: 1→0, readslot[0]=r,count: 1→0, predicted target = . Why is this wrong? is a return address; the switch needsL0orL1chosen by the data valuex, which has nothing to do with call/return nesting. The RAS answers the wrong question here. - True target computed →
L1. Why a flush now? The predicted , so the front end fetched down the wrong path and must squash it — one pipeline flush penalty. Why did the mistake even reach here? Because the RAS was consulted for a branch whose target is data-dependent, not stack-structured. - Later the real
RETarrives expecting — but step 1 already popped it, so nowcount=0. Why a second penalty? The pop consumed the legitimate return address early, so the realREThits the underflow case (cell C4): no live entry → no-prediction/mispredict. One misuse thus caused two mispredicts.
The fix: route switch/function-pointer/virtual-call targets to the indirect branch predictor or BTB; reserve the RAS for architectural CALL/RET only. Why? Those predictors key on branch history and data patterns — the right question for a data-dependent jump — whereas the RAS keys purely on LIFO nesting.
Verify: two mispredicts from one misuse — proof that RAS is a specialized predictor, not a general one. It answers exactly one question ("where does this return go?"), and using it elsewhere breaks both the misused branch and a future legitimate return.
Example 8 — Real-world word problem (cell C8)
Forecast: count the arrows.
- List every call still open at the bottom of the chain. Each
→is oneCALLwhoseREThasn't fired yet. Why? Max livecount= maximum nesting depth reached, because every unreturned call keeps one live entry on the ring. - Count the arrows:
main→server_loop→handle_request→parse_http→route→controller→db_query→malloc→memcpyhas 9 named functions and therefore 8 arrows = 8 open calls. So peakcount = 8. Why ? functions in a linear chain are joined by call transitions. - Compare to . Peak demand 8 exactly equals depth 8 → the ring is full but not overflowing (
count = N, no eviction);memcpy's return still fits. Why the razor's edge matters? Any one more nested call (e.g.memcpycalls a helper) would push atcount = N, evictingmain's return → a mispredict on the outermost return.
Verify: 8 transitions between 9 named functions (). ⇒ exactly sufficient, zero margin. A safer design picks (the parent's recommendation) to absorb library depth without eviction — matching the "16 entries ≈ 97%" data point.
Example 9 — Exam twist: expected accuracy from depth (cell C9)
- For : correct = returns needing ≤8 = . Why sum these two bands? Both the ≤4 and 5–8 bands fit within 8 entries, so their returns are never evicted and always predict correctly.
- Wrong for : the 9–16 band (8%) and >16 band (2%) exceed the ring → mispredict. Hit-rate . Why do these miss? Their required depth is larger than , so the outermost entries get evicted before their returns arrive.
- For : correct = ≤16 bands = ; only the >16 band (2%) misses. Hit-rate . Why the jump? Enlarging the ring to 16 rescues the entire 9–16 band that used to overflow.
Verify: and . These land on the parent's quoted curve (8 entries ≈ 92%, 16 entries ≈ 97%) — close, since our clean model omits speculative and mismatched-call effects that shave a couple of points off in real silicon.
Recall Quick self-test
A RET is popped while count = 0. What must a correct RAS output? ::: "No prediction" — the count guard refuses the pop and TOS is not decremented, avoiding a garbage target.
On overflow (push at count = N), which entry is lost and which is written? ::: The oldest live entry (at the current TOS slot) is overwritten by the new return address; count saturates at .
After a speculative branch flush, which two fields are restored from the snapshot? ::: Both TOS and count — together they define the stack; restoring one alone desyncs the ring.
Why can a switch statement not use the RAS? ::: Its targets are data-dependent, not LIFO-nested; popping corrupts a real return address and mispredicts the switch itself.
See also: Branch Prediction Basics · Branch Target Buffer · Indirect Branch Prediction · Speculative Execution · Pipeline Hazards · Call Stack · Instruction Fetch