on
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:
- Bluespec, a high-level synthesis language that, in my brief experience, is much more pleasant to wield than raw Verilog/VHDL. It “compiles” down to Verilog.
- Yosys, a swiss army chainsaw of synthesis. It reads in the Verilog, elaborates it into abstract logic elements (AND gates, registers, state machines, …), and synthesizes the design down into the specific hardware features that the ECP5 has (e.g. 4-input lookup tables, block RAMs, flip-flops…).
- Nextpnr takes Yosys’s output, which is an abstract graph of how the ECP5’s elements should be wired together, and runs “place and route”, i.e. it figures out exactly where in the FPGA’s physical layout the pieces should go, and how to route the wires between the elements to meet the performance target.
- Ecppack (part of Project Trellis) takes Nextpnr’s physical layout description, and serializes it down to a “bitstream” - literally a bunch of bits telling the FPGA how to configure itself.
- OpenFPGALoader takes the bitstream and fires it into the FPGA’s configuration RAM. When the programming is done, the FPGA is running your design!
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.
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, …).