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")
In [2]:
from mantle import FullAdder
We can print out the interface of the FullAdder
.
In [3]:
print(FullAdder)
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))
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!")
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))
.
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 Circuit
s.
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.
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
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
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 FullAdder
s and wires them up.
In [13]:
%cat build/add.v
You can also display the circuit using graphviz
.
In [14]:
#DefineAdd(4)