2pyrtl logo2pyrtl

Writing RTL

Modules, wires and logic

2pyrtl’s rtlpack is a library of foundational classes and methods that help you create

  • ·
    Modules (rtlpack.Module class equivalent to Verilog/SystemVerilog’s “module” or VHDL’s Entity-Architecture),
  • ·
    Wires (rtlpack.Wire class equivalent to Verilog/SystemVerilog’s wire/reg/logic or VHDL’s signal),
  • ·
    WireArrays (rtlpack.WireArray class equivalent to Verilog/SystemVerilog’s wire [15:0] my_wire_array [7:0]; or VHDLs array) that support multiple dimensions.
  • ·
    Interfaces (rtlpack.Interface class Collection of wires grouped to form an interface between two/more modules/component bound by a handshake mechanism like valid-ready or valid-credit protocol),
  • ·
    ModuleParams (rtlpack.ModuleParam class equivalent to Verilog/SystemVerilog’s parameter or VHDL’s generic).

Using these you can describe your RTL (hardware) as shown in the example below:

The shape of a module

from rtlpack import *

class Counter(Module):
    def __init__(self, global_config, name, parent_module, width):
        Module.__init__(self, global_config, name, parent_module=parent_module)
        self.width  = ModuleParam(self, name='WIDTH', value=width, default_value=8)
        self.enable = Wire(my_module=self, name='enable', direction='input', is_control=True)
        self.count  = Wire(my_module=self, name='count', direction='output', width=self.width)
        self.build()

    def _logic(self):
        self.add_flop(self.count, reset_value=0)
        if self.enable:
            self.count = self.count + 1

Your RTL module/design is a Python class inherited from Module. Wires are declared in the constructor — inputs, outputs and internal signals, with widths and parameters — and the behaviour goes in a method called _logic, written as plain Python.

That if is not a Python if that runs once. 2pyrtl reads the method and turns it into hardware: a condition on a wire becomes a multiplexer, an assignment becomes a driven signal, a loop becomes the structure it describes.

  • ·
    Declare in the constructor, describe in _logic. Every wire a module owns is made in __init__; nothing new appears later.
  • ·
    build() is always last. It closes the declaration and lets 2pyrtl build the module. Designs written with its old name, module_done(), still build; the console notes the change once.
  • ·
    Parameters are ModuleParam. They become real parameters/generics in the generated HDL, with the name you give them.
  • ·
    Control signals say so - is_control=True - and datapath signals carry an explicit width=.

Wires

What you writeWhat it is
Wire(my_module=self, name='a', direction='input')An input port.
direction='output'An output port.
direction='internal'A signal inside the module.
width=8Eight bits wide. A parameter may be used instead of a number.
is_control=TrueOne bit, and marked as control rather than data - the schematic draws it differently.
type_def=TrueNot a wire but a shape: the specimen a WireArray is made of.

For a bank of signals - a memory, a set of stages, a per-requester vector - use a WireArray: it is made from a specimen wire and indexed like a list, and it may be indexed by a wire as well as by a number, which is how a selected read or write is written.

slot_t  = Wire(self, name='slot_t', direction='internal', width=16, type_def=True)
self.mem = WireArray(self, name='mem', length=16, specimen=slot_t)

def _logic(self):
    self.add_flop(self.mem, reset_value=0)     # a whole array of flops
    if self.write_enable:
        self.mem[self.write_pointer] = self.data_in   # indexed by a wire
    self.add_comb(self.data_out, default_value=self.mem[self.read_pointer])

One array can be assigned to another whole when the two have the same shape - the same length at each level: self.copy = self.regs. If only the widths of their elements differ, each element takes the one in the same place, fitted as a single wire is, with a width warning. Arrays of different shapes are refused, because there is no one way to pair their elements - row by row or column by column, and with different counts some element would be left out:

ERROR: line 37: Shape Mismatch: y = grid. grid has 2 x 3 elements and y has 3 x 2, and an array
is assigned whole only from one of the same shape. Assign the elements in a loop, in the order you mean.

Combinational and sequential

A wire becomes hardware the moment you say what kind it is. add_comb(w, default_value=...) makes it combinational, with a value it takes when nothing else drives it; add_flop(w, reset_value=...) makes it a register that holds its value between clock edges and returns to the reset value on reset. Both accept a whole WireArray.

After that, ordinary Python describes the behaviour. An if on a wire becomes a multiplexer; elif and else extend it; a Python for loop repeats the hardware inside it, once per iteration, at build time.

def _logic(self):
    self.add_comb(self.grant, 0)                  # nothing granted unless said otherwise
    self.add_flop(self.last_owner, reset_value=0)

    for i in range(self.n):                       # unrolled at build time
        if self.request[i]:
            self.grant[i] = 1
            self.last_owner = i

Writes are order-sensitive

