Step 5 — Control Flow on a Stack Machine
The compiler already emits Jump, JumpIfFalse, and Loop (cp-06, step 5).
This step explains the runtime half: how ip_ moves through the chunk.
The Three Jump Opcodes
| Opcode | Layout | Stack effect | Action |
|---|---|---|---|
Jump | [op][hi][lo] | 0 | ip_ += offset16 |
JumpIfFalse | [op][hi][lo] | 0 (peek only) | if !isTruthy(peek()) then ip_ += offset16 |
Loop | [op][hi][lo] | 0 | ip_ -= offset16 (backwards jump) |
All offsets are unsigned 16-bit numbers measured from the byte after the operand. Two-byte operand → max range ±65 535 bytes — plenty for human code, trivially extended to 24-bit if anything pathological appears.
Why doesn't
JumpIfFalsepop? Becauseif/elseand short-circuit operators want the test value in different states after the branch. The compiler emits an explicitPopafterJumpIfFalsein cases (likeif-stmt) where the condition value is no longer needed.
Runtime Implementation
case Op::Jump: {
uint16_t off = readShort();
ip_ += off;
break;
}
case Op::JumpIfFalse: {
uint16_t off = readShort();
if (!isTruthy(peek())) ip_ += off;
break;
}
case Op::Loop: {
uint16_t off = readShort();
ip_ -= off;
break;
}
isTruthy is the language's falsiness rule:
bool isTruthy(const Value& v) {
switch (v.kind) {
case ValueKind::Nil: return false;
case ValueKind::Bool: return v.b;
default: return true; // 0 is truthy
}
}
This decision is a language design choice. Lua agrees (only nil and
false are falsy). Python disagrees (empty containers, 0, 0.0, "" are
all falsy). We follow Lua/Lox for simplicity.
if-else at Runtime
Recall the compiled pattern:
<cond>
JumpIfFalse ───┐
Pop │
<then-body> │
Jump ──────┐
else: ──────────────────┘ │
Pop │
<else-body> │
end: ────────────────────┘
At runtime:
- The condition pushes
true/false. JumpIfFalsepeeks, leaves it alone, optionally skips the then-branch.- The branch starts with
Pop(the condition is consumed exactly once). - The unconditional
Jumpover the else-arm leaves the stack unchanged. - The else-arm also begins with
Pop(matching the other side).
The two Pops guarantee that exactly one of them runs per execution and the
stack ends each branch in the same state. This is the stack-balance proof
for the construct.
while at Runtime
start: ◄── loopStart
<cond>
JumpIfFalse ───┐
Pop │
<body> │
Loop start │
end: ─────────────────┘
Pop
Loop re-evaluates the condition. The compiler computes the backward offset
at chunk-emit time as (chunk.code.size() + 3) - loopStart, where the +3
accounts for the Loop opcode + 2-byte operand we are about to emit.
Short-Circuit && / ||
print false && something_expensive(); // never calls
print true || something_expensive(); // never calls
&& compiles to:
<lhs>
JumpIfFalse end
Pop
<rhs>
end:
If lhs is falsy, JumpIfFalse leaves it on the stack and jumps to end
— the expression's result. Otherwise we Pop it and evaluate rhs,
leaving its value as the result.
|| is the mirror: JumpIfTrue end, except we don't have a JumpIfTrue
opcode. Two implementation choices:
- Add
Op::JumpIfTrue. - Reuse
JumpIfFalsewith the boolean inverted in a tiny code template:JumpIfFalse two-ahead; Jump end; Pop; <rhs>; end:.
cp-06 takes the second route to keep the opcode set minimal. Same runtime semantics.
Patch-Back
The compiler emits jumps with a placeholder offset (0xFFFF) and patches
the real distance once the target is known:
size_t emitJump(Op op) {
emit(op);
emit(uint8_t(0xff));
emit(uint8_t(0xff));
return chunk().code.size() - 2; // index of high byte
}
void patchJump(size_t offsetSlot) {
size_t jumpDist = chunk().code.size() - offsetSlot - 2;
if (jumpDist > 0xFFFF) error(..., "jump too large");
chunk().code[offsetSlot] = (jumpDist >> 8) & 0xff;
chunk().code[offsetSlot + 1] = jumpDist & 0xff;
}
Loop is symmetric but the offset is known at emit time (we always loop
backwards to a previously-seen loopStart).
Pitfalls
- Forgetting the Pop after JumpIfFalse. The condition value stays on the stack forever, then collides with the next statement's expectations — the bug surfaces much later as a wrong value in a totally different opcode.
- Wrong direction for
Loop.ip_ -= offnot+=. The disassembler prints the target address — sanity check by inspection. - Patch-back arithmetic. Off-by-two errors are common; write the
emitJump/patchJumppair once and reuse it religiously.