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.
# 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.binrisc-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
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
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.
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.
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'
registers: list[int] # 32 × 32-bit unsigned integers
pc: int # Program Counter, starts at TEXT_START (0x0)
memory: bytearray # 1 MB flat byte-addressable memoryx0 hardwired to 0 — reads always return 0; writes are silently ignored. Asserted explicitly.
TEXT_START = 0x00000000 # Code loaded here
DATA_START = 0x00010000 # Stack/heap starts here (64 KB gap)
MEM_SIZE = 0x00100000 # 1 MB totalThe 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.
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 |
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
...
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.
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.
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.
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).
pip install pytest
python -m pytest tests/ -v159 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.
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 --hexThe encodings for the canonical instructions (non-pseudo) should match byte-for-byte.