I designed this 16-bit processor from the instruction set up: a 22-instruction ISA, a multi-cycle VHDL core on an Intel MAX 10 FPGA, a Python assembler and a self-test program. The CPU runs the tests on itself and reports every verdict on a TFT screen attached to the board.
At a glance
Item
Detail
Board
Terasic DE10-Lite (Intel MAX 10 10M50), VHDL, Quartus
Clock
50 MHz board clock
ISA width
32-bit instructions; 16-bit data, registers and addresses
Registers
16 × 16-bit, R0 hard-wired to 0
Instructions
22
Pipeline
None: multi-cycle, 11-state FSM, 3–6 cycles per instruction
There are no flags: the only comparison is equality, through BEQ and BNE. HALT spins until reset.
Microarchitecture
There is no pipeline. An 11-state FSM runs fetch, fetch-wait and decode for every instruction, then the states below, plus reset and halt.
Instruction
Cycles
States after decode
NOP
3
none
ALU
4
exec-ALU
Branch, jump
4
exec-branch
Store
5
mem-addr, mem-write
Load
6
mem-addr, mem-read, mem-read-wait
The ROM and data RAM are M9K block RAM with one-cycle registered reads, so fetch and load each pay one wait state. At 50 MHz that gives 12.5 M ALU operations per second.
The 16 × 16 register file is flip-flops. Reset (KEY0) asserts asynchronously and releases through a two-flip-flop synchroniser.
Memory map
The 16 Kbit instruction ROM has its own address space. The data space:
Address
Size
Contents
0x2000–0x2FFF
4K × 16 (64 Kbit)
data RAM
0x3000–0x7FFF
20,480 words (320 Kbit)
128 × 160 RGB565 framebuffer
0xFFFC
1 word
test status: id in bits 15–8, state in bits 1–0
0xFFFD
1 word
display source: colour bars or framebuffer
0xFFFE
1 word
LEDs
Status states are 00 RUN, 01 PASS and 10 FAIL, mirrored on the 7-segment displays and LEDs.
Display and on-screen diagnostics
Panel: ST7735 1.8" 128 × 160 TFT on the Arduino header; VHDL SPI driver, mode 0, 6.25 MHz SCK.
Init: hardware reset, SWRESET, SLPOUT, COLMOD 16-bit, MADCTL, CASET/RASET, NORON, DISPON, all sequenced in hardware.
Refresh: the framebuffer is true dual-port. The CPU writes one port; the driver streams the other continuously at 137 clocks per pixel, about 18 frames/s.
Overlay: a 5×7 font stored as a 38-glyph lookup table, rendered pixel by pixel with no character buffer. A scoreboard keeps the state of each test id.
The self-test ROM
The CPU grades itself on its own screen: a broken instruction shows up as a FAIL with its test id on the TFT, the 7-segment displays and the LEDs, without a logic analyser.
Each of the 19 tests follows the same pattern:
Write the test id with RUN to 0xFFFC.
Compute a result with the instruction under test.
Compare it with the expected value using BEQ.
On a match, write PASS, run a short delay loop and move on.
On a mismatch, write FAIL and HALT.
IDs
Covers
01–02
LDI, MV
03–06
ADD, SUB, ADDI positive, ADDI negative
07–0C
AND, OR, XOR, ANDI, ORI, XORI
0D–0F
SHL, SHR, SAR
10
R0 stays 0
11–12
LD/ST, LDX/STX
13
BNE, J, BEQ
FF
all tests passed
The assembler
Two passes in Python: labels first, then encoding. Comments start with -- or ;, labels end with :, and immediates are decimal or 0x hex; there are no directives or macros.
The program opens by selecting the framebuffer, setting the status word to test 00 RUN and clearing the screen:
Text
START:
LDI R1, 1
ST R1, [0xFFFD] -- show the framebuffer
LDI R15, 0x0000
ST R15, [0xFFFC] -- status: test 00, RUN
LDI R5, 0x3000 -- framebuffer base
LDI R6, 0x5000 -- 20,480 words
LDI R7, 0
CLR_FB:
STX R7, [R5 + 0]
ADDI R5, R5, 1
ADDI R6, R6, -1
BNE R6, R0, CLR_FB
The output is VHDL ROM lines to paste into the ROM file. The first and last instructions assemble to:
FFFC is the offset −4: from address 10, PC + 1 − 4 lands on CLR_FB at address 7.
Known limits and next steps
BEQ is trusted before it is tested: every test is graded with BEQ, so a BEQ that always branches would pass everything. Next: test branches first, with one branch that must be taken and one that must not.
No timing check yet: the SDC file still names the Terasic template's clock port, not CLOCK_50, so the 50 MHz paths are not timing-checked. Next: fix the constraint.
Program in the bitstream: the ROM is a VHDL constant, so every program change means resynthesis. Next: a UART loader.
Small ISA: no stack, calls, interrupts, flags or multiply. Next: a stack with CALL/RET. An earlier design from December 2025 had flags and multiply; they were not carried over.
What the git history taught me about synchronous design is in this post.