Project --- A Two-Pass Assembler for RV32IMC
August 3, 2026·21 min read·intermediate
The previous chapters have treated the assembler as a black box: the programmer writes add x1, x2, x3 and the toolchain produces the 32-bit word 0x003100B3. This project opens the box. The reader will build…
The previous chapters have treated the assembler as a black box: the programmer writes add x1, x2, x3 and the toolchain produces the 32-bit word 0x003100B3. This project opens the box. The reader will build, from scratch, a two-pass assembler in Python that reads RISC-V assembly source, resolves labels, and emits a flat binary (and optionally an ELF object file) containing the encoded machine code.
The target ISA is RV32IMC: the 32-bit base integer set (RV32I), the integer multiply/divide extension (M), and the compressed instruction extension (C). Together, these three extensions form the practical minimum for a general-purpose RISC-V core [1]. The assembler handles the six 32-bit instruction formats (R, I, S, B, U, J), the subset of 16-bit compressed formats used by common instructions, a handful of pseudoinstructions (li, mv, j, ret, nop, call), and the .text, .data, .word, .half, and .byte directives.
Building an assembler reinforces three ideas from Part II. First, instruction encoding is mechanical, not magical. Second, the mapping from mnemonic to binary is defined entirely by the ISA specification, and reading that specification is a transferable skill (Chapter 22). Third, the two-pass structure is a clean example of a forward-reference problem and its standard solution.
01.Setup and Installation
The project requires Python 3.10 or later. No external libraries are needed. The assembler is a single-file script that reads a .s file and writes a .bin or .o file. A RISC-V cross-toolchain is needed only for verification (assembling the same source with riscv64-unknown-elf-as and comparing the output). The cross-toolchain setup is covered in Chapter 24.
macOS (Homebrew)
macOS setup
| # Python 3 (macOS ships Python 3 with Xcode CLT, | |
| # or install via Homebrew) | |
| brew install python | |
| # Verify | |
| python3 --version # should print 3.10 or later |
Linux (Arch as canonical)
Arch Linux setup
| # Python 3 is pre-installed on most Arch systems | |
| sudo pacman -S python | |
| # Verify | |
| python3 --version |
For Debian/Ubuntu: sudo apt install python3. For Fedora: sudo dnf install python3.
Windows
Python runs natively on Windows. Download the installer from python.org or use the Microsoft Store package.
Windows setup (PowerShell)
| # If using winget: | |
| winget install Python.Python.3.12 | |
| # Verify | |
| python --version |
All Python code in this project uses only the standard library, so no virtual environment or pip install step is needed.
02.Project Architecture
The assembler has four stages, executed in order:
-
Lexer. Reads each line of the source file and splits it into tokens: labels (ending with
:), mnemonics, register names, immediates (decimal or hexadecimal), and directives (starting with.). -
First pass. Walks through the token stream, maintaining a location counter (the current address). Each label stores its address in the symbol table. Instructions and data directives advance the location counter by their size (4 bytes for a 32-bit instruction, 2 bytes for a compressed instruction, 4 bytes for a
.word, 2 bytes for a.half, 1 byte for a.byte). -
Second pass. Walks through the token stream again. For each instruction, the encoder looks up the mnemonic in an instruction table, extracts the operands, resolves any label references via the symbol table, and produces the binary encoding.
-
Output. Writes the encoded bytes to a flat binary file (or an ELF object file for the optional extension).
03.The Lexer
The lexer processes one line at a time. It strips comments (everything after a # character), identifies labels, and splits the remainder into whitespace-and-comma-separated tokens.
A minimal RISC-V assembly lexer
import re
from dataclasses import dataclass
@dataclass
class Token:
kind: str # "label", "mnemonic", "register",
# "immediate", "directive", "symbol"
value: str
line: int
def lex_line(text: str, line_num: int) -> list[Token]:
"""Tokenize one line of assembly source."""
text = text.split("#", 1)[0].strip()
if not text:
return []
tokens: list[Token] = []
parts = re.split(r"[,\s]+", text)
for part in parts:
if not part:
continue
if part.endswith(":"):
tokens.append(
Token("label", part[:-1], line_num)
)
elif part.startswith("."):
tokens.append(
Token("directive", part, line_num)
)
elif part.startswith("x") and part[1:].isdigit():
tokens.append(
Token("register", part, line_num)
)
elif part.lstrip("-").isdigit():
tokens.append(
Token("immediate", part, line_num)
)
elif part.startswith("0x"):
tokens.append(
Token("immediate", part, line_num)
)
else:
# Could be a mnemonic or a label reference
tokens.append(
Token("symbol", part, line_num)
)
# Reclassify the first symbol as a mnemonic
for i, tok in enumerate(tokens):
if tok.kind == "symbol":
tokens[i] = Token(
"mnemonic", tok.value, tok.line
)
break
return tokensThis lexer is deliberately simple. It handles the subset of RISC-V assembly syntax that the project needs. A production assembler (like GNU as) has a far more elaborate lexer that handles string literals, expression evaluation, macro expansion, and multiple assembly dialects.
04.The Symbol Table and First Pass
The first pass walks through every line of the tokenized source, maintaining a location counter (abbreviated loc) that starts at the base address (typically 0x0000_0000 for a flat binary).
First pass: build the symbol table
def first_pass(
lines: list[list[Token]],
base_addr: int = 0
) -> dict[str, int]:
"""Build the symbol table.
Returns a dict mapping label names to addresses.
"""
symbols: dict[str, int] = {}
loc = base_addr
for tokens in lines:
for tok in tokens:
if tok.kind == "label":
if tok.value in symbols:
raise ValueError(
f"Line {tok.line}: duplicate "
f"label '{tok.value}'"
)
symbols[tok.value] = loc
# Advance loc by instruction size
instr = _find_mnemonic(tokens)
if instr is not None:
loc += _instruction_size(instr)
elif _has_directive(tokens, ".word"):
loc += 4
elif _has_directive(tokens, ".half"):
loc += 2
elif _has_directive(tokens, ".byte"):
loc += 1
return symbolsThe helper _instruction_size returns 2 for compressed (C-extension) instructions and 4 for all others. Determining whether an instruction is compressed requires checking whether the mnemonic starts with c. (e.g., c.add, c.lw, c.beqz).
05.RISC-V Instruction Formats
The six 32-bit RISC-V instruction formats are the backbone of the encoder. Each format places the opcode in bits [6:0] and the destination register rd in bits [11:7] (when present). The formats differ in how they arrange the remaining fields.
Table 1. RV32I instruction formats (32-bit)
| Format | Fields (MSB to LSB) | Example instructions |
|---|---|---|
| R | funct7 $ | |
| I | imm[11:0] $ | |
| S | imm[11:5] $ | |
| B | imm[12$ | 10:5\] |
| U | imm[31:12] $ | |
| J | imm[20$ |
The immediate encoding deserves special attention. In the I-format, the 12-bit immediate occupies a contiguous field. In the B-format, the immediate bits are scattered: bit 12 is in position 31, bits 10:5 are in positions 30:25, bits 4:1 are in positions 11:8, and bit 11 is in position 7. This scattering is deliberate. It keeps the register specifier fields (rs1, rs2, rd) in the same positions across all formats, which simplifies the hardware decoder. The assembler’s job is to reverse this mapping: given a signed offset, place each bit into the correct position.
06.Immediate Encoding Functions
Each instruction format requires its own immediate encoder. The encoder takes a Python integer, checks that it fits in the format’s immediate range, and returns a 32-bit word with the immediate bits in their correct positions.
Immediate encoders for B-type and J-type instructions
def encode_b_imm(offset: int) -> int:
"""Encode a signed branch offset (B-type).
The offset is in bytes and must be even
(the LSB is always 0 and is not stored).
Range: -4096 to +4094.
"""
if offset % 2 != 0:
raise ValueError(
f"B-type offset must be even: {offset}"
)
if not (-4096 <= offset <= 4094):
raise ValueError(
f"B-type offset out of range: {offset}"
)
imm = offset & 0x1FFF # keep 13 bits
# Bit layout: [12|10:5] in bits [31|30:25]
# [4:1|11] in bits [11:8|7]
b12 = (imm >> 12) & 1
b11 = (imm >> 11) & 1
b10_5 = (imm >> 5) & 0x3F
b4_1 = (imm >> 1) & 0xF
return (
(b12 << 31) |
(b10_5 << 25) |
(b4_1 << 8) |
(b11 << 7)
)
def encode_j_imm(offset: int) -> int:
"""Encode a signed jump offset (J-type).
Range: -1048576 to +1048574.
"""
if offset % 2 != 0:
raise ValueError(
f"J-type offset must be even: {offset}"
)
if not (-1048576 <= offset <= 1048574):
raise ValueError(
f"J-type offset out of range: {offset}"
)
imm = offset & 0x1FFFFF # keep 21 bits
b20 = (imm >> 20) & 1
b19_12 = (imm >> 12) & 0xFF
b11 = (imm >> 11) & 1
b10_1 = (imm >> 1) & 0x3FF
return (
(b20 << 31) |
(b10_1 << 21) |
(b11 << 20) |
(b19_12 << 12)
)The S-type immediate encoder splits a 12-bit immediate across bits [31:25] (upper 7 bits) and bits [11:7] (lower 5 bits). The U-type immediate simply shifts the 20-bit upper immediate to bits [31:12]. The I-type immediate occupies bits [31:20] as a contiguous 12-bit signed field.
07.The Instruction Table
The encoder uses a dictionary that maps each mnemonic to its format, opcode, funct3, and (for R-type) funct7. This table is derived directly from the RISC-V specification’s opcode map.
Instruction table (excerpt)
INSTRUCTIONS = {
# R-type: (format, opcode, funct3, funct7)
"add": ("R", 0b0110011, 0b000, 0b0000000),
"sub": ("R", 0b0110011, 0b000, 0b0100000),
"sll": ("R", 0b0110011, 0b001, 0b0000000),
"slt": ("R", 0b0110011, 0b010, 0b0000000),
"sltu": ("R", 0b0110011, 0b011, 0b0000000),
"xor": ("R", 0b0110011, 0b100, 0b0000000),
"srl": ("R", 0b0110011, 0b101, 0b0000000),
"sra": ("R", 0b0110011, 0b101, 0b0100000),
"or": ("R", 0b0110011, 0b110, 0b0000000),
"and": ("R", 0b0110011, 0b111, 0b0000000),
# M extension (R-type)
"mul": ("R", 0b0110011, 0b000, 0b0000001),
"mulh": ("R", 0b0110011, 0b001, 0b0000001),
"div": ("R", 0b0110011, 0b100, 0b0000001),
"rem": ("R", 0b0110011, 0b110, 0b0000001),
# I-type: (format, opcode, funct3)
"addi": ("I", 0b0010011, 0b000),
"slti": ("I", 0b0010011, 0b010),
"xori": ("I", 0b0010011, 0b100),
"ori": ("I", 0b0010011, 0b110),
"andi": ("I", 0b0010011, 0b111),
"lw": ("I", 0b0000011, 0b010),
"lh": ("I", 0b0000011, 0b001),
"lb": ("I", 0b0000011, 0b000),
"jalr": ("I", 0b1100111, 0b000),
# S-type: (format, opcode, funct3)
"sw": ("S", 0b0100011, 0b010),
"sh": ("S", 0b0100011, 0b001),
"sb": ("S", 0b0100011, 0b000),
# B-type: (format, opcode, funct3)
"beq": ("B", 0b1100011, 0b000),
"bne": ("B", 0b1100011, 0b001),
"blt": ("B", 0b1100011, 0b100),
"bge": ("B", 0b1100011, 0b101),
"bltu": ("B", 0b1100011, 0b110),
"bgeu": ("B", 0b1100011, 0b111),
# U-type: (format, opcode)
"lui": ("U", 0b0110111),
"auipc": ("U", 0b0010111),
# J-type: (format, opcode)
"jal": ("J", 0b1101111),
}The table for the C (compressed) extension is separate and maps compressed mnemonics (c.add, c.lw, c.sw, c.beqz, c.j, etc.) to their 16-bit encodings. Compressed instructions use a different set of formats (CR, CI, CSS, CIW, CL, CS, CB, CJ) with smaller register fields (3 bits, encoding only registers x8–x15) and shorter immediates.
08.The Second Pass: Encoding Instructions
The second pass iterates through the token stream a second time. For each instruction line, it determines the format, extracts operands, computes any offsets, and produces the binary word.
Second-pass encoder (R-type and B-type shown)
def encode_r(mnemonic: str, rd: int,
rs1: int, rs2: int) -> int:
fmt, opcode, funct3, funct7 = \
INSTRUCTIONS[mnemonic]
return (
(funct7 << 25) |
(rs2 << 20) |
(rs1 << 15) |
(funct3 << 12) |
(rd << 7) |
opcode
)
def encode_b(mnemonic: str, rs1: int,
rs2: int, offset: int) -> int:
fmt, opcode, funct3 = INSTRUCTIONS[mnemonic]
imm_bits = encode_b_imm(offset)
return (
imm_bits |
(rs2 << 20) |
(rs1 << 15) |
(funct3 << 12) |
opcode
)
def second_pass(
lines: list[list[Token]],
symbols: dict[str, int],
base_addr: int = 0
) -> bytearray:
"""Encode all instructions into a byte array."""
output = bytearray()
loc = base_addr
for tokens in lines:
instr = _find_mnemonic(tokens)
if instr is None:
# Handle directives (.word, .half, etc.)
_handle_directive(tokens, output)
loc += _directive_size(tokens)
continue
mnemonic = instr.value
size = _instruction_size(instr)
fmt = ("C" if mnemonic.startswith("c.")
else INSTRUCTIONS[mnemonic][0])
if fmt == "C":
# Compressed: separate C-extension table
word = _encode_compressed(
mnemonic, tokens, symbols, loc
)
elif fmt == "R":
rd, rs1, rs2 = _parse_r_operands(tokens)
word = encode_r(mnemonic, rd, rs1, rs2)
elif fmt == "B":
rs1, rs2, target = _parse_b_operands(
tokens, symbols
)
offset = target - loc
word = encode_b(mnemonic, rs1, rs2, offset)
# ... similar branches for I, S, U, J
else:
raise ValueError(
f"Unknown format: {fmt}"
)
output.extend(
word.to_bytes(size, byteorder="little")
)
loc += size
return outputThe key detail is the offset computation for B-type and J-type instructions: offset = target_address - current_PC. The current PC is the loc counter, and the target address comes from the symbol table. Because both addresses are known by the second pass, the offset can be computed exactly.
09.Pseudoinstruction Expansion
Several common RISC-V assembly mnemonics are pseudoinstructions that expand into one or two real instructions. The assembler handles these before the encoding step.
Table 2. Common RV32I pseudoinstructions
| Pseudo | Expansion | Notes |
|---|---|---|
nop | addi x0, x0, 0 | No operation |
mv rd, rs | addi rd, rs, 0 | Register copy |
j offset | jal x0, offset | Unconditional jump |
ret | jalr x0, x1, 0 | Return |
li rd, imm | lui rd, upper; | Two instructions |
addi rd, rd, lower | if imm 12 bits | |
call label | auipc x1, upper; | Two instructions |
jalr x1, x1, lower |
The li expansion is the most interesting case. If the immediate fits in 12 bits (signed), a single addi suffices. If it does not, the assembler splits it into a 20-bit upper part (loaded by lui) and a 12-bit lower part (added by addi). The split must account for sign extension: if bit 11 of the lower part is set, the upper part must be incremented by 1 because addi will sign-extend the lower 12 bits, effectively subtracting from the intended value.
10.Compressed Instruction Encoding
The C extension defines 16-bit compressed instructions that map one-to-one to their 32-bit counterparts but use shorter encodings. The assembler recognizes compressed mnemonics (prefixed with c.) and emits 2-byte words instead of 4-byte words.
The compressed formats use several conventions to save bits. Register fields are 3 bits wide, encoding only the eight registers x8–x15 (the “popular” registers that hold function arguments and temporaries). Immediates are shorter. Some compressed instructions have implicit operands: c.lwsp always loads relative to sp (x2), and c.jalr always writes the return address to ra (x1).
A compressed instruction is distinguished from a 32-bit instruction by its two least-significant bits: if bits [1:0] are not 11, the instruction is 16 bits. The first pass must account for this when computing label addresses.
11.Branch Offset Calculation
Branch offset calculation is the point where the two-pass design pays off. Consider the following fragment:
Forward branch example
| addi x1, x0, 10 | |
| beq x1, x0, done # forward reference | |
| addi x2, x0, 20 | |
| done: | |
| addi x3, x0, 30 |
During the first pass, the assembler records:
-
Address
0x00:addi x1, x0, 10(4 bytes). -
Address
0x04:beq x1, x0, done(4 bytes). -
Address
0x08:addi x2, x0, 20(4 bytes). -
Address
0x0C: labeldone.
During the second pass, when encoding the beq at address 0x04, the assembler looks up done in the symbol table and finds 0x0C. The offset is . The B-type immediate encoder places the bits of 8 into the scattered immediate fields. The resulting 32-bit word encodes a branch forward by 8 bytes.
Backward branches work identically, producing a negative offset. The B-type immediate is a 13-bit signed value (the LSB is always 0 and is not stored), giving a range of to bytes from the branch instruction.
12.Output: Flat Binary and ELF
The simplest output format is a flat binary: a file containing nothing but the encoded instruction bytes in order, starting at address 0. This is sufficient for loading into Spike or QEMU in bare-metal mode.
Writing the flat binary output
| def write_binary(output: bytearray, | |
| filename: str) -> None: | |
| with open(filename, "wb") as f: | |
| f.write(output) |
For integration with the GNU toolchain (linking multiple object files, using a linker script), the assembler can emit a minimal ELF object file. The ELF header, section header table, and .text section are the minimum required structures.
An ELF object file contains:
-
The ELF header (52 bytes for 32-bit ELF): identifies the file as ELF, specifies the target architecture (RISC-V), and points to the section header table.
-
The
.textsection: the encoded machine code. -
The section header table: an array of section descriptors. At minimum, the table contains a null entry, a
.textentry, a.shstrtabentry (section name strings), and a.symtabentry (symbol table, optional).
The ELF format is defined by the System V ABI and is documented in the “ELF Specification” (the Tool Interface Standard). The details of the header fields are mechanical and are left to the project’s reference implementation.
13.Testing the Assembler
The best way to test the assembler is to compare its output with the output of a known-good assembler. The GNU cross-assembler (riscv64-unknown-elf-as) is the reference. For each test case:
-
Assemble the source with
riscv64-unknown-elf-as -march=rv32imc -mabi=ilp32and extract the.textsection withriscv64-unknown-elf-objcopy -O binary. -
Assemble the same source with the project assembler.
-
Compare the two binary outputs byte by byte.
Automated comparison test
| # Assemble with GNU as | |
| riscv64-unknown-elf-as -march=rv32imc \ | |
| -mabi=ilp32 -o ref.o test.s | |
| riscv64-unknown-elf-objcopy -O binary \ | |
| -j .text ref.o ref.bin | |
| # Assemble with the project assembler | |
| python3 rvasm.py test.s -o test.bin | |
| # Compare | |
| cmp ref.bin test.bin && echo "PASS" || echo "FAIL" |
A minimal test suite should include:
-
Every R-type instruction with representative registers.
-
Every I-type instruction, including negative immediates.
-
Store instructions (S-type) with positive and negative offsets.
-
Branch instructions (B-type) with forward and backward targets.
-
luiandauipc(U-type). -
jalwith a forward and backward target (J-type). -
Each pseudoinstruction (
nop,mv,j,ret,lifor both small and large immediates). -
At least one compressed instruction (
c.add,c.lw). -
A program with mixed 32-bit and 16-bit instructions, verifying that label addresses account for both sizes.
14.Common Pitfalls
Sign extension of immediates. The I-type immediate is sign-extended to 32 bits by the hardware. If the assembler treats the immediate as unsigned, negative values produce the wrong encoding. Always sign-extend the immediate before placing it in the bit field.
Off-by-one in branch offsets. The branch offset is computed as , not . RISC-V defines the offset relative to the branch instruction itself, not the next instruction.
Forgetting the implicit zero bit. The B-type and J-type immediates do not store the least-significant bit (which is always 0 for instruction-aligned targets). The encoder must shift the offset right by 1 before placing it in the bit fields, and the decoder shifts left by 1 when reconstructing the offset. If the assembler stores the full offset without removing the LSB, the branch lands at twice the correct distance.
Compressed register encoding. The 3-bit register fields in compressed instructions encode registers x8–x15 as 0–7. If the source uses a register outside this range in a compressed instruction, the assembler should raise an error, not silently produce a wrong encoding.
15.Worked Examples
16.Exercises
References
- [1]Waterman, Andrew and Asanovi\'c (2024). “The RISC-V.”