Start by importing Magma
and Mantle
. Magma
is the core system which implements circuits and the methods to compose them, and Mantle
is a library of useful circuits.
In this tutorial we will be using the IceStick board, so we select 'ice40', the FPGA family used on the board, as the target.
In [1]:
import magma as m
m.set_mantle_target('ice40')
import mantle
A full adder has three single bit inputs, and returns the sum and the carry. The sum is the exclusive or of the 3 bits, the carry is 1 if any two of the inputs bits are 1. Here is a schematic of a full adder circuit (from logisim
).
We start by defining a Python function that implements a full adder.
The full adder function takes three single bit inputs and returns two outputs as a tuple.
The first element of tuple is the sum, the second element is the carry.
We compute the sum and carry using standard Python bitwise operators &
, |
, and ^
.
In [2]:
def fulladder(A, B, C):
return A^B^C, A&B|B&C|C&A # sum, carry
We can test our Python function to verify that our implementation behaves as expected.
We'll use the standard Python assert
pattern.
In [3]:
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 that we have an implementation of fulladder
as a Python function,
we'll use it to construct a Magma
Circuit
.
A Circuit
in Magma
corresponds to a module
in verilog
.
In [4]:
class FullAdder(m.Circuit):
name = "FullAdderExample"
IO = ["I0", m.In(m.Bit), "I1", m.In(m.Bit), "CIN", m.In(m.Bit), "O", m.Out(m.Bit), "COUT", m.Out(m.Bit)]
@classmethod
def definition(io):
O, COUT = fulladder(io.I0, io.I1, io.CIN)
io.O <= O
io.COUT <= COUT
First, notice that the FullAdder
is a subclass of Circuit
. All Magma
circuits are classes in python.
Second, the attribute IO
defines the interface to the circuit.
IO
is a list of alternating keys and values.
The key is the name of the argument, and the value is the type.
In this circuit, all the inputs and outputs have Magma
type Bit
.
We also qualify each type as an input or an output using the functions In
and Out
.
Third, we provide a function definition
. definition
must be a class method and this is indicated with the decorator @classmethod
.
The purpose of the definition
function is to create the actual full adder circuit.
The arguments are passed to definition
as the object io
.
This object has fields for each argument in the interface.
The body of definition
calls our previously defined python function fulladder
.
Note that when we call the python function fulladder
inside definition
it is passed Magma
values not standard python values.
When we tested fulladder
sbove we called it with ints.
When we called it inside definition
the values passed to the Python fulladder
function
are Magma
values of type Bit
.
The Python bitwise operators are overloaded to compute logical functions of the Magma
values (this corresponds to constructing the circuits to compute logical functions and
, or
, and xor
, and wiring inputs to outputs).
fulladder
returns two values.
These values are assigned to the python variables O
and COUT
.
Remember that assigning to a Python variable
sets the variable to refer to the object.
Magma
values are Python objects,
so assigning an object to a variable creates a reference to that Magma
value.
In order to complete the definition of the circuit,
O
and COUT
need to be wired to the outputs in the interface.
The python <=
operator is overloaded to perform wiring.
Let's test our FullAdder
circuit by comparing what it computes to the original python function. We do this by running a python circuit simulator and asserting that the values computed by the simulator are the same as the values computed by the python function.
In [5]:
from magma.simulator import PythonSimulator
fulladder_magma = PythonSimulator(FullAdder)
assert fulladder_magma(1, 0, 0) == fulladder(1, 0, 0), "Failed"
assert fulladder_magma(0, 1, 0) == fulladder(0, 1, 0), "Failed"
assert fulladder_magma(1, 1, 0) == fulladder(1, 1, 0), "Failed"
assert fulladder_magma(1, 0, 1) == fulladder(1, 0, 1), "Failed"
assert fulladder_magma(1, 1, 1) == fulladder(1, 1, 1), "Failed"
print("Success!")
As the final step we are going to compile the circuit to a bit stream and
download the bit stream to the ice40
FPGA on the IceStick
board.
In order to do this,
we first import the IceStick
class from the module loam
.
loam
has definitions for commonly used parts and boards.
In [6]:
from loam.boards.icestick import IceStick
We start by creating an instance of the IceStick board.
In [7]:
icestick = IceStick()
The IceStick board is based on a Lattice ICE40HX1K FPGA.
The GPIOs on the FPGA are brought out to two headers named J1
and J3
.
The loam
class IceStick
represents all the parts on the board
and how they are connected via wires.
More specifically, it keeps tracks of what GPIO pins are connected
to which pin on the headers.
In these tutorials,
we adopt the convention that J1
will be used for inputs
and J3
will be used for outputs.
We will use these headers to test our full adder by wiring up some switches
and LEDs to the inputs and outputs.
To test the full adder we configure the first three GPIO pins in J1
as inputs,
and the first two pins in J3
as outputs.
We also turn on
each pin that we are using.
Note the use of method chaining to set more than
one option on a GPIO pin.
Each time we call a configuration function on an object,
that object is returned so we can continue calling additional functions to configure
other options.
In [8]:
icestick.J1[0].input().on()
icestick.J1[1].input().on()
icestick.J1[2].input().on()
icestick.J3[0].output().on()
icestick.J3[1].output().on();
With our icestick
configured, we move on to
the setup of the top level Magma
main
program that runs on the ICE40.
The arguments to the main program are the the GPIO pins in the headers J1
and J3
that we turned on.
These arguments are referred to by name in the circuit main
,
specifically as main.J1
and main.J3
.
The type of main.J1
is In(Bits(3))
and main.J3
is Out(Bits(2))
.
Bits(n)
is a length n
array of Bit
values.
The length of these arrays depend on the number of GPIOs
that have been turned on.
Arrays of bits can be accessed using the standard Python indexing notation (e.g. [0]
).
We call fulladder
with three single bit inputs from main.J1
.
We then wire
the sum and carry outputs returned by fulladder
to main.J3
.
In [9]:
main = icestick.DefineMain()
fa = FullAdder()
sum, carry = fa(main.J1[0], main.J1[1], main.J1[2])
main.J3[0] <= sum
main.J3[1] <= carry
m.EndDefine()
When we've finished defining our main
function, we call the Magma
function EndDefine
. Any call to a Magma
Define
function such as DefineMain
or DefineCircuit
must be accompanied by an EndDefine
call. This is because Magma
maintains a stack of definitions, so the EndDefine
call signals to Magma
that the current definition on the stack has been completed and should be removed. Failure to call EndDefine
can lead to nasty error messages that are hard to decipher.
Now we can use the Magma
compile
function to generate verilog code. In addition to the verilog, Magma
generates a physical constraints file (.pcf
) that contains a mapping between physical pin numbers (e.g. 112) and named ports on the (compiled) top-level verilog module (e.g. J1[0]).
In [10]:
m.compile('build/fulladder', main)
Then we can use yosys
and the icestorm
tools to compile and program the FPGA.
In [11]:
%%bash
cd build
yosys -q -p 'synth_ice40 -top main -blif fulladder.blif' fulladder.v
arachne-pnr -q -d 1k -o fulladder.txt -p fulladder.pcf fulladder.blif
icepack fulladder.txt fulladder.bin
#iceprog fulladder.bin
You can test the program by connecting up some switches and LEDs to the headers.
Here is an example circuit to see the sum of the inputs displayed on a set of LEDs. We have J1 wired up to the right three switch/LED circuits and J3 wired up to the right two LED circuits (without switches).
In [12]:
%cat build/fulladder.v
The logical functions are implemented using verilog modules And2
, Or2
, and XOr2
. These in turn are implemented using 4-bit LUTs using the ICE40 primitive module SB_LUT4
. The top level main
module instances the logical functions and wires them up.
It is all quite simple.
To see which physical pins correspond to the J3 and J1 header pins,
we can inspect the pcf
file.
In [13]:
%cat build/fulladder.pcf
This tells us that J1[0]
refers to pin 112, etc.
At this point, you may be interested in viewing the FullAdder implementation in the mantle standard library, which is optimized for the ice40 architecture (it uses a single lookup table and a carry unit, as opposed to the above implementation which uses a lookup table for each logical operation).
The definition can be found at https://github.com/phanrahan/mantle/blob/master/mantle/lattice/mantle40/fulladder.py
In [14]:
from mantle.lattice.mantle40.fulladder import FullAdder
m.compile("build/mantle_full_adder", FullAdder)
%cat build/mantle_full_adder.v
In [ ]: