Building a Virtual Machine, JVM-inspired — Byte-code execution (Part 8)

Ondrej Kvasnovsky
10 min readJan 16, 2025

--

Introduction

In our previous article, we implemented compilation that transforms our source code into bytecode. Now we’ll tackle the next crucial step: executing this bytecode efficiently. This brings us closer to how real virtual machines like the JVM operate, moving away from interpreting text directly to working with compiled bytecode.

Implementation goals

With our compiler (tiny_vm_compile) now producing bytecode files, we need to:

  1. Load bytecode when the VM starts, requiring the bytecode file path as an argument to tiny_vm_run
  2. Parse the loaded bytecode
  3. Execute bytecode operations directly instead of interpreting string instructions
  4. Maintain compatibility with all our existing features (threading, synchronization, etc.)

Let’s see this in action with a simple example. Here’s our source file (hello.tvm):

function greet
set message 42
print message

function main
sync greet
exit

When compiled, this produces the following bytecode:

[Compiler] Bytecode for function 'greet':
0: LOAD_CONST message = 42
1: PRINT message
[Compiler] Bytecode for function 'main':
0: INVOKE_SYNC greet
1: RETURN

Running the VM with this bytecode produces:

[2025-01-02 19:14:42.723389] [VM] Starting TinyVM...

[2025-01-02 19:14:42.723875] [VM] Loaded function 'greet' with 2 instructions
[2025-01-02 19:14:42.723879] [VM] Loaded function 'main' with 2 instructions
[2025-01-02 19:14:42.723882] [VM] Successfully loaded compiled bytecode from: /Users/ondrej/Documents/Projects/c/tiny-vm/tiny-vm_07_compilation/examples/hello.tvmc

[2025-01-02 19:14:42.723910] [Thread 0] Thread instructions started
[2025-01-02 19:14:42.723916] [Thread 0] Sync function 'greet' started
[2025-01-02 19:14:42.723920] [Thread 0] SET message = 42
[2025-01-02 19:14:42.723922] [Thread 0] PRINT message = 42
[2025-01-02 19:14:42.723923] [Thread 0] Thread instructions finished

[2025-01-02 19:14:42.723937] [VM] TinyVM finished.

The implementation

VM entry point

We are going to rename our main.c to vm_main.c, to avoid ambiguity with the main that contains compiler code. The main function now takes one argument, which has the be a path to the compiled byte-code, a file ending with .tvmc.

// src/vm_main.c
#include "utils/logger.h"
#include "core/vm.h"
#include "compiler/compiler.h"

int main(const int argc, char* argv[]) {
if (argc < 2) {
print("Usage: %s <bytecode_file>", argv[0]);
return 1;
}

print("[VM] Starting TinyVM...");

CompilationResult* compiled = load_compiled_bytecode(argv[1]);
if (!compiled) {
print("[VM] Compilation failed");
return 1;
}
print_compilation_result(compiled);

// Create and run VM
VM* vm = create_vm(compiled);
run_vm(vm);

// Cleanup
destroy_vm(vm);

print("[VM] TinyVM finished.");
return 0;
}

VM updates

The VM creation process has been simplified to work directly with compiled bytecode:

// src/core/vm.h
#ifndef TINY_VM_CORE_VM_H
#define TINY_VM_CORE_VM_H

#include "../types.h"
#include "../compiler/compiler.h"

// Core VM functions
VM* create_vm(CompilationResult* compiled);

void run_vm(VM* vm);

void destroy_vm(VM *vm);

#endif

The implementation simply transfers the compiled functions to the VM’s functions array:

// src/core/vm.c
// ... no changes here

VM* create_vm(const CompilationResult* compiled) {
VM *vm = malloc(sizeof(VM));

// ... no changes here

for (int i = 0; i < compiled->function_count; i++) {
vm->functions[i] = compiled->functions[i]; // Shallow copy
vm->function_count++;
}

return vm;
}

// ... the rest is untouched

Function management

Function handling has been simplified significantly. We now only need to find functions in the VM’s function table:

