FullAdder - Combinational Circuits

This notebook walks through the implementation of a basic combinational circuit, a full adder. This example introduces many of the features of Magma including circuits, wiring, operators, and the type system.

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


import lattice ice40
import lattice mantle40

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!")


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!")


Success!

Running FullAdder on the IceStick

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


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

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).

Verilog

If you know verilog, it is instructive to look at the verilog code that was generated.


In [12]:
%cat build/fulladder.v


module XOr2 (input [1:0] I, output  O);
wire  SB_LUT4_inst0_O;
SB_LUT4 #(.LUT_INIT(16'h6666)) SB_LUT4_inst0 (.I0(I[0]), .I1(I[1]), .I2(1'b0), .I3(1'b0), .O(SB_LUT4_inst0_O));
assign O = SB_LUT4_inst0_O;
endmodule

module And2 (input [1:0] I, output  O);
wire  SB_LUT4_inst0_O;
SB_LUT4 #(.LUT_INIT(16'h8888)) SB_LUT4_inst0 (.I0(I[0]), .I1(I[1]), .I2(1'b0), .I3(1'b0), .O(SB_LUT4_inst0_O));
assign O = SB_LUT4_inst0_O;
endmodule

module Or2 (input [1:0] I, output  O);
wire  SB_LUT4_inst0_O;
SB_LUT4 #(.LUT_INIT(16'hEEEE)) SB_LUT4_inst0 (.I0(I[0]), .I1(I[1]), .I2(1'b0), .I3(1'b0), .O(SB_LUT4_inst0_O));
assign O = SB_LUT4_inst0_O;
endmodule

module FullAdderExample (input  I0, input  I1, input  CIN, output  O, output  COUT);
wire  XOr2_inst0_O;
wire  XOr2_inst1_O;
wire  And2_inst0_O;
wire  And2_inst1_O;
wire  Or2_inst0_O;
wire  And2_inst2_O;
wire  Or2_inst1_O;
XOr2 XOr2_inst0 (.I({I1,I0}), .O(XOr2_inst0_O));
XOr2 XOr2_inst1 (.I({CIN,XOr2_inst0_O}), .O(XOr2_inst1_O));
And2 And2_inst0 (.I({I1,I0}), .O(And2_inst0_O));
And2 And2_inst1 (.I({CIN,I1}), .O(And2_inst1_O));
Or2 Or2_inst0 (.I({And2_inst1_O,And2_inst0_O}), .O(Or2_inst0_O));
And2 And2_inst2 (.I({I0,CIN}), .O(And2_inst2_O));
Or2 Or2_inst1 (.I({And2_inst2_O,Or2_inst0_O}), .O(Or2_inst1_O));
assign O = XOr2_inst1_O;
assign COUT = Or2_inst1_O;
endmodule

module main (input [2:0] J1, output [1:0] J3);
wire  FullAdderExample_inst0_O;
wire  FullAdderExample_inst0_COUT;
FullAdderExample FullAdderExample_inst0 (.I0(J1[0]), .I1(J1[1]), .CIN(J1[2]), .O(FullAdderExample_inst0_O), .COUT(FullAdderExample_inst0_COUT));
assign J3 = {FullAdderExample_inst0_COUT,FullAdderExample_inst0_O};
endmodule

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


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

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


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


In [ ]: