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:
- A “Ghost Variable” logic bug that turned the 10th Fibonacci number () 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
- Iteration 1:
let temp = bappends("temp", 1.0)at index4.abecomes1.0. - Iteration 2:
let temp = bappends a new("temp", 1.0)at index5.bbecomes2.0. - The Trap: When
a = tempruns,env_get("temp")begins scanning at index 0. It stops at the very first match: index 4. - Subsequent Iterations: Every iteration appends a new
tempwith the latest value, butenv_getalways returns the “ghost” from Iteration 1 (1.0).
Because a was locked at 1.0 forever, b simply incremented linearly () 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.