Decisions that are fixed live in CONTEXT.md; the full roadmap is docs/PLAN.md. This file describes
what is actually built.
+--------------------------------------------------------------------------+
pc_q -> | IMEM (comb) -> decoder -> imm_gen |
| | |
| regfile(rs1,rs2) -> ALU (a: rs1|pc, b: rs2|imm) -> LSU -> mmio/DMEM |
| ^ | \ | |
| | | branch_unit load_value |
| +---- wb mux <-----+--- pc+4 / csr <-------------+ |
+--------------------------------------------------------------------------+
next_pc = jalr ? {alu_y[31:1],0} : (jal | taken branch) ? pc+imm : pc+4
One instruction retires per clock (CPI = 1), so cycles == instret; the lockstep test asserts it.
| Block | File | Notes |
|---|---|---|
| ALU | alu.sv |
ALU_COPY_B implements LUI; shifts use b[4:0] |
| Decoder | decoder.sv |
Legality identical to the ISS; illegal forces every side-effecting control low |
| Immediates | imm_gen.sv |
I/S/B/U/J selected by the decoder |
| Branches | branch_unit.sv |
funct3 2 and 3 never take |
| Load/store | lsu.sv |
Byte lanes, masks, sign/zero extension, forced alignment |
| Address decode + MMIO | mmio.sv |
DMEM / CONSOLE / TOHOST / CYCLE and the exact fault rules |
| Registers | regfile.sv |
x0 = 0, reset to 0, BYPASS parameter (see below) |
| Counters | csr_counters.sv |
Zicntr read-only: cycle/time/instret + high halves |
| Memory | mem_comb.sv |
Combinational read (simulation); mem_sync.sv arrives with the pipeline |
| Top | tiny_cpu_top.sv |
CORE parameter, memory loader port, RVFI + debug outputs |
- Retire record (
rvfi_*) is registered: after a clock edge it describes the instruction that retired in the cycle that just ended;rvfi_validis high for exactly that cycle. Field conventions matchsim/golden/iss.py(word-alignedmem_addr, lane-positioned data,rd_addr = 0for no write). - Faults (illegal instruction, bus fault, misaligned or out-of-IMEM jump) set
fault, stop the core, and retire nothing - no register, memory or MMIO side effect. The ISS raises on exactly the same instructions. - Halt (
ecall,ebreak, TOHOST word store) retires, then setshalted. The core is frozen afterwards. - Loader: with
rst_nlow, pulseload_en+load_sel(0 IMEM, 1 DMEM) +load_addr(word) +load_data.
- The regfile write-first bypass must be off in the single-cycle core (
BYPASS = 0). The pipelined core needs it (the writer in WB is a different instruction from the reader in ID), but here the writer is the reader:lw x1, 0(x1)would read its own loaded value as the address, which is both wrong and a real combinational loop (Verilator's UNOPTFLAT lint caught it). The pipeline keepsBYPASS = 1. jalrclears bit 0 of the target. No official rv32ui test exercises an odd target, sojalr_clears_bit0_of_the_targetinsim/test_core_lockstep.pypins it (found by mutation testing).uses_rs1/uses_rs2are already decoded for the Phase 4 hazard unit; the single-cycle core ignores them.cycle_dependentretires (rdcycle,rdtime, the CYCLE MMIO read) are flagged by the RTL and masked by the lockstep comparison, because the ISS cannot know pipeline timing.
pc_q --> IMEM(sync) ==> [IF/ID] --> decode, regfile read ==> [ID/EX] --> ALU, branch ==> [EX/MEM] --> LSU, MMIO ==> [MEM/WB] --> reg write, retire
^ addressed id_pc DMEM addressed here store/console/tohost CCSR read
| by pc_q (sync read) effects happen here
+--------------------------- redirect (taken branch / jal / jalr) from EX: flush IF/ID + ID/EX ---------------------------+
Real output of sim/test_pipe_lockstep.py::five_instructions_overlap_and_flushes_match_redirects (each cell is the pc
in that stage after the clock edge; the jal at 0x48 is in EX when it flushes):
cycle IF ID EX MEM WB
18 0x0048 0x0044 0x0040 0x003c 0x0038 five instructions in flight
19 0x004c 0x0048 0x0044 0x0040 0x003c
20 0x0050 0x004c 0x0048 0x0044 0x0040 <- flush (jal 0x48 -> 0x54)
21 0x0054 -- -- 0x0048 0x0044 two bubbles = the 2-cycle penalty
22 0x0058 0x0054 -- -- 0x0048
Deviation from the original plan: the taken-branch flush landed in Phase 3, not 4. Without it the NOPs after a taken branch retire on the wrong path and the lockstep stream can never match the ISS.
| Producer ahead of the consumer | forwarding on | forwarding off (cfg_forward_en = 0) |
|---|---|---|
| ALU / link, distance 1 | forwarded EX/MEM -> EX, 0 stalls | 2 stalls |
| ALU / link, distance 2 | forwarded MEM/WB -> EX, 0 stalls | 1 stall |
| load or CSR read, distance 1 | 1 stall (load-use), then MEM/WB | 2 stalls |
| load or CSR read, distance 2 | 0 stalls (MEM/WB) | 1 stall |
| any producer, distance >= 3 | register file write-first bypass, 0 | 0 |
| taken branch / jump | flush IF/ID + ID/EX: 2 cycles (predict not-taken); a flush hides any stall on the squashed instruction | same |
forward_unitpicksdbg_fwd_a/bper operand (0 none, 1 EX/MEM, 2 MEM/WB; the nearer producer wins; never x0).hazard_unitraisesdbg_stall(holdpc_q, the IF/ID register and IMEM's output register viaimem_re = 0, and put a bubble in ID/EX) anddbg_load_use. A redirect from EX always wins over a stall.- Loads and CSR reads are "late" producers. Load data exists only after MEM, and a CSR is read in WB, so neither can be forwarded from EX/MEM; their consumers wait one cycle and take the value from MEM/WB.
- With forwarding off the cost is 2, not 3, because the register file's write-first bypass delivers a value written in WB to a reader in ID in the same cycle. (The original plan's "3" assumed no bypass.)
- Exact timing is specified outside the RTL by
sim/golden/pipemodel.py: withT(i)the cycle instructioniis in EX,T(i) >= T(i-1)+1,>= T(branch)+3after a taken branch,>= T(p)+2for a late producer /+1otherwise with forwarding,>= T(p)+3without. The RTL must match it to the cycle on every program (cycles = T(last) + 2). - Pinned numbers:
sim/golden/hazard_cases.py(paper-derived, checked against the model and the RTL, including the debug flags) andsim/perf_baseline.json(cycles per program, per core and mode;python -m golden.perf).
What the baseline shows: on loop-heavy code CPI with forwarding is 1.4-1.65, and almost all of the excess is the 2-cycle penalty on every taken loop branch (predict-not-taken), not data hazards - the case for a branch predictor (PLAN stretch #2).
- Synchronous memories.
pc_qis the IMEM address; the BRAM output register is the instruction in ID, withid_pccarried beside it. DMEM is addressed in EX, so load data is back in MEM, where the LSU extracts and extends it. The IF stage therefore has no instruction word to show (dbg_stage_insn[IF] = 0; look it up from the pc). - Write-first DMEM (
mem_syncREAD_NEW=1). A load reads DMEM in EX at the same clock edge at which the store ahead of it writes in MEM; without write-first the load returns stale data. This is the memory twin of a forwarding path, andmem_raw_adjacentpins it (the only test that fails when it is removed). - Cycle count. First retire is visible after 5 edges, then one per cycle:
cycles = instret + 4 + 2 * redirects. The lockstep test asserts this exactly, and the flush flag fires once per redirect. - CSR read in WB.
instretseen by an instruction equals the number retired before it, as in the ISS.dbg_pcis the architectural pc (next instruction to retire), not the fetch pc.
A faulting or halting instruction travels to WB like any other. While it sits there (older_stop) younger
instructions in MEM are gated from writing DMEM, CONSOLE or TOHOST; once it leaves, active freezes the core.
Faulting instructions never retire; nothing younger ever retires. younger_instructions_have_no_effect_after_*
runs dense code with stores directly behind the faulting/halting instruction and checks that nothing leaks.
lockstepmust mask registers last written by timing-dependent reads (rdcycle, CYCLE MMIO) in the final register compare as well as per-retire; the single-cycle core hid this because there cycle == instret.- A taken jump flushes its own padding NOPs, so the instruction at a jump target is only 1 instruction behind the
jump's link register.
Progpads after instructions, not at labels: the dynamicraw_hazards()check found this.
| Layer | Where | What |
|---|---|---|
| Unit | sim/test_*.py |
Every block vs the golden model / independent tables; decoder legality vs the ISS on 4000+ words and every single-bit mutation of each opcode |
| Constants | sim/golden/test_consts.py |
sim/consts.py == rtl/tiny_cpu_pkg.sv == golden/iss.py |
| Lockstep | sim/test_core_lockstep.py |
45 images (40 riscv-tests + 5 programs) retire-for-retire against the ISS; 13 fault cases; halt/frozen-core checks; CPI = 1 |
| Lint | scripts/lint.sh |
-Wall clean for every module standalone and the whole design |
| Pipeline lockstep | sim/test_pipe_lockstep.py | In both forwarding modes: the 40 riscv-tests + 5 programs, 9 hazard-free programs, 60 hazard-free and 200 hazard-dense random programs (RANDOM_SEEDS=N scales it; nightly runs 5000) retire-for-retire vs the ISS; 13 fault cases; younger-instruction gating after fault/halt; cycle count == the independent model; 5-deep overlap |
| Hazard patterns | sim/test_pipe_hazards.py | 17 paper-derived patterns x 2 modes: pinned extra cycles, dbg_stall count == model, one dbg_load_use pulse where expected, forwarding source observed |
| Hazard units | sim/test_forward_unit.py, test_hazard_unit.py | Exhaustive over (control bits x small register space): 5184 and 20736 combinations |
| Timing model | sim/golden/test_pipemodel.py | Paper-derived cases + random-program sanity, no RTL |
| Program builder | sim/golden/progbuild.py, hazard_free.py | Prog(pad=N) mini-assembler; raw_hazards() proves a program hazard-free from its dynamic ISS trace (no toolchain needed) |
Run: bash scripts/lint.sh && bash sim/run.sh all (after make -C sw all). all = unit + lockstep (single-cycle) + pipe.
wasm/harness.cpp Verilates tiny_cpu_top twice - CORE="SINGLE" and CORE="PIPE" - with distinct --prefix, and links
both into one module. TinyCpuSim owns both models and reset(core) selects one; program memory is written to both, and
both are held in reset while loading, so the inactive core is never clocked by accident.
Every value comes from an output port of the Verilated model. The one exception is data memory, which the RTL does not
expose: the harness keeps a shadow copy fed from the retire records (rvfi_mem_*). The JavaScript contract is
docs/STATE_SCHEMA.md.
Verification (node wasm/test.mjs, 534 checks): each fixture in wasm/test/fixtures.json runs on SINGLE and on PIPE with
forwarding on and off. It checks console output, final registers (timing-dependent ones excluded), data memory, instruction
count, the SHA-256 of the retire stream against the Python ISS, the halt/tohost behaviour, and the cycle count against the
pipeline model. Four mutations were caught: a forwarding pin ignored, a console byte dropped, a cycle counter off by one, and
the shadow memory's byte order reversed.
Size: 194 KB raw, 77 KB gzipped (budget 400 KB), enforced by build.sh.
/app is one React island (CpuApp) over useTinyCpu:
- Assembler (
web/src/lib/assembler.ts): two passes; labels, numeric local labels,.equ, the data directives and the GNU pseudo-instructions. Output is byte-identical to GNU as/ld for every source ingnu-asm.json(the presets, the five macro programs preprocessed, and 16 snippets covering every mnemonic and directive). - Run: one
TinyCpuSimper run. The fullCycleState[]is kept (capped at 20,000 cycles), so stepping, scrubbing and playback only move an index; nothing re-runs.run-model.tsderives statistics, console output and data memory at any cycle from the states themselves (memory by replaying retire records, the same rule as the harness's shadow copy). - Views: source editor with the executing line highlighted and errors marked; a pipeline view (five stage cards, hazard badges, and a stage timeline over the last ten cycles); registers and data memory with changed values highlighted; console; statistics. Single-cycle runs show one instruction per cycle; hazard badges appear only for the pipeline.
- Checks:
web/src/lib/__tests__/(Vitest, 53 tests, including every preset on both cores with forwarding on and off, read against the expected console output) andweb/e2e/(Playwright, 4 tests against the production build).
The /app page has four views selected by LevelTabs:
- L2 Pipeline (default): stage cards, hazard badges, and the stage timeline (as in Phase 6).
- L1 Datapath (
DatapathView, SVG, fixed 900x430 coordinates): highlights the wires the current retired instruction uses and labels them with the values read from the register file and the retire record.datapath.tsderives those values from a single-cycle run of the same program: operands come from the register file before the instruction, results from its retire record. A pipeline run does not expose per-instruction operands, so the single-cycle run supplies them; the architecture is identical on both cores. - L3 Hazard lab (
HazardLab,hazard-lab.ts): eight short programs, one per hazard kind (independent, ALU distance 1 and 2, load-use, store of a loaded value, store then load, taken and not-taken branches). Each runs on the pipeline with forwarding on, the pipeline with forwarding off, and the single-cycle core. The table shows cycles, stalls, flushes and the difference from single-cycle; the timelines show the first eight cycles. - L4 Programs (
ProgramsView,programs-compare.ts): every preset on the single-cycle core and the pipeline, with CPI and PASS/FAIL, plus a data-memory heatmap of loads and stores per word (accessCountsinrun-model.ts).
Tests: web/src/lib/__tests__/phase7.test.ts (67 Vitest tests in the suite now) runs these against the WASM module, and
web/e2e/app.spec.ts (9 Playwright tests) covers the tabs, the hazard lab, the datapath, the programs view, phone width with no
horizontal scroll, and the theme toggle.
Clock speed is not modelled: the cycle counts are what the programs view reports, and Phase 9's FPGA numbers will turn them into time.