hello, fpga!
Table of Contents
1. Overview
What we're doing: In this guide we'll build the hello world of the FPGA world - blinky. Blinky is a simple project for controlling LEDs. This is the perfect introductory project because the logic is simple which allows us to focus on the complexity of the FPGA. For this project we will be using open source tools.
What to expect: The final product will be hardware that toggles LEDS. Specifically, the LEDs will cycle through binary numbers. That is, they will turn on/off in a pattern that represents 1 in binary, then 2, then 3, etc… A quick example below shows what to expect if we let * be on:
time/LED | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
---|---|---|---|---|---|---|---|---|
t1 | * | |||||||
t2 | * | |||||||
t3 | * | * | ||||||
t4 | * | |||||||
t5 | * | * | ||||||
t6 | * | * | ||||||
t7 | * | * | * | |||||
t8 | * |
It has this cool recursive effect where every step you look for the first LED that's off, turn it on and then turn off every LED before it.
2. Code
2.1. Logical Representation: Verilog
Let's start with all of the code and then break it down into parts:
module top( input CLK, output LED1, output LED2, output LED3, output LED4, output LED5, output LED6, output LED7, output LED8 ); localparam COUNTER_WIDTH = 32; reg [COUNTER_WIDTH-1:0] counter; always @(posedge CLK) counter <= counter + 1; assign LED1 = counter[COUNTER_WIDTH-1]; assign LED2 = counter[COUNTER_WIDTH-2]; assign LED3 = counter[COUNTER_WIDTH-3]; assign LED4 = counter[COUNTER_WIDTH-4]; assign LED5 = counter[COUNTER_WIDTH-5]; assign LED6 = counter[COUNTER_WIDTH-6]; assign LED7 = counter[COUNTER_WIDTH-7]; assign LED8 = counter[COUNTER_WIDTH-8]; endmodule
We start off by defining the top
module. Top is a special module name in verilog similar to main
in c. Our build tools know that it's the entry point for our program. We can see that it have a single input (the clock) and multiple outputs (the LEDs).
Next, we create a counter. We represent it as an array of bits ie a binary number.
Finally, we have the logic of the app. Every time the clock ticks we do the following:
- Increment the counter
- Turn each LED on if the corresponding binary digit for the counter is 1. For example, if the counter is 11, it's binary representation will be 001011, so the first, second and fourth LED will be on.
2.2. Phsyical Representation: Constraints
Now that we've defined the logic of our program we need to map our inputs and outputs to the physical hardware in a constraints file. This is fairly straightforward and can be done with help from documentation from the manufacturer (although it can take a long time to find the documentation!). Documentation for the Alchitry CU can be found here.
# Clock set_io CLK P7 # Onboard LEDs set_io LED1 J11 set_io LED2 K11 set_io LED3 K12 set_io LED4 K14 set_io LED5 L12 set_io LED6 L14 set_io LED7 M12 set_io LED8 N14
Thats it! That's all the code we need.
3. Build Process
In this section we will learn how the code above is uploaded to the FPGA.
Below is a breakdown of each step in the build process: Before we continue we need to explain how FPGAs work at a low level. FPGAs are made of LUTs. LUTs are programmable logic gate. What's a logic gate? It's an electrical component that takes as input different binary signals and produces a binary signal as an output. Take an and-gate as an example. It takes two binary singles. If both signal are on then it will also output an on signal, hence the name "and", because if the first input is on AND the second is on it will be on. Another example is an or-gate. If it's first input is on OR it's second input is on then it will output an on signal. There are lots of logic gates we can come up with. We could have one that only outputs an on signal if the second input is on. We could have one that is always off, no matter what input it receives. The secret of LUTs is that they can be configured to represent any logic gate we can think of. And unlike our and-gates and or-gates, they can have more than 2 inputs. The LUTs in our FPGA each have 4.
3.1. Synthesis
So, we have our high level verilog and our low level LUTs, how do we get from one to the other? Synthesis is the first step. Synthesis is the process of taking our high level verilog code and representing it in terms of LUTs using a low level representation. In our case, our low level representation will be in JSON. If your familiar with traditional processors this will remind you of compiling a program to a lower level language. Here is a chunk of the file:
"$specify$134": { "hide_name": 1, "type": "$specify2", "parameters": { "DST_WIDTH": "00000000000000000000000000000001", "FULL": "0", "SRC_DST_PEN": "0", "SRC_DST_POL": "0", "SRC_WIDTH": "00000000000000000000000000000001", "T_FALL_MAX": "00000000000000000000000100100000", "T_FALL_MIN": "00000000000000000000000011100111", "T_FALL_TYP": "00000000000000000000000100000000", "T_RISE_MAX": "00000000000000000000000100111100", "T_RISE_MIN": "00000000000000000000000011111110", "T_RISE_TYP": "00000000000000000000000100011001" }, "attributes": { "module_not_derived": "00000000000000000000000000000001", "src": "/usr/local/bin/../share/yosys/ice40/cells_sim.v:2247.2-2247.42" }, "port_directions": { "DST": "input", "EN": "input", "SRC": "input" }, "connections": { "DST": [ 10 ], "EN": [ "1" ], "SRC": [ 4 ] } }
Notice that this is still sort of a high level representation because we are representing most values as text and not refrencing any of the physical hardware. This is another logical representation of how our FPGA should be configured.
3.2. Place and Route
Our next step is to map the logic above to the physical hardware on the FPGA, that is, make a physical representation. This is similar to how we needed to map our inputs and outputs with the constrains file after we wrote our project's logic with verilog. This is where the place and route step comes in. Here is a sample from the resulting file:
.io_tile 16 0 000000000000000000 000100000000000000 000000000000000000 000000000000000000 000000000000001100 000000000000001000 000100000000000000 000000000000000000 000000000000000000 000000000000000000 000000000000000000 010011010000000000 000000000000000000 000000000000000001 000000000000000000 000000000000000000 .io_tile 17 0 000000000000000000 000000000000000000 000000000000000000 000000000000000000 000000000000000000 000000000000000000 000000000000000000 000000000000000000 000000000000000000 000000000000000000 000000000000000000 000000000000000000 000000000000000000 000000000000000000 000000000000000000 000000000000000000
We can see that the building blocks of the FPGA are being configured. Most configurations are like .io_tile 17 0
- zeroed out. These aren't being configured to do anything and that makes sense if you think about it. Since our program is fairly simple it doesn't require a lot of configuration so most of the hardware isn't configured.
3.3. Generate Bistream
Now that we've determined how we will configure or physical hardware we need to upload the configuration to the FPGA. How does this work? Well, an FPGA has a configuration port that reads in a sequence 1 and 0s and is able to pass those on to the actual LUTs for configuration. This sequence of 1 and 0s is called the bitstream. Whenever an FPGA is powered on it reads in it's configuration from this port and programs itself. Our next step is to create a bitstream from our physical representation that we will then be able to upload to the device.
3.4. Upload
Now that we have the bitstream we are ready to upload it. How the 1s and 0s are moved from the a machine via usb and onto the device is left as an exercise for the reader.
4. Tooling
4.1. Icestorm
git clone https://github.com/YosysHQ/icestorm.git icestorm cd icestorm make -j$(nproc) sudo make install
5. Makefile
Now that we know how to get our code uploaded to our FPGA let's create a Makefile to automate this process:
################################################################################ # Reference ################################################################################ # Makefile Reference: # https://gist.github.com/isaacs/62a2d1825d04437c6f08 # Rules: # <target>: <prerequisites...> # <commands> # # Magic Variables # $@ # (target) # $< # (first prerequisite) # $^ # (all prerequisites) ################################################################################ # Constants ################################################################################ BUILD = ./build VERILOG = src/$(app)/main.v CONSTRAINT = src/$(app)/main.pcf FPGA_PKG = cb132 FPGA_TYPE = hx8k ################################################################################ # Interface ################################################################################ .PHONY: main build clean main: check-env upload build: $(BUILD)/main.bin clean: rm $(BUILD)/* # Helpers check-env: ifndef app $(error app is undefined) endif ################################################################################ # CORE # Building and Uploading ################################################################################ # Upload # Purpose: Upload the bitstream to the FPGA # Docs: https://clifford.at/icestorm upload: $(BUILD)/main.bin iceprog $< # Bitstream # Purpose: Generate a bitstream file # Docs: https://clifford.at/icestorm # Context: The FPGA is programmed by streaming the bitstream file to the # configuration port. $(BUILD)/main.bin: $(BUILD)/main.asc icepack $< $@ # Place and Route # Purpose: Map primitives to physical locations on the hardware # Docs: https://github.com/YosysHQ/nextpnr $(BUILD)/main.asc: $(BUILD)/main.json nextpnr-ice40 --${FPGA_TYPE} --package ${FPGA_PKG} --json $< --pcf $(CONSTRAINT) --asc $@ # Yosys Open SYnthesis Suite # Purpose: Synthesize into json # Docs: https://yosyshq.net/yosys/documentation.html # Context: Synthesis converts high-level Verilog descriptions to a # network of technology-specific primitives (LUT, flip-flip, etc). $(BUILD)/main.json: $(VERILOG) yosys -ql logs/main-yosys.log -p 'synth_ice40 -top top -json $@' $<
And that's it! You can upload the program to your FPGA by simply running the following:
make main