// src/function/function.h

#ifndef TINY_VM_FUNCTION_H
#define TINY_VM_FUNCTION_H

#include "../types.h"

// Function management
Function* find_function(const VM* vm, const char* name);

#endif
// src/function/function.c

#include <stdio.h>
#include <string.h>

#include "function.h"

Function* find_function(const VM* vm, const char* name) {
Function* found = NULL;
for (int i = 0; i < vm->function_count; i++) {
if (strcmp(vm->functions[i]->name, name) == 0) {
found = vm->functions[i];
break;
}
}
return found;
}

Byte-code loading

The bytecode loader reads our compiled format and reconstructs the function structures:

// src/compiler/bytecode.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "../utils/logger.h"
#include "compiler.h"

// File operations for saving compiled bytecode
void save_compiled_bytecode(const char* filename, CompilationResult* compiled) {
// ... implemented in the previous article
}


// File operations for loading compiled bytecode
CompilationResult* load_compiled_bytecode(const char* filename) {
FILE* file = fopen(filename, "rb");
if (!file) {
print("[VM] Error: Could not open file: %s", filename);
return NULL;
}

CompilationResult* result = malloc(sizeof(CompilationResult));

// Read number of functions
fread(&result->function_count, sizeof(int), 1, file);
result->functions = malloc(sizeof(Function*) * result->function_count);

// Read each function
for (int i = 0; i < result->function_count; i++) {
Function* func = malloc(sizeof(Function));
result->functions[i] = func;

// Read function name
int name_len;
fread(&name_len, sizeof(int), 1, file);
func->name = malloc(name_len);
fread(func->name, 1, name_len, file);

// Read code length
fread(&func->code_length, sizeof(int), 1, file);

// Allocate and read bytecode
func->byte_code = malloc(sizeof(BytecodeInstruction) * func->code_length);

// Read each instruction
for (int j = 0; j < func->code_length; j++) {
BytecodeInstruction* instr = &func->byte_code[j];

// Read opcode and indexes
fread(&instr->opcode, sizeof(OpCode), 1, file);
fread(&instr->var_index, sizeof(uint16_t), 1, file);
fread(&instr->var_index2, sizeof(uint16_t), 1, file);
fread(&instr->var_index3, sizeof(uint16_t), 1, file);
fread(&instr->constant, sizeof(int32_t), 1, file);

// Read name if present
int has_name;
fread(&has_name, sizeof(int), 1, file);
if (has_name) {
int instr_name_len;
fread(&instr_name_len, sizeof(int), 1, file);
instr->name = malloc(instr_name_len);
fread(instr->name, 1, instr_name_len, file);
} else {
instr->name = NULL;
}
}

// Read constant pool
fread(&func->constant_pool_size, sizeof(int), 1, file);
func->constant_pool = malloc(sizeof(char*) * func->constant_pool_size);

for (int j = 0; j < func->constant_pool_size; j++) {
int const_len;
fread(&const_len, sizeof(int), 1, file);
func->constant_pool[j] = malloc(const_len);
fread(func->constant_pool[j], 1, const_len, file);
}

print("[VM] Loaded function '%s' with %d instructions", func->name, func->code_length);
}

fclose(file);
print("[VM] Successfully loaded compiled bytecode from: %s", filename);
return result;
}

Notice that the constants are stored in the constant pool.

Bytecode execution

The first step is to adjust the execute_bytecode function signature, we no longer need to send the instructions there, because the byte-code is attached to the ThreadContext.

// src/execution/execution.h

#ifndef TINY_VM_EXECUTION_H
#define TINY_VM_EXECUTION_H

#include "../types.h"

void execute_bytecode(ThreadContext* thread);

// synchronous and asynchronous function execution
void sync_function(ThreadContext* caller, const Function* function);
ThreadContext* async_function(VM* vm, const Function* function);

#endif

Now we implement the core of the VM, which executes the byte-code using operation instructions.

// src/execution/execution.c

#include "execution.h"

