Bundles & Handshake
Kathryn has no native record/struct signal type. kathryn.lib fills the gap
with two classes built purely on the public DSL: Bundle, a named group
of wires treated as one interface, and Decoupled, a Bundle subclass
implementing the standard valid/ready handshake.
from kathryn.lib import Bundle, DecoupledBundle
Section titled “Bundle”Bundle(name, fields) declares one wire per field into the currently open
module scope, each named <bundle>_<field>, with fields mapping field name
to width:
self.mid = Bundle("mid", {"data": 8}) # declares wire "mid_data", 8 bitsself.mid.data *= self.acc # fields are ordinary wires- Fields are plain attributes (
bundle.data) — everything you can do to a wire, you can do to a field.bundle.field("data")is the dynamic equivalent, andbundle.field_names()lists them in declaration order. - At least one field is required, and the names
_name/_fieldsare reserved — both raiseValueError.
Within one module, two code fragments “connect” by simply sharing the Bundle object: one drives a field, the other reads it. No connect call is needed.
connect_from(other)
Section titled “connect_from(other)”Bridges two separately created bundles of the same shape: a field-by-field
combinational assign, every field forwarded from other. The two field-name
sets must match exactly (ValueError otherwise); each field is forwarded with
a plain comb assign, so width differences follow the normal
assignment-resize rules (Conversion & Resize).
self.outb = Bundle("outb", {"data": 8})self.outb.connect_from(self.mid) # outb_data *= mid_datamark_inputs() / mark_outputs()
Section titled “mark_inputs() / mark_outputs()”Expose every field as a top-level IO port named <bundle>_<field>, in one
call. Both return the bundle for chaining:
self.cfg = Bundle("cfg", {"mode": 2, "limit": 8}).mark_inputs()Decoupled
Section titled “Decoupled”Decoupled(name, payload) is a Bundle with two control fields added on top
of your payload: valid (1 bit) and ready (1 bit). The contract is the
standard one — the producer drives valid plus the payload and must hold
them until ready; the consumer drives ready; a transfer (“fire”)
happens on any cycle where both are high.
flowchart LR
P["producer"] -->|"valid + payload"| C["consumer"]
C -->|"ready"| P
P -.->|"fire = valid and ready"| T["transfer this cycle"]
C -.-> T
- Payload field names may not shadow
validorready(ValueError). payload_names()lists just the payload fields, excluding the two control fields.
fire()
Section titled “fire()”Returns the 1-bit expression valid & ready — true exactly on transfer
cycles. Use it to gate state updates so they happen once per accepted beat:
self.in_ch.ready *= 1 # always readywith zif(self.in_ch.fire()): self.acc |= self.acc + self.in_ch.data # accumulate once per beatIO marking: mark_producer_io() / mark_consumer_io()
Section titled “IO marking: mark_producer_io() / mark_consumer_io()”Within one module, both sides just share the object (the producer fragment
assigns valid/payload, the consumer fragment assigns ready). At a
module IO boundary the fields need directions, and the two markers differ
only in which way ready points:
mark_producer_io()— this module produces:valid+ payload become outputs,readybecomes an input.mark_consumer_io()— this module consumes:valid+ payload become inputs,readybecomes an output.
Ports are named <bundle>_<field>, and both return self for chaining:
@initdef com_declare(self): self.in_ch = Decoupled("in_ch", {"data": 8}).mark_consumer_io() self.out_ch = Decoupled("out_ch", {"data": 8}).mark_producer_io()input wire [0:0] in_ch_valid,output reg [0:0] in_ch_ready,input wire [7:0] in_ch_data,output reg [0:0] out_ch_valid,input wire [0:0] out_ch_ready,output reg [7:0] out_ch_data,Worked example: accumulate, relay, count
Section titled “Worked example: accumulate, relay, count”tc35_lib_bundle_handshake wires the pieces into one round trip: a consumer
channel accumulates its payload on every fire, the value flows through a
plain-Bundle connect_from chain, and a producer channel offers it out while
counting accepted beats:
flowchart LR
IN["in_ch<br/>(Decoupled, consumer)"] -->|"on fire: acc += data"| ACC["acc reg"]
ACC --> MID["mid bundle"]
MID -->|"connect_from"| OUTB["outb bundle"]
OUTB --> OUT["out_ch<br/>(Decoupled, producer)"]
OUT -->|"on fire: sent += 1"| SENT["sent reg"]
@flowdef my_flow(self): self.acc.reset(0) self.sent.reset(0)
# consumer side: always ready; accumulate payload on fire self.in_ch.ready *= 1 with zif(self.in_ch.fire()): self.acc |= self.acc + self.in_ch.data
# plain-bundle relay: acc -> mid -> (connect_from) outb -> out_ch payload self.mid.data *= self.acc self.outb.connect_from(self.mid)
# producer side: always offering the current acc; count accepted beats self.out_ch.valid *= 1 self.out_ch.data *= self.outb.data with zif(self.out_ch.fire()): self.sent |= self.sent + 1The producer holds valid constantly high, so backpressure is entirely in
the consumer’s hands: while the external out_ch_ready stays low, sent is
frozen; raise it for exactly three cycles and sent advances by exactly
three. See the Examples Gallery.