Gateware Blinky
This tutorial walks through the process of developing a simple “blinky” example for Cynthion’s ECP5 FPGA. We’ll use the Amaranth Language & toolchain to create the design and generate a FPGA bitstream using the Yosys Open SYnthesis Suite.
Prerequisites
Before you begin, please make sure you have installed the Cynthion tools by following Getting Started with Cynthion.
Install Toolchain
To generate bitstreams for Cynthion you will need a synthesis toolchain that can convert the Verilog produced by Amaranth into a bitstream for Cynthion’s ECP5 FPGA.
For these tutorials we recommend YoWASP which provides unofficial WebAssembly-based packages for Yosys and NextPNR. It runs a little slower than the official OSS CAD Suite distribution but it’s platform-independent and much easier to get started with.
Install YoWASP using pip:
pip install yowasp-yosys yowasp-nextpnr-ecp5
Create a new Amaranth module
Create a new file called gateware-blinky.py
and add the following code to it:
1from amaranth import *
2
3class Top(Elaboratable):
4 def elaborate(self, platform):
5 m = Module()
6
7 return m
Amaranth designs are built from a hierarchy of smaller modules, which are called elaboratables. The Top
class expresses that this will be the top-level or entry-point of your design.
Right now the Top
module does not do anything except to create an Amaranth Module()
and return it from the elaborate(...)
method.
The elaborate(...)
method also takes an argument called platform
that contains resources specific to the board or platform the module is compiled for.
In this case, the argument will be an instance of the Cynthion Board Description and contain a map of Cynthion resources such as LEDs, USB PHY’s, USER PMOD connectors and board constraints.
Obtain a platform resource
Edit gateware-blinky.py
and add the highlighted line:
1from amaranth import *
2
3class Top(Elaboratable):
4 def elaborate(self, platform):
5 m = Module()
6
7 leds: Signal(6) = Cat(platform.request("led", n).o for n in range(0, 6))
8
9 return m
Amaranth platform resources can be obtained via the platform.request(name, number=0)
method where name
is the name of the resource and number
is the index of the resource if there are more than one defined.
In this case we use a Python list comprehension to obtain all six FPGA led’s and concatenate them into a six-bit addressable Amaranth Signal
using the Cat
operation.
Timer State
To make the LED blink at predictable intervals we’ll use a simple timer.
To start with, let’s define the timer state by adding the highlighted lines:
1from amaranth import *
2
3class Top(Elaboratable):
4 def elaborate(self, platform):
5 m = Module()
6
7 leds: Signal(6) = Cat(platform.request("led", n).o for n in range(0, 6))
8
9 half_freq: int = int(60e6 // 2)
10 timer: Signal(25) = Signal(range(half_freq))
11
12 return m
First we’ll declare a variable half_freq
which is exactly half of Cynthion FPGA’s default clock frequency in Hz, next we’ll declare timer
to be an Amaranth Signal
which is wide enough to contain a value equal to half_freq - 1
.
If we increment the timer
by one for each clock cycle until it reaches half_freq - 1
we get a timer with a 500ms period.
Timer Logic
Now that we have a state definition for our timer we can move forward to the implementation logic, edit your file and add the highlighted lines:
1from amaranth import *
2
3class Top(Elaboratable):
4 def elaborate(self, platform):
5 m = Module()
6
7 leds: Signal(6) = Cat(platform.request("led", n).o for n in range(0, 6))
8
9 half_freq: int = int(60e6 // 2)
10 timer: Signal(25) = Signal(range(half_freq))
11
12 with m.If(timer == half_freq - 1):
13 m.d.sync += leds.eq(~leds)
14 m.d.sync += timer.eq(0)
15
16 with m.Else():
17 m.d.sync += timer.eq(timer + 1)
18
19 return m
Amaranth combines normal Python expressions with Amaranth in order to describe a design. Whenever you see the prefix m.
you are making a call to the Module
object you created at the beginning of the elaborate(...)
method. These calls are what build the logic which makes up a design.
The with m.If(...):
and with m.Else():
blocks operate much like you’d expect where, every clock-cycle, the expression timer == half_freq - 1
will be evaluated and trigger the corresponding branch.
The first block represents the point at which the timer has expired and we’d like to change the state of the LEDs and then reset the timer back to zero.
In the second block the timer is still active so we simply increment timer
by one.
Put It All Together
The contents of gateware-blinky.py
should now look like this:
1#!/usr/bin/env python3
2#
3# This file is part of Cynthion.
4#
5# Copyright (c) 2024 Great Scott Gadgets <info@greatscottgadgets.com>
6# SPDX-License-Identifier: BSD-3-Clause
7
8from amaranth import *
9
10class Top(Elaboratable):
11 def elaborate(self, platform):
12 m = Module()
13
14 leds: Signal(6) = Cat(platform.request("led", n).o for n in range(0, 6))
15
16 half_freq: int = int(60e6 // 2)
17 timer: Signal(25) = Signal(range(half_freq))
18
19 with m.If(timer == half_freq - 1):
20 m.d.sync += leds.eq(~leds)
21 m.d.sync += timer.eq(0)
22
23 with m.Else():
24 m.d.sync += timer.eq(timer + 1)
25
26 return m
27
28if __name__ == "__main__":
29 from luna import top_level_cli
30 top_level_cli(Top)
Build and Upload FPGA Bitstream
Make sure your Cynthion CONTROL port is plugged into the host, open a terminal and then run:
python gateware-blinky.py
The blinky gateware will now be synthesized, placed, routed and automatically uploaded to the Cynthion’s FPGA.
Once this process has completed successfully all six of Cynthion’s FPGA LEDs should be flashing on and off.
Exercises
Modify the tutorial to turn the FPGA LEDs into a binary counter that increments by one every 250ms.
Connect the USER PMOD A port to the output of your counter and use a logic analyzer (e.g. GreatFET One) to view the values as they increment.