5.3.12 · D2Advanced Microarchitecture

Visual walkthrough — Return address stack

2,080 words9 min readBack to topic

This page rebuilds the central result of the RAS topic from nothing — no jargon assumed. We want to answer one question with pictures:

When a program returns from a function, how does the processor guess where it is going before it actually knows?

By the end you will see, step by step, why the answer is a tiny hardware stack, and exactly when it fails. If you have never seen the word "stack" or "return", start at Step 1 — everything is built for you.


Step 1 — What is a "call" and a "return"?

WHAT. A program is a list of instructions, each living at a numbered address (like house numbers on a street). A call is an instruction that says: "jump over there and run that block of instructions, but come back here when you're done." A return is the instruction at the end of that block that says: "okay, go back."

WHY this matters. The processor reads instructions far in advance to stay fast (see Instruction Fetch). When it meets a return, it must immediately know the address to jump back to — but that address depends on who called this function. Different callers ⇒ different return addresses for the same return instruction.

PICTURE. Below, main lives at low addresses, foo at higher ones. The red arrow is the call jumping forward; the black dashed arrow is the return jumping back. Notice the return lands on the instruction right after the call.

Figure — Return address stack

Step 2 — Nesting: calls stack up inside each other

WHAT. Functions call functions. main calls foo; before foo finishes, foo calls bar. Now two returns are "waiting to happen."

WHY. The order is forced by the program's meaning: bar must finish and return before foo can finish and return. You can never return out of foo while you are still inside bar. This is the whole secret we will exploit.

PICTURE. Read the nesting like measuring cups sitting inside one another. The most-recently-opened function (innermost, red) is the first one that must close.

Figure — Return address stack

Step 3 — Turn the nesting into a Last-In-First-Out list

WHAT. We now write down the "waiting" return addresses in a vertical list. Every time we call, we add () a return address on top. Every time we return, we take () the address off the top.

WHY this exact rule. From Step 2, the return we need next is always the one belonging to the most recent unfinished call. "Most recent in, first one out" has a name: LIFOLast In, First Out. A list that only adds and removes from the top is called a stack.

PICTURE. Watch the top of the stack (red slot). A push grows it upward; a pop shrinks it. The red slot is always exactly the address the next return wants.

Figure — Return address stack

Step 4 — Trace a full call/return chain

WHAT. Let's run a real sequence and watch the stack breathe. Addresses: main calls foo (return 0x1004), foo calls bar (return 0x2004), then two returns.

WHY. To see that both returns are predicted correctly — the stack top matches the true destination every time.

PICTURE. Six snapshots left→right. The red slot is the current top. Follow it: pushed 1004, pushed 2004, popped 2004 (correct!), popped 1004 (correct!), empty.

Figure — Return address stack
Cycle Event RAS action Stack (bottom→top) Top (red)
1 CALL foo push 0x1004 [0x1004] 0x1004
2 CALL bar push 0x2004 [0x1004, 0x2004] 0x2004
3 RET (bar) pop → 0x2004 [0x1004] 0x1004
4 RET (foo) pop → 0x1004 []

Both returns predicted with zero pipeline flushes — no wasted work in the pipeline.


Step 5 — The pointer arithmetic (how hardware really stores it)

WHAT. Real hardware doesn't grow a list infinitely; it uses a fixed ring of slots and a pointer called TOS (Top-Of-Stack) that walks around the ring. A separate count remembers how many slots are actually live.

WHY a ring + count? Silicon is finite, so we reuse slots in a circle. But a plain circle can't tell "empty" from "full" — both look like the pointer being somewhere. The count (occupancy) fixes that: it tells us whether a pop is legal and whether a push is evicting old data.

PICTURE. A circular buffer of slots. The red arrow is TOS. Pushing rotates it one way; popping rotates it back.

Figure — Return address stack

Step 6 — Degenerate case: pop on an empty stack (underflow)

WHAT. What if a return is predicted but the stack is empty (count = 0)? Maybe we started running inside a function whose call we never saw.

