CodeGym /Courses /C++ SELF /Call stack

Call stack

C++ SELF
Level 16, Lesson 0
Available

1. Execution model and the call stack

When you write doSomething();, it seems like the computer just “jumped” to another part of the program, did the job and came back. But how exactly does it return? Where does it “remember” where to return to? And why do local variables inside a function disappear after return, as if they were “switched off” from reality?

Now we'll build a simple, working model of execution that will be very useful later (especially when we move to recursion, but without details for now).

At a beginner level it's enough to know: program execution is a single “thread” that runs top to bottom and sometimes “branches” into functions. To prevent these branches from becoming a maze with no exit, the execution process has a special memory structure — the call stack.

The call stack: a stack of plates and the LIFO rule

The word “stack” is literally a “pile”. Imagine that each function call places a plate on the table with a note: “I am the call to foo(), here are my local variables, here are my parameters, and here is the address to return to”. When the function finishes, the plate is removed.

Importantly, the plate that is removed is the last one that was put on. This rule is called LIFO: Last In, First Out.

This rule fits function calls perfectly: if main() called f(), and f() called g(), then it makes sense that g() must finish first, then f() continues, and only then main() continues.

Stack frame: what is stored “on the plate”

In this section we'll slightly “disassemble the plate”. Not at the CPU and register level (that's a whole different universe), but at the level of a model that helps you write code without mystery.

A stack frame is the data that belongs to a single function call: its parameters, local variables, and most importantly — the return address (where to continue after return).

It's convenient to picture a stack frame as a table:

Part of the stack frame What it is Why it's needed
Parameters what was passed into the function so the function knows what to operate on
Local variables what was declared inside the {} a temporary “workspace” for the function
Return address the “address” in the caller to resume execution after the call finishes
Administrative the “internals” we don't need to model this now

Key idea: each function call has its own stack frame. Even if it's two calls to the same function, they are two distinct sets of local variables.

Mini demonstration: order of entry and exit of functions

Now we'll do the fairest experiment: just print start/end and see in which order they appear. This example is small, but it clearly demonstrates the LIFO rule.

#include <iostream>

void h() {
    std::cout << "h: start\n"; // h: start
    std::cout << "h: end\n";   // h: end
}

void g() {
    std::cout << "g: start\n"; // g: start
    h();
    std::cout << "g: end\n";   // g: end
}

void f() {
    std::cout << "f: start\n"; // f: start
    g();
    std::cout << "f: end\n";   // f: end
}

int main() {
    f();
}

If you run it, you'll see something like this:

f: start
g: start
h: start
h: end
g: end
f: end

This is the “stack of plates” in action: while h() hasn't finished, g() is “paused” exactly at the line h();, and f() is paused at the line g();.

Return address: why the caller “freezes” at the call site

Almost every beginner initially imagines something strange: “functions seem to work in parallel”. That's normal — the brain fills gaps. But in single-threaded code it's simpler: there is no parallelism, execution proceeds strictly sequentially.

When the line:

int b = inner(a + 1);

is executed, the caller (outer) must remember that after inner(...) finishes it needs to continue with assigning the result to b, and then execute the following lines. That's why the stack frame stores the return address.

Let's look at an example:

#include <iostream>

int inner(int x) {
    std::cout << "inner: x=" << x << '\n'; // inner: x=11
    return x * 2;
}

int outer(int a) {
    std::cout << "outer: before\n";        // outer: before
    int b = inner(a + 1);
    std::cout << "outer: after\n";         // outer: after
    return b + 3;
}

int main() {
    std::cout << outer(10) << '\n';        // 25
}

Here outer literally “stops” at the call to inner, waits for the return, receives the value and continues.

Life of local variables: they live exactly until the call ends

Now an important point, which causes many bugs that look like “ghosts”: local variables do not live “forever”, they live as long as the stack frame lives. When a function does return, its frame is popped from the stack and the local variables of that call cease to exist.

In terms of the C++ standard this is related to automatic storage duration — that's how typical local variables are described: they live within a block and are destroyed when exiting it.

Example:

#include <iostream>

int add10(int x) {
    int y = x + 10; // local variable
    return y;
}

int main() {
    std::cout << add10(1) << '\n'; // 11
    std::cout << add10(5) << '\n'; // 15
}

Here there is no “memory between calls”: y is created anew on each entry to add10(). It doesn't “accumulate”, it doesn't “age”, it doesn't “remember a past life”.

Two calls to the same function are two different worlds of local variables

This seems obvious until you run into a situation like “but my variable was 7, why is it 0 again?”. The answer: because that's a different variable, belonging to a different stack frame.

Let's do a small trick: print the value of a local variable. (We haven't covered pointers deeply yet, so we won't go into addresses — the logic is clear without them.)

#include <iostream>

void demo(int x) {
    int local = x * 10;
    std::cout << "local=" << local << '\n'; // local=20 (then local=50)
}

int main() {
    demo(2);
    demo(5);
}

Even without looking at memory addresses, the idea is the same: each call creates its own local.

2. Practical technique: indented tracing of calls

In this section we'll do something loved by teachers and debuggers alike: print function entry/exit with indentation so the depth of calls is visible. It's almost like a “manual stack”, just without magic.

We'll make two small functions trace_enter and trace_exit, and keep depth in a variable depth that we pass by reference (so changes are visible to all participants in the call chain).

#include <iostream>
#include <string>

void trace_enter(const std::string& name, int& depth) {
    for (int i = 0; i < depth; ++i) std::cout << "  ";
    std::cout << "-> " << name << '\n';
    ++depth;
}

void trace_exit(const std::string& name, int& depth) {
    --depth;
    for (int i = 0; i < depth; ++i) std::cout << "  ";
    std::cout << "<- " << name << '\n';
}

Note: depth is not “part of the stack”, it's just our teaching flashlight. We highlight what already happens inside the program.

3. Mini-project: console todo list and a thin main

From now on we'll connect examples into a small application that's easy to extend. We won't do anything overly complex: just a list of strings (std::vector<std::string>) and the commands add/list/exit. The main goal is to see how function calls “live” on the stack and why it's better to keep main() thin.

Application skeleton

Let's start with the skeleton:

#include <iostream>
#include <string>
#include <vector>

void run_app() {
    std::vector<std::string> tasks;
    std::cout << "TodoApp started\n"; // TodoApp started
}

int main() {
    run_app();
}

Boring for now — but it's a good “skeleton”: main() doesn't handle business logic, it hands control to run_app().

Adding commands and viewing the stack via tracing

Now let's make functions: show the menu, read a command, handle the command. And add tracing so we can see how run_app() calls other functions.

#include <iostream>
#include <string>

void trace_enter(const std::string& name, int& depth) {
    for (int i = 0; i < depth; ++i) std::cout << "  ";
    std::cout << "-> " << name << '\n';
    ++depth;
}

void trace_exit(const std::string& name, int& depth) {
    --depth;
    for (int i = 0; i < depth; ++i) std::cout << "  ";
    std::cout << "<- " << name << '\n';
}

void print_menu(int& depth) {
    trace_enter("print_menu", depth);
    std::cout << "Commands: add, list, exit\n";
    trace_exit("print_menu", depth);
}

std::string read_command(int& depth) {
    trace_enter("read_command", depth);
    std::string cmd;
    std::getline(std::cin, cmd);
    trace_exit("read_command", depth);
    return cmd;
}

The idea is simple: when you see the indented output, you'll have an “x-ray” view of the call nesting.

Connecting everything in run_app and observing stack frames

Now we'll assemble the chain: main() → run_app() → print_menu() → read_command(). Even without recursion the stack is working and already matters.

#include <iostream>
#include <string>
#include <vector>

void trace_enter(const std::string& name, int& depth) {
    for (int i = 0; i < depth; ++i) std::cout << "  ";
    std::cout << "-> " << name << '\n';
    ++depth;
}

