Abstract Counters¶
zuspec.dataclasses provides a family of abstract counter classes that
work at every abstraction level — simulation, RTL synthesis, and formal
verification — without requiring explicit per-cycle callback overhead.
Motivation¶
A naive simulation counter updates a value on every clock edge. For a 32-bit counter running at 1 GHz that means one billion Python function calls per simulated second — far too slow for anything beyond trivial designs.
The abstract counter avoids this by recording only the start cycle and computing the current value on demand:
value = (current_cycle - start_cycle) % modulus
This “lazy” strategy has zero per-cycle overhead regardless of the counter width or frequency.
Quick start — free-running counter¶
import zuspec.dataclasses as zdc
@zdc.dataclass
class LatencyProbe(zdc.Component):
cnt: zdc.Counter = zdc.inst(zdc.Counter)
@zdc.proc
async def _run(self):
t0 = self.cnt.snapshot() # record start time
# ... do some work ...
await self.cnt.wait_for(t0 + 256) # stall until 256 cycles later
latency = self.cnt.value - t0
snapshot is an alias for value that
communicates timestamping intent to readers of the code.
Counter types¶
Class |
Key parameters |
Description |
|---|---|---|
|
|
Free-running counter, modulus |
|
|
Counts 0 … PERIOD-1 then wraps. PERIOD need not be a power of two. |
|
|
Calls |
|
|
|
Free-running Counter¶
Counter wraps freely at 2**WIDTH. Use it for timestamps,
latency measurements, and any application where the exact rollover point does
not matter.
Waiting for a specific value¶
await self.cnt.wait_for(target) # absolute target value
await self.cnt.wait_ge(threshold) # first cycle where value >= threshold
target may also be a zero-argument callable so the threshold can be
computed lazily:
await self.cnt.wait_for(lambda: self.deadline)
Edge case: if value == target already, wait_for()
returns immediately. If the target was already passed (target < current),
the wait runs until the next rollover past target.
Scheduling callbacks¶
schedule() registers a one-shot callback and returns a
ScheduleHandle:
handle = self.cnt.schedule(self.cnt.value + 100, callback=self._on_alarm)
# ... later ...
handle.cancel() # prevent firing
handle.reschedule(self.cnt.value + 200) # move the alarm
The handle’s fired property is True once the
callback has executed at least once.
ModuloCounter¶
ModuloCounter is the go-to counter for frequency dividers, baud
rate generators, and PWM periods.
@zdc.dataclass
class BaudTick(zdc.Component):
baud: zdc.ModuloCounter = zdc.inst(zdc.ModuloCounter, kwargs={'PERIOD': 868})
@zdc.proc
async def _run(self):
while True:
await self.baud.wait_next() # fires every 868 cycles
self.tx_bit = ~self.tx_bit
wait_next() suspends until the counter wraps to 0. To wait
for a specific phase within the period use wait_for():
await self.baud.wait_for(434) # mid-period sample point
Override on_rollover() instead of wait_next() for
a callback-style interface.
WatchdogCounter¶
WatchdogCounter fires on_timeout() if
the design does not call kick() within TIMEOUT
cycles of the last arm/kick.
Arm → kick → disarm lifecycle¶
@zdc.dataclass
class SafeController(zdc.Component):
wdog: zdc.WatchdogCounter = zdc.inst(zdc.WatchdogCounter, kwargs={'TIMEOUT': 1000})
@zdc.proc
async def _run(self):
self.wdog.arm()
while self.active:
await zdc.tick()
if self.heartbeat:
self.wdog.kick() # extend the window
self.wdog.disarm()
async def on_timeout(self): # override to handle expiry
self.fault = 1
arm()— start or restart the timeout window. Passcyclesto overrideTIMEOUTfor this activation only.kick()— reset the window; keeps the watchdog armed.disarm()— stop the watchdog without firing.
CounterBank¶
CounterBank provides COUNT independent counters that share a
single clock domain, avoiding the overhead of instantiating COUNT separate
Component objects.
@zdc.dataclass
class MultiChannel(zdc.Component):
timers: zdc.CounterBank = zdc.inst(zdc.CounterBank,
kwargs={'COUNT': 4, 'WIDTH': 16})
@zdc.proc
async def _run(self):
snap = self.timers.snapshot_all() # atomic snapshot of all 4
await self.timers[0].wait_for(snap[0] + 100)
Individual counters are accessed by index (self.timers[i]) and expose the
same wait_for() / wait_ge() /
schedule() interface as a standalone Counter.
RTL synthesis¶
ModuloCounter and WatchdogCounter fields inside a
proc()-based component are synthesised automatically by the
zuspec-synth pass. The synthesiser replaces await cnt.wait_next()
with an equivalent await zdc.cycles(PERIOD) pattern, which the RTL backend
maps to a hardware cycle-count register.
The counter field itself is neutralised (removed from the synthesised port list) so the generated SystemVerilog contains only the internal state register.
// Generated for Blinker(ModuloCounter(PERIOD=8))
always_ff @(posedge clk) begin
if (!rst_n) state <= S_IDLE;
else case (state)
S_IDLE: begin state <= S_1; S_1_cnt <= 8-1; end
S_1: begin
if (S_1_cnt == 0) begin led <= ~led; state <= S_IDLE; end
else S_1_cnt <= S_1_cnt - 1;
end
endcase
end
Note
wait_for() with a runtime-computed target is not
synthesisable. Use wait_next() or pass a compile-time
constant.
Formal verification¶
zuspec-be-fv automatically derives SystemVerilog Assertions from counter
fields via AbstractionFormalPass:
from zuspec.be.fv.passes import AbstractionFormalPass
fpass = AbstractionFormalPass(Blinker)
for sva in fpass.all_properties():
print(sva)
# Recommended BMC depth for full coverage:
print(fpass.min_required_depth())
Two assertions are generated per counter:
- range_invariant
0 <= cnt && cnt < PERIOD— the counter stays in bounds.- monotone_progress
Each cycle the counter increments by 1, or rolls over from
PERIOD-1to 0 — no arbitrary jumps.
min_required_depth() returns the
minimum BMC depth needed to observe all wait_for() targets,
making it easy to set the solver bound correctly.
Edge cases¶
Situation |
Behaviour |
|---|---|
|
Returns immediately (no suspension). |
|
Waits until the next rollover then continues to target. |
|
Equivalent to |
|
Silently ignored. |
|
Restarts the timeout window from now. |
|
Raises |
FAQ¶
- Can I use a counter across clock-domain boundaries (CDC)?
Counters are tied to a single
ClockDomain. To sample a counter value in a different domain, usesnapshot()in the source domain and transfer the captured integer via a synchroniser or FIFO.- What is the difference between
wait_geandwait_for? wait_for(t)waits untilvalue == t(mod modulus).wait_ge(t)waits untilvalue >= t, which is useful when the exact rollover boundary doesn’t matter and over-shooting is acceptable.- How do I implement a step ≠ 1 counter?
Instantiate a
ModuloCounterwithPERIOD = target_stepand callwait_next()in a loop. Eachwait_next()advances the logical time by exactlyPERIODcycles. Alternatively, useCounterdirectly and callwait_for(snapshot() + step)for a one-shot advance.