WHY it's dangerous. The bare (TOS-1) mod N still produces some slot — but that slot holds stale garbage from a previous program. Using it would be a confident wrong prediction: a guaranteed misprediction flush.

PICTURE. Left: empty ring, count = 0. A naive pop rotates TOS onto a slot with old junk (shown in red as a trap). Right: the guarded behaviour — if count = 0, the RAS answers "no prediction" and lets a fallback predictor handle it.

Figure — Return address stack

Step 7 — Degenerate case: push on a full stack (overflow)

WHAT. Now the opposite: the call chain is deeper than slots. With , a 5th nested call has nowhere new to go.

WHY overflow is tolerated, not fixed. We deliberately overwrite the oldest entry (the outermost caller). Why the oldest? Because it is the one whose return happens furthest in the future — we have the most time before we need it, and the innermost returns (needed soonest) stay intact and correct.

PICTURE. Five nested calls into a 4-slot ring. The red slot ret_A (oldest) gets stamped over by ret_E. Every return except the very last is still correct; only A's return mispredicts.

Figure — Return address stack
Event Stack (bottom→top) Note
A→B→C→D [ret_A, ret_B, ret_C, ret_D] full, count = 4
D→E [ret_B, ret_C, ret_D, ret_E] ret_A evicted, count stays 4
E,D,C,B return correct each time innermost survivors
A returns misprediction ret_A was lost

This is why depth matters: 4 entries ≈ 85% accuracy, 16 entries ≈ 97%, 32 entries ≈ 98.5%. See Branch Prediction Basics.


Step 8 — Degenerate case: speculation wrongly touched the stack

WHAT. The processor guesses branches before it knows they were right (Speculative Execution). It might push/pop the RAS along a path that later turns out wrong and gets thrown away.

WHY a checkpoint. If a speculative call A → call B → return popped B, but call A itself was mispredicted, that pop must be undone. The cure: snapshot the whole stack state (TOS and count) at each risky branch, and on a flush restore it.

PICTURE. Two side-by-side stacks: a committed one (only real, retired calls) and a speculative one. On a flush, an arrow copies the committed snapshot back over the speculative stack.

Figure — Return address stack

The one-picture summary

Everything above in a single frame: nesting forces reverse-order returns → LIFO → a ring of slots with a TOS pointer and a count → guarded against underflow, overflow, and speculation.

Figure — Return address stack
Recall Feynman retelling (say it out loud, no jargon)

Imagine stacking plates. Every time you call a function, you write "come back to here" on a plate and drop it on top of the pile. Because a function can only finish after everything it started has finished, the plate you need next is always the top one — so when a return happens, you grab the top plate and jump there. That's the whole trick: last plate down is the first plate up.

Real hardware uses a small round tray with a fixed number of spots and a finger (TOS) pointing at the top plate, plus a tally of how many plates are really on the tray. If someone tries to grab a plate when the tray is empty, the tally says "there's nothing here — I won't guess." If the pile gets taller than the tray, the oldest plate at the bottom gets crushed — we lose that one, but it's the plate we won't need for the longest time, so it's the safest to sacrifice. And because the processor sometimes plays out guesses that turn out wrong, it keeps a photograph of the real tray and slaps that photo back down whenever a guess is cancelled.

Recall

Why is a stack (LIFO) the right structure for returns? ::: Because program nesting forces returns to happen in the exact reverse order of calls, so the return needed next always belongs to the most recent unfinished call — the top of the stack. What does a pop return when count == 0, and why not just trust the ring pointer? ::: It returns "no prediction"; a bare ring pointer always points at some slot, which would be stale garbage from before — a guaranteed misprediction. On overflow, which entry is overwritten and why is that the safest choice? ::: The oldest (outermost caller's) entry; its return is furthest in the future, so we have the most time before its loss matters and all sooner returns stay correct. What two values must be restored together after a speculative flush? ::: The TOS pointer and the occupancy count (from the committed snapshot).