Skip to content

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, Decoupled

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 bits
self.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, and bundle.field_names() lists them in declaration order.
  • At least one field is required, and the names _name / _fields are reserved — both raise ValueError.

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.

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_data

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(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 valid or ready (ValueError).
  • payload_names() lists just the payload fields, excluding the two control fields.

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 ready
with zif(self.in_ch.fire()):
self.acc |= self.acc + self.in_ch.data # accumulate once per beat

IO 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, ready becomes an input.
  • mark_consumer_io() — this module consumes: valid + payload become inputs, ready becomes an output.

Ports are named <bundle>_<field>, and both return self for chaining:

@init
def 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,

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"]
@flow
def 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 + 1

The 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.