Skip to content

Repository files navigation

Custom Assembler & Microcode ROM Generator

Java-based two-pass assembler and microcode generator for a custom 32-bit CPU.

This project is a core companion to the Custom 32-bit CPU from Scratch repository. It ensures perfect consistency between the assembler, hardware (Logisim + FPGA), and the cycle-accurate emulator.


Overview

The assembler serves two purposes in a single tool:

  1. Assembler — Translates human-readable assembly code into machine code (Program.rom)
  2. Microcode Generator — Automatically produces the Microcode.rom file that defines all 29 control signals for every instruction

Because the instruction set and microcode sequences are defined once in Java enums, the assembler and the CPU hardware are guaranteed to stay in sync.

asm-mc

Features

  • Two-pass assembly with full forward label resolution
  • Strong operand type-checking (REGISTER, IMMEDIATE, MEMORY, LABEL, etc.)
  • Easily extensible instruction definitions via Java enums
  • Automatic Microcode ROM generation (4K × 29-bit)
  • Outputs clean binary files ready for hardware and emulator
  • Perfect consistency between software toolchain and hardware

Instruction Format

All instructions follow this fixed 32-bit format:

Field Size Description
opcode 8 bits Instruction operation code
r1 4 bits First register operand
r2 4 bits Second register operand
literal 16 bits Immediate value, memory address, or branch offset

Operand Typing

Every parsed operand is assigned an OperandType before the assembler attempts to match the signature to a CPUInstruction:

1  package com.gabe.assembler;
2  
3  /** Different types of operands */
4  public enum OperandType {
5      /** GPR or SPR */ REGISTER,
6      /** Register storing pointer */ REGISTER_IND,
7      /** Register storing pointer with offset */ REGISTER_IND_OFFSET,
8      /** Immediate value */ IMD,
9      /** Memory location */ MEM,
10     /** Memory location of pointer */ MEM_IND,
11     /** A label, later substituted with MEM */ LABEL
12  }

Example classification:

Operand Type
r1 REGISTER
#42 IMD
$2000 MEM (a memory address, in this case hex 0x2000)
LABEL1 LABEL (later replaced with MEM after the address has been resolved)

Mnemonic-to-Instruction Binding

Each AssemblerMnemonic maps specific operand signatures to CPUInstructions.

Example for the MOV mnemonic:

1  MOV(Map.of(
2      new OperationHeader(REGISTER, REGISTER), CPUInstruction.MOV,
3      new OperationHeader(REGISTER, IMD),      CPUInstruction.MOVI,
4      new OperationHeader(REGISTER, MEM),      CPUInstruction.MOVFROMABS,
5      new OperationHeader(MEM, REGISTER),      CPUInstruction.MOVTOABS
6       /* ... */
7  ));

When parsing:

MOV r1, #42

Because the operands are REGISTER (r1), and IMD (the immediate value 42), lookup finds the corresponding opcode from CPUInstruction.MOVI.

Instruction Definition and Microcode

A CPUInstruction entry contains:

Microcode steps (List)

Opcode (byte)

Operand layout (InstructionData)

Here’s an example for MOV, specifically the move to absolute form like

1 mov $1234, r2

shown internally:

1 MOVTOABS(
2     List.of(
3           STORE_LIT | LOAD_ADDR,   // instruction literal value→memory address register
4           STORE_INS_A | LOAD_RAM,  // r2 → ram (at the address)
5           MC_END                   // end instruction
6     ),
7     0x49, // opcode
8     InstructionData.lit1register2()
9 ),

Instruction Format

The format of all instructions is as such:

--------opcode-------- ----r1---- ----r2---- ------------------------------literal------------------------------
8 bits 4 bits 4 bits 16 bits

​ The first 8 bits are always populated and taken to be the current opcode. Following it, are two 4 bit segments, which are used by some instructions to index certain registers. Lastly, there are 16 bits available as a literal number for calculations, a memory address for absolute or indirect addressing operations, or whatever else is needed.

​ The assembler uses the InstructionData record to map how the parts of the assembly code end up placed in r1, r2, and lit.

Take this instruction, for example:

1 mov r2, #$6000

As you can see, we are taking the hex literal 0x6000, and moving it into r2.

This is what the corresponding cpu instruction consists of:

1 MOVI(List.of(
2       STORE_LIT | LOAD_INS_A,
3       MC_END
4 ), 0x46, InstructionData.register1lit2()),

here, InstructionData.register1lit2() is what specifies how this instruction’s components get assembled. It is

1   public static InstructionData register1lit2(){
2     return new InstructionData(DataSource.OPERAND1, DataSource.EMPTY,             
      DataSource.OPERAND2);
3   }

This gives us the following:

--------opcode-------- ----r1---- ----r2---- ------------------------------literal------------------------------
0x46 0x2 empty 0x6000

Fully assembled, the instruction becomes 0x46206000, ready to be decoded and executed by the cpu.

Table of Opcodes currently implemented in CPUInstruction.java

OPCODES (1)-1

Getting Started

Prerequisites

  • Java Development Kit (JDK) 8 or newer
  • Apache Maven

Building the Project

git clone https://github.com/gw12343/custom-assembler.git
cd custom-assembler
mvn clean package

This produces a runnable JAR in the target/ directory. Running the Assembler After building, run the assembler with your assembly file:

bash

java -jar target/custom-assembler-*.jar path/to/your/program.asm

Output files generated:

  • Program.rom — Machine code for the CPU

  • Microcode.rom — Control ROM for the CPU

Typical Workflow

  1. Write your program in a .asm file
  2. Run the assembler
  3. Load Program.rom + Microcode.rom into:
    • Logisim test circuit
    • Cycle-accurate C emulator
    • FPGA implementation
  4. Debug and iterate

This pipeline guarantees that the hardware, toolchain, and emulator are always perfectly synchronized.


Example Programs

; ==========================================
; Fibonacci - First 15 Numbers 
; Displays ALL 15 numbers on the display at $6000
; ==========================================

    mov sp, #$100
    mov r4, #$6000          ; Display address ($6000)

    mov r6, #15             ; Total numbers to display

    mov r2, #0              ; F(n-2) = 0
    mov r3, #1              ; F(n-1) = 1

    ; --- Display first number (0) ---
    mov [r4], r2
    dec r6

    ; --- Display second number (1) ---
    mov [r4], r3
    dec r6

; ==========================================
; Main loop - generate and display remaining numbers
; ==========================================
fib_loop:
    mov r1, r2              ; r1 = F(n-2)
    add r1, r3              ; r1 = next Fibonacci number

    mov [r4], r1            ; Display current number

    mov r2, r3              ; Shift window
    mov r3, r1

    dec r6
    cmp r6, #0
    jne fib_loop

    hlt                     ; Finished after 15 numbers

Integration with the Full CPU Project. This assembler is designed to work seamlessly with the complete system:

All three components share the same microcode definitions, ensuring bit-exact behavior from simulation to real hardware running at 50 MHz on a Nexys A7 FPGA.

About

assembler for my custom 32 bit cpu arch and isa

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages