A one page CPU in SpinalHDL

FPGA’s are fun and interesting devices, sadly the usual ways of programming them, Verilog and VHDL, are tedious and annoying to work with. Sadly most FPGA tools support only these languages. Luckily a few projects exist to make a nicer and more fun language to work with, which are translated to either Verilog or VHDL so they can be used with all normal FPGA tools. One of these languages is SpinalHDL, a Scala based language that promises more abstraction, less boilerplate code and also has a good amount of already existing libraries. The only downside is the long name, SpinalHDL, so I’ll short it to Spinal for now :

To try out and learn Spinal a bit, I decided to make a CPU, an always popular project for FPGA’s. To make it a bit more interesting, I wanted to make a CPU which code fits on a single page of paper, inspired by the fun CPU’s made by Revaldinho, which can be found here. Those CPU’s also have the added rule of having a emulator and assembler also fit in a single page of paper, which I choose not to stick to.

The CPU designed is an 8 bit Harvard CPU, meaning it has a separate interface for the instruction memory and the data memory. It has a 13 bit instruction memory bus and a 10 bit data memory bus, meaning it can access 8Kwords of instruction memory and 1Kword of RAM. The CPU is a accumulator CPU, meaning it has a single register called ACC and all calculation results are stored in that. Instructions operate on the ACC and an immediate value or on the ACC and a value from RAM. The CPU also has an interrupt input. When this input is high, the CPU will jump to address 256 on the next cycle. The IRQ pin must be cleared with the instruction on address 256, else the CPU will never continue operation.

All instructions are 16 bit wide and every instruction takes 3 clock cycles to complete. Pipelining or having some instructions be done in less cycles then others is a bit much when you are limited to 66 lines of code :)

The first cycle is an instruction fetch, the second cycle executes the instruction and alters the program counter. The third cycle is for RAM writeback

So on to the instructions, which there are plenty of for such a small amount of code.
ADDI imm, add ACC with IMM, store in ACC
SUBI imm, substract ACC, IMM, store in ACC
ANDI imm, AND ACC with IMM, store in ACC
OR imm, OR ACC with IMM, store in ACC
XORI imm, XOR ACC with IMM, store in ACC
SRLI imm, Shift ACC right logical with IMM, store in ACC
SLLI imm, Shift ACC left logical with IMM, store in ACC
CMPI imm, If IMM is equal or greater then ACC, ACC is 1, else 0

All these instructions also have a ACC with RAM variant: ADD, SUB, AND, OR, XOR, SRL, SLL and CMP

There are also a few IMM only instructions:
SRAI imm, Shift ACC right arithmetic with IMM
CMPSI imm, If signed IMM is equal or greater then signed ACC, ACC is 1, else 0
SCPU, Store upper 5 bits of the program counter in ACC (IMM not used)
SPCL imm, Add imm to lower 8 bits of program counter, store in AC

Several instructions can change the program counter:
JMPZI imm, if ACC is zero, jump to imm
JMP ram, jump to acc shifted left 8 places + value from RAM

A short PC relative jump can be achieved with an IMM operation. These instructions substract 2 to 16 (only even numbers) from the program counter is ACC is 0 after the calculation.
JADDI imm, jmpoffset
JSUBI imm, jmpoffset
JANDI imm, jmpoffset
JORI imm, jmpoffset
JXORI imm, jmpoffset
JSLRI imm, jmpoffset
JSLLI imm, jmpoffset
JCMPI imm, jmpoffse

No CPU is complete without being able to store data to RAM:
STA ramaddr, store ACC into RAM

So then finally, the code for the CPU, GPLv3 licensed. A small SoC with GPIO, a timer and UART will follow soon as well.

package mylib
import spinal.core._
class OPC extends Component {
  val io = new Bundle {
    val din_instr = in  Bits (16 bits); val addr_instr = out Bits(13 bits)
		val din_ram = in  Bits (8 bits); val dout_ram = out  Bits (8 bits)
		val addr_ram = out  Bits (10 bits); val we_ram = out Bool; val IRQ = in Bool
  }
	val r_addr = Reg(Bits(10 bits)) init("0000000000")
	val r_pc, r_pcIRQ = Reg(UInt(13 bits)) init(0)
	val r_acc, r_accIRQ = Reg(UInt(8 bits)) init(0)
	val state = Reg(UInt(2 bits)) init(2)
	val instr = Reg(UInt(16 bits)) init(0)
	io.addr_instr := r_pc.asBits
	io.dout_ram := "x00"
	io.addr_ram := io.din_instr(9 downto 0).asBits
	io.we_ram := False
	when(state === 0){		//fetch new instruction stage
		instr := io.din_instr.asUInt
		state := 1
	}.elsewhen(state === 1){		//Stage for ALU part for instruction, and handle PC (increase or do a jump)
		r_pc := r_pc + 1
		val input = Mux(instr(13) && !(!instr(15) && instr(14)), io.din_ram.asUInt, instr(7 downto 0))
		when(instr(15) === False) {
			val tr_acc = instr(12 downto 10).mux(		//ACC with IMM/RAM instructions
				0 -> (r_acc + input),
				1 -> (r_acc - input),
				2 -> (r_acc & input),
				3 -> (r_acc | input),
				4 -> (r_acc ^ input),
				5 -> (r_acc |>> input).resize(8),
				6 -> (r_acc |<< input).resize(8),
				7 -> Mux((r_acc < input), U(1, 8 bits), U(0, 8 bits)))
			when((instr(15) === False) && (instr(14) === True) && (tr_acc === 0)){
				r_pc := r_pc - ((((instr(13).asUInt << 2) + instr(9 downto 8))+1)<<1).resized
			}
			r_acc := tr_acc
		}.elsewhen(instr(15) === True && instr(14) === False && instr(13) === False){
			r_acc := instr(12 downto 10).mux(		//needed some more, ACC with IMM for signed cmp and shift arith
			  0 -> (r_pc(12 downto 8) >> 8).resized,
				1 -> (r_pc(7 downto 0) + input),
				5 -> (r_acc >> input).resize(8),
				7 -> Mux(r_acc.asSInt < input.asSInt, U(1, 8 bits), U(0, 8 bits)),
				default -> r_acc)
		}
		when(instr(15) === True && instr(14) === True && instr(13) === False && r_acc === 0){
			r_pc := instr(12 downto 0)
		}.elsewhen(instr(15) === True && instr(14) === True && instr(13) === True){
			r_pc := ((r_acc << 8) + io.din_ram.asUInt).resized
		}.elsewhen(io.IRQ === True && r_pc =/= "0000100000000"){		//IRQ, store PC and acc
			r_pcIRQ := r_pc
			r_accIRQ := r_acc
			r_pc := "0000100000000"
		}.elsewhen(instr === "x8800"){
			r_pc := r_pcIRQ
			r_acc := r_accIRQ
		}
		state := 2
	}.elsewhen(state === 2){		//RAM writeback stage
		when(instr(15) === True && instr(14) === False && instr(13) === True){
			io.we_ram := True
			io.dout_ram := r_acc.asBits
		}
		state := 0
	}
}



So, what do you think ?