Getting started

This tutorial serves to gently introduce basics of developing hardware using Transactron.

Installing Transactron

To install the latest release of Transactron, run:

$ pip install --upgrade transactron

For the purpose of this tutorial, the amaranth-boards package is suggested to interact with your favorite FPGA dev board. As amaranth-boards is not regularly released to pypi, it is recommended to install the latest development snapshot:

$ pip install "amaranth-boards@git+https://github.com/amaranth-lang/amaranth-boards.git"

Controlling LEDs and switches

The following example demonstrates the use of Transactron for interacting with basic inputs/outputs on FPGA development boards. It defines a circuit which allows to control a LED using a switch. Please bear with the triviality for now: things will get more interesting later.

from amaranth import *
import amaranth.lib.data as data

from transactron import TModule, Transaction
from transactron.lib.basicio import InputSampler, OutputBuffer


class LedControl(Elaboratable):
    def elaborate(self, platform):
        m = TModule()

        switch = platform.request("switch")
        led = platform.request("led")

        layout = data.StructLayout({"val": 1})

        m.submodules.switch_sampler = switch_sampler = InputSampler(layout, synchronize=True)
        m.d.comb += switch_sampler.data.val.eq(switch.i)

        m.submodules.led_buffer = led_buffer = OutputBuffer(layout, synchronize=True)
        m.d.comb += led.o.eq(led_buffer.data.val)

        with Transaction().body(m):
            led_buffer.put(m, val=switch_sampler.get(m).val)

        return m

Transactron components are standard Amaranth elaboratables. The main difference is that in the elaborate method, TModule should be used instead of Module.

To expose an input to Transactron code, the InputSampler component is added as a submodule. To use it, a method layout must be specified, which here is an instance of StructLayout. The data attribute then needs to be combinationally connected to the input to be exposed. The get method then allows to access the input from Transactron code using a method call. The OutputBuffer component exposes an output instead. It provides a put method.

Note

The synchronize=True constructor parameter for InputSampler and OutputBuffer is used because FPGA dev board I/O is not synchronous to the global clock. It is not needed for synchronous signals.

Transactron methods can only be called from within transaction (or method) definitions. Here, we define a simple transaction by constructing a Transaction object and immediately calling body(). Inside the body, the put method is called to set the LED value to the one received from the switch using the get method. Because of the layout definition layout, both the put parameter and the field of the structure returned from get are named val. The transaction defined here will run in every cycle, ensuring that the LED always shows the value of the switch.

Note

