No. 39 / project of 147 on the ladder

RV32IM Zicntr counters

introduces — cycle/time/instret CSRs - the first piece of real ISA work after the P38 packaging cleanup

harden statelast run2026-05-03
cells28,013non-filler
slack6.98ns setup
area900 x 900 die, 201236 stdcell areaμm²
signoff
  • DRCPASS
  • LVSPASS
  • antennaPASS

P39 adds the RISC-V Zicntr extension on top of the P38 platform: read- only cycle, time, and instret counters at CSR addresses 0xC00/0xC01/0xC02 plus their h halves at 0xC80/0xC81/0xC82.

Status: RTL pass. Directed test passes (4002 clocks, all Zicntr checks green). Arch-test sweep aggregates PASS=56 FAIL=0 NOT RUN=0 across rv32i/I + M + Zicsr + Zifencei + the new Zicntr batch (2 tests at upstream rev a7c9930). No P39 harden run recorded yet.

What changed in the RTL

P39’s src/top.sv adds two 64-bit counters:

counterincrements whenexposed at
csr_cycle_counterevery clockcycle (0xC00), cycleh (0xC80), time (0xC01), timeh (0xC81)
csr_instret_counterFSM enters S_WBinstret (0xC02), instreth (0xC82)

time aliases cycle. The architecture allows that as long as time is monotonically non-decreasing; a separate 64-bit mtime is a future rung. The aliasing is documented, not hidden.

instret increments only on the S_WB cycle. In our multi-cycle FSM:

  • A normal instruction reaches S_WB exactly once. Counted correctly.
  • A trapping instruction (misalign, access fault, illegal) leaves S_FETCH straight into the trap handler. Not counted - which is what the spec wants.
  • The halt loop spins in S_HALT, never reaching S_WB. Not counted.
  • A multiply or divide spends extra cycles in S_MUL/S_DIV/ S_DIV_SIGN, then returns to S_WB exactly once. Counted once.

What changed in the directed test

runtime/main.c adds zicntr_probe() between the existing rv32m_probe() and the first UART putc. It reads cycle/time/ instret, runs a 50-iteration mix() loop, reads them again, and checks:

cycle    advanced
time     advanced
instret  advanced
(cycle - cycle0)  >  (instret - instret0)        # CPI > 1 in our FSM
cycleh / instreth read without trapping

If any of those fail the runtime returns a stable per-check error code (40-43) and the testbench reports FAIL with that value in x5.

Arch-test sweep

P39 adds a fifth scoped batch (rv32i/Zicntr) to the unified make arch_test target. Recorded aggregate:

rv32i/I        PASS=39 FAIL=0 NOT RUN=0
rv32i/M        PASS=8  FAIL=0 NOT RUN=0
rv32i/Zicsr    PASS=6  FAIL=0 NOT RUN=0
rv32i/Zifencei PASS=1  FAIL=0 NOT RUN=0
rv32i/Zicntr   PASS=2  FAIL=0 NOT RUN=0
total          PASS=56 FAIL=0 NOT RUN=0

The Zicntr batch is small at upstream rev a7c9930 - only two tests (Zicntr-csrrc-00 and Zicntr-csrrs-00) which exercise CSRRC and CSRRS on the counter alias addresses. The framework does not include a CSRRW Zicntr test because writes to the user-mode counter aliases are illegal in M-mode anyway.

The sweep aggregator was extended to find the new compliance/results_zicntr/ directory and report it. SWEEP.md is written by scripts/p38_arch_test_sweep.py after all five batches complete.

The DUT plugin is unchanged from P38 - copied verbatim into projects/39_zicntr_counters/arch_test/. The plugin is RTL-agnostic; each rung that wants its own results directory copies the four files and points the runner at them.

Harden result

NOT RUN. The librelane config is a copy of P38’s, same SDC, same fanout target. The expected delta in stdcell area is small - two 64-bit counter registers plus their increment paths is roughly 1k extra cells.

What just happened?

P39 is the first rung past the P37/P38 plateau that adds real ISA behaviour. It is small but it is the right next step: FreeRTOS uses cycle and instret for run-time stats, and the eventual port will want them as real CSRs rather than zero-stubs. P40 (the M-mode runtime API audit per the roadmap) is the next rung; together with P39 it sets up the FreeRTOS port at P41.