5.1.16 · D2C Programming

Visual walkthrough — Stack frames — how function calls work at the memory level

2,251 words10 min readBack to topic

Step 1 — Memory is just numbered boxes, and one number remembers "the top"

WHAT. Picture the computer's memory (RAM) as a tall column of boxes. Each box holds one small value and each has an address — a whole number that names it. In the figure the address is printed on the left of every box.

WHY. Before we can talk about "where a function keeps its stuff", we need a way to point at a box. That is all an address is: the box's house-number. We also need a special helper that always remembers where the currently-used region ends. That helper is a register (a tiny built-in storage slot inside the CPU) called the stack pointer, written .

PICTURE. Look at the orange arrow labelled . It is not a box of data — it is a finger pointing at the address of the topmost box we are currently using.

Figure — Stack frames — how function calls work at the memory level

Step 2 — "Down" is the direction of growth (this trips everyone up)

WHAT. On every mainstream CPU (x86, ARM), the stack starts high and grows toward smaller addresses. "Making the stack bigger" means subtracting from .

WHY not the other way? It is a historical convention so the stack (growing down) and other memory (growing up) can share one region and only collide if the program is truly out of memory. The key rule you must burn in:

  • push: we claim new boxes below the current top, so the address number goes down.
  • pop: we give boxes back, so the address number goes up.

PICTURE. The two arrows: a magenta arrow shows a push dragging downward (address 1000 → 992), a violet arrow shows a pop letting climb back up. Same finger, opposite directions.

Figure — Stack frames — how function calls work at the memory level

Step 3 — One call needs three things, so one frame has three parts

WHAT. When main calls a function f, three questions must be answered. Each answer becomes a slot in f's private region — its stack frame.

WHY these three? Because a call must survive being interrupted by itself calling something else, and must come back cleanly. The three questions:

Question the call must answer Slot that answers it
"Where do I jump back to when done?" return address
"Where do I put MY locals without wrecking the caller's?" the local-variable slots
"How do I still find my own locals after moves?" ==frame pointer ==

PICTURE. One frame, exploded into its parts: the return address at the top (highest address of the frame), then the saved old , then a fresh region of locals at the bottom (lowest addresses). A green finger is planted at a fixed spot; a orange finger sits at the very bottom.

Figure — Stack frames — how function calls work at the memory level

Step 4 — The caller's job: hand over arguments, then CALL

WHAT. Before jumping into f, the caller pushes the argument values (or on ARM/x64 often loads them into registers — see Calling conventions (cdecl, stdcall)), then executes CALL f.

WHY the two sub-steps?

  • Push arguments firstf needs the values it was handed, so they must live somewhere f can read them.
  • CALL next — on x86 the CALL instruction does two things atomically: it pushes the return address (the address of the instruction right after the call) and then jumps to f. That is exactly the "where do I come back to?" question being answered.

PICTURE. Left half: main's frame with x inside. Watch the two magenta pushes: first 3 then 2 land below x, then a violet push drops the return address on top. has crawled down three boxes.

Figure — Stack frames — how function calls work at the memory level

Step 5 — The callee's prologue: nail down BP, carve out locals

WHAT. The first few instructions inside f are boilerplate called the prologue. In order:

  1. push BP — save the caller's frame pointer so we can restore it later.
  2. BP = SP — plant our own finger here, marking this frame's base.
  3. SP -= N — slide down by bytes to reserve room for f's locals.

WHY each line?

  • Save old is shared hardware; if we overwrite it without saving, the caller loses track of its own frame. So we stash the old value first.
  • — now marks a fixed landmark inside our frame.
  • — reserving bytes in one subtraction is cheaper than pushing each local separately, and it makes all locals addressable relative to .

  • — locals were carved below (lower addresses) → subtract.
  • — the arguments and return address were pushed before → they sit above it → add.

