Porting a New Board To Migen

*Taps mic* Is this thing on? So it's really been 11 months since I last wrote something? I really need to change that! I'm going to attempt to write smaller articles in between my larger, more involved ones to keep me motivated to keep writing.

So today, I'm going to write about a simpler topic I meant to discuss last year, but never got around to it: How to get started with Migen on your Shiny New FPGA Development Board! This post assumes you have previous experience with Python 3 and experience synthesizing digital logic on FPGAs with Verilog. However, no Migen experience is assumed! Yes, you can port your a development board to Migen without having used Migen before!

What Is Migen- And Why Use It?

Migen is a Python library (framework, maybe?) to build digital circuits either for simulation, synthesis on an FPGA, or ASIC fabrication (the latter of which is mostly untested). Migen is one of many attempts to solve some deficiences with Verilog and VHDL, the languages most commonly used in industry to develop digital integrated circuits (ICs). If necessary, a designer can use and import Verilog/VHDL directly into their designs using one of the above languages too.

All the languages above either emit Verilog or an Intermediate Representation that can be transformed into Verilog/VHDL. I can think of a few reasons why:

  1. FPGA synthesis/ASIC front-ends are typically proprietary, can't be readily modified, and mostly only support Verilog and VHDL as input.
  2. Many issues with Verilog, such as synthesis-simulation behavior mismatch, don't occur if Verilog can be emitted in a controlled manner, as is done with the above languages.[1]
  3. From my own experience looking at yosys, which can be used as the front-end Verilog compiler to an open-source synthesis toolchain, there is little gain to targeting a file format meant for FPGA synthesis (or ASIC fabrication) directly from a higher-level language. Targeting the synthesis file format directly must be shown to be bug-free with respect to having generated Verilog and then generating the synthesis file format from the Verilog; this is nontrivial.

The Migen manual discusses its rationale for existing, but the important reasons that apply to me personally are that Migen:

As an added bonus, I can write a Migen design once and, within reason, generate a bitstream of my design without needing a separate User Constraints File (UCF) for each board that Migen supports. This facilitates design reuse by others who may not have the same board as I do, but has a new board with the required I/O peripherals anyway.

For the above reasons, I am far more productive writing HDL than I ever was writing Verilog.[2]

Choice of Tools Disclaimer

Of the linked languages above, I have only personally used Migen. Migen was the first Verilog alternative I personally discovered when I started using FPGAs for projects again in 2015 (after a 3 year break). When I first saw the typical code structure of a Migen project, I immediately felt at home writing my own basic designs, and could easily predict which Migen code would emit which Verilog constructs without reading the source. In fact, I ported my own Shiny New FPGA Development Board I just bought to Migen before I even tested my first Migen design!

Because of my extremely positive first impressions, and time spent learning Migen's more complicated features, I've had little incentive to learn a new HDL. That said, I maintain it is up to the reader to experiment and decide which HDL works best for their FPGA/ASIC projects. I only speak for my own experiences, and the point of me writing this post is to make porting a new board to Migen easier than when I learned how to do it. The code re-use aspect of Migen is important to me, and when done correctly, a port to a new board is very low-maintenance.

Leveraging Python To Build FPGA Applications

To motivate how to port a new development board, I need to show Migen code right now. If you haven't seen Migen before now, don't panic! I'll briefly explain each section:

from migen import *
from migen.build.platforms import icestick

class Rot(Module):
    def __init__(self):
        self.clk_freq = 12000000
        self.ready = Signal()
        self.rot = Signal(4)
        self.divider = Signal(max=self.clk_freq)
        self.d1 = Signal()
        self.d2 = Signal()
        self.d3 = Signal()
        self.d4 = Signal()
        self.d5 = Signal()

        ###
        self.comb += [j.eq(self.rot[i]) for i, j in enumerate([self.d1, self.d2, self.d3, self.d4])]
        self.comb += [self.d5.eq(1)]

        self.sync += [
            If(self.ready,
                If(self.divider == int(self.clk_freq) - 1,
                    self.divider.eq(0),
                    self.rot.eq(Cat(self.rot[-1], self.rot[:-1]))
                ).Else(
                    self.divider.eq(self.divider + 1)
                )
            ).Else(
                self.ready.eq(1),
                self.rot.eq(1),
                self.divider.eq(0)
            )
        ]

