惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Google DeepMind News
Google DeepMind News
博客园_首页
H
Help Net Security
T
Tailwind CSS Blog
S
SegmentFault 最新的问题
GbyAI
GbyAI
Scott Helme
Scott Helme
D
Docker
Hacker News: Ask HN
Hacker News: Ask HN
P
Privacy & Cybersecurity Law Blog
Jina AI
Jina AI
雷峰网
雷峰网
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Spread Privacy
Spread Privacy
G
GRAHAM CLULEY
C
Cisco Blogs
The Hacker News
The Hacker News
F
Full Disclosure
Y
Y Combinator Blog
Blog — PlanetScale
Blog — PlanetScale
Recent Announcements
Recent Announcements
G
Google Developers Blog
量子位
K
Kaspersky official blog
Cisco Talos Blog
Cisco Talos Blog
The Cloudflare Blog
A
About on SuperTechFans
C
Cybersecurity and Infrastructure Security Agency CISA
Last Week in AI
Last Week in AI
博客园 - 三生石上(FineUI控件)
Microsoft Security Blog
Microsoft Security Blog
Martin Fowler
Martin Fowler
T
Tenable Blog
P
Palo Alto Networks Blog
H
Heimdal Security Blog
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
W
WeLiveSecurity
Schneier on Security
Schneier on Security
The Register - Security
The Register - Security
F
Fortinet All Blogs
Stack Overflow Blog
Stack Overflow Blog
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
T
The Blog of Author Tim Ferriss
N
News and Events Feed by Topic
Hugging Face - Blog
Hugging Face - Blog
小众软件
小众软件
V
V2EX
爱范儿
爱范儿

CatCoding

Obr: a Web UI for My Obsidian Vault | CatCoding ghr:把开源维护工作搬进终端 | CatCoding GitHeron:把网页标注写到 GitHub | CatCoding 开发 Runnel 的一些记录 | CatCoding macOS 奇怪的安全扫码机制 | CatCoding hexo-rs:玩 Vibe Coding | CatCoding How Fiber Network Works | CatCoding 在开源中使用 LLM | CatCoding Rust 背锅了:Cloudflare 故障分析 | CatCoding 零知识证明入门 | CatCoding Rust Week 2025 杂记 | CatCoding 从椭圆曲线到 secp256k | CatCoding Future 的大小对性能的影响 | CatCoding Fiber Network: 基于 CKB 实现的闪电网络 | CatCoding 2024:简单的理想生活 | CatCoding CKB new script verification with VM pause | CatCoding CKB RBF 设计和实现 | CatCoding CKB 交易池重构 | CatCoding xz-backdoor 观感 | CatCoding 从明天起,做一个 Rust 程序员 | CatCoding 我喜欢的 shell 工具 | CatCoding 中外程序员差异 | CatCoding Copilot,最好的编程助手 | CatCoding
RISC-V from Scratch: Building a Virtual Machine | CatCoding
2025-09-23 · via CatCoding

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 {
                        let instruction = self.fetch_instruction();
                        let decoded_instruction = self.decode(instruction);
                        self.execute_instruction(decoded_instruction);
                        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 => {
                        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; 
            Some(Instruction::IType {
                rd,
                rs1,
                imm,
                funct3,
            })
        }
        0x33 => {
                        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,     }
}

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 => {
                                        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) => {
                                        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) => {
                                        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 {}
}
  1. Cross-Compile: Use cargo with the specific target and a linker script to build the executable. We need to add options for Cargo in .cargo/config.toml
[target.riscv32imac-unknown-none-elf]
rustflags = ["-C", "link-arg=-Tlink.ld"]

The content for link.ld is as follows. It tells the linker the layout of the binary file generated. Notice that we specify the entry point at address 0x80:

OUTPUT_ARCH(riscv)
ENTRY(_start)

SECTIONS {
    . = 0x80;
    .text : {
        *(.text.boot)
        *(.text)
    }
    .rodata : {
        *(.rodata)
    }
    .data : {
        *(.data)
    }
    .bss : {
        *(.bss)
    }
}

Then we can build the program to a binary:

cargo build --release --target riscv32imac-unknown-none-elf
  1. Disassemble and check the binary code: We can use the tool riscv64-unknown-elf-objdump to double-check the generated binary file:
riscv64-unknown-elf-objdump -d ./demo/target/riscv32imac-unknown-none-elf/release/demo

./demo/target/riscv32imac-unknown-none-elf/release/demo:     file format elf32-littleriscv

Disassembly of section .text._start:

00000080 <_start>:
  80:   4501                    li      a0,0
  82:   4605                    li      a2,1
  84:   45ad                    li      a1,11
  86:   4729                    li      a4,10
  88:   00e61763                bne     a2,a4,96 <_start+0x16>
  8c:   46a9                    li      a3,10
  8e:   9532                    add     a0,a0,a2
  90:   00e61863                bne     a2,a4,a0 <_start+0x20>
  94:   a809                    j       a6 <_start+0x26>
  96:   00160693                addi    a3,a2,1
  9a:   9532                    add     a0,a0,a2
  9c:   00e60563                beq     a2,a4,a6 <_start+0x26>
  a0:   8636                    mv      a2,a3
  a2:   feb6e3e3                bltu    a3,a1,88 <_start+0x8>
  a6:   6585                    lui     a1,0x1
  a8:   c188                    sw      a0,0(a1)
  aa:   8082                    ret

The complete cross-compile Rust code is available at: riscv-demo

Using the VM to Execute Binary

The first problem is how do we parse the executable file? It turns out there is a crate called elf that can help us parse the header of an ELF file. We extract the interested parts from the header and record the base_mem so that we can convert virtual address to physical address. Of course, we also load the code into memory:

pub fn new_from_elf(elf_data: &[u8]) -> Self {
    let mut memory = vec![0u8; MEM_SIZE];

    let elf = ElfBytes::<elf::endian::AnyEndian>::minimal_parse(elf_data)
        .expect("Failed to parse ELF file");

     Get the program entry point
    let entry_point = elf.ehdr.e_entry as u32;

     Iterate through program headers, load PT_LOAD type segments
    for segment in elf.segments().expect("Failed to get segments") {
        if segment.p_type == PT_LOAD {
            let virt_addr = segment.p_vaddr as usize;
            let file_size = segment.p_filesz as usize;
            let mem_size = segment.p_memsz as usize;
            let file_offset = segment.p_offset as usize;

                        let phys_addr = virt_addr - entry_point as usize;
                        if phys_addr + mem_size > MEM_SIZE {
                panic!(
                    "Segment is too large for the allocated memory. vaddr: {:#x}, mem_size: {:#x}",
                    virt_addr, mem_size
                );
            }

                        if file_size > 0 {
                let segment_data = &elf_data[file_offset..file_offset + file_size];
                memory[phys_addr..phys_addr + file_size].copy_from_slice(segment_data);
            }
        }
    }

    let mut vm = VM {
        x_registers: [0; 32],
                pc: entry_point,
        memory,
        mem_base: entry_point,
    };
    vm.x_registers[0] = 0;
    vm
}

What’s left is that we need to extend our VM to support all the instruction formats used in this binary file, including li, bne, beq, etc.

There are 16-bit compressed instructions, so we can’t always increment the PC by 4; sometimes we only need to increment it by 2 for shorter ones.

Another interesting thing is that some of them are conditional jump instructions, so we need to get the return new_pc from the execution of the instruction.

So now we need to update the core logic of fetch and execution of instructions:

// Check the lowest 2 bits to determine instruction length
if first_half & 0x3 != 0x3 {
    // 16-bit compressed instruction
    pc_increment = 2;
    new_pc = self.execute_compressed_instruction(first_half);
} else {
    // 32-bit instruction
    pc_increment = 4;
    if physical_pc.saturating_add(3) >= self.memory.len() {
        break;
    }
    let second_half = u16::from_le_bytes([
        self.memory[physical_pc + 2],
        self.memory[physical_pc + 3],
    ]);
    let instruction = (second_half as u32) << 16 | (first_half as u32);

    if instruction == 0 {
        break;
    }

    new_pc = self.execute_instruction(instruction);
}

The complete new VM which can run compiled RISC-V binary files is available at: riscv-vm

References

公号同步更新,欢迎关注👻