Add

In this tutorial, we will construct a n-bit adder from n full adders. Magma has built in support for addition using the + operator, so please don't think Magma is so low-level that you need to create logical and arithmetic functions in order to use it! We use this example to show how circuits are composed to form new circuits.

Since we are using the ICE40, we need to set the target of Mantle to "ice40".


In [1]:
import magma as m
m.set_mantle_target("ice40")

Mantle FullAdder

In the last example, we defined a Python function that created a full adder. In this example, we are going to use the built-in FullAdder from Mantle. Mantle is our standard library of useful circuits.


In [2]:
from mantle import FullAdder


import lattice ice40
import lattice mantle40

We can print out the interface of the FullAdder.


In [3]:
print(FullAdder)


FullAdder(I0: In(Bit), I1: In(Bit), CIN: In(Bit), O: Out(Bit), COUT: Out(Bit))

This tells us that the full adder has three inputs I0, I1, and CIN. Note that the type of these arguments are In(Bit). There are also two outputs O and COUT, both with type Out(Bit). In Magma arguments in the circuit interface are normally qualified to be inputs or outputs.


In [4]:
fulladder = FullAdder()
print(fulladder.I0, type(fulladder.I0))
print(fulladder.I1, type(fulladder.I1))
print(fulladder.CIN, type(fulladder.CIN))
print(fulladder.O, type(fulladder.O))
print(fulladder.COUT, type(fulladder.O))


I0 In(Bit)
I1 In(Bit)
CIN In(Bit)
O Out(Bit)
COUT Out(Bit)

Before testing the full adder on the IceStick board, let's test it using the Python simulator.


In [5]:
from magma.simulator import PythonSimulator

fulladder = PythonSimulator(FullAdder)

assert fulladder(1, 0, 0) == (1, 0), "Failed"
assert fulladder(0, 1, 0) == (1, 0), "Failed"
assert fulladder(1, 1, 0) == (0, 1), "Failed"
assert fulladder(1, 0, 1) == (0, 1), "Failed"
assert fulladder(1, 1, 1) == (1, 1), "Failed"
print("Success!")


Success!

class Add2 - Defining a Circuit

Now let's build a 2-bit adder using FullAdder. We'll use a simple ripple carry adder design by connecting the carry out of one full adder to the carry in of the next full adder. The resulting adder will accept as input a carry in, and generate a final carry out. Here's a logisim diagram of the circuit we will construct:

Here is a Python class that implements a 2-bit adder.


In [6]:
class Add2(m.Circuit):
    IO = ['I0', m.In(m.UInt[2]), 'I1', m.In(m.UInt[2]), 'CIN', m.In(m.Bit),
           'O', m.Out(m.UInt[2]), 'COUT', m.Out(m.Bit) ]
    @classmethod
    def definition(io):
        n = len(io.I0)
        O = []
        COUT = io.CIN
        for i in range(n):
            fulladder = FullAdder()
            Oi, COUT = fulladder(io.I0[i], io.I1[i], COUT)
            O.append(Oi)
        io.O <= m.uint(O)
        io.COUT <= COUT

Although we are making an 2-bit adder, we do this using a for loop that can be generalized to construct an n-bit adder. Each time through the for loop we create an instance of a full adder by calling FullAdder(). Recall that circuits are python classes, so that calling a class returns an instance of that class.

Note how we wire up the full adders. Calling an circuit instance has the effect of wiring up the arguments to the inputs of the circuit. That is,

O, COUT = fulladder(I0, I1, CIN)

is equivalent to

m.wire(IO, fulladder.I0)
m.wire(I1, fulladder.I1)
m.wire(CIN, fulladder.CIN)
O = fulladder.O
COUT = fulladder.COUT

The outputs of the circuit are returned.

Inside this loop we append single bit outputs from the full adders to the Python list O. We also set the CIN of the next full adder to the COUT of the previous instance.

Finally, we then convert the list O to a Uint(n). In addition to Bits(n), Magma also has built in types UInt(n) and SInt(n) to represent unsigned and signed ints. Magma also has type conversion functions bits, uint, and sint to convert between different types. In this example, m.uint(C) converts the list of bits to a UInt(len(C)).

DefineAdd Generator

One question you may be asking yourself, is how can this code be generalized to produce an n-bit adder. We do this by creating an add generator. A generator is a Python function that takes parameters and returns a circuit class. Calling the generator with different parameter values will create different circuits. The power of Magma results from being to use all the features of Python to create powerful hardware generators.

Here is the code:


In [7]:
def DefineAdd(n):
    class _Add(m.Circuit):
        name = f'Add{n}'
        IO = ['I0', m.In(m.UInt[n]), 'I1', m.In(m.UInt[n]), 'CIN', m.In(m.Bit),
               'O', m.Out(m.UInt[n]), 'COUT', m.Out(m.Bit) ]
        @classmethod
        def definition(io):
            O = []
            COUT = io.CIN
            for i in range(n):
                fulladder = FullAdder()
                Oi, COUT = fulladder(io.I0[i], io.I1[i], COUT)
                O.append(Oi)
            io.O <= m.uint(O)
            io.COUT <= COUT
    return _Add

def Add(n):
    return DefineAdd(n)()

def add(i0, i1, cin):
    assert len(i0) == len(i1)
    return Add(len(i0))(i0, i1, cin)

First, notice that a circuit generator by convention begins with the prefix Define. In this example, DefineAdd has a parameter n which is the width of the adder.

A circuit generator returns a subclass of Circuit. A standard way to write this is to construct a new Circuit class within the body of the generator. The code within the body of the generator can refer to the arguments to the generator.

