Skip to content

Latest commit

 

History

9 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 

Repository files navigation

rvasm — RV32I Assembler, Interpreter & Disassembler

A complete, from-scratch implementation of the RISC-V 32-bit integer base instruction set (RV32I) in Python. This project implements a two-pass assembler, a fetch-decode-execute VM, and a disassembler — 40 instructions, 6 encoding formats, verified against hand-computed expected values.


Quick Start

# Assemble a .s file and run it
python -m rvasm exec programs/fibonacci.s

# Assemble to a .bin file
python -m rvasm asm programs/factorial.s -o factorial.bin

# Run a .bin file with trace and register dump
python -m rvasm run factorial.bin --trace --regs

# Disassemble a .bin file
python -m rvasm disas factorial.bin

Project Structure

risc-v-assembler-interpreter/
├── rvasm/
│   ├── __init__.py
│   ├── __main__.py       # CLI: asm, run, disas, exec subcommands
│   ├── isa.py            # ISA tables: opcodes, funct3, funct7, register names
│   ├── utils.py          # Bit helpers: mask32, sign_extend, pack/extract immediates
│   ├── lexer.py          # Regex tokenizer: mnemonics, registers, immediates, labels
│   ├── assembler.py      # Two-pass assembler + pseudo-instruction expansion
│   ├── interpreter.py    # RV32IVM: fetch-decode-execute + ecall handler
│   └── disassembler.py   # Machine code → assembly text
├── tests/
│   ├── test_utils.py         # 50 unit tests: mask32, sign_extend, B/J immediates
│   ├── test_assembler.py     # 56 unit tests: every instruction format + error handling
│   └── test_interpreter.py   # 53 unit tests + integration tests
├── programs/
│   ├── fibonacci.s       # fib(10) = 55
│   ├── factorial.s       # 10! = 3628800
│   └── bubblesort.s      # Sort + sum array = 259
└── README.md

ISA Coverage: Full RV32I Base

All 40 instructions across all 6 encoding formats are implemented.

Format Instructions
R-type add sub and or xor sll srl sra slt sltu
I-type (arith) addi andi ori xori slti sltiu slli srli srai
I-type (load) lw lh lb lhu lbu
I-type (jump) jalr
S-type sw sh sb
B-type beq bne blt bge bltu bgeu
U-type lui auipc
J-type jal
System ecall ebreak

Pseudo-instructions: nop mv li la not neg seqz snez sltz sgtz beqz bnez blez bgez bltz bgtz j jr ret call


Assembler Design

Two-Pass Architecture

Pass 1 scans for label definitions and computes each label's byte address. This is necessary because branch targets are often forward references (the label appears after the branch instruction in the source).

Pass 2 encodes each instruction, resolving label references from the symbol table to compute PC-relative offsets for branches and jumps.

Encoding Formats

Each format has a dedicated encoder function that packs fields into the correct bit positions. The interesting ones are:

B-type (branches) — the offset bits are deliberately scrambled to simplify hardware implementation (the sign bit stays at position 31, and the layout aligns with J-type for easy decoding):

word[31]    = imm[12]  (sign bit)
word[30:25] = imm[10:5]
word[11:8]  = imm[4:1]
word[7]     = imm[11]
word[0]     = 0 (always; branches are 2-byte aligned)

J-type (JAL) — similarly scrambled:

word[31]    = imm[20]
word[30:21] = imm[10:1]
word[20]    = imm[11]
word[19:12] = imm[19:12]
word[0]     = 0 (always)

Getting these right by hand — not just copying a table, but verifying them with roundtrip pack/extract unit tests — is the real test of understanding the spec.

Error Handling

The assembler tracks line numbers throughout and raises descriptive errors:

AssemblerError: Line 5: Unknown mnemonic 'frobulate'
AssemblerError: Line 12: Unknown register 'x99'
AssemblerError: Line 8: Undefined label 'taget'  # typo
AssemblerError: Line 3: Duplicate label 'loop'

Interpreter Design

RV32IVM State

registers: list[int]   # 32 × 32-bit unsigned integers
pc: int                # Program Counter, starts at TEXT_START (0x0)
memory: bytearray      # 1 MB flat byte-addressable memory

x0 hardwired to 0 — reads always return 0; writes are silently ignored. Asserted explicitly.

Memory Layout

TEXT_START = 0x00000000   # Code loaded here
DATA_START = 0x00010000   # Stack/heap starts here (64 KB gap)
MEM_SIZE   = 0x00100000   # 1 MB total

The gap between TEXT_START and DATA_START prevents a classic toy-VM bug: if code and data share the same region without protection, a sw instruction can silently overwrite instructions (accidental self-modifying code). The bubble sort program explicitly initializes its array at DATA_START = 0x10000.

Ecall Convention

Syscall number in a7, argument/return in a0:

a7 Syscall Description
1 print_int Print a0 as signed decimal
4 print_str Print null-terminated string at a0
10 exit Halt with exit code a0
11 print_char Print character with ASCII value a0
64 write Write a2 bytes from a1 to stdout
93 exit Linux-style exit

Trace Mode (--trace)

python -m rvasm exec programs/fibonacci.s --trace
  [00000000]  00a00513  addi a0, zero, 10
  [00000004]  00000293  addi t0, zero, 0
  [00000008]  00100313  addi t1, zero, 1
  ...

Design Decisions

Why Python?

Python's bit-manipulation operators (&, |, <<, >>) work cleanly on arbitrary-precision integers, making the encoding/decoding logic readable and direct. The one gotcha — Python ints don't overflow — is handled by an explicit mask32(value) call in every path that writes to a register. This is cleaner than fighting with C's undefined behavior on signed overflow.

Sign Extension Helper

Rather than inlining sign-extension logic per instruction (where it's easy to get the bit width wrong), there's a single tested sign_extend(value, bits) helper used everywhere:

def sign_extend(value: int, bits: int) -> int:
    sign_bit = 1 << (bits - 1)
    return (value & (sign_bit - 1)) - (value & sign_bit)

This is unit-tested for I (12-bit), B (13-bit), J (21-bit), and S (12-bit) immediates including their scrambled-bit-layout pack/extract roundtrips.

Signed vs Unsigned

RISC-V is careful about this: slt/blt/bge are signed; sltu/bltu/bgeu are unsigned. In Python, the distinction is achieved by converting the register's unsigned 32-bit value to a signed Python int via to_signed32() before signed comparisons. There are dedicated unit tests that specifically verify the same bit pattern produces opposite results from slt and sltu.


Extensions Deliberately Excluded

This implements only RV32I — the 32-bit integer base instruction set. Extensions were excluded intentionally:

Extension What it adds Why excluded
M Integer multiply/divide (mul, div, rem) Not base ISA; the factorial program works around this with repeated addition
A Atomic memory operations (lr.w, sc.w, amo*) Requires memory ordering semantics beyond our single-threaded model
F / D Single/double precision floats Adds 32 FP registers and a separate register file; significant scope increase
C Compressed 16-bit instruction encoding Requires variable-width instruction fetch; fundamentally changes the decode loop
Zicsr Control/status registers Requires privilege model and CSR address space

The design of RV32I is specifically intended to be a self-contained, complete base: you can write any algorithm with just these 40 instructions (as the factorial, fibonacci, and bubble sort programs demonstrate).


Test Suite

pip install pytest
python -m pytest tests/ -v

159 tests, all passing. Test categories:

  • test_utils.py (50 tests): mask32, to_signed32, sign_extend, and pack/extract roundtrips for all 6 immediate formats. These test the primitives that everything else depends on.
  • test_assembler.py (56 tests): Hand-computed expected 32-bit encodings for every instruction format, label resolution (forward and backward), pseudo-instruction expansion, and error handling.
  • test_interpreter.py (53 tests): Every instruction's execution semantics, signed vs unsigned correctness, 32-bit overflow wrapping, load/store with sign extension, branches taken/not-taken, JAL/JALR return addresses, and end-to-end integration tests.

Cross-Validation Against Reference Toolchain

To verify correctness against a reference, use Compiler Explorer (godbolt.org) with RISC-V rv32gc gcc and compare the hex output of objdump -d against the output of:

python -m rvasm asm programs/fibonacci.s --hex

The encodings for the canonical instructions (non-pseudo) should match byte-for-byte.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages