A simplified DDR3-style memory controller — 8 banks, open-page policy, bank-parallel command scheduler, auto-refresh — and a verification environment whose actual subject is timing: 12 JEDEC timing parameters plus a structural class, all checked as assertions on the live command bus.
Everything in results/ is tool output. Nothing here is estimated.
| Lint | Icarus -g2012 0 errors; Verilator -Wall 0 warnings, with and without SVA |
| Regression | TESTS=10 PASS=10 FAIL=0 |
| Timing violations, tests 1–9 | 0 on all 13 tracked classes — see results/timing_report.txt |
| Concurrent SVA | no property fired (make sim fails the build if one does) |
| Scoreboard | 261 read-data comparisons against the golden memory, 427 writes applied |
| Functional coverage | 83/83 bins = 100.0% (all three crosses at 100%) |
| Injection test | mis-programming cfg_trcd produced 8 tRCD violations, tightest ACT→column 3 cycles vs. spec 6 |
| Waveform | results/ddr_controller.vcd, 9.4 MB, 458 signals |
./run.sh # venv + cocotb, then lint, regression, wavesA memory controller that returns the right data is easy. A memory controller that returns the right data while never violating a DRAM timing parameter is the job. Get tRCD wrong by one cycle and the part still "works" in simulation against a forgiving model — and corrupts data in silicon at temperature.
So this project is built around a passive checker that taps the same wires a real DRAM would see, reconstructs bank state on its own, and never looks inside the controller.
rtl/ddr_timing_defs.svh, DDR3-1600 11-11-11 style, in controller clocks:
| tRCD | tRP | tRAS | tRC | tRRD | tCCD | tWR | tWTR | tRTP | tRFC | tREFI | CL | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| spec | 6 | 6 | 15 | 21 | 4 | 4 | 6 | 4 | 4 | 44 | 780 | 6 |
The checker is hard-parameterized to those values. The controller reads its own programmable registers. That split is deliberate: it makes a timing bug injectable at runtime with no rebuild, and it mirrors how this actually fails in a real system — bad SPD or mode-register programming, not bad RTL.
results/timing_report.txt, accumulated across tests 1–9:
param spec observed margin violations tightest in
------------------------------------------------------------------
tRCD 6 7 +1 0 test_02_single_rw
tRP 6 7 +1 0 test_04_page_miss
tRAS 15 16 +1 0 test_09_random_stress
tRC 21 23 +2 0 test_09_random_stress
tRRD 4 5 +1 0 test_05_bank_interleave
tCCD 4 5 +1 0 test_03_page_hit
tWR 6 7 +1 0 test_09_random_stress
tWTR 4 5 +1 0 test_08_write_read_turnaround
tRTP 4 5 +1 0 test_09_random_stress
tRFC 44 46 +2 0 test_06_refresh
tREFI 780 260 (max) +520 0 test_09_random_stress
"observed" is the tightest separation ever seen on the command bus, and which test produced it. Every parameter has a +1 or +2 margin — the controller counts a timer to zero and then takes one arbitration cycle to issue, so it lands exactly one cycle late every time. That is the signature of a correct implementation, and it is measured, not asserted.
CL and STRUCT have no separation to report (CL is checked per-cycle against a
predicted data-valid pipeline; STRUCT is a rule, not an interval). CL is
independently measured in test 7: [6, 6, 6, 6, 6, 6].
user request ┌──────────────── ddr_controller ─────────────────┐
(valid/ready, we, │ │
{ba,row,col}, wdata, │ ┌──────────┐ ┌───────────────────────────┐ │
wstrb, tag) ───────────────▶ │ │ accept │──▶│ per-bank pending slot x8 │ │
│ └──────────┘ └─────────────┬─────────────┘ │
│ ┌───────────────────────────▼───────────────┐ │
│ │ per-bank FSM x8 │ │ ┌──────────────┐
│ │ IDLE ─ACT→ ACTIVE ─PRE→ IDLE │ │ cmd │ │
│ │ timers: tRCD tRAS tRP tRC tWR tRTP │ │ bus │ ddr3_model │
│ └───────────────────────────┬───────────────┘ ├─────▶│ memory + │
│ ┌───────────────────────────▼───────────────┐ │ │ CL return │
│ │ round-robin scheduler over banks │ │ └──────┬───────┘
│ │ + global timers tRRD / tCCD / tWTR │ │◀────────────┘
│ │ + refresh engine: tREFI → PRE-ALL → REF │ │ rdata @ CL
│ │ → tRFC │ │
│ └───────────────────────────────────────────┘ │
response ◀──────────────────────│ ┌──────────────┐ │
(valid, rdata, tag) │ │ read tag FIFO│ │
│ └──────────────┘ │
└──────────────────────┬───────────────────────────┘
│ passive tap
┌────────────▼──────────────────────────┐
│ ddr_timing_checker │
│ layer 1: counters + immediate asserts│
│ layer 2: concurrent SVA properties │
│ -> viol_cnt[13], obs_sep[13] │
└───────────────────────────────────────┘
Reordering and the golden model. The scheduler may reorder commands across banks but keeps requests to the same bank in order. The bank index is part of the address, so two addresses in different banks can never alias — cross-bank reordering therefore cannot create a read/write hazard, and the golden memory model stays exact without restricting the stimulus. Read data carries a tag.
Stated simplifications (declared, not hidden):
- Single-data-rate data path on the controller clock; no DQS, burst or PHY. Every command-bus timing parameter is unaffected — that is where the scheduling correctness lives.
- Write latency WL = 0: write data accompanies the WR command, so tWR is measured from the WR command itself.
- The DRAM model stores a bounded
MEM_ROWS x MEM_COLSwindow per bank and reports an error on any access outside it. Silent aliasing in a memory model is exactly how a real addressing bug hides; every test assertsmodel_oob == 0. - No ODT, ZQ calibration, MRS sequence, self-refresh or levelling.
Project 1 established that no open-source simulator runs UVM. This project hit the next wall:
$ iverilog -g2012 sva_probe.sv
sorry: concurrent_assertion_item not supported.
Icarus has no concurrent-SVA support at all. Verilator 5.050 does execute
concurrent assertions, and cocotb drives a Verilator build — both verified before
any RTL was written. So the regression runs on cocotb + Verilator --assert:
one simulation gives live SVA and a Python scoreboard.
The checker is written in two layers so it is not hostage to one tool's SVA subset:
| Layer | Form | Portable to | Produces |
|---|---|---|---|
| 1 | counters + immediate assertions (assert(sep >= LIMIT) else begin count++; $display(...) end) |
Icarus and Verilator | exact violation counts and the tightest separation observed — machine-readable, so cocotb asserts on numbers rather than grepping a log |
| 2 | concurrent SVA (is_rd |-> ##CL dq_i_valid, etc.), +define+SVA_CONCURRENT |
Verilator --assert |
the natural form for CAS latency and the structural rules |
On the first run, layer 2 fired and layer 1 was silent:
%Error: ddr_timing_checker.sv:294: Assertion failed in tb_top.u_chk.a_cas_latency:
SVA: read data did not arrive CL=6 cycles after READ
The DRAM model injected the read into its return pipeline at stage CL. But a
value written at edge T is first readable at edge T+1, so injecting at stage CL
lands the data at T+CL+1 — the model's real latency was 7, not 6.
Layer 1 missed it because I had written its prediction pipeline with the same structure as the model, so it carried the identical off-by-one and agreed with itself. Layer 2, written as a declarative property with no knowledge of the implementation, did not.
That is the whole argument for having a second, structurally different
formulation, and it is why make sim fails the build if any concurrent assertion
fires. Fixed in both files (inject at CL-1), and test 7 now measures
[6, 6, 6, 6, 6, 6] directly.
| # | Test | Proves | Measured |
|---|---|---|---|
| 1 | test_01_reset_init |
no command issued during reset, CKE low | 10 cycles, 0 violations |
| 2 | test_02_single_rw |
ACT precedes WR; read returns the written word | 1 data check |
| 3 | test_03_page_hit |
same row ⇒ no re-ACTIVATE | 8 WRITEs, 1 ACTIVATE |
| 4 | test_04_page_miss |
row change ⇒ PRE→ACT each time, tRP/tRCD honoured | ≥5 PRE, ≥5 ACT |
| 5 | test_05_bank_interleave |
bank parallelism is real | 8 of 8 banks open simultaneously |
| 6 | test_06_refresh |
REF appears, all banks precharged first, tRFC honoured | 7 REFRESH, 3 PRE-ALL, intervals [199,197,193,201,201,201] at cfg_trefi=200 |
| 7 | test_07_cas_latency |
data lands exactly CL after READ | [6,6,6,6,6,6] |
| 8 | test_08_write_read_turnaround |
tWTR / tWR / tRTP under mixed traffic | 16 data checks, 0 violations |
| 9 | test_09_random_stress |
500 random requests, all banks/rows/cols, random strobes | 238 data checks, 0 violations |
| 10 | test_10_timing_violation_injection |
the checker actually checks | see below |
A checker that has never fired is not evidence. Test 10 runs three phases against the same RTL, changing only the controller's timing registers at runtime:
phase 1 registers programmed from spec -> 0 violations
phase 2 cfg_trcd = 2, DRAM really needs 6 -> 8 tRCD violations
tightest ACT->column: 3 cycles vs spec 6
phase 3 registers restored -> 0 new violations
The assertion in phase 2 runs the other way round: the test fails if no
violation appears, because that would mean the checker is inert. Full output in
results/timing_injection.txt.
top.ddr.cmd_x_bank 34/34 every command type x every bank
top.ddr.page_x_rw 6/6 page hit / miss / empty x read / write
top.ddr.rw_x_bank 16/16 read and write on all 8 banks
cmd_x_bank carries ign_bins: PRE-ALL and REFRESH are not per-bank commands —
they are issued with ba=0 by definition, so every other bank column is
unreachable rather than merely unhit.
ddr-controller-verification/
├── PLAN.md README.md Makefile run.sh requirements.txt
├── rtl/
│ ├── ddr_cmd_defs.svh command encoding, shared
│ ├── ddr_cmd_extra.svh encodings only the checker needs
│ ├── ddr_timing_defs.svh the DRAM's real timing table
│ ├── ddr_controller.sv bank FSMs, scheduler, refresh engine
│ ├── ddr3_model.sv functional DRAM (no timing checks - on purpose)
│ └── ddr_timing_checker.sv both assertion layers + viol/observed counters
├── tb_cocotb/
│ ├── tb_top.sv controller + model + checker
│ ├── ddr_bfm.py request driver, response + command monitors,
│ │ regression-wide timing accumulator
│ ├── scoreboard.py golden memory, tag-matched read checking
│ ├── coverage_model.py command / bank / page-state coverage
│ └── test_ddr_controller.py the 10 tests
└── results/ logs, timing reports, coverage, VCD
./run.sh # everything
make lint # Icarus + Verilator, with and without SVA
make sim # regression; fails if any SVA fires
make waves # + VCD
gtkwave results/ddr_controller.vcd
cd tb_cocotb && make TESTCASE=test_06_refresh # one testAdd dram_cs_n/ras_n/cas_n/we_n, dram_ba, dram_addr, dbg_bank_active and
dbg_ref_state. Three things to look for:
- Bank parallelism in test 5:
dbg_bank_activereaches0xFF— eight rows open at once, with ACT commands spaced exactly tRRD+1 apart. - The refresh sequence in test 6:
dbg_ref_pendingrises, the scheduler stops issuing,dbg_bank_activedrains to zero, a PRECHARGE-ALL (addr[10]=1) goes out, then REFRESH, then a tRFC gap before the next ACTIVATE. - The injected bug in test 10 phase 2: ACT and the following WRITE to bank 6 are only 3 cycles apart instead of 6, with the violation printed at that time.
- DRAM protocol and scheduling, not just a memory model. Per-bank state machines with six independent timers, a round-robin scheduler that exploits bank parallelism, open-page row tracking, and a refresh engine that has to quiesce and precharge the whole device before it can issue REF — plus the reasoning about why cross-bank reordering is safe for the golden model.
- Assertion-based verification done properly. Two structurally different formulations of the same 13 checks, deliberately redundant — and the redundancy paid for itself by catching a CAS-latency off-by-one that the implementation-shaped checker replicated. Violations are counted into readable registers rather than left as log lines, so the pass criterion is a number.
- Making the bug injectable, and the report honest. Timing limits live in the checker, timing registers live in the controller, so a real mis-programming can be injected at runtime with no rebuild; the reported margin for every parameter is the tightest separation actually observed on the bus, with the test that produced it named.