Systems Programming

Building a 64-bit RISC VM in Java: My Full Journey

Ever wanted to build a CPU from scratch? Follow my complete journey of creating a 64-bit RISC-V virtual machine in Java, from the fetch-decode-execute loop to running code.

A

Alex Miller

A systems software enthusiast passionate about low-level programming and computer architecture.

7 min read20 views

Building a 64-bit RISC VM in Java: My Full Journey

Have you ever stared at a line of high-level code, like let sum = a + b;, and wondered what’s really happening under the hood? Not just the compiler magic, but deep down in the silicon. How does the machine actually understand what "add" means? For years, that question gnawed at me. I wanted to pull back the curtain and see the gears of computation turning. So, I decided to embark on a project that felt both ambitious and slightly absurd: building a 64-bit RISC virtual machine from scratch, using Java.

Why Java? It’s a high-level, garbage-collected language, seemingly the opposite of what you’d choose for a low-level project like an emulator. And why RISC-V? This relatively new, open-source instruction set architecture (ISA) has been gaining massive traction. I saw this as the perfect combination: the simplicity and elegance of RISC-V as the target, and the powerful, familiar ecosystem of Java as my development environment. It was a challenge to bridge the high-level world I lived in with the low-level world I wanted to understand.

This post is the full story of that journey. It’s not just a technical tutorial, but a chronicle of the design choices, the frustrating bugs, the "aha!" moments, and the profound satisfaction of finally running a compiled program on a CPU that existed only in my mind and my code. Whether you’re a seasoned systems programmer or a curious Java developer, I hope my experience can demystify the process and inspire you to build your own.

Why This Unconventional Pairing? The Rationale for RISC-V and Java

The choice of technology is the first crucial step in any project. For this one, the choices were deliberate and, I believe, key to its success as a learning exercise.

Why RISC-V?

In the world of ISAs, you have giants like x86 and ARM, known for their power but also their complexity. They carry decades of baggage and features. RISC-V, on the other hand, is a clean slate. It’s a Reduced Instruction Set Computer (RISC) architecture that is:

  • Open and Free: No licensing fees or closed-door documentation. The specifications are available for anyone to read and implement.
  • Modular: It has a small, mandatory base integer instruction set (like RV32I or RV64I). Everything else, from multiplication and division (M extension) to floating-point (F/D extensions), is an optional extension. This makes it perfect for a hobby project—I only needed to implement the base RV64I to have a working, albeit simple, CPU.
  • Elegant: The instruction formats are clean and consistent, making the decoding logic surprisingly straightforward compared to more complex ISAs.

Why Java?

This is the choice that raises eyebrows. "Shouldn't you use C or C++ for performance?" Yes, if my goal were to build the world's fastest emulator. But my goal was understanding. Java offered several advantages for this specific goal:

  • Focus on Logic: With automatic memory management (garbage collection), I didn't have to worry about `malloc` and `free` for the emulator's own data structures. I could focus entirely on the logic of the CPU simulation.
  • Rich Tooling: Powerful IDEs, debuggers, and testing frameworks are standard in the Java ecosystem. Setting breakpoints inside my virtual CPU's execution loop was invaluable for debugging.
  • Bitwise Operations: Java has excellent support for the bitwise operations (`&`, `|`, `^`, `<<`, `>>`, `>>>`) that are the bread and butter of CPU emulation.
  • It's a Challenge: Proving you can tackle low-level concepts in a high-level language is a powerful learning experience in itself.

Architecting the Void: The Core Components of Our VM

A computer, at its core, is beautifully simple. My VM needed to mirror this structure. In Java, this translated to a few key classes and data structures:

  • The VM Class: The main orchestrator, holding everything together and running the main execution loop.
  • Memory: A simple `byte[]` array. For a 64-bit machine, I allocated a few megabytes. `private final byte[] memory = new byte[16 * 1024 * 1024]; // 16MB RAM`
  • CPU Registers: RISC-V has 32 general-purpose 64-bit registers. An array of longs was the perfect fit. `private final long[] registers = new long[32];` I also needed a Program Counter (PC) to track the current instruction. `private long pc = 0;`
  • Instruction Decoder: A set of methods responsible for taking a 32-bit integer (the instruction) and breaking it into its constituent parts (opcode, registers, immediate values).
Advertisement

The Heartbeat: Implementing the Fetch-Decode-Execute Cycle

The soul of any CPU is the fetch-decode-execute cycle. This is the main loop of the VM. It's a continuous, three-step dance.

  1. Fetch: Read the 32-bit instruction from the memory location pointed to by the Program Counter (PC). In Java, this involves reading 4 bytes from our `memory` array. After fetching, we increment the PC by 4 to point to the next instruction.
  2. Decode: This is where the bit-fiddling happens. A 32-bit RISC-V instruction is like a compressed package of information. We use bitwise ANDs and shifts to extract the opcode (what to do), source registers (rs1, rs2), destination register (rd), and any immediate values.
  3. Execute: Based on the decoded information, we perform the operation. This could be adding the values from two registers and storing the result in a third, or loading a value from memory into a register.

In code, the main loop looks something like this:

public void run() {
  while (running) {
    // 1. Fetch
    int instruction = fetchInstruction();

    // 2. Decode & 3. Execute
    decodeAndExecute(instruction);

    // Ensure register x0 is always zero, a RISC-V rule
    registers[0] = 0;
  }
}

Teaching It to Think: Implementing the RV64I Instruction Set

