What a 16-bit CPU taught me about synchronous design
October 17, 2026
·3 min read
I built a 16-bit CPU on a MAX 10 FPGA in ten days in January. Reading back the commit history, most of it is bug fixes, and each one taught me something about synchronous design that I had read before but not really understood. These are the ones worth writing down.
1. Signals update too late
The first version wrote the previous instruction's result into the register file. ADD R1, R2, R3 followed by anything would leave R1 holding whatever the ALU computed one instruction earlier.The ALU result was a VHDL signal, assigned and then written to the register file in the same clocked process. Inside a process, a signal assignment doesn't take effect until the process finishes, so the write-back read the old value. A variable updates immediately:
Vhdl
variable alu_result : std_logic_vector(15 downto 0); -- Use variable for immediate update
...
when x"10" => alu_result := std_logic_vector(valA + valB); -- ADD
...
if r_dest /= 0 then
regs(r_dest) <= alu_result; -- this cycle's result, not the last one
end if;
The rule I now follow: a signal is a wire between processes; within one process, a value you compute and use in the same cycle is a variable.
2. Count the latency of every register in the read path
Block RAM on the MAX 10 has a registered output: the data appears one clock after the address. The state machine allowed exactly one wait state for that. But my first memory map added its own output registers on top, so loads came back late and the CPU read stale data.The fix was to keep exactly one register, the RAM's own, and select between RAM, framebuffer and I/O with a combinational multiplexer after it. Now the latency the state machine assumes and the latency the hardware has are the same number by construction.
3. Don't clock logic from a divided clock
To watch the CPU step on the LEDs, I first clocked it from a counter: 1 Hz, then about 119 Hz. It worked, but a clock made in fabric logic isn't on the FPGA's clock network: it is skewed, and the timing analyser can't reason about it. The right tool is a : everything stays on the 50 MHz clock and only advances when a slow strobe is high.
On this page
clock enable
In the end I didn't need either. Once the ST7735 display showed which test was running and whether it passed, the CPU could run at full speed and I read its state off the screen instead of the LEDs.
4. Synchronise the reset release
The reset button drives the whole design. Asserting reset asynchronously is fine, but releasing it asynchronously is not: if the release lands near a clock edge, some flip-flops leave reset one cycle before others, and the state machine starts in an impossible state. Two flip-flops in series fix it: reset still asserts instantly, but the release is aligned to the clock.
5. Make the hardware report on itself
The single most useful decision was letting the CPU grade itself. The ROM runs 19 tests, one per instruction class. Each writes its id and RUN to a status register, computes, compares, then writes PASS or FAIL. The display driver turns that register into a scoreboard drawn in hardware, so a broken instruction names itself on screen.
It has a blind spot I only saw later: every test is judged with BEQ, and BEQ is only tested near the end. A branch that always fires would make every earlier test pass. Branches have to be verified first, with one case that must be taken and one that must not.
What's next
The timing constraints file still names the board template's clock port rather than the one the design uses, so Quartus has not actually checked the 50 MHz paths. Fixing that comes first. After it: a UART loader so programs don't need resynthesis, then a stack and CALL/RET.