Getting started with FPGA hacking

I recently got a ULX3S FPGA board, and have been playing with it. This post will be a short introduction to getting a fully OSS toolchain up and running to develop for this board, using the Bluespec programming language and the Yosys ecosystem of synthesis and programming tools.

The ULX3S is a great little hacking board. Its centerpiece is a Lattice ECP5 FPGA, a very featureful little FPGA. The board has a bunch of interesting peripherals hooked up to the FPGA: a USB port, an HDMI port, some DRAM, and of course the obligatory buttons and LEDs.

The toolchain

Most importantly for me, the ECP5’s bitstream format was reverse-engineered by Project Trellis, meaning that I can program this board using a fully open-source toolchain. This is a big deal, because the vendor proprietary FPGA tools are… How to put it? Not good. I have a couple of Xilinx dev boards around here, but I could never get deep into them because Xilinx’s toolchain is really unpleasant to use.

In contrast, with the ECP5 I can use a fully OSS toolchain to go from source to running hardware! In the setup I’m using, source code flows through the following tools to become hardware:

To make life easier on myself, I made a git repository containing a flake.nix, which lets me automatically add all these tools (and more) into my $PATH when I enter my “FPGA hacking” directory.

I also wrote a bunch of redo scripts to manage the build, 3although I’m not entirely sold on that yet - so far, it seems unconditionally recompiling everything from scratch and letting Bluespec manage dependencies ends up being faster. But really, given a particular Bluespec source file, all that redo goop boils down to the following commands, which map exactly to the above list of tools:

bsc -verilog -g mkTop Top.bsv
yosys -p 'read_verilog -sv mkTop.v' -p 'synth_ecp5 -top mkTop -json mkTop.json'
nextpnr-ecp5 --85k --json mkTop.json --lpf ulx3s.lpf --textcfg mkTop.pnr
ecppack mkTop.pnr mkTop.bit
openFPGALoader -b ulx3s mkTop.bit

Bluespec Blinky

In FPGA development, it’s traditional to start with “blink a LED” to verify that the toolchain is set up correctly. This is also a good excuse to take a look at some basic Bluespec SystemVerilog (BSV) code.

As the name suggests, BSV uses a SystemVerilog-ish syntax, so if you’ve used Verilog or SystemVerilog (or even VHDL) in the past, it’ll look somewhat familiar. Let’s look at a LED blinker module:

package Top;

interface ITop;
  (* always_ready *)
  method Bit#(8) leds();
endinterface

module mkTop (ITop);
  Reg#(UInt#(32)) counter <- mkReg(0);

  rule incr;
	counter <= counter + 1;
  endrule

  method Bit#(8) leds;
    return pack(counter)[27:20]
  endmethod
endmodule

endpackage

If we compile and program that onto the board, the 8 status LEDs will start counting from 0 to 255 for ever. Yay!

Let’s dissect the program a bit more. First, there’s package and endpackage. Each file is one package, which can contain a bunch of stuff, and can import other packages. So far, so boring.

Then, we come to interface and module. Modules are the building block of BSV hardware designs. You can think of them somewhat similarly to classes in object-oriented languages. And following that metaphor, a module implements an interface. In our case, the module named mkTop implements the ITop interface, which has a single method leds() returning an 8-bit array.

I’m glossing over how that leds() method turns into “blink physical LEDs on the board”. I may cover that in a later post, but for now suffice it to say that, as this code works its way through the toolchain, that method turns into just an array of 8 bits, and those 8 bits then get mapped to physical pins on the FPGA. From our BSV code’s perspective, the leds() method gets called on every cycle and provides a new value for those bits.

Design by specification

What are the goals of our blinky module? We want to blink the board’s 8 LEDs. But the FPGA on this board runs at 25MHz, so if we naively try to change the LED state on every clock cycle, it’ll be blinking too fast for the human eye. So, we introduce a slowing device: the module is going to have a 32-bit counter, which counts up by 1 on every clock cycle. We’re going to display some of the upper bits of the counter, so even though the bottom bit is changing at 25MHz, the bits we’re sending to the LEDs will be changing every 20ms-10s or so, slow enough for us to see the pattern.

If you look at the Bluespec source, that’s pretty much exactly what it says. We define a register containing a 32-bit integer, initialized at zero. Then we define a rule to increment the counter, and finally we implement the leds() method by returning bits 27 through 20 of the counter as our LED values.

If we run this design though the toolchain and program it into the FPGA, we get blinking LEDs! The LEDs are so bright that the video doesn’t make it obvious, but the eight LEDs are counting up in binary.

Video showing the ULX3S FPGA board. In the top right, a line of 8 LEDs are flashing rapidly.

Beyond Blinky

Rules are where you spend most of your time as a Bluespec programmer, so let’s poke at them a bit more.

In the module we’ve written so far, our rule is really simple: it’ll execute on every clock cycle, and on every clock cycle it’ll increment our counter. Let’s makes things fancier by making the counter run only when a button on the board is being pressed.

First, we expand our interface:

interface ITop;
  (* always_ready *)
  method Bit#(8) leds();
  (* always_enabled *)
  method Action btn(bit value);
endinterface

Our interface now has an “input” method (characterized by the Action return value), which provides us with the value of a button: 0 if not pressed, 1 if pressed.

In our module implementation, we add an internal Wire that lets us see what value is being set by the method:

module mkTop (ITop);
  Wire btn_pressed <- mkWire;

  ...

  method btn(bit value);
    btn_pressed <= value;
  endmethod
endmodule

Strictly speaking, the wire will only take on a definite value when the btn() method gets invoked. Similarly to our leds() from before, the btn() method ends up compiling down to a wire connected to a physical button. In Bluespec semantics, that means the btn() method will be called on every clock cycle with the current value of that pin.

As an aside, that’s what the (* always_enabled *) annotation is about. That’s telling the compiler that this method will get invoked on every clock cycle, so it doesn’t need to keep track of whether the method is able to run, or whether anyone is trying to run it on a particular cycle.

The compiler uses that annotation to make some simplifications to the generated design, and in places other than the top-level “interface to the world”, the compiler would also enforce this, and complain if you try to wire up this method in a way that won’t get invoked on every clock cycle.

Back on track: we now have a Wire called btn_pressed, whose value is 0 or 1 depending on whether the physical button is being pressed. How do we hook that up to our counter? We add a condition on the rule!

rule incr (btn_pressed == 1);
  counter <= counter + 1;
endrule

In plain English: “on every clock where btn_pressed is 1, increment the counter.”

This is fairly similar to the Verilog you’d write to make this happen, but on a conceptual level Bluespec is doing more heavy lifting for us. It’s generating the control circuitry to ensure that the code in our rule only runs when the conditions are satisfied, checks for conflicts with other rules and schedules them appropriately (e.g. preventing two rules from writing to the counter in the same cycle), and generally enforces that the rule’s invariants aren’t violated by anything else in the design.

Of course, in our case, that’s easy, there’s just one rule and a couple of trivial methods that don’t create conflicts. But as designs get more complex, this will be a life saver to keep things straight.

That’s all for this post! We went from nothing to button-controlled blinking LEDs, entirely with open-source software. I think that’s pretty great! I’m continuing to play with FPGAs, and may write more about my further adventures down the road (e.g. clock domain crossing, serial ports, CPUs, …).