3. Building the VM Execution Engine
The VM now requires two stacks: a Data Stack (for mathematical operations and
passing arguments) and a Call Stack (for managing execution flow and scoping).
function executeVM(mainBytecode) {
const dataStack = [];
const globalEnv = {};
// The Call Stack tracks nested execution contexts (Frames)
const callStack = [{
ip: 0,
code: mainBytecode,
env: globalEnv
}];
while (callStack.length > 0) {
const frame = callStack[callStack.length - 1]; // Peek at the current
frame
// If we've reached the end of the current function/script, pop the frame
if (frame.ip >= frame.code.length) {
callStack.pop();
continue;
}
const inst = frame.code[frame.ip++]; // Fetch instruction and increment IP
switch (inst.op) {
case Opcodes.CONST:
dataStack.push(inst.val);
break;
case Opcodes.LOAD:
// Prototype chain allows falling back to global scope if not in local
if (!(inst.val in frame.env)) throw new Error(`Undefined:
${inst.val}`);
dataStack.push(frame.env[inst.val]);
break;
case Opcodes.STORE:
frame.env[inst.val] = dataStack.pop();
break;
case Opcodes.MAKE_FUNC:
// Push the Code Object to the stack
dataStack.push(inst.val);
break;
case Opcodes.CALL:
const func = dataStack.pop(); // Pop the Code Object
// Create a new local environment inheriting from global (or parent)
const localEnv = Object.create(globalEnv);
// Pop arguments off the data stack in reverse order and bind them
for (let i = inst.argCount - 1; i >= 0; i--) {
localEnv[func.params[i]] = dataStack.pop();
}
// Push the new Call Frame
callStack.push({ ip: 0, code: func.code, env: localEnv });
break;
case Opcodes.RET:
// The return value is whatever is currently sitting on top of the
data stack.
// We pop the Call Frame, returning execution to the previous IP.
callStack.pop();
break;
case Opcodes.ADD:
dataStack.push(dataStack.pop() + dataStack.pop());
break;
case Opcodes.MUL:
dataStack.push(dataStack.pop() * dataStack.pop());
break;
}
}
// The final result of the program rests on top of the data stack
return dataStack.pop();
}
Why this architecture scales well
By separating the compilation of Code Objects from the main execution thread,
the VM handles function calls exactly like standard data manipulation. Because
local environments utilize Object.create(globalEnv), lexical scoping and
variable shadowing are handled natively by the JavaScript engine's own
prototype chain, avoiding the overhead of traversing a complex custom scope
array during LOAD operations.