Writing RTL
Modules, wires and logic
2pyrtl’s rtlpack is a library of foundational classes and methods that help you create
- ·Modules (
rtlpack.Moduleclass equivalent to Verilog/SystemVerilog’s “module” or VHDL’s Entity-Architecture), - ·Wires (
rtlpack.Wireclass equivalent to Verilog/SystemVerilog’s wire/reg/logic or VHDL’s signal), - ·WireArrays (
rtlpack.WireArrayclass equivalent to Verilog/SystemVerilog’swire [15:0] my_wire_array [7:0];or VHDLs array) that support multiple dimensions. - ·Interfaces (
rtlpack.Interfaceclass 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.ModuleParamclass 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 + 1Your 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 explicitwidth=.
Wires
| What you write | What 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=8 | Eight bits wide. A parameter may be used instead of a number. |
is_control=True | One bit, and marked as control rather than data - the schematic draws it differently. |
type_def=True | Not 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 = iWrites are order-sensitive
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 ^ bA name used as a moving label
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 drivenLet 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 15WARNING: 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:
| Operators | When 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, >> k | A 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 - 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.