← Back to all posts

VM Design > Ghost Variables

[ Code & Dev ]
[ c ] [ programming-languages ] [ systems-programming ] [ compiler-design ]

When you build your own programming language from scratch, your test suite is your mirror. Everything works in isolated unit tests until you write a classic benchmark—like calculating the 10th Fibonacci number—and the interpreter either blows up immediately or outputs a confidently incorrect answer.

Recently, while developing gem-script (a lightweight dynamic scripting language with an AST parser and bytecode VM in C), I ran into a memorable bug back-to-back:

  1. A “Ghost Variable” logic bug that turned the 10th Fibonacci number (F10=55F_{10} = 55) into 1.0.

Here is the breakdown of why both happened, how we tracked them down, and key lessons for anyone building an interpreter in C.

1. The Benchmark: Classic Fibonacci

To test variable declarations (let), reassignments, arithmetic, and while loops, I wrote fibonacci.gem:

let n = 10
let a = 0
let b = 1
let i = 0

while (i < n) {
    let temp = b
    b = a + b
    a = temp
    i = i + 1
}

a

Bug : The Ghost in the Symbol Table

The script ran, but the output was baffling:

$ ./gem ./test/fibonacci.gem
VM: POP: 10.000000
VM: POP: 0.000000
VM: POP: 1.000000
VM: POP: 0.000000
VM: POP: 10.000000
VM: POP: 1.000000    <-- Expected 55.000000!

(Note: The VM outputs multiple POP lines because top-level statements are evaluated and returned in a REPL-like compilation loop).

The final expression a evaluated to 1.0 instead of 55.0. Why did the loop fail to calculate the sequence?

Tracing the Environment

In gem-script, variables are stored in an Environment struct containing an array of symbols:

void env_define(Environment *env, char *name, Value val) {
    Symbol symbol = {.val = val};
    strncpy(symbol.name, name, 64);
    env->symbols[env->count++] = symbol; // Appends to the end
}

And variable resolution used a forward linear scan:

bool env_get(Environment *env, char name[64], Value *ret_val) {
    for (int i = 0; i < env->count; i++) {
        if (strcmp(env->symbols[i].name, name) == 0) {
            *ret_val = env->symbols[i].val;
            return true;
        }
    }
    return false;
}

The Mechanics of the Bug

Inside the loop body:

let temp = b
b = a + b
a = temp

Look at what happens to the symbol table across loop iterations:

graph TD
    subgraph "Iteration 1"
    E1["Index 4: temp = 1.0"]
    end
    subgraph "Iteration 2"
    E2["Index 5: temp = 1.0"]
    end
    subgraph "Iteration 3"
    E3["Index 6: temp = 2.0"]
    end
  1. Iteration 1: let temp = b appends ("temp", 1.0) at index 4. a becomes 1.0.
  2. Iteration 2: let temp = b appends a new ("temp", 1.0) at index 5. b becomes 2.0.
  3. The Trap: When a = temp runs, env_get("temp") begins scanning at index 0. It stops at the very first match: index 4.
  4. Subsequent Iterations: Every iteration appends a new temp with the latest value, but env_get always returns the “ghost” from Iteration 1 (1.0).

Because a was locked at 1.0 forever, b simply incremented linearly (1,2,3,41, 2, 3, 4\dots) instead of compounding into the Fibonacci series.

The Fix: Reverse Scanning for Lexical Shadowing

When managing scopes in a linear table, the most recent (innermost) definition must shadow older ones.

Scanning backwards solves this:

bool env_get(Environment *env, char name[64], Value *ret_val) {
    for (int i = env->count - 1; i >= 0; i--) { // Start from the newest!
        if (strcmp(env->symbols[i].name, name) == 0) {
            *ret_val = env->symbols[i].val;
            return true;
        }
    }
    return false;
}

With reverse lookup in place:

$ ./gem ./test/fibonacci.gem
...
VM: POP: 55.000000

4. Bonus Architecture Lesson: Stack Hygiene in VMs

While investigating the symbol table, another subtle issue emerged in the bytecode interpreter:

case OP_DEFINE_GLOBAL: {
    uint8_t nameIdx = *vm->ip++;
    Value nameVal = vm->chunk->constants.values[nameIdx];
    Value val = *(vm->stackTop - 1); // Peeking instead of popping!
    env_define(&vm->globals, nameVal.string, val);
    break;
}

By peeking at the stack rather than popping the expression result, statements inside loops left orphan values on the stack on every single iteration. For a loop with 1,000 iterations, the VM stack would silently accumulate 4,000 leaked values.

Rule of Thumb for Stack VMs:

Every statement must leave the stack at the exact depth it started with, unless it explicitly produces an evaluated expression for an enclosing node.