A read captures whatever drives a wire at that moment, so a reader written before the writer sees the old value. Build a value first, mirror it out afterwards. Sibling if blocks over the same target are allowed and the last one wins - which is exactly how priority logic is written, walking from low priority to high.

When an assignment drives hardware

Inside _logic, and inside the helpers it calls, an assignment drives hardware when its target holds a wire that add_comb or add_flop declared, or an element of a declared array. 2pyrtl checks this when the line runs, by looking at what the target holds, however the target is written: self.y, a plain name, an element of a list or a dict, an attribute. When the target holds anything else - nothing yet, a number, an input wire, an expression - the line is ordinary Python: it rebinds the name or replaces the element, and no hardware is made.

So a name, list element or attribute that holds a declared wire is that wire. Assigning to it drives the wire; it never makes the name refer to something else. To refer to something else, use a fresh name.

self.y = self.a & self.b                          # the wire itself
self.regs[self.idx] = self.din                    # an element, at a wire's index
self.count += 1                                   # augmented assignment drives too

cnt = self.counters[1]                            # a short name for a declared element
if self.inc:
    cnt = cnt + 1                                 # counters[1] = counters[1] + 1 while inc

for out, src in zip(self.outs, self.srcs):        # a for loop over declared outputs
    out = src ^ self.key                          # outs[k] = srcs[k] ^ key, for each k

target = self.q_reg if self.REGISTERED else self.q_comb   # the target chosen in Python
target = self.a ^ self.b                                  # q_comb = a ^ b

A name used as a moving label

The rule has one trap. A name meant as a moving label - holding one declared wire, then meant to hold another - drives the first wire instead, and the module still builds:
for k in range(3):
    out = self.outs[k]               # from the second pass, out still holds outs[k - 1]...
    out = self.srcs[k] ^ self.key    # ...so outs[0] = outs[1], and outs[1], outs[2] are never driven
Let a for loop make the name - it does so without an assignment, as above - or write the element itself: self.outs[k] = self.srcs[k] ^ self.key.

An assignment fits its value to its target, and says so at your line when it has to: a wider value keeps its low bits, a narrower one gets 0 in the bits above. From this module:

    def _logic(self):                       # a, k4 and y are 4 bits wide
        self.add_comb(self.k4, 0)
        self.k4 = 20                        # line 13
        self.add_comb(self.y, 0)
        self.y = self.a + 100               # line 15
WARNING: line 13: Width Mismatch: k4 = 20. Value 20 needs 5 bits and k4 has only 4, so k4 gets its low 4 bits (4).
WARNING: line 15: Width Mismatch: y = a + 100. a + 100 has 8 bits and y has only 4, so y gets its low 4 bits.

What you can write in an expression

These operators work on wires, and on a wire and a number:

OperatorsWhen the two widths differ
& | ^Refused: the build stops with "Width mismatch ... Can't guess what you are trying to do!" Make the widths equal first.
+ - *The narrower operand gets 0 in the bits above. + is worked out one bit wider than the wider width, so every sum fits; - in the wider width, with a warning; * in the two widths added.
== != < <= > >=The narrower operand is compared with 0 in the bits above, with a warning. The result is one bit.
~One operand: every bit inverted.
<< k, >> kA shift by a number k. A wire as the amount is not supported yet.

A shift can also be written as a multiply or divide by a power of two: w * 2**k shifts left, w / 2**k shifts right, and w % 2**k keeps the low k bits. Bit k of a wire is (w / 2**k) % 2.

A number does not widen a subtraction

A subtraction is worked out in the width of its wire, so a number that needs more bits is cut to that width - a - 100 on a 4-bit a takes away 4, and the console says so. Widen first: copy a into a wider wire, and subtract from that.

Not yet

// is not supported, and neither are / and % by a wire or by a number that is not a power of two: the width of their result comes out wrong.

Anything that is not a wire is ordinary Python and runs at build time: a helper method that returns an expression, a loop that builds a reduce tree, a table of constants. That is the real power of writing hardware in Python - the structure is computed, and only what you assign to wires becomes hardware.

Helper methods

A helper defined outside _logic is transformed like _logic is: it may test wires with if, and it may write wires and arrays. It has to be callable by name - as a plain function, a method on the module, or through the module it lives in.

def bit_of(self, w, k):                        # returns an expression
    return (w / (2 ** k)) % 2 if k > 0 else w % 2

def _logic(self):
    for k in range(4):
        if self.bit_of(self.shift, k) == 1:    # a real mux, per stage
            self.stage[k] = self.stage[k - 1] * (2 ** (2 ** k))

A helper reached indirectly - out of a dict, behind a lambda, passed in as an argument - cannot be found before it runs, so it is not transformed. A write from such a helper is refused outright; see when it goes wrong.

Next

Hierarchy and interfaces is how modules are built out of other modules. Project settings is every knob the build itself has.