My own instruction set with an assembler and emulator, a CPU drawn gate by gate in Logisim that runs the same bytecode, and the arithmetic core built again on a breadboard with real ICs.

loading

The C# emulator ported to JavaScript so it runs here. Left is the assembly; right is the assembled 32-bit words, the registers (a box lights up when an instruction writes it), RAM 0–15 and the console. Click the console and type to feed READ.

One bytecode, three machines

In this project, I made three separate but related systems: an assembler -> disassembler -> emulator toolchain, a Logisim simulation, and a breadboard ALU, all built around the same ISA spec sheet that I defined here.

The emulator has the most robust implementation of my ISA, while the Logisim simulation and the physical buildout both implement a subset of the full set of instructions.

SimpleISA

Every instruction is one 32-bit word, [OPCODE | P1 | P2 | P3]. Each opcode fixes what its three slots mean (register, immediate, jump target or padding), so decoding is just a table lookup. 40 08 05 00 is SET R0 5. There are 32 registers: eight special ones (ZERO, ONE, CHAR, FLAGS, RNDM, USER, IP and a spare) and R0R15.

CategoryMnemonicOpcodeOperandsNotes
NoneNONEhardware0x00-No-op for one cycle. Also what the assembler's trailing padding words decode to.
MathADDhardware0x10out R1 R2out = R1 + R2
MathSUBhardware0x11out R1 R2out = R1 − R2, wraps (0 − 1 = 65535 in the emulator)
MathMULThardware0x12out R1 R2
MathDIVhardware0x13out R1 R2Integer division; ÷0 gives 0
MathLSHFhardware0x14out R1Shift left one bit (in the ALU)
MathRSHFhardware0x15out R1Shift right one bit (in the ALU)
MathGTHANhardware0x16out R1 R2out = 1 if R1 > R2, else 0
MathEQhardware0x17out R1 R2out = 1 if R1 = R2, else 0
MathLTHANhardware0x18out R1 R2out = 1 if R1 < R2, else 0
MathSETBITemulator0x19out bitSet bit bit of out (0 = LSB)
MathCLRBITemulator0x1Aout bitClear bit bit of out
LogicNOThardware0x20out R1
LogicANDhardware0x21out R1 R2
LogicORhardware0x22out R1 R2
LogicNORhardware0x23out R1 R2
LogicNANDhardware0x24out R1 R2
LogicXORhardware0x25out R1 R2
LogicRSHFVARhardware0x26out R1 R2Shift R1 right by R2 bits
FlowJMPhardware0x30labelIP = label (an instruction index)
FlowJMPZhardware0x31label R1Jump if R1 = 0
FlowJMPEQhardware0x32label R1 R2Jump if R1 = R2
MemorySEThardware0x40R1 valR1 = immediate 0–255
MemoryMOVhardware0x41out R1Destination first: MOV R0 R1 copies R1 into R0
MemoryLOADhardware0x42out addrout = RAM[addr]
MemorySTRhardware0x43R1 addrRAM[addr] = R1
MemoryPOPspec only0x44R1Reserved for the stack (not yet in the assembler or emulator)
MemoryPUSHspec only0x45R1Reserved for the stack
MemoryPRNTemulator0x46-If FLAGS bit 0 is set: clear it and print CHAR
MemoryREADemulator0x47-If a key is waiting: CHAR = key, set FLAGS bit 1. Non-blocking, so programs poll.
MemoryINCemulator0x48R1R1 += 1
MemoryDECemulator0x49R1R1 −= 1
MemoryRNDMemulator0x4Amin maxIf FLAGS bit 2 is set: clear it, RNDM = random in [min, max)

The 24 marked hardware run on the Logisim CPU, the rest only exist in the emulator. Note that the toolchain numbers the general registers from 0x08 and the Logisim ROM images number them from 0x00, so the same SET R0 5 is 40000500 there.

Here it is running count_to_five from ROM, slowed down so the steps are visible. The seven-segment display is the register bus climbing to 5 while the instruction pointer walks 0, 1, 2, 3, 4, 5, 3, 4, 5…

Redesigning it: v1 → v2

v1 worked, but each block (ALU, LOGIC, FLOW, MEMORY) decoded part of the instruction itself, so control signals got duplicated and buffered all over the sheet. In v2 one control unit reads the opcode and the three parameter fields and does all the routing in a single clock tick: it picks which block drives the register file and derives the three write enables (Reg_WE, IP_WE, RAM_WE).

That got rid of most of the control buffers, and it's what made RAM easy: LOAD and STR are just one more data source and one more write enable. The IP also went from 4 to 8 bits, so programs can be 256 instructions instead of 16. v2 is the base I'll extend for the stack.

Logisim layout of CPU v1
v1: 16-word ROM, per-block decoding, 4-bit instruction counter.
Logisim layout of CPU v2
v2: 256-word ROM, central control unit, 256×8 RAM, 8-bit IP.
Inside the v2 control unit in Logisim
Inside the v2 control unit. The high nibble of the opcode picks ALU, LOGIC, FLOW or MEMORY, three muxes route the A/B/C register values to that block, and the green muxes at the bottom derive Reg_WE, IP_WE and RAM_WE from the same bits.

From simulation to breadboard

The full CPU has far too many gates to wire by hand, so I drew a much smaller circuit (logisim/logisim_small/simplercpu.circ): just the arithmetic core. It reads operands from ROM and runs them through a 2-to-1 mux, a 4-bit full adder and an XOR IC, enough for ADD and SUB. Once that worked in Logisim I built it with real 74LS chips on a Digilent breadboard, an AT28C256 EEPROM as the program ROM and an LED bar for the result.

The mini ALU built on a breadboard

Parts

What broke and what I learned

Isolate the faulty instruction. When the CPU crashed on one instruction it was hard to see why mid-program. Running just that instruction and stepping the clock made most bugs obvious in a few ticks.

Write registers on the falling edge. v1 wrote registers on the rising edge, so within one tick a value could race out of a register, through the ALU and back in before anything settled. Writing on the falling edge gives the control unit and ALU the first half of the cycle to settle, and let me remove the NOT on the clock in the main circuit. I also put a mux on the register output so I don't see intermediate values between ticks.

Build the IP as a register, not a counter. The v1 instruction pointer was a bunch of T flip-flops that didn't act the way they were supposed to, so I changed it to a register built from D flip-flops, which do.

v1 instruction counter built from T flip-flops
The v1 instruction counter: four T flip-flops plus the load logic.
v1 register file in Logisim
The register file: eight 8-bit registers behind a demux for writes and three muxes for the A, B and C read ports.

Testing the toolchain

There's an xUnit suite that GitHub Actions runs on every push. Every example program is assembled and compared byte-for-byte to its committed .bin, then disassembled and reassembled to check assemble(disassemble(bin)) == bin. There are also per-opcode encode/decode round trips and a label test (SET R0 5 / : TOP / JMP TOP has to give 30 01 00 00). The JavaScript emulator on this page was checked against the same binaries.

Rock Paper Scissors

A full game in SimpleISA. It asks RNDM for a 1–3 computer choice, spins on READ until a key shows up, echoes it with PRNT, maps r/p/s to 1/2/3, then walks the win table with EQ/JMPZ pairs and prints W, L or T. Pick it in the emulator above, click the console and type r, p or s. The listing below has the working-out comments trimmed; the file in the repo keeps them, including the block where I talk myself through which way JMPZ jumps.