Step 2 — Call Frames and Slot-Base Addressing

Goal

Make fn add(a, b) { return a + b; } print add(3, 4); work. Two questions:

  1. Where do a and b live during execution?
  2. How does add "return" without losing the rest of the program?

Answer to both: call frames.

A Call Frame

struct CallFrame {
    FunctionPtr     fn;        // function being executed
    const uint8_t*  ip;        // instruction pointer into fn->chunk.code
    size_t          slotBase;  // index into VM::stack_ where this frame's slot 0 lives
};

std::vector<CallFrame> frames_;

The crucial field is slotBase. It says: "all local variables in this function are accessed relative to stack_[slotBase]."

The Invariant at Op::Call N

When the compiler emits Op::Call N, the runtime stack looks like:

                          ┌── top
[ … caller's stuff, <fn>, arg1, arg2, …, argN ]
                  ▲
                  └── this is `slotBase` for the new frame

So:

  • slotBase = stack_.size() - N - 1 (the -1 accounts for the function itself).
  • frame.slots[0] = <fn> — the function value lives at slot 0 of its own frame. This is a convenient invariant that lets us implement closures cheaply later (the closure object is always reachable from inside its body).
  • frame.slots[1..N] = arg1..argN — parameters are already in place because the call ABI deliberately leaves the args on the stack.

This is why the compiler emits addLocal(p, …) for each parameter when it opens a function: parameters get slot indices 1, 2, … N, and the runtime fulfils that mapping for free.

Op::Return

                              ┌── top
[ … caller's stuff, <fn>, args/locals…, RESULT ]
                  ▲
                  └── slotBase of returning frame

The handler:

Value result = pop();
stack_.resize(frame.slotBase);   // drops <fn> + all locals + args
frames_.pop_back();
if (frames_.empty()) return Status::Ok;   // returning from <script>
push(result);                    // caller sees the value on top

Because slotBase includes the function value's slot, the resize cleans everything in one operation. No per-local pops.

Op::GetLocal slot / Op::SetLocal slot

These are now trivial:

case Op::GetLocal: {
    uint8_t slot = readByte();
    push(stack_[frame.slotBase + slot]);
    break;
}
case Op::SetLocal: {
    uint8_t slot = readByte();
    stack_[frame.slotBase + slot] = peek();   // assignment is an expression: leaves value on stack
    break;
}

Why doesn't SetLocal pop? Because a = 1 + (b = 2) is a valid expression in many languages — the assigned value is the expression's result. MiniLang is more conservative, but keeping the value on the stack lets expr-stmt use a uniform Pop afterwards.

callValue

The unified call entry point:

void callValue(Value callee, int argc, int line) {
    if (callee.kind != ValueKind::Fn)
        throw RuntimeError(line, "can only call functions");
    auto fn = callee.fn;
    if (fn->arity != argc)
        throw RuntimeError(line, "function '" + fn->name + "' expects "
                           + std::to_string(fn->arity) + " argument(s)");
    if (frames_.size() == kMaxFrames)
        throw RuntimeError(line, "stack overflow (max call depth)");
    frames_.push_back({fn, fn->chunk.code.data(),
                       stack_.size() - argc - 1});
}

That's it. After callValue, control falls back into the dispatch loop with a fresh frame = frames_.back(), and execution begins at fn->chunk byte 0.

Recursion Just Works

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

Each recursive call pushes a new frame. No special case. The slot-base trick is the entire reason recursion works without renaming variables — each frame has its own slice of stack_.

Bootstrapping the Script

The top-level program is itself a function called <script> with arity 0. The VM bootstraps it by pushing the function value and calling it:

push(Value::makeFn(script));
callValue(stack_.back(), 0, 0);
run_loop();

The compiler always ends <script> with Nil; Return, so the loop terminates gracefully when the program finishes.

Try It

echo 'fn fib(n) { if (n < 2) { return n; } return fib(n-1) + fib(n-2); }
print fib(10);' | ./build/mlr
# 55

Add --dump to inspect the bytecode — you will see two separate chunks if you add a top-level disassembly of fib (the framework only dumps the script in cp-07; cp-12 dumps every function).

Pitfalls

  • Off-by-one in slotBase. Forgetting the <fn> slot makes parameter slot 1 map to slot 0 of the caller — silent data corruption.
  • frames_.push_back invalidates frame references. We always re-acquire frame = frames_.back() at the top of each iteration.
  • Returning from <script>. Without the empty-frames check the VM would pop() the script's "result" into a non-existent caller.