#include <unistd.h>
#include <pthread.h>

#include "../types.h"
#include "../thread/thread.h"
#include "../function/function.h"
#include "../utils/logger.h"
#include "../memory/memory.h"
#include "../synchronization/synchronization.h"

void execute_bytecode(ThreadContext* thread) {
const BytecodeInstruction* instr = &thread->current_function->byte_code[thread->pc];

switch(instr->opcode) {
case OP_NOP:
break;

case OP_PRINT: {
const char* var_name = instr->name;
const jint value = get_value(thread, var_name);
print("[Thread %d] PRINT %s = %d", thread->thread_id, var_name, value);
break;
}

case OP_LOAD_CONST: {
Variable* var = get_variable(thread, instr->name);
if (var) {
var->value = instr->constant;
print("[Thread %d] SET %s = %d", thread->thread_id, var->name, var->value);
}
break;
}

case OP_ADD: {
const char* target = thread->current_function->constant_pool[instr->var_index];
const char* op1 = thread->current_function->constant_pool[instr->var_index2];
const char* op2 = thread->current_function->constant_pool[instr->var_index3];

const jint val1 = get_value(thread, op1);
const jint val2 = get_value(thread, op2);
Variable* target_var = get_variable(thread, target);

if (target_var) {
target_var->value = val1 + val2;
print("[Thread %d] ADD %s = %s(%d) + %s(%d) = %d",
thread->thread_id, target, op1, val1, op2, val2, target_var->value);
}
break;
}

case OP_SLEEP: {
usleep(instr->constant * 1000);
break;
}

case OP_SETSHARED: {
Variable* var = get_shared_variable(thread, instr->name);
if (var) {
var->value = instr->constant;
print("[Thread %d] SETSHARED %s = %d", thread->thread_id, var->name, var->value);
}
break;
}

case OP_MONITOR_ENTER: {
SynchronizationLock* mutex = get_sync_lock(thread->vm, instr->name);
if (mutex) {
print("[Thread %d] Waiting for lock '%s'", thread->thread_id, mutex->name);
pthread_mutex_lock(&mutex->mutex);
mutex->locked = 1;
print("[Thread %d] Acquired lock '%s'", thread->thread_id, mutex->name);
}
break;
}

case OP_MONITOR_EXIT: {
SynchronizationLock* mutex = get_sync_lock(thread->vm, instr->name);
if (mutex && mutex->locked) {
pthread_mutex_unlock(&mutex->mutex);
mutex->locked = 0;
print("[Thread %d] Released lock '%s'", thread->thread_id, mutex->name);
}
break;
}

case OP_INVOKE_SYNC: {
const Function* function = find_function(thread->vm, instr->name);
if (function) {
sync_function(thread, function);
} else {
print("[Thread %d] Error: Function '%s' not found", thread->thread_id, instr->name);
}
break;
}

case OP_INVOKE_ASYNC: {
const Function* function = find_function(thread->vm, instr->name);
if (function) {
async_function(thread->vm, function);
} else {
print("[Thread %d] Error: Function '%s' not found", thread->thread_id, instr->name);
}
break;
}

case OP_RETURN: {
// this should only end the current function, on stack,
// but not kill a thread, we will need
// function call stack soon!
thread->is_running = 0;
break;
}

default:
print("[Thread %d] Error: Unknown opcode: %d", thread->thread_id, instr->opcode);
}
}

ThreadContext* async_function(VM* vm, const Function* function) {
return create_thread(vm, function);
}

Synchronous function calls

The execution of synchronous functions reveals a current limitation in our design:

// src/execution/execution.c

void sync_function(ThreadContext* caller, const Function* function) {
// Save caller's context (because we don't have function call stack)
const Function* original_function = caller->current_function;
const int original_pc = caller->pc;

// Set up function context
caller->current_function = function;
caller->pc = 0;

print("[Thread %d] Sync function '%s' started", caller->thread_id, function->name);

// Execute function instructions
while (caller->is_running && caller->pc < function->code_length) {
execute_bytecode(caller);
caller->pc++;
}

// Restore caller's context
caller->current_function = original_function;
caller->pc = original_pc;
}

