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

OpcodeLayoutStack effectAction
Jump[op][hi][lo]0ip_ += offset16
JumpIfFalse[op][hi][lo]0 (peek only)if !isTruthy(peek()) then ip_ += offset16
Loop[op][hi][lo]0ip_ -= 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 JumpIfFalse pop? Because if/else and short-circuit operators want the test value in different states after the branch. The compiler emits an explicit Pop after JumpIfFalse in cases (like if-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:

  1. The condition pushes true/false.
  2. JumpIfFalse peeks, leaves it alone, optionally skips the then-branch.
  3. The branch starts with Pop (the condition is consumed exactly once).
  4. The unconditional Jump over the else-arm leaves the stack unchanged.
  5. 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 JumpIfFalse with 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_ -= off not +=. The disassembler prints the target address — sanity check by inspection.
  • Patch-back arithmetic. Off-by-two errors are common; write the emitJump/patchJump pair once and reuse it religiously.