This document records the process of porting a 16-bit CPU, originally built as a Logisim-evolution
circuit (cpu.circ) for a computer architecture lab, into synthesizable Verilog targeting the
Vivado/Quartus FPGA toolchain. The port was done task-by-task, mirroring the five tasks of the
original lab, with every module written by hand and verified in simulation against the lab's test data before moving on to the next task.
Every RTL module (alu.v, registerfile.v, control_fsm.v, ins_mem.v, data_mem.v,
condition_encoding.v, cpu.v) was written from scratch, not auto-translated from the .circ file.
Each draft was reviewed against the original circuit's structure and the lab's real golden traces,
bugs were itemized with concrete fixes, and only once a task's modules passed its own test data did
the next task begin. All five tasks now pass end-to-end against every test file the lab provided.
- 16-bit data width throughout (registers, ALU, memory words, instructions).
- 8 general-purpose registers (
R0-R7),R0is a normal writable register (not hardwired to zero). - Non-pipelined, strictly sequential 5-stage FSM: one instruction fully completes all five stages
before the next one begins. Because of this,
IR/RA/RBstay frozen across DECODE-MEMORY, which is what lets several design choices below work out consistently. - 64K x 16-bit instruction memory and 64K x 16-bit data memory, each modeled as a
reg [15:0] mem [0:65535]array. - Debug output
cpu_outis a fixed 48-bit concatenation{PC, IR, RZ}(bits 47:32 / 31:16 / 15:0) that never changes shape across any task. - Halt condition:
IR == 16'h4380, which decodes toBEQ -1, R0, R0(a self-branch, i.e. an infinite loop on its own address) - purely combinational, and the same sentinel is used by every test program in the lab to mark "end of simulation."
| State | Encoding | What happens |
|---|---|---|
| FETCH | 3'b000 |
IR <= instr_word, PC <= PC + 1 (or branch target) |
| DECODE | 3'b001 |
RA <= regfile_A, RB <= regfile_B |
| EXECUTE | 3'b010 |
RZ <= alu_c, RD <= RB (forwards store data), branch target computed |
| MEMORY | 3'b011 |
RW <= is_load ? data_word : RZ; data_mem write enabled for stores |
| WRITEBACK | 3'b100 |
rf_we asserted only if the instruction actually writes back |
The state register is a plain state + 1 wraparound back to FETCH.
| Bits | Field | Notes |
|---|---|---|
[15:14] |
Opcode | 00=ALU, 01=Branch, 10=Load, 11=Store |
[13:11] |
ALU op / Condition code | ALU function select for ALU ops; condition code for Branch |
[10] |
I-bit | 0 = register operand, 1 = immediate operand |
[9:7] |
Dest reg / Data reg / Offset | ALU: destination register. Store: "data register" (read via the B port). Branch: 3-bit signed offset, e.g. -1 for the halt self-loop |
[6:4] |
Src reg 1 / Addr reg 1 / Branch reg A | First operand / address register / first compared register |
[3:0] |
Src op 2 / Addr imm | 4-bit sign-extended immediate when I=1. In register mode (I=0), only the low 3 bits [2:0] select the second register. |
Branch instructions were confirmed (by hand-decoding real test data, e.g. BGE 3, R1, 0 = 0x6d90 and
BNE -4, R5, 0 = 0x4e50) to reuse exactly the same bit positions as ALU/Load/Store - no new fields
were needed. The only wrinkle is that the "dest reg" field [9:7], which has no meaning for a branch
(branches don't write back), is repurposed as a signed 3-bit branch offset, sign-extended to 16 bits
with {{13{IR[9]}}, IR[9:7]}.
alu.v- combinational 16-bit ALU: ADD, SUB, AND, OR, NOR, LSL, LSR, ASR, plus N/C/Z/V flags.registerfile.v- 8x16 register file, synchronous write, combinational read (two read ports).control_fsm.v- the 5-state FSM described above; owns every*_wecontrol signal in the datapath. Takes two datapath-computed conditions as inputs (writes_back,is_branch_taken) so it can gaterf_weand the EXECUTE-stagepc_wecorrectly without knowing instruction semantics itself.ins_mem.v- 64K x 16 instruction memory, combinational read, optional$readmemhload via aMEM_FILEparameter.data_mem.v- 64K x 16 data memory, combinational read, synchronous (nonblocking) write. Noresetport, matching the original circuit's RAM configuration ("Use clear pin: No").condition_encoding.v- combinational branch condition evaluator (EQ/NE/LT/LE/GT/GE) from the ALU'sZ/N/Vflags, using the standard signed two's-complement comparison scheme (LT=N != V, etc.).cpu.v- top-level datapath: instantiates all of the above, decodes instruction fields, and owns the six architectural registersPC, IR, RA, RB, RZ, RW, RD.
Verified against a 28-vector test table covering every operation and flag combination. Bugs found and fixed along the way:
- Missing
endkeywords after the ADD/SUBbeginblocks (syntax error). - A missing
OP_NORcase entirely. - Shift amount read from
B[4:0]instead of the spec'sB[3:0](caught by test vectors specifically designed to expose bit-4 leakage). - SUB's carry flag came out inverted relative to the expected "NOT borrow" convention. The first fix
attempt,
A + (~B) + 1, still produced the wrong carry - the real cause was Verilog's context-determined width propagation:~Bwas silently extended to 17 bits by the surrounding addition's width before being inverted. The actual fix forces self-determined widths via concatenation:{carry, C} = {1'b0, A} + {1'b0, ~B} + 17'b1;.
Verified by replaying the exact 16-cycle stimulus extracted from the .circ file's own
RegisterFileTester ROMs. Bugs fixed:
- A typo,
registers[Addr+C], should have beenregisters[Addr_C]. - The
A/Bread-port outputs were never actually driven (missingassign). - An incorrect guard hardwired
R0to zero on writes (we && Addr_C != 3'b0); the spec and the RegisterFileTester's own gold data both confirmR0is a normal, fully writable register.
Built control_fsm.v, ins_mem.v, and the first version of cpu.v. Verified 17/17 cpu_out
transitions against aluinstrtest.rom/.out. Notable bugs along the way: an FSM draft that crammed
everything into a single combinational block with no clocked state register at all; a later draft that
left a stray state-update line inside the combinational block, creating a zero-delay combinational loop
(Iteration limit reached at time 0 ps); 17-bit register widths that should have been 16; the ALU's B
input wired straight to RB instead of through the immediate/register mux; and several missing
semicolons/commas in module instantiations.
Built data_mem.v and extended cpu.v/control_fsm.v for Load/Store. Verified 70/70 transitions
against each of linklisttest1 and linklisttest2 (a real singly-linked-list traversal test - not
just straight-line arithmetic). Key design resolution: the FSM's control table asserts rd_we in
EXECUTE and rw_we in MEMORY, which only works because data_mem is addressed by the live
combinational alu_c, not the registered RZ - since RA/RB are frozen from DECODE through MEMORY
in this non-pipelined design, alu_c stays valid the whole time, letting the RAM's read/write start a
cycle "early" without needing a fourth address register. Bugs fixed:
data_mem's address was wired toPCinstead ofalu_c.data_mem's write data was wired toRZinstead ofRD(the value has to be forwarded one cycle, since by the time the store's memory access happens,RBhas already been overwritten by DECODE of whatever comes next in some drafts).- A blocking assignment (
mem[addr] = data_in;) insidedata_mem's clocked write block, which breaks the "write takes one cycle" semantics real synchronous RAM has - fixed to nonblocking (<=). - A broken hand-rolled write-enable latch with a missing
else, causing memory corruption after the first store - replaced with a direct combinational.we(is_store && rw_we). addr_b_selimplicitly declared as a 1-bit wire (silent truncation) instead of[2:0].
Built condition_encoding.v and extended control_fsm.v (new is_branch_taken input, EXECUTE-stage
conditional pc_we) and cpu.v (branch target computation, ALU-op override to force SUB for
comparisons, condition-code evaluation). Verified against all four multiplication1-4 tests - a real
shift-add multiply routine with backward-taken branches, so cpu_out's PC field does not increase
monotonically in these golden traces, unlike every earlier task. Final results: 60/60, 53/53, 41/41,
48/48 transitions matched. Bugs fixed:
- Dangling-else bug in the PC update block. An early draft wrapped only the
if (is_branch_taken)arm in abegin...endthat closed before the trailingelse, so thatelsesilently re-attached to the outerif (reset)/else if (pc_we)chain instead of the intended innerif. Net effect: PC never advanced on ordinary FETCH cycles, but incremented every single clock during DECODE/MEMORY/ WRITEBACK. Fixed by wrapping the entire innerif/elsepair in onebegin...end. - Stale-
IR-during-FETCH double branch.is_branch_takenwas computed straight fromIR, but during the FETCH stateIRstill holds the previous instruction until the FETCH→DECODE clock edge. Sincepc_weis also unconditionally high during FETCH, the branch-target arm of the PC mux was firing a second time - using the previous branch's offset - immediately after any taken branch, corruptingPC(observed directly: a correct branch 2→5, followed by an incorrect 5→8 using the same +3 offset again, instead of the expected 5→6). Fixed by gating branch-taken withrz_we, which is asserted only during EXECUTE:wire is_branch_taken = is_branch && cond_result && rz_we;. This is the same principle every other IR-derived decode signal in the design already follows implicitly (they're only ever acted on during DECODE-WRITEBACK, whenIRis guaranteed current) -is_branch_takenwas the one signal that also had an effect during FETCH, so it needed the same protection made explicit. condition_encoding.v'scasestatement had nodefault, which would infer a latch for the two unused 3-bit condition codes; addeddefault: C = 1'b0;. It also originally used nonblocking (<=) assignment inside a combinationalalways @(*)block - not a functional bug in isolation, but inconsistent with the rest of the design's convention, so cleaned up to blocking (=).
Every task was validated the same way, against the lab's own real golden data - never invented test cases:
- Golden traces are change logs, not per-cycle dumps. Each
.outfile only gets a new row whencpu_outactually changes value: Fetch changesPC/IR, Execute changesRZ; Decode/Memory/ Writeback never touchcpu_out, so they never add a row. All testbenches samplecpu_outevery clock but only check it against the next expected row on an actual change. - ROM contents are loaded via hierarchical poke, not
$readmemh. The simulator in use here had working-directory issues resolving$readmemhfile paths from the GUI, so every testbench instead pokes instruction words directly into the instruction memory's internal array from aninitialblock at time 0 (dut.imem_inst.mem[i] = 16'hXXXX;), bypassing file I/O entirely. - Self-checking, not eyeballed. Every testbench decodes the real
.outfile into areg [47:0] expected[]array and a real.romfile into hierarchical pokes, then runs awhile (!halt && cycle_count < MAX_CYCLES)loop that fails loudly (with cycle number, expected value, and actual value) on any mismatch, and also fails if the number ofcpu_outchanges doesn't match the golden row count, or ifhaltnever asserts within the timeout.
Test files delivered and their final results:
| Test | Instructions | Golden rows | Result |
|---|---|---|---|
tb_alu.v |
28 vectors | - | All passed |
tb_registerfile.v |
16-cycle replay | - | All passed |
tb_cpu_alu.v (Task 3) |
9 | 17 | 17/17 |
tb_cpu_linklist1.v (Task 4) |
41 | 70 | 70/70 |
tb_cpu_linklist2.v (Task 4) |
42 | 70 | 70/70 |
tb_cpu_mult1.v (Task 5) |
26 | 60 | 60/60 |
tb_cpu_mult2.v (Task 5) |
26 | 53 | 53/53 |
tb_cpu_mult3.v (Task 5) |
26 | 41 | 41/41 |
tb_cpu_mult4.v (Task 5) |
26 | 48 | 48/48 |
- Blocking (
=) vs. nonblocking (<=): nonblocking belongs in every clocked (always @(posedge clk)) block, to correctly model all registers updating "simultaneously" from their pre-edge values, the way real flip-flops behave. Blocking belongs in combinational (always @(*)) blocks, where top-to-bottom, immediate evaluation is exactly what's wanted. Using blocking assignment fordata_mem's write was the clearest example of what goes wrong when this is mixed up: it broke read-during-write timing. wirevs.regis about how a signal is driven, not what it physically is. A signal driven by a continuousassignis awire. A signal written inside any procedural (always/initial) block - combinational or clocked - must be declaredreg, even if it has no actual memory (e.g. everyalways @(*)output inalu.vandcontrol_fsm.v). A submodule's output port always connects to awirein the parent, regardless of whether that port isregorwireinternally - the reg/wire choice never crosses a module boundary.- Context-determined width propagation. Operators like
~,+,-are context-determined and will silently extend their operands to match the width of the surrounding expression before applying the operator - this is what caused the SUB carry-flag bug twice. Concatenation ({1'b0, X}) forces self-determined width and sidesteps the problem. - The dangling-else trap. In
if (cond) begin ... end else ..., thebegin/endplacement -not indentation- decides whichifanelsebinds to. An innerifwithout its ownelse, sitting inside abegin...endthat closes right after it, will silently hand the nextelseto the outerifinstead. - Signals that are valid everywhere except one specific state. Any signal decoded from
IRis only guaranteed current from DECODE through WRITEBACK, not during FETCH (whereIRstill holds the previous instruction until the clock edge). Signals that are only acted on during DECODE-WRITEBACK (the vast majority) never notice this.is_branch_takenwas the one exception, because it also affects the FETCH-stagePCupdate - which is why it needed to be explicitly masked withrz_we. - Zero-delay combinational loops. A signal read and written within the same
always @(*)block with no intervening clocked register producesIteration limit reached at time 0 psin simulation - always trace this back to a combinational block that's missing its clocked counterpart.
The target toolchain for this project is Vivado/Quartus, but all verification during development was
done with ModelSim. Every module here is written in plain,
synthesizable Verilog with no simulation-only constructs in the RTL itself ($readmemh is optional and
parameterized off by default; the hierarchical-poke trick lives entirely in the testbenches, never in
the DUT).
The FSM design above is strictly sequential - one instruction fully completes all five stages before
the next one starts - and gets data, control, and structural hazards "for free" purely because
instructions never overlap. This second phase rebuilds cpu.v as a true overlapping 5-stage pipeline
(Fetch / Decode / Execute-Compute / Memory / Writeback), following a second lab handout's staged
roadmap, and adds the real hazard-handling logic that overlap requires. Every stage was built as its
own increment, compiled, and verified in simulation against a hand-checked golden trace before the next
stage began - the same discipline as Tasks 1-5 above, just applied to a second, separate cpu.v.
Same instruction encoding, same six architectural registers, same halt sentinel (IR == 16'h4380) as
Part 1. Fetch/Decode share one live IR register (decode is purely combinational off IR); three real
clocked latches carry state between the remaining stages: the Decode→Execute latch (*_ex signals -
is_load_ex, is_store_ex, writes_back_ex, is_branch_ex, addr_c_ex, alu_fun_ex, imm_ext_ex,
addr_a_ex, addr_b_sel_ex, plus RA/RB), the Execute→Memory latch (*_mem signals), and the
Memory→Writeback latch (writes_back_wb, addr_c_wb).
Built in six increments, mirroring the pipelining lecture's own progression:
| Stage | What it added | Result |
|---|---|---|
| A | Pure structural pipelining - instructions flow through all 5 stages with zero hazard handling | Passed |
| B | Stall-only hazard detection: freeze PC/IR/Decode and inject a bubble into the _ex latch on any RAW hazard |
Passed |
| C | Memory→Compute forwarding: an ALU producer sitting in Memory forwards RZ into whichever consumer is now in Compute (gap-0 ALU-to-ALU, zero stall) |
Passed |
| D | Writeback→Compute forwarding: a producer sitting in Writeback forwards RW into Compute (gap-1 ALU, zero stall; load-use, one mandatory stall) |
Passed |
| E Part 1 | Control hazards, naive: target + comparison computed in Compute, so every taken branch discards 2 wrong-path instructions | Passed |
| E Part 2 | Control hazards, hardware-optimized: target adder + a dedicated comparator moved into Decode, cutting the penalty to 1 wrong-path instruction | Passed |
No dedicated structural-hazard detection logic exists anywhere in the pipeline - the datapath is built so two instructions never contend for the same physical resource in the same cycle:
- Separate instruction and data memories (
ins_mem/data_mem), so a Fetch-stage instruction read and a Memory-stage data access never compete for the same port. - The register file has two combinational read ports (serving Decode) and one synchronous write port (serving Writeback). Since the pipeline only ever has one instruction in Decode and one in Writeback per cycle, there's never a third contender for either port - a same-address read/write in the same cycle is a data-hazard timing question (below), not a resource conflict.
- A single ALU instance serves whichever one instruction currently occupies Compute; the pipeline never lets two instructions share a stage, so there's only ever one ALU request per cycle.
- Stage E Part 2 adds a second, independent comparator in Decode specifically so branch resolution doesn't have to share the Compute-stage ALU with whatever instruction is already in Execute.
RAW hazards - a consumer in Decode needing a register a still-in-flight producer hasn't written back yet - are resolved by forwarding wherever possible, falling back to a stall only where forwarding physically can't reach:
// final stall equation, after Stage D
wire stall = (writes_back_ex && is_load_ex && (addr_c_ex == addr_a || addr_c_ex == addr_b_sel))
|| (writes_back_wb && (addr_c_wb == addr_a || addr_c_wb == addr_b_sel));The first term is the one truly mandatory stall: a load producer sitting in Execute can't forward its result because the loaded value isn't ready until Writeback. The second term is a same-cycle register-file read/write collision (producer in Writeback while the consumer is simultaneously in Decode) that forwarding can't reach either, since the consumer isn't in Compute yet.
// final forwarding equations
wire fwd_mem_a = writes_back_mem && !is_load_mem && (addr_c_mem == addr_a_ex);
wire fwd_mem_b = writes_back_mem && !is_load_mem && (addr_c_mem == addr_b_sel_ex) && !I_bit_ex;
wire fwd_wb_a = !fwd_mem_a && writes_back_wb && (addr_c_wb == addr_a_ex);
wire fwd_wb_b = !fwd_mem_b && writes_back_wb && (addr_c_wb == addr_b_sel_ex) && !I_bit_ex;
wire [15:0] alu_a_sel = fwd_mem_a ? RZ : (fwd_wb_a ? RW : RA);
wire [15:0] alu_b_sel = I_bit_ex ? imm_ext_ex : (fwd_mem_b ? RZ : (fwd_wb_b ? RW : RB));Memory-stage forwarding (fresher, one instruction closer to the consumer) always wins over
Writeback-stage forwarding when both could match. addr_a_ex/addr_b_sel_ex exist because the
forwarding comparison happens at Compute, not Decode, so a consumer's own source-register addresses
have to be latched one stage forward to be compared against a producer sitting in Memory or Writeback.
A branch's outcome isn't known until it resolves, and whatever got fetched on the not-yet-corrected
PC in the meantime has to be discarded before it can do damage. Two designs were built, in order:
Part 1 (naive, matching the lecture's baseline). Target and comparison are both computed in
Compute, one stage later than Decode (is_branch_taken = is_branch_ex && cond_result,
target = pc_ex + sign_extend(addr_c_ex)). That one-stage lag means exactly 2 wrong-path instructions
get fetched behind every taken branch, needing two structurally different discards: IR <= 16'h0000
in the PC/IR block (kills the instruction fetched on the stale PC) and a forced bubble in the _ex
latch (kills the instruction that had already reached Decode by the time the branch resolved).
Part 2 (hardware-optimized, matching the lecture's follow-up). The target adder and a dedicated
subtract-based comparator move into Decode itself, off the live IR/register-file outputs:
wire [15:0] target = PC + {{13{addr_c[2]}}, addr_c};
wire is_branch_taken = is_branch && decode_cond_result; // from the new Decode-stage comparatorSince resolution now happens while the branch is still live in Decode, only 1 wrong-path instruction is
ever fetched - the second one is simply never fetched, because PC redirects before that fetch would
happen - and only one discard mechanism is needed (the _ex-latch bubble from Part 1 was removed
entirely, since nothing wrong-path rides into _ex anymore).
The trade-off: Decode has no forwarding network, so a branch depending on a very recent producer - free via Compute-stage forwarding in Part 1 - now needs the register file to actually hold the correct value before Decode re-reads it, which costs two new, branch-specific stall terms:
|| (is_branch && writes_back_ex && (addr_c_ex == addr_a || addr_c_ex == addr_b_sel)) // producer in Execute, gap=0
|| (is_branch && writes_back_mem && (addr_c_mem == addr_a || addr_c_mem == addr_b_sel)) // producer in Memory, gap=1The pre-existing writes_back_wb term already covers gap≥2 for a branch the same way it does for any
other consumer - no change needed there.
Same discipline as Tasks 1-5, extended to pipeline-specific signals: each stage's testbench checks
cpu_out every cycle against a golden trace, plus that stage's own key internal signals (stall,
fwd_mem_a/b, fwd_wb_a/b for B-D; stall, is_branch_taken, target for E), plus final
register-file contents. Every golden trace was captured from a reference implementation that was itself
compiled and simulated - never hand-derived on paper - and every testbench was sanity-checked by
deliberately reintroducing a known bug and confirming the testbench actually fails, not just that it
passes on correct code.
| Test | Covers | Result |
|---|---|---|
tb_cpu_stageA.v |
Structural pipelining, no hazards | Passed |
tb_cpu_stageB.v |
Stall-only hazard detection | Passed |
tb_cpu_stageC.v |
Memory→Compute forwarding | Passed |
tb_cpu_stageD.v |
Writeback→Compute forwarding, gap-0/1/2 ALU and load cases | Passed |
tb_cpu_stageE.v |
Naive 2-cycle control-hazard squash; taken/not-taken; forwarding into a branch operand | Passed |
tb_cpu_stageE2.v |
Decode-stage target/comparator, 1-cycle squash, the two new branch-specific stall terms | Passed |
- Synthesize each module (or the full
cpu.vhierarchy) in Vivado/Quartus and resolve any synthesis warnings - in particular, double-check there are no inferred latches now thatcondition_encoding.vhas itsdefaultcase. - Add I/O: a way to actually observe
cpu_outon hardware (seven-segment display, UART, or onboard LEDs for a subset of bits), plus a reset button/switch mapped to theresetinput. - Timing closure (Part 1 FSM): constrain the clock and check that the combinational path through
alu_c→data_mem(address) →data_outand thealu_c→condition_encoding→is_branch_taken→control_fsm→pc_wepath both meet timing at the target clock frequency, since both are longer combinational chains than a typical single-cycle path. - Timing closure (Part 2 pipeline): Stage E Part 2 put a full
regfile_A/regfile_B→ subtract → comparator →is_branch_taken→PCmux chain directly on the critical path from register-file read to the next fetch address - worth checking this doesn't become the limiting path once actually synthesized, since it's now longer than the plain ALU →RZpath any other instruction takes. - Consider adding a hardware smoke test (blink an LED after
haltasserts) before trying to load a full test program via BRAM initialization on the actual board. - Decode-stage forwarding: Stage E Part 2 deliberately leaves the gap-0/gap-1 branch-operand hazard as a stall rather than adding forwarding paths into Decode (out of scope for this course, per the handout). A real design wanting branches to never stall on a recent producer would need to extend the forwarding network into Decode, or add a branch predictor so the penalty is only paid on a misprediction rather than on every taken branch.