PICTURE. The full frame of f. Green finger points at the saved-old- box. Above it, labelled with , are the args and return address; below it, labelled with , are the freshly carved local slots. Orange rests at the very bottom.

Figure — Stack frames — how function calls work at the memory level

Step 6 — Worked trace: add(2,3) builds and tears down a frame

WHAT. Let us run the full life of one real frame.

int add(int a, int b) {
    int s = a + b;   // one local: s
    return s;
}
int main(void) {
    int x = add(2, 3);
    return 0;
}

WHY trace it? To see that cleanup is just moving back — nothing gets individually erased.

PICTURE. Four panels, left to right:

  1. In main: only x on the stack.
  2. After pushing args + CALL: [x][3][2][retaddr].
  3. After add's prologue: [x][3][2][retaddr][savedBP][s], with s = 2+3 = 5 computed.
  4. After RET: has jumped all the way back up; the four boxes add used are logically gone; the value 5 rode home in a register and got stored into x.
Figure — Stack frames — how function calls work at the memory level

Step 7 — Recursion: the same picture, stacked N times

WHAT. Recursion is not a new mechanism — it is Step 5 happening again before the previous one finished. See Recursion.

int fact(int n) {
    if (n <= 1) return 1;
    return n * fact(n - 1);
}

WHY it "remembers" every n. Each call runs its own prologue, so each gets its own slot for n. Three simultaneous calls ⇒ three separate n boxes stacked up, each waiting on the one below.

PICTURE. fact(3) at its deepest: three frames piled downward, n=3 on top waiting, n=2 below waiting, n=1 at the bottom about to return 1 first (deepest returns first — strict LIFO). Then watch them unwind upward: .

Figure — Stack frames — how function calls work at the memory level

Step 8 — The degenerate case: returning a pointer to a local

WHAT. The dangerous edge case. What if a function hands back the address of one of its own locals?

int *bad(void) {
    int local = 42;
    return &local;   // BUG
}

WHY it is poison. From Step 6 we learned that RET just moves back up — the frame's boxes are not erased, but they are now fair game for the next call to overwrite. The returned pointer aims at boxes that no longer belong to you. It may still read 42 by pure luck, right up until the next function call reuses those exact boxes.

PICTURE. Two panels: (1) inside bad, the pointer aims at the box holding 42. (2) after return, has climbed above that box, and a new call other() has moved in and written 99 there — the pointer now reads garbage.

Figure — Stack frames — how function calls work at the memory level

The one-picture summary

Everything above compressed into a single frame diagram: caller region on top, one full callee frame below it, both fingers ( nailed down, at the floor), every slot labelled with which of the three questions it answers and which direction it was pushed.

Figure — Stack frames — how function calls work at the memory level
Recall Feynman: retell the whole walkthrough in plain words

Memory is a tall stack of numbered boxes, and one finger () always points at the top box we are using. On real chips "growing the stack" means the finger moves down to smaller numbers. When one function calls another, the caller drops the argument values into boxes, then the CALL also drops a return address — a note saying "come back here." The called function then plants a second finger () at a fixed landmark so it can always find its own locals, and slides the top finger down to reserve blank boxes for them. Doing real work means reading and writing those boxes at fixed distances from . When it finishes, it just slides the top finger back up — the boxes are not scrubbed, they are simply abandoned, which is why cleanup is instant and why returning a pointer into them is a time bomb. Recursion is this exact ritual done again before the previous one finishes, so many copies of a local stack up, and the deepest one always returns first. That last-in-first-out pile is the call stack.

Recall

Which direction does move on a push? ::: Down — to a smaller address (on x86/ARM). Why do locals use but args use ? ::: Locals were carved below (lower addresses → subtract); args and return address were pushed before , so they sit above it (add). Why does returning &local fail? ::: On return moves up and the frame's boxes are abandoned; the next call may overwrite them, so the pointer aims at reusable/garbage memory. In fact(3), which frame returns first? ::: fact(1) — the deepest one — strict LIFO.