Step 2 — Call Frames and Slot-Base Addressing
Goal
Make fn add(a, b) { return a + b; } print add(3, 4); work. Two questions:
- Where do
aandblive during execution? - 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-1accounts 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
SetLocalpop? Becausea = 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 letsexpr-stmtuse a uniformPopafterwards.
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 parameterslot 1map to slot 0 of the caller — silent data corruption. frames_.push_backinvalidatesframereferences. We always re-acquireframe = frames_.back()at the top of each iteration.- Returning from
<script>. Without the empty-frames check the VM wouldpop()the script's "result" into a non-existent caller.