Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ddr-controller-verification

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.


1. Result summary

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, waves

2. Why timing is the interesting part

A 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.

The timing table

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.

What the bus actually did

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].


3. Architecture

  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_COLS window 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 asserts model_oob == 0.
  • No ODT, ZQ calibration, MRS sequence, self-refresh or levelling.

4. Tool choice, and why there are two assertion layers

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

The two layers disagreeing found a real bug

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.


5. Test list — all 10 pass

# 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

Test 10: proving the checker has teeth

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.

Functional coverage — 83/83 bins, 100%

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.


6. Layout

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

7. How to run

./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 test

Reading the waveform

Add 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_active reaches 0xFF — eight rows open at once, with ACT commands spaced exactly tRRD+1 apart.
  • The refresh sequence in test 6: dbg_ref_pending rises, the scheduler stops issuing, dbg_bank_active drains 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.

8. What this demonstrates

  • 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.

About

DDR3-style memory controller (8 banks, bank-parallel scheduler, auto-refresh) with assertion-based timing checking: 0 violations across 12 JEDEC parameters, each with a measured margin.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages