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.
The assembler serves two purposes in a single tool:
- Assembler — Translates human-readable assembly code into machine code (
Program.rom) - Microcode Generator — Automatically produces the
Microcode.romfile 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.
- 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
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 |
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) |
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, #42Because 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, r2shown 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, #$6000As 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
- Java Development Kit (JDK) 8 or newer
- Apache Maven
git clone https://github.com/gw12343/custom-assembler.git
cd custom-assembler
mvn clean packageThis 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.asmOutput files generated:
-
Program.rom — Machine code for the CPU
-
Microcode.rom — Control ROM for the CPU
- Write your program in a .asm file
- Run the assembler
- Load Program.rom + Microcode.rom into:
- Logisim test circuit
- Cycle-accurate C emulator
- FPGA implementation
- Debug and iterate
This pipeline guarantees that the hardware, toolchain, and emulator are always perfectly synchronized.
; ==========================================
; 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:
- Hardware: Logisim CPU + FPGA implementation
- Emulator: Cycle-accurate C emulator with GUI
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.