This approach of saving and restoring context is a temporary solution. A proper function call stack would allow us to handle nested function calls more elegantly.

Threading support

The TinyVM implementation focuses on core virtual machine mechanics rather than complex language features. Our language model is intentionally minimal, supporting only functions as the primary unit of execution. This simplification allows us to concentrate on fundamental VM concepts like bytecode execution, threading, and synchronization without getting bogged down in language grammar complexities.

The thread management functions have been updated to work with bytecode execution:

// src/thread/thread.h
#ifndef TINY_VM_THREAD_H
#define TINY_VM_THREAD_H

#include "../types.h"

ThreadContext* create_thread(VM* vm, const Function* function);

void* execute_thread_instructions(void* arg);

#endif

The key change in our thread implementation is how we handle execution. Instead of processing text instructions line by line, each thread now executes a complete function with its associated bytecode.

// src/thread/thread.c
#include "thread.h"
#include "../core/vm.h"
#include "../execution/execution.h"
#include "../utils/logger.h"
#include "../memory/memory.h"

ThreadContext* create_thread(VM* vm, const Function* function) {
pthread_mutex_lock(&vm->thread_mgmt_lock);

if (vm->thread_count >= vm->thread_capacity) {
pthread_mutex_unlock(&vm->thread_mgmt_lock);
return NULL;
}

ThreadContext* thread = &vm->threads[vm->thread_count++];
thread->local_scope = create_local_scope();
thread->current_function = function;

thread->pc = 0;
thread->is_running = 1;
thread->thread_id = vm->next_thread_id++; // Assign and increment thread ID
thread->vm = vm;

pthread_create(&thread->thread, NULL, execute_thread_instructions, thread);

pthread_mutex_unlock(&vm->thread_mgmt_lock);
return thread;
}

void* execute_thread_instructions(void* arg) {
ThreadContext* thread = (ThreadContext*) arg;
print("[Thread %d] Thread instructions started", thread->thread_id);

while (thread->is_running && thread->pc < thread->current_function->code_length) {
execute_bytecode(thread);
thread->pc++;
}

print("[Thread %d] Thread instructions finished", thread->thread_id);
return NULL;
}

Testing the new VM

Let’s test our VM with a more complex example that uses threads and synchronization:

function createCounter
setshared counter 1000
print counter

function incrementCounter
lock counter_lock
set increment 10
add counter increment counter
print counter
unlock counter_lock
exit

function decrementCounter
lock counter_lock
set decrement -10
add counter decrement counter
print counter
unlock counter_lock
exit

function main
sync createCounter
async incrementCounter
async decrementCounter
exit

The output demonstrates that our bytecode execution correctly handles threading, synchronization, and shared memory:

[2025-01-02 20:57:21.297375] [VM] Starting TinyVM...
[2025-01-02 20:57:21.297850] [VM] Loaded function 'createCounter' with 2 instructions
[2025-01-02 20:57:21.297856] [VM] Loaded function 'incrementCounter' with 6 instructions
[2025-01-02 20:57:21.297860] [VM] Loaded function 'decrementCounter' with 6 instructions
[2025-01-02 20:57:21.297864] [VM] Loaded function 'main' with 4 instructions
[2025-01-02 20:57:21.297868] [VM] Successfully loaded compiled bytecode from: /Users/ondrej/Documents/Projects/c/tiny-vm/tiny-vm_07_compilation/examples/counter.tvmc

[2025-01-02 20:57:21.297913] [Thread 0] Thread instructions started
[2025-01-02 20:57:21.297917] [Thread 0] Sync function 'createCounter' started
[2025-01-02 20:57:21.297921] [Thread 0] Created shared variable counter
[2025-01-02 20:57:21.297923] [Thread 0] SETSHARED counter = 1000
[2025-01-02 20:57:21.297924] [Thread 0] Found shared variable counter
[2025-01-02 20:57:21.297925] [Thread 0] PRINT counter = 1000

