I’ve always wanted to learn RISC-V. A few days ago, I finally got my hands dirty with it now.

This post will guide you through the process of building a simple RISC-V VM from the ground up, using Rust as our implementation language.

Understanding the Core Concepts

Before writing any code, I need to grasp the fundamentals of RISC-V.

  • RISC vs. CISC: RISC (Reduced Instruction Set Computing) architectures use a small, highly optimized set of instructions. This is in contrast to CISC (Complex Instruction Set Computing), which has a large number of complex instructions. RISC-V’s simplicity makes it ideal for building a VM.
  • Modular Architecture: RISC-V has a base instruction set (RV32I for 32-bit systems) and optional extensions like M (for multiplication) or F (for floating-point). We’ll focus on the RV32I base to keep things simple.
  • The Three Pillars: At its core, a CPU (and thus our VM) consists of three main components:
    • Registers: A small set of high-speed memory locations used for calculations. RISC-V has 32 general-purpose registers (x0-x31).
    • Memory: A much larger space for storing program code and data.
    • Program Counter (PC): A special register that holds the memory address of the next instruction to be executed.

We can get all the details of RISC-V instructions from RISC-V Technical Specifications.

The VM’s Core Logic

Our VM is essentially a program that emulates a real CPU’s behavior. The core of our VM is the instruction loop, which follows a simple fetch-decode-execute cycle.

  1. Fetch: Read the 32-bit instruction from the memory address pointed to by the PC.
  2. Decode: Parse the instruction’s binary code to determine its type and what operation to perform.
  3. Execute: Perform the operation (e.g., an addition) and update the relevant registers or memory.

Here’s a simplified Rust code snippet to illustrate the VM structure and the run loop:

pub struct VM { x_registers: [u32; 32], pc: u32, memory: Vec<u8>, } impl VM { pub fn run(&mut self) { loop { // 1. Fetch the instruction  let instruction = self.fetch_instruction(); // 2. Decode  let decoded_instruction = self.decode(instruction); // 3. Execute  self.execute_instruction(decoded_instruction); // 4. Increment the PC  self.pc += 4; } } } 

The fetch instruction turns out to be very simple, we just load 4 bytes in little-endian format into a u32 integer:

 /// Fetch 32-bit instruction from memory at current PC  fn fetch_instruction(&self) -> Option<u32> { let pc = self.pc as usize; if pc + 4 > self.memory.len() { return None; } // RISC-V uses little-endian byte order  let instruction = u32::from_le_bytes([ self.memory[pc], self.memory[pc + 1], self.memory[pc + 2], self.memory[pc + 3], ]); Some(instruction) } 

Then we need to decode the integer into a RISC-V instruction. Here’s how we decode IType and RType instructions. The specifications for these two types are:

/// Decode 32-bit instruction into structured format fn decode(&self, code: u32) -> Option<Instruction> { let opcode = code & 0x7f; match opcode { 0x13 => { // I-type instruction (ADDI, etc.)  let rd = ((code >> 7) & 0x1f) as usize; let rs1 = ((code >> 15) & 0x1f) as usize; let funct3 = (code >> 12) & 0x7; let imm = (code as i32) >> 20; // Sign-extended  Some(Instruction::IType { rd, rs1, imm, funct3, }) } 0x33 => { // R-type instruction (ADD, SUB, etc.)  let rd = ((code >> 7) & 0x1f) as usize; let rs1 = ((code >> 15) & 0x1f) as usize; let rs2 = ((code >> 20) & 0x1f) as usize; let funct3 = (code >> 12) & 0x7; let funct7 = (code >> 25) & 0x7f; Some(Instruction::RType { rd, rs1, rs2, funct3, funct7, }) } _ => None, // Unsupported opcode  } } 

Then we want to execute the instruction, just following the specification. For demonstration purposes, we return the execution debug string as a result:

/// Execute decoded instruction fn execute(&mut self, instruction_type: Instruction) -> Result<String, String> { match instruction_type { Instruction::IType { rd, rs1, imm, funct3, } => { match funct3 { 0x0 => { // ADDI - Add immediate  self.write_register(rd, self.x_registers[rs1] + imm as u32); Ok(format!( "ADDI x{}, x{}, {} -> x{} = {}", rd, rs1, imm, rd, self.x_registers[rd] )) } _ => Err(format!("Unsupported I-type funct3: {:#x}", funct3)), } } Instruction::RType { rd, rs1, rs2, funct3, funct7, } => { match (funct3, funct7) { (0x0, 0x00) => { // ADD - Add registers  let result = self.x_registers[rs1] + self.x_registers[rs2]; self.write_register(rd, result); Ok(format!( "ADD x{}, x{}, x{} -> x{} = {}", rd, rs1, rs2, rd, self.x_registers[rd] )) } (0x0, 0x20) => { // SUB - Subtract registers  let result = self.x_registers[rs1] - self.x_registers[rs2]; self.write_register(rd, result); Ok(format!( "SUB x{}, x{}, x{} -> x{} = {}", rd, rs1, rs2, rd, self.x_registers[rd] )) } _ => Err(format!( "Unsupported R-type instruction: funct3={:#x}, funct7={:#x}", funct3, funct7 )), } } } } 

The simplest VM code is available at: riscv-vm-v0

From Rust to RISC-V binary

Now we need to write more complex assembly code for testing our VM, but we don’t want to write assembly code by hand.

To test our VM, we will write Rust code then use cross-compile toolchains to compile it into RISC-V executable files.

  1. Prepare the Environment: Install the riscv32imac-unknown-none-elf target toolchain. This is a bare-metal target, meaning it doesn’t rely on any operating system.
rustup target add riscv32imac-unknown-none-elf 

Next, you’ll need a RISC-V linker. You can get this from the official RISC-V GNU toolchain.

# On Linux or macOS sudo apt-get install gcc-riscv64-unknown-elf # Alternatively, on macOS brew install riscv-gnu-toolchain 

Note: The gcc-riscv64-unknown-elf package includes both 32-bit and 64-bit tools.

  1. Write “Bare-Metal” Rust: Our Rust program must be written for a “bare-metal” environment, meaning you cannot use the standard library and must provide your own entry point and panic handler.
#[unsafe(no_mangle)] pub extern "C" fn _start() { let mut sum = 0; for i in 1..=10 { sum += i; } // Store the result (which should be 55) in a known memory location.  let result_ptr = 0x1000 as *mut u32; unsafe { *result_ptr = sum; } } #[panic_handler] fn panic(_info: &PanicInfo) -> ! { loop {原文:http://catcoding.me/p/riscv-vm/