Other than method calls, transaction and method bodies can contain arbitrary Python and Amaranth code. The Python code of the definition is run once, while Amaranth [assignments]{inv:#lang-assigns} are active only in cycles when the defined transaction or method runs. This will be showcased in later part of the tutorial.

The example component can be synthesized and programmed to your FPGA dev board using the following code. The code uses the Digilent Arty A7 board. For it to work, Vivado and xc3sprog need to be installed. To use it with a different dev board, an appropriate platform needs to be imported instead and the correct toolchain for the board needs to be installed.

from transactron import TransactronContextElaboratable
from amaranth_boards.arty_a7 import ArtyA7_35Platform

ArtyA7_35Platform().build(TransactronContextElaboratable(LedControl()), do_program=True)

Please notice TransactronContextElaboratable, which is used to wrap the LedControl elaboratable. It provides the context required by Transactron code, including the transaction manager. Without the wrapper, synthesis will fail. Typically, there should be only one TransactronContextElaboratable for the entire project.

Method readiness

For now, our example is not very interesting. We will now spice it up a little by adding triggers to our input and output. The input of InputSampler can be sampled only in cycles when the trigger is active; same with setting the output of OutputBuffer. The triggers will be controlled by board buttons.

from amaranth import *
import amaranth.lib.data as data

from transactron import TModule, Transaction
from transactron.lib.basicio import InputSampler, OutputBuffer


class LedControl(Elaboratable):
    def elaborate(self, platform):
        m = TModule()

        switch = platform.request("switch")
        led = platform.request("led")
        btn_switch = platform.request("button", 0)
        btn_led = platform.request("button", 1)

        layout = data.StructLayout({"val": 1})

        m.submodules.switch_sampler = switch_sampler = InputSampler(layout, synchronize=True, polarity=True)
        m.d.comb += switch_sampler.data.val.eq(switch.i)
        m.d.comb += switch_sampler.trigger.eq(btn_switch.i)

        m.submodules.led_buffer = led_buffer = OutputBuffer(layout, synchronize=True, polarity=True)
        m.d.comb += led.o.eq(led_buffer.data.val)
        m.d.comb += led_buffer.trigger.eq(btn_led.i)

        with Transaction().body(m):
            led_buffer.put(m, val=switch_sampler.get(m).val)

        return m

Warning

The code assumes that the buttons on the FPGA dev board are active high (pulled down), as is the case on the Arty A7 board. If the buttons on your dev board are active low (pulled up), change the polarity parameters to False.

Please notice that flipping the switch now does not result in changes of the LED state unless the trigger buttons are both pressed. This is because the transaction body now does not run in every cycle. Instead it runs only in the cycles when both called methods (get and put) are ready, which is controlled by respective trigger buttons btn_switch and btn_led.

Also notice that the transaction definition did not need to be changed for this change in behavior. This is because, for a transaction to run in a given clock cycle, every method called by the transaction must be ready in that cycle. This condition is implicit in transaction definitions. It allows to safely change prerequisite conditions for calling methods without modifying the caller code.

Transaction conflicts

In the following example we have two switches controlling the state of a single LED. Each of the switches has its own trigger button, but the LED output is always triggered.

from amaranth import *
import amaranth.lib.data as data

from transactron import TModule, Transaction
from transactron.lib.basicio import InputSampler, OutputBuffer


class LedControl(Elaboratable):
    def elaborate(self, platform):
        m = TModule()

        switch1 = platform.request("switch", 0)
        switch2 = platform.request("switch", 1)
        led = platform.request("led")
        btn_switch1 = platform.request("button", 0)
        btn_switch2 = platform.request("button", 1)

        layout = data.StructLayout({"val": 1})

        m.submodules.switch1_sampler = switch1_sampler = InputSampler(layout, synchronize=True, polarity=True)
        m.d.comb += switch1_sampler.data.val.eq(switch1.i)
        m.d.comb += switch1_sampler.trigger.eq(btn_switch1.i)

        m.submodules.switch2_sampler = switch2_sampler = InputSampler(layout, synchronize=True, polarity=True)
        m.d.comb += switch2_sampler.data.val.eq(switch2.i)
        m.d.comb += switch2_sampler.trigger.eq(btn_switch2.i)

        m.submodules.led_buffer = led_buffer = OutputBuffer(layout, synchronize=True)
        m.d.comb += led.o.eq(led_buffer.data.val)

        with Transaction().body(m):
            led_buffer.put(m, val=switch1_sampler.get(m).val)

        with Transaction().body(m):
            led_buffer.put(m, val=switch2_sampler.get(m).val)

        return m

Pressing the first button changes the state of the LED to the state of the first switch. The same thing happens with the second button and the second switch. Now try pressing both buttons at once. The state of the LED should now show the state of one of the switches, and the other one should be ignored.

What happens is that we now have two different transactions, both trying to call led_buffer.put. Method calls are exclusive: in each clock cycle, at most one running transaction can call a given method. When only one of the buttons is pressed, only one of the switchN_sampler.get methods is ready, and the transaction that calls the ready method is run. But when both buttons are pressed, both transactions are runnable, but they can’t both run in the same clock cycle because of the led_buffer.put call. A situation like this is called a transaction conflict.

Transactron automatically ensures that conflicting transactions are never run in the same clock cycle. Resolving transaction conflicts is performed by an arbitration circuit generated by the transaction manager. This circuit is an implicit part of every project using Transactron.

Connecting transactions as submodules

Did you notice that the two transactions in the previous example are almost identical? Both of them call some method (the get method of a sampler) and pass the result immediately to another method (the put method of a buffer). This pattern occurs so often in Transactron that there is a library component for it, ConnectTrans. Import it:

from transactron.lib.connectors import ConnectTrans

Now replace the two transaction definitions with:

        m.submodules += ConnectTrans.create(led_buffer.put, switch1_sampler.get)
        m.submodules += ConnectTrans.create(led_buffer.put, switch2_sampler.get)

The synthesized circuit should work exactly like before.

Notice that we didn’t use val to reference the parameter of put or the field of the result of get. This is because ConnectTrans works at the level of structures, not individual fields. The connection requires that the output layout of get and the input layout of put are both the same layout.

Note

In ConnectTrans.create(method1, method2), the output of method2 is connected to the input of method1. But at the same time, the output of method1 is also connected to the input of method1: the connection is bidirectional. In this example, both the input layout of get and the output layout of put is empty, so everything works as expected.

As a consequence, the method arguments of create() can be swapped without changing the resulting behavior.

Data structures

Try flipping a switch when the corresponding button is pressed. You will see that the LED state is immediately updated. For the state to change only in the instant one of the buttons is pressed, an edge=True parameter should be added to the constructor of InputSampler. This will make the button sampler to be edge sensitive instead of level sensitive. The connecting transactions will therefore run only for a single cycle after the button is pressed, rather than continuously.

With that possibility, let’s now revisit the example with a single switch, but spice it up even further by adding a data structure between the switch sampler and the LED buffer: a FIFO queue.

from amaranth import *
import amaranth.lib.data as data

from transactron import TModule
from transactron.lib.basicio import InputSampler, OutputBuffer
from transactron.lib.connectors import ConnectTrans
from transactron.lib.fifo import BasicFifo


class LedControl(Elaboratable):
    def elaborate(self, platform):
        m = TModule()

        switch = platform.request("switch")
        led = platform.request("led", 0)
        led_fifo_write = platform.request("led", 1)
        led_fifo_read = platform.request("led", 2)
        btn_switch = platform.request("button", 0)
        btn_led = platform.request("button", 1)

        layout = data.StructLayout({"val": 1})

        m.submodules.switch_sampler = switch_sampler = InputSampler(layout, synchronize=True, polarity=True, edge=True)
        m.d.comb += switch_sampler.data.val.eq(switch.i)
        m.d.comb += switch_sampler.trigger.eq(btn_switch.i)

        m.submodules.led_buffer = led_buffer = OutputBuffer(layout, synchronize=True, polarity=True, edge=True)
        m.d.comb += led.o.eq(led_buffer.data.val)
        m.d.comb += led_buffer.trigger.eq(btn_led.i)

        m.submodules.fifo = fifo = BasicFifo(layout, 4)
        m.d.comb += led_fifo_write.o.eq(fifo.write.ready)
        m.d.comb += led_fifo_read.o.eq(fifo.read.ready)

        m.submodules += ConnectTrans.create(fifo.write, switch_sampler.get)
        m.submodules += ConnectTrans.create(led_buffer.put, fifo.read)

        return m

The FIFO component BasicFifo provides, among others, the two methods write and read. The write method inserts new data to the back of the queue, while read returns data at the front of the queue and removes it. Both of these methods are ready only when the respective actions can be correctly performed: write requires the queue not to be full, while read requires it to be nonempty. This way, Transactron automatically provides backpressure, which can help prevent overflow and underflow.

To illustrate this, the readiness signals of write and read are connected to LEDs number 1 and 2. At the beginning only the write method is ready. Pressing the first button runs the write method, which inserts the current value of the switch into the FIFO and makes the read method ready. After a few more presses of the first button the FIFO gets filled up, after which the write method is no longer ready. Further presses of the first button will now have no effect.

Pressing the second button will remove a value from the FIFO and display it on the first LED. The second button can be pressed until the FIFO is emptied. Pressing it again will not alter the state of the first LED. Try playing with the button and the switch some more.

Try swapping the BasicFifo for a Stack. For that, only the import and class name need to be changed.

RPN calculator

We will now implement a larger example: a reverse Polish notation (RPN) calculator. In this notation, operations are entered postfix, and parentheses are not needed: the expression (2 + 3) * 4 becomes 2 3 + 4 * in RPN. The algorithm for computing the value of RPN expressions reads symbols (numbers and operators) from left to right and uses a stack to store intermediate results. When a number is read, it is pushed to the stack. Reading an operator causes two numbers to be popped from the stack, and the result to be pushed back.

Hardware which implements a RPN calculator needs to be able to perform these two kinds of operations. The stack data structure in Transactron standard library, Stack, can perform at most one push and one pop per clock cycle. So, if used directly, performing a RPN operation would require more than one clock cycle. Instead, we will create a specialized stack structure, which will store the top value of the stack in a register so that a single clock cycle will suffice.

from amaranth import *
from transactron import TModule, Method, def_method
from transactron.lib.stack import Stack


class RpnStack(Elaboratable):
    def __init__(self, shape):
        self.shape = shape
        self.layout = [("val", shape)]

        self.peek = Method(o=self.layout)
        self.peek2 = Method(o=self.layout)
        self.push = Method(i=self.layout)
        self.pop_set_top = Method(i=self.layout)

    def elaborate(self, platform):
        m = TModule()

        m.submodules.stack = stack = Stack(self.layout, 32)

        top = Signal(self.shape)
        nonempty = Signal()

        @def_method(m, self.peek, ready=nonempty, nonexclusive=True)
        def _():
            return {"val": top}

        @def_method(m, self.peek2, nonexclusive=True)
        def _():
            return stack.peek(m)

        @def_method(m, self.push)
        def _(val):
            m.d.sync += nonempty.eq(1)
            m.d.sync += top.eq(val)
            with m.If(nonempty):
                stack.write(m, val=top)

        @def_method(m, self.pop_set_top)
        def _(val):
            m.d.sync += top.eq(val)
            stack.read(m)

        self.push.add_conflict(self.pop_set_top)

        return m

In the constructor we declare methods provided by our component. The peek and peek2 methods will return the top of the stack and the element immediately below it, both methods will not take any parameters. The push method will insert a new element to the stack, while pop_set_top will remove one element and change the value of the one below it.

The methods are defined inside elaborate using the Stack component and two additional registers, top and nonempty. Methods are defined using def_method decorator syntax. The method definition is written using Python def function syntax. It works much like the body context manager used for defining transactions – the Python code inside the definition is evaluated exactly once. Method inputs are passed as parameters, while the result is provided using return as a dict.

The first method, peek, returns the value at the top of the stack, which is stored in the register top. It is ready only when the stack is not empty. The nonexclusive=True parameter to def_method allows this method to be called by multiple transactions in a single clock cycle. This is justified by the fact that peek does not alter the state of the component in any way.

TODO: to be continued…