Components¶
Components provide the structural aspect of a Zuspec model. Depending on their application, Zuspec components have close similarities to SystemVerilog module, SystemC component, and PSS component types.
Instances of Zuspec components are never created directly. Zuspec automatically creates component instances for fields of Component type. Creating an instance of a Zuspec component class outside a component hierarchy (for example, in a test) must be done using the runtime factory.
import zuspec.dataclasses as zdc
@zdc.dataclass
class Top(zdc.Component):
clock : zdc.bit = zdc.input()
reset : zdc.bit = zdc.input()
# Create root component - triggers full tree elaboration
top_i = Top()
Component Lifecycle¶
When a root component is instantiated, the following sequence occurs:
Construction - Root component and all child component fields are constructed via the ObjFactory
Tree Building -
__comp_build__recursively initializes:Creates runtime implementation (
CompImplRT) for each componentSets timebase on root component
Discovers
@processdecorated methodsInitializes Memory, RegFile, and AddressSpace fields
Applies
__bind__mappings (bottom-up)
Validation - Top-level ports are validated as bound
Process Start - Processes start lazily when
wait()is called
Ports and Binding¶
Fields that are initialized using input, output, port, mirror, or are of type Bundle, are considered ports. A port is either a producer (eg output) or a consumer (eg input). Ports are bound together as part of the component elaboration process.
The following can be bound together: - Input and output - Input and input - Port and export - Export and export - Port and port - Bundle and bundle mirror - Bundle mirror and bundle mirror
Binds may be specified in two ways: - Inline as part of field declaration - Within a __bind__ method declared in the component
In both cases, the bind method shall return a dict of mappings between ports.
@zdc.dataclass
class Initiator(zdc.Component):
clock : zdc.bit = zdc.input()
reset : zdc.bit = zdc.input()
wb_i : WishboneInitiator = zdc.field()
@zdc.dataclass
class Top(zdc.Component):
clock : zdc.bit = zdc.input()
reset : zdc.bit = zdc.input()
initiator : Initiator = zdc.field(bind=zdc.bind[Self,Initiator](lambda s,f:{
f.clock : s.clock,
f.reset : s.reset,
f.wb_i : s.consumer.wb_t,
}))
The example above shows the “inline” form of binding. The bind parameter to the field method specifies a method to call that returns a dict. The method must accept two parameters: a handle to the parent class self and a handle to the field
@zdc.dataclass
class Initiator(zdc.Component):
clock : zdc.bit = zdc.input()
reset : zdc.bit = zdc.input()
wb_i : WishboneInitiator = zdc.field()
@zdc.dataclass
class Top(zdc.Component):
clock : zdc.bit = zdc.input()
reset : zdc.bit = zdc.input()
initiator : Initiator = zdc.field()
def __bind__(self): return {
self.initiator.clock : self.clock,
self.initiator.reset : self.reset,
self.initiator.wb_i : self.consumer.wb_t,
}
The example above shows the method form of binding. The __bind__ method returns a dict mapping ports.
Memory and RegFile Binding¶
AddressSpace fields can be bound to Memory and RegFile instances
using the At helper to specify address offsets.
@zdc.dataclass
class ControlRegs(zdc.RegFile):
status : zdc.Reg[zdc.u32] = zdc.field()
control : zdc.Reg[zdc.u32] = zdc.field()
@zdc.dataclass
class SoC(zdc.Component):
mem : zdc.Memory[zdc.u32] = zdc.field(size=0x10000)
regs : ControlRegs = zdc.field()
aspace : zdc.AddressSpace = zdc.field()
def __bind__(self): return {
self.aspace.mmap : (
zdc.At(0x0000_0000, self.mem),
zdc.At(0x1000_0000, self.regs),
)
}
The aspace.base provides a MemIF handle for byte-level access
to the mapped regions.
Hierarchical Port Binding¶
When a parent component has a port that needs to be passed down to child components, there are two approaches:
Approach 1: Direct Reference Assignment¶
Recommended for behavioral models where ports need to be shared with child components.
@zdc.dataclass
class Child(zdc.Component):
_mem_if = None # Store as regular field (not a port)
def set_interface(self, mem_if):
"""Set the interface reference"""
self._mem_if = mem_if
async def operation(self):
# Use the interface
data = await self._mem_if.read(0x1000)
@zdc.dataclass
class Parent(zdc.Component):
mem = zdc.port() # Port bound to external interface
child: Child = zdc.field()
def setup(self):
"""Call after binding is complete"""
self.child.set_interface(self.mem)
In tests or top-level components, call setup() at the start of your
run() method after the component tree is fully bound:
@zdc.dataclass
class Top(zdc.Component):
parent: Parent = zdc.field()
memory: Memory = zdc.field()
def __bind__(self):
return {
self.parent.mem: self.memory.mem_if
}
async def run(self):
self.parent.setup() # Initialize child interfaces
# Now proceed with operations
await self.parent.child.operation()
Approach 2: Hierarchical Binding in __bind__¶
For hierarchical port-to-port bindings, the parent’s __bind__
can include mappings for child ports. This approach works when
child ports can be statically bound:
@zdc.dataclass
class Child(zdc.Component):
mem = zdc.port()
@zdc.dataclass
class Parent(zdc.Component):
mem = zdc.port()
child: Child = zdc.field()
def __bind__(self):
return {
self.child.mem: self.mem, # Bind child port to parent port
}
Note: Approach 1 is simpler and more flexible for operations-level behavioral models. Use Approach 2 when you need static port connectivity.
Supported Exec Methods¶
Exec methods are evaluated automatically based on events in the model. User code may not invoke these methods directly.
A @comb exec method is evaluated whenever one of the variables references changes. The @comb exec is exclusively used with RTL descriptions.
The @process async exec method creates an independent background thread
that starts when the first wait() call is made anywhere in the component tree.
A @process exec method is an independent thread of control in the model.
Processes start lazily to allow the component tree to be fully constructed before simulation begins.
When to Use @process¶
Monitoring or reactive behaviors that run continuously
Clock generation or periodic activities
Protocol monitors or checkers
Models requiring concurrent background activity
When NOT to Use @process¶
Operations-level behavioral models (use direct async methods instead)
Synchronous operations that complete immediately
Simple request/response patterns
For operations-level models, implement async methods that perform their work and return, rather than background processes.
Examples¶
Good: Background monitoring process
@zdc.dataclass
class Monitor(zdc.Component):
regs: StatusRegs = zdc.field()
@zdc.process
async def monitor(self):
"""Continuous monitoring in background"""
while True:
status = await self.regs.status.read()
if status.error:
print("Error detected!")
await self.wait(zdc.Time.ns(100))
Good: Operations-level behavioral model
@zdc.dataclass
class Device(zdc.Component):
regs: DeviceRegs = zdc.field()
async def transfer(self, addr: int, size: int):
"""Direct async method - NOT @process"""
# Perform operation and return immediately
await self.regs.addr.write(addr)
# ... perform transfer ...
await self.regs.status.write(COMPLETE)
Original example for reference:
@zdc.dataclass
class Worker(zdc.Component):
@zdc.process
async def run(self):
for i in range(10):
await self.wait(zdc.Time.ns(100))
print(f"Iteration {i} at {self.time()}")
sync¶
A @sync exec method is evaluated on the active transition of its associated clock or reset. All assignments to outputs are considered nonblocking. The @sync exec is exclusively used with RTL descriptions
import zuspec.dataclasses as zdc
@zdc.dataclass
class Counter(zdc.Component):
clock : zdc.bit = zdc.input()
reset : zdc.bit = zdc.input()
count : zdc.u32 = zdc.output()
@zdc.sync(clock=lambda s:s.clock, reset=lambda s:s.reset)
def inc(self):
if self.reset:
self.count = 0
else:
self.count += 1
self.count += 1
The synchronous counter above produces the value ‘0’ on the count output while reset is active. While reset is not active, the count output increments by one on each active clock edge. Assignments are delayed, so only the last increment statement takes effect. The expected output is as follows:
reset |
clock |
count |
|---|---|---|
1 |
1 |
0 |
0 |
1 |
1 |
0 |
1 |
2 |
0 |
1 |
3 |
0 |
1 |
… |
Supported Special-Purpose Methods¶
@activity decorated async methods may be declared on a component. The body of the method adheres to activity semantics.