[2025-01-02 20:57:21.297939] [Thread 1] Thread instructions started
[2025-01-02 20:57:21.297941] [Thread 1] Waiting for lock 'counter_lock'
[2025-01-02 20:57:21.297942] [Thread 1] Acquired lock 'counter_lock'
[2025-01-02 20:57:21.297945] [Thread 1] SET increment = 10
[2025-01-02 20:57:21.297942] [Thread 0] Thread instructions finished
[2025-01-02 20:57:21.297956] [Thread 1] Found shared variable counter
[2025-01-02 20:57:21.297961] [Thread 1] ADD counter = increment(10) + counter(1000) = 1010
[2025-01-02 20:57:21.297965] [Thread 1] PRINT counter = 1010
[2025-01-02 20:57:21.297971] [Thread 1] Released lock 'counter_lock'
[2025-01-02 20:57:21.297973] [Thread 1] Thread instructions finished

[2025-01-02 20:57:21.297944] [Thread 2] Thread instructions started
[2025-01-02 20:57:21.297978] [Thread 2] Waiting for lock 'counter_lock'
[2025-01-02 20:57:21.297979] [Thread 2] Acquired lock 'counter_lock'
[2025-01-02 20:57:21.297981] [Thread 2] SET decrement = -10
[2025-01-02 20:57:21.297982] [Thread 2] Found shared variable counter
[2025-01-02 20:57:21.297984] [Thread 2] ADD counter = decrement(-10) + counter(1000) = 990
[2025-01-02 20:57:21.297985] [Thread 2] PRINT counter = 990
[2025-01-02 20:57:21.297986] [Thread 2] Released lock 'counter_lock'
[2025-01-02 20:57:21.297988] [Thread 2] Thread instructions finished

[2025-01-02 20:57:21.297998] [VM] TinyVM finished.

Bytecode execution differences

Our bytecode execution model is also much simpler compared to the JVM’s advanced execution engine. Let’s see what are the main differences to learn more about how JVM executes the byte-code:

Execution engine

TinyVM:

  • Direct bytecode interpretation

JVM — Multiple execution modes:

  • Interpreted mode for initial execution
  • Just-In-Time (JIT) compilation for hot methods
  • Adaptive optimization based on runtime profiling
  • Support for different JIT compilers (C1/C2 in HotSpot)

Memory management

TinyVM:

  • Allocates global variables directly in the heap
  • Basic heap allocation without garbage collection

JVM: Sophisticated memory management:

  • Generational garbage collection
  • Multiple GC algorithms (Serial, Parallel, G1, ZGC)
  • Object allocation optimization
  • Escape analysis for stack allocation

Thread management

TinyVM:

  • Basic thread creation and synchronization

JVM — Advanced threading features:

  • Thread pooling and management
  • Biased locking
  • Lock coarsening and elimination
  • Integration with OS thread scheduling

Function call handling

TinyVM:

  • Simple function calls without proper call stack

JVM: Complete method invocation system:

  • Method resolution and dispatch
  • Virtual method table (vtable)
  • Interface method tables
  • Dynamic dispatch optimization

Error handling

TinyVM:

  • Minimal error reporting

JVM: Comprehensive exception handling:

  • Stack trace generation
  • Exception tables
  • Try-catch-finally blocks
  • Stack unwinding

Performance features

TinyVM:

  • No runtime optimization

JVM — Extensive runtime optimization:

Native integration

TinyVM:

  • No native code integration

JVM: Complete native interface (JNI):

  • Native method resolution
  • Data marshalling
  • Native library loading
  • Security checks

The complete source code for this article is available in the tiny-vm_07_compilation directory of the TinyVM repository.

The next steps

Our current implementation has one significant limitation: the lack of a proper function call stack. This makes it difficult to handle nested function calls elegantly and limits our ability to implement more advanced features. In the next article, we’ll implement a proper function call stack to address these limitations.

--

--

No responses yet