If you've never seen Migen code before, and/or are unfamiliar with its layout, I'll explain some of the more interesting points here in comparison to Verilog:

I omitted discussing any Migen data types, input arguments to their constructors, other features provided by the package, and common code idioms that I didn't use above for the sake of keeping this blog post on point. Most of the user-facing features/constructors are documented in the user manual. I can discuss features (and behavior) not mentioned in the manual in a follow-up post.

Adding a New Board

The above code was adapted from the rot.v example of Arachne PNR. In words, the above code turns on an LED, counts for 12,000,000 clock cycles, then turns off the previous LED and lights another of 4 LEDs; after 4 LEDs the cycle repeats. A fifth LED is always kept on as soon as the FPGA loads its bitstream.

Our goal is to get this simple Migen design to work on a new FPGA development board, walking through the process. Since this example is tailored to the iCE40 FPGA family, I'm choosing to port the iCEstick board to Migen... which already has a port...

Interactive Porting

My original intention was to write this blog post while I was creating the icestick.py platform file to be committed into migen.build. Unfortunately, at the time, Migen did not have any support for targeting the IceStorm synthesis toolchain.[3] So I ended up implementing the IceStorm backend and doing my blog post as intended went by the wayside while debugging.

That said, I'm going to attempt to simulate the process of adding a board from the beginning. I will only assume that the IceStorm backend to Migen exists, but the icestick.py board file does not.

We Need a Constraints File

Before I can start writing a board file for iCEstick, we need to know which FPGA pins are connected to what. For many boards, the manufacturer will provide a full User Constraints File (UCF) with this information. For demonstrative purposes[4] however, I will examine the schematic diagram of iCEstick instead to create my Migen board file. This can be found in a file provided by Lattice at a changing URL called "icestickusermanual.pdf".

We need to know the format of FPGA pin identifiers that Arachne PNR, the place-and-route tool for IceStorm, will expect as well. The format differs for each of the FPGA manufacturers and even between FPGA families of the same manufacturer; Xilinx, for example uses [A-Z]?[0-9]*, as does the Lattice ECP3 family. Fortunately, IceStorm uses pin numbers that correspond to the device package, and these are easily visible on the schematic:

One side (port) of connections of the iCE40 FPGA to iCEstick peripherals. In this image, LED, IrDA, and one side of 600 mil breadboard-compatible breakout connections can be seen.

If we examine the schematic and user manual, we will find the following peripherals:

We might not have an actual full constraints file to work with due to how arachne-pnr works, but we have all the information to create a Migen board file anyway, since we have the schematics. Armed with this information, we can start creating a board file for iCEStick.

Anatomy of a Migen Board File

Relative to the root of the migen package, migen places board definition files under the build/platforms directory. All paths in this section, unless otherwise noted are relative to the package root.

Platform Class

from migen.build.generic_platform import *
from migen.build.lattice import LatticePlatform
from migen.build.lattice.programmer import IceStormProgrammer

class Platform(LatticePlatform):
    default_clk_name = "clk12"
    default_clk_period = 83.333

    def __init__(self):
        LatticePlatform.__init__(self, "ice40-1k-tq144", _io, _connectors,
            toolchain="icestorm")

    def create_programmer(self):
        return IceStormProgrammer()

A board file consists of the definition of Python class conventionally named Platform. Platform should inherit from a class defined for each supported FPGA manufacturer. As of this writing, Migen exports AlteraPlatform, XilinxPlatform, and LatticePlatform, and more are possible in the future. Vendor platforms are defined in a subdirectory under build for each vendor, in the file platform.py.

