Interface Protocols¶
This guide explains why zdc.IfProtocol exists, what properties
it captures, and how those properties map to the RTL structures emitted
by the synthesizer.
Why Callable Is Insufficient¶
Earlier Zuspec versions modelled cross-component method calls as plain
Callable port annotations:
dat: Callable[[], Awaitable[zdc.u32]] = zdc.port()
This works well for ad-hoc simulation but gives the synthesizer no information beyond the argument and return types. In hardware, two interfaces that carry the same payload can require entirely different RTL structures depending on timing and flow-control constraints:
A RAM with a fixed 4-cycle read latency needs no handshake signals.
A cache that can backpressure needs a
req_readysignal.A DMA engine that issues multiple reads before waiting for any response needs an in-flight counter and a response FIFO.
Callable cannot express any of these distinctions, so the synthesizer has
to guess — or refuse to proceed.
IfProtocol solves this by letting the designer attach a small set of
named properties to the interface class. The synthesizer then selects the
correct RTL template automatically.
Defining a Protocol¶
Subclass zdc.IfProtocol and add keyword arguments to set properties.
Declare one or more async methods with type-annotated arguments and
return types; the body is always ...:
import zuspec.dataclasses as zdc
class RamIface(zdc.IfProtocol,
max_outstanding=1,
req_always_ready=True,
resp_always_valid=True,
fixed_latency=4):
"""Fixed-latency ROM read."""
async def read(self, addr: zdc.u32) -> zdc.u32: ...
Use the class as a port type on a Component:
@zdc.dataclass
class Controller(zdc.Component):
rom: RamIface = zdc.port()
@zdc.proc
async def _run(self):
data = await self.rom.read(0x1000)
Protocol Properties¶
The table below lists every property, its default value, meaning, and the RTL signal(s) it affects.
Property |
Default |
Meaning |
|---|---|---|
|
|
The target always accepts a new request. When |
|
|
The request path passes through a register (one-cycle delay). Emits an extra pipeline stage on the request path. |
|
|
The response is always valid on the wire — requires |
|
|
If set to integer N, the response arrives exactly N cycles after
the request without any flow-control signal. Implies
|
|
|
The response channel can stall the sender. Emits a |
|
|
Maximum number of simultaneous in-flight requests. When > 1 the
synthesizer generates an in-flight counter and possibly a response
buffer (depending on |
|
|
When |
|
|
Minimum number of cycles between successive requests. Values > 1 emit a down-counter gate on the request path. |
Property Validation¶
The metaclass checks illegal combinations at class-definition time:
resp_always_valid=Truerequiresfixed_latencyto be set.fixed_latencyandresp_has_backpressure=Trueare mutually exclusive.max_outstandingandinitiation_intervalmust both be ≥ 1.
Per-Method Overrides with @zdc.call()¶
When a single IfProtocol class declares multiple methods you can override
properties on individual methods using the @zdc.call() decorator:
class MixedIface(zdc.IfProtocol, max_outstanding=4):
async def load(self, addr: zdc.u32) -> zdc.u32: ...
@zdc.call(max_outstanding=1)
async def flush(self) -> None: ...
flush is constrained to at most 1 outstanding call even though the
interface as a whole supports 4.
The Five Synthesis Scenarios¶
The synthesizer selects one of five RTL templates (Scenarios A–E) based on the resolved property set.
Scenario A — Fixed Latency (no handshake)¶
Trigger: fixed_latency=N (implicitly resp_always_valid=True).
Generated signals:
req_addr [W-1:0] // request payload
resp_data [W-1:0] // response payload (valid N cycles later)
No req_ready, req_valid, resp_valid, or resp_ready signals.
The synthesizer inserts an N-stage shift-register delay line on the data
path.
Example: a synchronous ROM or a pipelined multiplier with a known latency.
Scenario B — Basic Handshake (max_outstanding=1)¶
Trigger: max_outstanding=1, fixed_latency=None.
Generated signals:
req_valid // requester asserts while sending
req_ready // target asserts when it can accept
req_payload [W-1:0]
resp_valid // target asserts with response
resp_ready // (only if resp_has_backpressure=True)
resp_data [W-1:0]
This is the simplest two-phase handshake, equivalent to a classic valid/ready interface.
Example: a single-entry scratchpad or a register-mapped peripheral.
Scenario C — In-Order Multi-Outstanding¶
Trigger: max_outstanding=N > 1, in_order=True.
Adds to Scenario B:
An in-flight counter (
inflight_cnt, ⌈log₂N⌉+1 bits) that gatesreq_validwhen it reachesmax_outstanding.A response FIFO of depth
max_outstandingto absorb responses that arrive before the consumer is ready.
Example: a DDR controller or AXI read channel where the slave returns responses in address order.
Scenario D — Out-of-Order Multi-Outstanding¶
Trigger: max_outstanding=N > 1, in_order=False.
Adds to Scenario C:
A transaction ID field on the request (
req_id, ⌈log₂N⌉ bits).A reorder buffer (ROB) indexed by
req_idso responses can be matched to their callers regardless of arrival order.resp_idon the response path.
Example: an AXI4 master with multiple outstanding reads using ARID/
RID tagging.
Scenario E — Pipelined (initiation_interval > 1)¶
Trigger: initiation_interval=II > 1.
Wraps any scenario above with a down-counter that blocks req_valid for
II − 1 cycles after each accepted request.
Example: a floating-point unit that accepts a new operand every 3 cycles.
Choosing the Right Scenario¶
Use this decision tree:
Does the target always respond after a known fixed number of cycles? → Scenario A (
fixed_latency=N).Is only one request ever in flight at a time? → Scenario B (
max_outstanding=1).Multiple in flight, responses in order? → Scenario C (
max_outstanding=N,in_order=True).Multiple in flight, responses may arrive out of order? → Scenario D (
in_order=False).Target needs a minimum gap between requests? → Add
initiation_interval=IIto any of the above.
Form A vs Form B¶
The synthesizer recognizes two forms of protocol usage.
Form B (recommended) uses a dedicated IfProtocol class as the port
type. All protocol metadata is in one place and reusable across components:
class MemIface(zdc.IfProtocol, max_outstanding=4, in_order=True):
async def read(self, addr: zdc.u32) -> zdc.u32: ...
@zdc.dataclass
class Core(zdc.Component):
imem: MemIface = zdc.port()
Form A (legacy) inlines protocol information as a Callable without
properties, relying on defaults. It is still supported for backward
compatibility but produces only Scenario B (or an error if the synthesizer
needs more information):
# Form A — no protocol properties, synthesizes as Scenario B only
dat: Callable[[], Awaitable[zdc.u32]] = zdc.port()
See Migration: Callable → IfProtocol for step-by-step instructions to upgrade Form A ports to Form B.
See also
Split Transactions — zdc.Completion[T], zdc.spawn(),
and zdc.select() for multi-outstanding split-transaction patterns.
Core Types — Full API reference for all new protocol primitives.