Like Verilog modules, Magma circuits must have unique names. Because Python does not provide the facilities to dynamically generate the class name, dynamically constructed Magma circuits are named using the name class variable. Python generators need to create unique names for each generated circuit because Magma will cache circuit definitions based on the name.

Note how the name of the circuit is set using the format string f'Add{n}'. For example, if n is 2, the name of the circuit will be Add2. Magma allows you to use Python string manipulation functions to create mnemonic names. As we will see, the resulting verilog module will have the same name. This is very useful for debugging.

We also can create the parameterized types within the generator. In this example, we use the type UInt(n) which depends on n. The loop within definition can also refer to the parameter n'

Finally, notice we defined three interrelated functions: DefineAdd(n), Add(n), and add(i0, i1, cin). Why are there three functions? Because there are three stages in using Magma to create hardware. The first stage is to generate or define circuits. The second stage is to create instances of these circuits. And the third stage is to wire up the circuits.

Functions named DefineX are generators. Generators are functions that return Circuits.

Functions named X return circuit instances. This is done by calling DefineX and then instancing the circuit. This may seem very inefficient. Fortunately, circuits classes are cached and only defined once.

Finally, functions named lowercase x do one more thing. They wire the arguments of to x to the circuit. They can also construct the appropriate circuit class depending on the types of the arguments. In this example, add constructs an n-bit adder, where n is the width of the inputs.

We strongly recommend that you follow this naming convention.

Running on the IceStick

In order to test the adder, we setup the IceStick board to have two 2-bit inputs and one 3-bit output. As before, J1 will be used for inputs and J3 for outputs.


In [8]:
N = 2

from loam.boards.icestick import IceStick

icestick = IceStick()
for i in range(N):
    icestick.J1[i].input().on()
    icestick.J1[i+N].input().on()
    
for i in range(N+1):
    icestick.J3[i].output().on()

We define a main function that instances our 2-bit adder and wires it up to J1 and J3. Notice the use of Python's slicing syntax using our width variable N.


In [9]:
main = icestick.DefineMain()
O, COUT = add( main.J1[0:N], main.J1[N:2*N], 0 )
main.J3[0:N] <= O
main.J3[N] <= COUT
m.EndDefine()

As before, we compile.


In [10]:
m.compile('build/add', main)

And use our yosys, arcachne-pnr, and icestorm tool flow.


In [11]:
%%bash
cd build
yosys -q -p 'synth_ice40 -top main -blif add.blif' add.v
arachne-pnr -q -d 1k -o add.txt -p add.pcf add.blif 
icepack add.txt add.bin
#iceprog add.bin


/Users/hanrahan/git/magmathon/notebooks/tutorial/icestick/build

You can test the program by connecting up some switches and LEDs to the headers. You should see the sum of the inputs displayed on the LEDs. First, we need to find out what pins J1 and J3 are wired up to. (Note: you can use % to execute shell commands inline in Jupyter notebooks)


In [12]:
%cat build/add.pcf


set_io J1[0] 112
set_io J1[1] 113
set_io J1[2] 114
set_io J1[3] 115
set_io J3[2] 60
set_io J3[1] 61
set_io J3[0] 62

In this example, we have J1 wire up to the four switch/LED circuits on the left, and J3 wired up to the three LED (no switch) circuits on the right

Again, it can be useful to examine the compiled Verilog.

Notice that it includes a Verilog definition of the mantle FullAdder implemented using the SB_LUT4 and SB_CARRY primtives. The Add2 module instances two FullAdders and wires them up.


In [13]:
%cat build/add.v


module FullAdder (input  I0, input  I1, input  CIN, output  O, output  COUT);
wire  SB_LUT4_inst0_O;
wire  SB_CARRY_inst0_CO;
SB_LUT4 #(.LUT_INIT(16'h9696)) SB_LUT4_inst0 (.I0(I0), .I1(I1), .I2(CIN), .I3(1'b0), .O(SB_LUT4_inst0_O));
SB_CARRY SB_CARRY_inst0 (.I0(I0), .I1(I1), .CI(CIN), .CO(SB_CARRY_inst0_CO));
assign O = SB_LUT4_inst0_O;
assign COUT = SB_CARRY_inst0_CO;
endmodule

module Add2 (input [1:0] I0, input [1:0] I1, input  CIN, output [1:0] O, output  COUT);
wire  FullAdder_inst0_O;
wire  FullAdder_inst0_COUT;
wire  FullAdder_inst1_O;
wire  FullAdder_inst1_COUT;
FullAdder FullAdder_inst0 (.I0(I0[0]), .I1(I1[0]), .CIN(CIN), .O(FullAdder_inst0_O), .COUT(FullAdder_inst0_COUT));
FullAdder FullAdder_inst1 (.I0(I0[1]), .I1(I1[1]), .CIN(FullAdder_inst0_COUT), .O(FullAdder_inst1_O), .COUT(FullAdder_inst1_COUT));
assign O = {FullAdder_inst1_O,FullAdder_inst0_O};
assign COUT = FullAdder_inst1_COUT;
endmodule

module main (input [3:0] J1, output [2:0] J3);
wire [1:0] Add2_inst0_O;
wire  Add2_inst0_COUT;
Add2 Add2_inst0 (.I0({J1[1],J1[0]}), .I1({J1[3],J1[2]}), .CIN(1'b0), .O(Add2_inst0_O), .COUT(Add2_inst0_COUT));
assign J3 = {Add2_inst0_COUT,Add2_inst0_O[1],Add2_inst0_O[0]};
endmodule

You can also display the circuit using graphviz.


In [14]:
#DefineAdd(4)