Each FPGA vendor in turn inherits from GenericPlatform, which is defined in build/generic_platform.py and exports a number of useful methods for use in Migen code (I'll introduce them as needed). The GenericPlatform constructor accepts the following arguments:

A Platform class definition should also define the class variables default_clk_name and default_clk_period, which are used by GenericPlatform. default_clk_name should match the name of a resource in the io list that represents a clock input to the FPGA. default_clk_period is used by vendor-specific logic in Migen to create a clock constraint in nanoseconds for default_clk_name. The default clock is associated with the sys clock domain for sync statements.

Lastly, the create_programmer function should return a vendor-specific programmer. Adding a programmer is beyond the scope of this article. If a board can support more than one programming tool, the convention is to return a programmer based on a programmer class variable for the given board. This function can be omitted if no programmer fits, or one can be created on-the-fly using GenericPlatform.create_programmer.

Finalization

Some platforms Migen supports, such as the LX9 Microboard, have a do_finalize method. Finalization in Migen allows a user to defer adding logic to their design until overall resource usage is known. In particular, LX9 Microboard has an Ethernet peripheral, and the Ethernet clocks should use separate timing constraints from the rest of the design. The linked code detects whether the Ethernet peripheral was used using GenericPlatform.lookup_request("eth_clocks"), and adds appropriate platform constraints to the current design to be synthesized if necessary. If the Ethernet peripheral was not used in the design, the extra constraints are not added, and the ConstraintError from lookup_request is ignored.

Finalization operates on an internal Migen data structure called Fragments. Fragments require knowledge of Migen internals to use properly, so for the time being I suggest following the linked example if you need to add constraints conditionally. Of course, timing constraints and other User Constraints File data can be added at any point in your design manually using GenericPlatform.add_period_constraint and GenericPlatform.add_platform_command respectively.

iCEStick does not have any peripherals which need special constraints, and only a single clock; Migen will automatically add a constraint for the default clock. More importantly, in the case of IceStorm/iCEStick, only a global clock constraint is supported due to limitations in specifying constraints. Therefore, I omit the do_finalize method for the iCEStick board file. However, one use I have found for do_finalize in platforms compatible with IceStorm is to automatically instantiate pins with pullup resistors enabled. This gets around the limitations of Arachne PNR's constraints file format without needing to instantiate Verilog primitives directly in the top level of a Migen source file, and I can show code upon request.

I/O and Connectors

After defining a Platform class for your board, all you need to do is fill in a list of _io and _connectors in your board file, pass them into your Platform's vendor-specific base class constructor, and Migen will take care of the rest!

As I stated before, io and connectors input arguments to the vendor-specific platform constructor are lists of tuples with a specific format. Let's start with an I/O tuple:

(io_name, id, Pins("pin_name", "pin_name") or Subsignal(...), IOStandard("std_name"), Misc("misc"))

An io_name is the name of the peripheral, and should match the string passed into the request function to gain access to the peripheral's signals from Migen. id is a number to distinguish multiple copies of identically-functioning peripherals, such as LEDs. For simple peripherals, Pins is a helper class which should contain strings corresponding to the vendor-specific pin identifiers where the peripheral connects to the FPGA; in the case of IceStorm, there are just the pin numbers as defined on the package pinout. I will discuss Subsignals in the next paragraph. These tuple entries are used to create inputs and output names in the Migen-generated Verilog, and provide a variable-name to FPGA pin mapping in a Migen-generated User Constraints File (UCF)

Without going into excess detail[6], Subsignals are a helper class to for resources that use FPGA pins which can be seperated cleanly by purpose. The inputs to a Subsignal constructor are identical to an I/O tuple entry, except with id omitted. The net effect for the end user is that a resource is encapsulated as a class whose Migen Signals are accessed via the class' members, i.e. comb += [my_resource.my_sig.eq(5)]. This is known as a Record in Migen. Records also come with a number of useful methods for constructing Migen statements quickly. Think of them as analogous to C structs. It is up to your judgment whether an I/O peripheral should use Subsignals, but in general, I notice that Migen board files make heavy use of them.

The remaining inputs to an I/O tuple entry are optional. IOStandard is another helper class which contains a toolchain-specific string that identifies which voltages/logic standard should use. And lastly, the Misc helper class contains a space-separated string of other information that should be placed into the User Constraints File along with IOStandard. Such information includes slew rate and whether pullups should be enabled. These are in fact currently ignored in the IceStorm toolchain, but for my own reference I have filled them in as necessary.

A connector tuple is a bit simpler:

(conn_name, "pin_name, pin_name, pin_name,...")

conn_name is analogous to io_name. The second element of a connector tuple is a space-separated string of pin names matching the vendor's format which indicates which pins on the FPGA are associated with that particular connector. Ideally, the pins should be listed in some order that makes sense for the connector.

By default, pins that are associated with connectors are not exposed by the Platform via the request method. Instead, a user needs to notify the platform that they wish to use the connector as extra I/O using the GenericPlatform.add_extension method. Here is an example adding a PMOD I2C peripheral using add_extension:

my_i2c_device = [
    ("i2c_device", 0,
        Subsignal("sdc", Pins("PMOD:2"), Misc("PULLUP")),
        Subsignal("sda", Pins("PMOD:3"), Misc("PULLUP"))
    )
]

plat.add_extension(my_i2c_device)
plat.request("i2c_device")

Note that adding a peripheral using add_extension is similar to adding a peripheral to the io list, except that the Pins() element takes on the form "conn_name:index". conn_name should match a tuple in the connectors list, and index is a zero-based index into the string of FPGA pins associated with the connector. This allows you to create peripherals on-the-fly that are (in theory) board and vendor-agnostic.

With the last concepts out of the way, let's jump right into creating the _io and _connectors list for our Platform. Each listed peripheral is implied to be a tuple inside _io = [...] or _connectors = [...]:

("user_led", 0, Pins("99"), IOStandard("LVCMOS33")),
("user_led", 1, Pins("98"), IOStandard("LVCMOS33")),
("user_led", 2, Pins("97"), IOStandard("LVCMOS33")),
("user_led", 3, Pins("96"), IOStandard("LVCMOS33")),
("user_led", 4, Pins("95"), IOStandard("LVCMOS33")),

user_leds are simple peripherals found on just about every development board; iCEStick has 5 of them, all identical in function (but the 5th one is green!). Resources with identical function that differ only in a pin should each be declared in their own tuple, incrementing the id index.

Resource signal names are by convention; if a resource does not yet exist, it's up to you what you want to name the resource. However, I suggest looking at other board files for prior examples. user_led, user_btn, serial.rx, serial.tx, spiflash, and audio are all commonly-used I/O names used between board files.

("serial", 0,
    Subsignal("rx", Pins("9")),
    Subsignal("tx", Pins("8"), Misc("PULLUP")),
    Subsignal("rts", Pins("7"), Misc("PULLUP")),
    Subsignal("cts", Pins("4"), Misc("PULLUP")),
    Subsignal("dtr", Pins("3"), Misc("PULLUP")),
    Subsignal("dsr", Pins("2"), Misc("PULLUP")),
    Subsignal("dcd", Pins("1"), Misc("PULLUP")),
    IOStandard("LVTTL"),
),

Next we have another common peripheral- a UART/serial port. A UART peripheral makes sense to divide using Subsignals, since each pin has a distinct purpose. Although in practice most users will only use rx and tx[7], I include all possible pins just in case. I don't remember why I included PULLUP as constraints information for a majority of pins. Note that it's perfectly okay to associate a constraint with all Subsignals at once, as I do for the (unused) IOStandard.

("irda", 0,
    Subsignal("rx", Pins("106")),
    Subsignal("tx", Pins("105")),
    Subsignal("sd", Pins("107")),
    IOStandard("LVCMOS33")
),

The infrared port on iCEStick is another serial port, sans most of the control signals. I omit the optional I/O tuple/Subsignal entries here, and define the IOStandard similarly to the previous serial peripheral.

("spiflash", 0,
    Subsignal("cs_n", Pins("71"), IOStandard("LVCMOS33")),
    Subsignal("clk", Pins("70"), IOStandard("LVCMOS33")),
    Subsignal("mosi", Pins("67"), IOStandard("LVCMOS33")),
    Subsignal("miso", Pins("68"), IOStandard("LVCMOS33"))
),

spiflash and its Subsignal have standardized, self-explanatory names. I suggest using these signal names when appropriate for all peripherals connected via an SPI bus. I don't remember why I added the IOStandard per-signal instead of all-at-once here, but the net effect would be the same either way if IceStorm made use of IOStandard.

("clk12", 0, Pins("21"), IOStandard("LVCMOS33")),

Clock signals should be included as well, with at least one clock's io_name matching the default_clk_name. Migen may automatically request a clock for your design if certain conditions are met; for now, assume you don't have to request the clock.

("GPIO0", "44 45 47 48 56 60 61 62"),
("GPIO1", "119 118 117 116 115 114 113 112"),
("PMOD", "78 79 80 81 87 88 90 91"),

And lastly we have the connectors. The connector pins for GPIO0-1 and PMOD are ordered in increasing pin order, which matches the order they are laid out on their respective connectors. This is a happy coincidence. Make sure to check your schematic and declare FPGA pins in connector order, not the other way around!

Building Our Design For Our "New" Board

Now that we have created our board file, we now need to write the remaining logic to attach the board's I/O to our Rot top level. Assuming the Rot module is already defined, we can create a script that will synthesize our design like so:

if __name__ == "__main__":
    plat = icestick.Platform()
    m = Rot()
    m.comb += [plat.request("user_led").eq(m.d1) for l in [m.d1, m.d2, m.d3, m.d4, m.d5]]
    plat.build(m, run=True, build_dir="rot", build_name="rot_migen")
    plat.create_programmer().flash(0, "rot/rot_migen.bin")

Once again, I will explain each line. It should be appended to the CODR(Rot) top level and then run using your Python 3 interpreter:

If all goes well, and you were following along, you should now have a blinking LED example on your iCEStick! The final iCEStick platform board file is here, which you can use as a reference, and I've made the Rot top level available as a gist. Take a look at the output files Migen generated, including the output Verilog and User Constraints File, to get a feel of how our "shiny new" board file was used!

Happy Hacking!

If you have read up to this point, you now have some grasp on the Migen framework, and can now start using Migen in your own designs on your own development (or even deployed) designs!

Porting Migen to support your design takes relatively little effort, and you will quickly make up the time spent porting. Besides automating HDL idioms that are tedious to write by hand (such as FSMs), and generating Verilog that is free of certain bug classes, Migen saves time as it automates generating input files and build commands for your synthesis toolchain, and then invoking the synthesis flow automatically. Additionally, if you write your top-level Module and glue code correctly, you can have a single HDL design that runs on all platforms that Migen supports, even between vendors, with much less effort than is required to do the same in Verilog alone![8]

Migen is certainly a step in the right direction for the future of hacking with FPGAs, and I hope you as the reader give it a try with your Shiny New Development Board like I did, and see whether your experiences were as positive as mine.

Acknowledgements

I'd like to thank S├ębastien Bourdeauducq, the primary architect and maintainer of Migen, for looking over an initial version of this post and offering feedback.

Footnotes

[1] For better or worse, emitting Verilog requires someone intimately familiar with the Verilog specification. Like all good specifications, it is terse, and requires a lot of memorization. But both Yosys and Migen are by and large the work of one individual each, so it can be done.

[2] In the interest of fairness, fusesoc exists now to alleviate the burdern of Verilog code-sharing. I was unaware of its existence when I started using FPGAs again. I think Migen build integration would be an interesting project; in general, I find setting up a board-agnostic Migen design easier than w/ FuseSoC, but importing Verilog code as a package is still incredibly useful.

[3] I erroneously assumed that because the Xilinx backend code can use the yosys Verilog compiler that there was support for support for yosys and by extension IceStorm in the Lattice backend. Not having had any Lattice FPGAs before iCEstick (yes, I jumped on the FOSS FPGA toolchain bandwagon), I never actually bothered to check beforehand!

[4] Historically, I've found that IceStorm Verilog code samples only define the pins their designs actually use. Unlike Quartus or ISE, Arachne PNR will error out instead of warn if constraints that are defined aren't actually used. Migen doesn't have this issue because it will only generate constraints that are actually used for Arachne PNR.

[5] Each Platform consists of a single FPGA and attached peripherals. I assume a board with multiple FPGAs should implement a Platform for each FPGA in a single board file.

[6] Subsignals also modify how signal names are generated for the remainder of the synthesis toolchain.

[7] iCEStick disappoints me in that the engineers wired nearly all the connections required to use the FT2232H in FT245 queue mode, which is much faster than a serial port; most dev boards do not bother connecting anything besides serial TX and RX, and possibly RTS/CTS. But alas, there's still not enough control connections to use queue mode.

[8] Portability of code gives me joy, and HDL portability is no exception.