This was the most time-consuming, yet rewarding, part of the project. I started with the simplest instructions and built up from there. The RV64I set is organized into a few formats, which makes implementation manageable. A giant `switch` statement on the instruction's opcode is a very effective pattern here.

For example, to decode an R-type instruction like `ADD rd, rs1, rs2`:

// Inside decodeAndExecute(int instruction)
int opcode = instruction & 0x7F;
int rd = (instruction >> 7) & 0x1F;
int funct3 = (instruction >> 12) & 0x7;
int rs1 = (instruction >> 15) & 0x1F;
int rs2 = (instruction >> 20) & 0x1F;
int funct7 = (instruction >> 25) & 0x7F;

switch (opcode) {
  // ... other cases
  case 0b0110011: // R-type instructions
    if (funct3 == 0b000 && funct7 == 0b0000000) { // ADD
      registers[rd] = registers[rs1] + registers[rs2];
    }
    // ... other R-type instructions like SUB, SLL, etc.
    break;
  // ... other cases
}

Handling I-type instructions like `ADDI rd, rs1, imm` (Add Immediate) required careful handling of the 12-bit immediate value, especially ensuring it was correctly sign-extended to 64 bits if it was a negative number.

Here's a quick breakdown of the main instruction formats I implemented:

FormatExamplePurposeKey Fields Extracted
R-type ADD, SUB, XOR Register-to-register operations opcode, rd, rs1, rs2
I-type ADDI, LW, LD Operations with a 12-bit immediate value opcode, rd, rs1, imm
S-type SW, SD Storing register data to memory opcode, rs1, rs2, imm
B-type BEQ, BNE Conditional branches opcode, rs1, rs2, imm

A Place for Everything: Memory, Registers, and the Program Counter

The abstractions for memory and registers were simple, but their interaction was critical. While the registers were just a `long[]` array, memory access was more nuanced. RISC-V is a little-endian architecture, meaning the least significant byte is stored at the lowest memory address.

To handle reading and writing 8, 16, 32, and 64-bit values correctly to my `byte[]` memory, I found Java's `java.nio.ByteBuffer` to be a lifesaver. It allows you to wrap an existing byte array and specify the endianness.

// Example of writing a 64-bit long (value) to memory at a given address
ByteBuffer.wrap(memory)
          .order(ByteOrder.LITTLE_ENDIAN)
          .putLong(address, value);

This little utility saved me from a world of manual byte-shuffling and potential endianness bugs.

Bringing It to Life: Loading and Running a Real Program

A VM is useless without software to run. But how do you get a RISC-V program? You compile it! I used the official RISC-V GCC toolchain to compile a very simple C program.

Here's the C code:

// simple_add.c
int main() {
  volatile int a = 10;
  volatile int b = 32;
  volatile int sum = a + b; // The answer should be 42!
  return 0;
}

I compiled it using `riscv64-unknown-elf-gcc` and used `objcopy` to dump just the raw machine code into a binary file: `simple_add.bin`. This stripped away all the complex ELF file format headers, leaving me with pure instructions that I could load directly into my VM's memory array at address `0x0`.

The final step was to create a simple launcher in my Java `main` method:

public static void main(String[] args) throws IOException {
  byte[] program = Files.readAllBytes(Paths.get("path/to/simple_add.bin"));

  Vm vm = new Vm();
  vm.loadProgram(program);
  vm.run();

  // After execution, inspect the registers/memory to see the result!
  System.out.println("VM execution finished.");
}

The first time I ran this, set a breakpoint after the `ADD` instruction, and saw the correct value (42) in the destination register... it was pure magic. My Java code was executing real, compiled machine code.

The Struggle and the Glory: Challenges and "Aha!" Moments

The journey was far from smooth. Here are a few hurdles that taught me the most:

  • Sign Extension: My biggest "aha!" moment. For a long time, my `ADDI` instruction worked for positive numbers but failed spectacularly for negative ones. I had forgotten to sign-extend the 12-bit immediate value to 64 bits. A 12-bit `-1` is `0xFFF`, but a 64-bit `-1` is `0xFFFFFFFFFFFFFFFF`. Fixing this required checking the sign bit of the immediate and filling the upper bits accordingly.
  • Branch Offsets: B-type (branch) instructions have a weirdly encoded offset. The bits for the address to jump to are scattered throughout the instruction. Reconstructing the offset correctly required careful reading of the RISC-V specification and precise bit-masking.
  • Off-by-One Errors: In a system with a Program Counter, memory addresses, and register indices, off-by-one errors are your constant companion. Debugging often came down to tracing execution step-by-step and realizing my PC was pointing to `PC+3` instead of `PC+4`.

The triumph wasn't just seeing the final result. It was the moment each individual instruction—`ADD`, `LW`, `BEQ`—started working as intended. Each small victory built the momentum to tackle the next, more complex part of the ISA.

Conclusion: More Than Just Code

Building a RISC-V VM in Java was one of the most rewarding projects I've ever undertaken. It wasn't about creating a production-ready emulator, but about peeling back layers of abstraction to touch the fundamental principles of computing. I now look at any line of code and have a much more tangible, intuitive understanding of how it translates into fetches, decodes, and executes.

If you've ever been curious about what lies beneath, I can't recommend a project like this enough. You don't need to be a C wizard or an electrical engineer. Pick a simple ISA like RISC-V, use a language you love, and just start. Begin with one instruction. Then another. Before you know it, you'll have a working CPU that you built from the ground up.

The goal isn't the destination; it's the deep, satisfying understanding you gain along the way.

You May Also Like