void trace_exit(const std::string& name, int& depth) {
    --depth;
    for (int i = 0; i < depth; ++i) std::cout << "  ";
    std::cout << "<- " << name << '\n';
}

void print_menu(int& depth) {
    trace_enter("print_menu", depth);
    std::cout << "Commands: add, list, exit\n"; // Commands: add, list, exit
    trace_exit("print_menu", depth);
}

std::string read_command(int& depth) {
    trace_enter("read_command", depth);
    std::string cmd;
    std::getline(std::cin, cmd);
    trace_exit("read_command", depth);
    return cmd;
}

void run_app() {
    int depth = 0;
    trace_enter("run_app", depth);

    std::vector<std::string> tasks;

    print_menu(depth);
    std::cout << "Enter command: ";            // Enter command:
    std::string cmd = read_command(depth);

    std::cout << "You typed: " << cmd << '\n'; // for example: You typed: add

    trace_exit("run_app", depth);
}

int main() {
    run_app();
}

If you type add, you'll see roughly this picture:

-> run_app
  -> print_menu
Commands: add, list, exit
  <- print_menu
Enter command:   -> read_command
  <- read_command
You typed: add
<- run_app

This is a visualization of the stack: while we are in read_command(), the previous functions haven't disappeared — they are “waiting” to continue at their return address.

4. What return really means

It's important to pause and say in words what return does. It doesn't “teleport” a value. It terminates the current function, after which its stack frame is popped, and execution continues in the caller at the saved return address.

Practically this means two things. First, everything that was local in the current function no longer exists: variables go away with the frame. Second, the caller becomes “active” again, and execution resumes at the line immediately after the call.

That's why we think of local variables as “temporary”: they're convenient, but you can't use them after exiting the function because they don't survive the call. The relation between local objects with automatic storage duration and scope exit is one of the basic bricks of the execution model.

5. Visual scheme of the stack during nested calls

To finally fix the idea, let's draw a simple diagram. This isn't about the exact byte layout, it's about the meaning.

flowchart TD
    A["main()"] --> B["run_app()"]
    B --> C["print_menu()"]
    B --> D["read_command()"]

At the moment when we're inside read_command(), the “stack” looks roughly like:

[ top ]
read_command frame
run_app frame
main frame
[ bottom ]

When read_command() does return, its frame is popped and we are back “inside” run_app().

6. Common mistakes

Mistake #1: expecting the caller to continue “in parallel”.
Sometimes it feels like run_app() might “get time to do something else” while read_command() runs. In single-threaded code this is not the case: the caller is paused until return. Keeping in mind “one thread of execution” and the “return address” reduces confusion.

Mistake #2: thinking local variables “remember” values between calls.
A local variable belongs to a specific stack frame. A new call — a new frame — new local variables. If you need to keep state between calls, that should be a variable “higher up” (for example, in run_app()) or a parameter/container passed between functions.

Mistake #3: confusing the order of calls with the order of completions.
Calls go “inwards”, completions go “outwards”. So for the chain main → run_app → read_command the first to complete will be read_command, then run_app will continue, and only then main will finish. This is the LIFO rule — useful to repeat like a mantra, only without mysticism.

Mistake #4: using return as “just an exit” without understanding consequences.
An early return often makes code simpler, but remember: it terminates the function right now, the frame is popped, local variables are destroyed, and the caller resumes. If you haven't updated the needed state before the return (for example, wrote a result into a container that lives higher), it might be “too late”.

Mistake #5: trying to explain the stack via a “global magical memory”.
Beginners sometimes say: “well, variables are somewhere out there”. It's more useful to keep a concrete model: each call has its own stack frame with parameters, local variables and a return address. This explains execution order, variable lifetimes, and why nested calls behave predictably.

1
Task
C++ SELF, level 16, lesson 0
Locked
Call stack
Call stack
1
Task
C++ SELF, level 16, lesson 0
Locked
Return trace
Return trace
1
Task
C++ SELF, level 16, lesson 0
Locked
Depth Indentation
Depth Indentation
1
Task
C++ SELF, level 16, lesson 0
Locked
Input validation
Input validation
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION