Part IIInstruction Set Architectures

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

Bash
# 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

Bash
# 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)

Bash
# 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:

  1. 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 .).

  2. 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).

  3. 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.

  4. 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

Python
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 tokens

This 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

Python
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 symbols

The 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)

FormatFields (MSB to LSB)Example instructions
Rfunct7 $rs2rs2
Iimm[11:0] $rs1rs1
Simm[11:5] $rs2rs2
Bimm[12$10:5\]
Uimm[31:12] $rdrd
Jimm[20$10:110:1

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

Python
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)

Python
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 x8x15) 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)

Python
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 output

The 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

PseudoExpansionNotes
nopaddi x0, x0, 0No operation
mv rd, rsaddi rd, rs, 0Register copy
j offsetjal x0, offsetUnconditional jump
retjalr x0, x1, 0Return
li rd, immlui rd, upper;Two instructions
addi rd, rd, lowerif imm >> 12 bits
call labelauipc 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 2122^{12} 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 x8x15 (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

Riscv
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: label done.

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 0x0C0x04=8\texttt{0x0C} - \texttt{0x04} = 8. 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 4096-4096 to +4094+4094 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

Python
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:

  1. 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.

  2. The .text section: the encoded machine code.

  3. The section header table: an array of section descriptors. At minimum, the table contains a null entry, a .text entry, a .shstrtab entry (section name strings), and a .symtab entry (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:

  1. Assemble the source with riscv64-unknown-elf-as -march=rv32imc -mabi=ilp32 and extract the .text section with riscv64-unknown-elf-objcopy -O binary.

  2. Assemble the same source with the project assembler.

  3. Compare the two binary outputs byte by byte.

Automated comparison test

Bash
# 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.

  • lui and auipc (U-type).

  • jal with a forward and backward target (J-type).

  • Each pseudoinstruction (nop, mv, j, ret, li for 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 targetcurrent_PC\text{target} - \text{current\_PC}, not target(current_PC+4)\text{target} - (\text{current\_PC} + 4). 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 x8x15 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. [1]Waterman, Andrew and Asanovi\'c (2024). “The RISC-V.”
Book mode
computer-architectureinstruction-set-architectures
Was this helpful?