I wanted to understand a computer from the very bottom — not the API, not the ISA, but the gates. So I took the Nand2Tetris course, and then did the thing the course stops just short of: I built the whole Hack computer in Verilog, from a single NAND up to a 16-bit CPU with VGA and a keyboard, synthesized it onto a Digilent Basys3, and played a game on a real monitor.
I spend my days as an FPGA engineer, high up the abstraction ladder — AXI, PetaLinux, AI Engines. But a nagging question kept returning: if you keep asking “and what is that made of?” all the way down, where do you land? What is the smallest true thing a computer is built from?
The answer, famously, is a single logic gate. That’s the premise of Nand2Tetris — “The Elements of Computing Systems” by Nisan and Schocken, which I took through Coursera’s Build a Modern Computer from First Principles. You start with one primitive, the NAND gate, and climb: from NAND you build every other gate, from gates an ALU, from the ALU and some flip-flops a CPU, and from the CPU a whole machine that runs software you also write.
Nand2Tetris is brilliant, but it lives in software: you draw your chips in a teaching HDL and test them in a hardware simulator, and the final “computer” runs inside a CPU emulator. It’s the right pedagogy — but the machine never becomes a machine. There is no clock you can probe, no pixel that is actually lit, no key that is actually pressed.
Sitting in a drawer was a Digilent Basys3 — a Xilinx Artix-7 (xc7a35t) board with a VGA port and a USB host that speaks PS/2. So the project chose itself: take the Hack computer off the emulator and put it on silicon. Re-write the chips in real Verilog, make the memories real block RAM, give it a VGA controller for eyes and a keyboard decoder for ears, close timing, generate a bitstream — and play a game on an actual monitor.
Stay honest to the spirit of Nand2Tetris: the datapath is still composed from the course’s gate-level
chips (the ALU is literally muxes, adders and Or8Way), not a behavioral a+b. The FPGA
only gets behavioral help where physics demands it — the large memories, which must be block RAM.
The Hack architecture is a clean climb. Each rung is built only from the rungs beneath it —
this is the whole point. The repo I started from had the lower rungs (the combinational and sequential
chips), but the top of the ladder — the parts that make it a computer — was missing:
PC.v was empty, Inc16.v was a stub, and there was no CPU, memory, ROM, or top level at all.
The ALU is a good sanity check that this is the real thing and not a shortcut: it computes its 18 functions
by zeroing/negating its inputs, choosing between x&y and x+y, and optionally
negating the output — all wired from the course’s own Mux16, Add16 and
Or8Way chips. Before touching the FPGA I completed and fixed the ladder, including two latent
gate bugs (DMux and Or16 had swapped primitive terminals), then proved the new
rungs in simulation.
In the Nand2Tetris emulator, memory is combinational: you put an address on A
and the value at RAM[A] is instantly there in the same cycle. That is a lie the
emulator tells you for free. On an Artix-7, the only way to fit 32K×16 of ROM plus 16K of RAM plus an
8K framebuffer is block RAM — and block RAM is synchronous: you present an
address on one clock edge and the data arrives on the next.
That one-cycle read latency is exactly the kind of detail the emulator hides and hardware won’t. The
fix is a small, clean idea: run everything on one 100 MHz clock, but only let the CPU
step once every 16 clocks via a clock-enable (cpu_en). The block RAMs are read
every clock, so between two CPU steps the pc/address lines have been
stable for many cycles and the read data is long since valid. The latency is real — it’s just
fully hidden in the gap between steps. No gated clocks, no negedge tricks, clean single-edge inference.
The CPU’s registers only latch on cpu_en, so the long combinational path
(instruction → decode → ripple-carry ALU → next PC) is
genuinely a multicycle path — a fact I had to tell the timing engine before
it would close (see the war-stories). The result meets timing at 100 MHz with room to spare.
The CPU sees one 15-bit data address space; three regions live inside it. The screen and keyboard are just
memory — write a word to 16384 and 16 pixels light up; read 24576 and
you get whatever key is held.
| Address | Region | Size | Meaning |
|---|---|---|---|
0x0000–0x3FFF | Data RAM | 16K words | general read/write memory & variables |
0x4000–0x5FFF | Screen | 8K words | 512×256 monochrome framebuffer (16 px / word) |
0x6000 | Keyboard | 1 word | current key code (0 = none), read-only |
The whole ISA is two instruction types. If the top bit is 0 it’s an A-instruction (load
a 15-bit constant into A). If it’s 1 it’s a C-instruction, packed like this:
| 15 | 14 | 13 | 12 | 11 | 10 | 9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
| 1 | 1 | 1 | a | c1 | c2 | c3 | c4 | c5 | c6 | d1 | d2 | d3 | j1 | j2 | j3 |
| op = C | comp (A or M as y; zx nx zy ny f no) | dest A/D/M | jump < = > | ||||||||||||
That is the entire decode job of the CPU: split those fields, feed comp to the ALU, route the
result to the chosen destinations, and load the program counter with A when the jump condition matches.
A computer you can’t see or touch isn’t much fun. Two peripherals turn the Hack core into something you can actually play with.
A 640×480 @ 60 Hz controller (25 MHz pixel clock via pix_en) paints the 512×256
Hack framebuffer into the top-left of the frame. The screen buffer is a true dual-port
block RAM: the CPU writes it as memory while the VGA scans it out as pixels — no arbitration,
no tearing. Horizontal/vertical timing was verified to the exact tick (period 800/96, 525×800).
The Basys3 USB host presents a keyboard as a PS/2 device. A receiver samples the 11-bit frames on the
falling edge of PS2Clk, tracks the E0/F0 extended & release
prefixes, and translates Set-2 scancodes into the Nand2Tetris key codes — letters, digits, and the
arrow keys (130–133) the game needs.
The CPU itself stays faithful to the course design — A and D registers, the composed ALU, jump logic, and the program counter — with one addition for the FPGA: a step-enable so a single fast clock can drive the block RAMs while the CPU advances slowly.
CPU.v — instruction decode and the ALU, wired from the course chips
wire isC = instruction[15]; // 1 => C-instruction
wire isA = ~instruction[15]; // 1 => A-instruction
// A register: A-instruction value, else ALU result
Mux16 muxA(.a(instruction), .b(aluOut), .s(isC), .out(aRegIn));
wire loadA = isA | (isC & instruction[5]); // dest A
Register aRegister(.in(aRegIn), .load(loadA & en), .clk(clk), .out(aReg));
// ALU y input: A register or M (inM), chosen by the a-bit
Mux16 muxY(.a(aReg), .b(inM), .s(instruction[12]), .out(aluY));
ALU alu(.x(dReg), .y(aluY),
.zx(instruction[11]), .nx(instruction[10]),
.zy(instruction[9]), .ny(instruction[8]),
.f(instruction[7]), .no(instruction[6]),
.out(aluOut), .zr(zr), .ng(ng));
assign writeM = isC & instruction[3]; // dest M
wire pos = ~ng & ~zr;
wire doJump = isC & ((instruction[2] & ng) | (instruction[1] & zr) | (instruction[0] & pos));
PC pc0(.in(aReg), .load(doJump & en), .inc(en), .reset(reset), .clk(clk), .out(pcOut));
Nothing went to the board on faith. Every layer has a self-checking Verilog testbench, run in Vivado’s
xsim — from single chips up to the full machine executing real programs and the game logic:
The distance between a passing simulation and a working monitor was, as always, where the actual engineering happened. Three that cost real time:
WNS = -0.673 ns. The critical path was
ROM → instruction decode → the 16-bit ripple-carry Add16 → the PC register —
far too long for a 10 ns period. But it doesn’t need to fit in 10 ns: the CPU only steps once
every 16 clocks, so that path has ~15 clocks to settle and is only ever captured on a cpu_en tick.
The static-timing engine didn’t know that. A set_multicycle_path exception on the paths into
the CPU registers and the memory block RAMs turned the same physical design from -0.673 ns into
+1.74 ns of positive slack. The lesson is pure computer architecture: a slow, enable-gated core
on a fast clock is a multicycle design, and you have to say so.0xE0, came out as 0x70 — the exact same bits, shifted right by
one. The bare PS/2 decoder passed in isolation, so the module was fine. The culprit was timing: the
board’s power-on reset counts down over 255 clocks, but my testbench only waited
40 before it started clocking in the frame. The keyboard was still held in reset when the
start bit arrived, so it missed one falling edge and every subsequent bit landed one position off. A one-line
fix (wait for POR to finish) — but a perfect reminder that on real hardware, reset is not instantaneous
and off-by-one in time looks exactly like off-by-one in data.pc, instruction,
outM and the keyboard so I could watch the CPU on real silicon. Three separate battles: (1)
Vivado 2025.2 rejected the property C_CLK_INPUT_FREQ_HZ on the ILA core, which aborted insertion
half-way and left a broken hub; (2) inserting the debug core after opt_design pruned my
mark_debug taps (nothing was driving them yet) → “driverless nets” at placement;
(3) the auto-created dbg_hub came up with an unconnected clock. The clean resolution was to insert
the ILA before opt_design so the taps keep a load and the hub is generated with a proper
clock, and to extend the same multicycle exception to the ILA’s capture registers. Both a plain and an
ILA-instrumented bitstream now build and meet timing.The whole computer — CPU, ROM, RAM, framebuffer, VGA, keyboard — barely dents the little
xc7a35t. It is almost all memory; the logic is a rounding error.
| Resource | Used | Of device | Note |
|---|---|---|---|
| Slice LUTs | 274 | 1.3 % | the entire CPU + I/O logic |
| Slice registers | 135 | 0.3 % | A / D / PC + pipeline |
| Block RAM tiles | 28.5 | 57 % | 32K ROM + 16K RAM + 8K screen |
| Bonded IOB | 34 | 32 % | VGA(14) + PS/2(2) + clk/btn + 16 LEDs |
| Timing | ● WNS +1.74 ns @ 100 MHz | all constraints met | |
The payoff. A falling-blocks game: a 16×16 block drops one row per tick, you steer it left/right, it locks at the bottom and stacks, a new one spawns, and the board resets when the stack tops out. On the board it runs on the real CPU, drawn over VGA, driven by the keyboard. To write it I also built a small Hack assembler (in Python) that turns readable assembly into the 16-bit machine code the ROM boots from.
The screen below is a faithful Hack CPU emulator running in your browser, executing the
exact same 258-word ROM (tetris.hex) that is programmed onto the Basys3. Same bits, same
fetch/decode/execute, same framebuffer scanned to pixels. Click the screen and use the arrow keys (or
the buttons). What you see here is what lights up on the monitor.
tetris.asm — the tick loop, in Hack assembly (assembled by tools/asm.py)
(FALLNOW)
@bx
D=M
@100
A=D+A // &height[bx]
D=M // D = height[bx]
@15
D=A-D // landing row = 15 - height[bx]
@landing
M=D
@by
D=M
@landing
D=M-D // landing - by
@LAND
D;JLE // by >= landing -> lock the block
@DO_DOWN // else fall one row: erase, by++, base += 512, redraw
D=A
@after_move
M=D
@CALL_ERASE
0;JMP
That @100 A=D+A is the whole trick to an array on a machine with no indexed addressing:
height[] lives at RAM word 100, and the column index is just added to the base to form the
pointer. The block’s screen address is maintained the same way — falling one row is
base += 512 because a 16-pixel-tall block spans 16 screen rows of 32 words each.
| Layer | Technology | Role |
|---|---|---|
| Course / method | Nand2Tetris | NAND-up architecture & the Hack ISA |
| Board | Digilent Basys3 (xc7a35t) | Artix-7 FPGA, VGA port, USB-HID host |
| RTL | Verilog | gate-level chips + CPU/Memory/Computer + I/O |
| Display | VGA 640x480@60 | 25 MHz pixel pipe, 512x256 framebuffer |
| Input | PS/2 (USB-HID) | Set-2 scancode → Hack key codes |
| Tools | Vivado 2025.2 + xsim | synth / impl / bitstream / simulation |
| Debug | Integrated Logic Analyzer | live capture of pc / instruction / outM |
| Toolchain | tools/asm.py (Python) | Hack assembler: .asm → ROM hex |
Everything — RTL, testbenches, the assembler, the game, the build/program scripts and prebuilt bitstreams — is in the repository. From a fresh clone:
assemble a program, simulate, then build & flash the Basys3
# 1. write & assemble a program to a ROM image
python3 tools/asm.py sim/games/tetris.asm -o sim/games/tetris.hex
# 2. simulate the whole computer (Vivado xsim)
bash build/run_sim.sh tb_tetris sim/tb_tetris.v Computer.v CPU.v ...
# 3. synthesize, close timing, generate the bitstream (arg2: 1=insert ILA)
vivado -mode batch -source build/build.tcl -tclargs sim/games/tetris.hex 0
# 4. program the board over JTAG, then plug in a VGA monitor + USB keyboard
vivado -mode batch -source build/program.tcl
The instruction ROM is the full 32K Hack address space, so a larger high-level game (compiled through the
Nand2Tetris Jack → VM → asm toolchain into a bigger .hack) drops
straight into the same ROM slot — the hardware doesn’t change.