First Steps with rules_openxc7: A Beginner’s Guide
This tutorial teaches you how to build your first FPGA bitstream with Bazel
and the open-source rules_openxc7 toolchain. You do not need any prior
Bazel experience, and you do not need to install AMD Vivado or compile EDA
tools from source. In four small files, we create a complete working project
that synthesizes Verilog with Yosys, places and routes with nextpnr,
generates a 7-series bitstream, and flashes your development board.
Who this tutorial is for
This guide is for digital designers and students who want to build hardware without getting stuck in tool setup. Traditional FPGA development often demands two extremes: downloading a 100 GB vendor suite, or maintaining complex shell scripts across personal machines.
Bazel offers a third path. Bazel is an open-source build system that tracks all file inputs and outputs. It downloads the required tools automatically, runs every build step in an isolated sandbox, and caches intermediate results so repeated builds are fast.
With rules_openxc7, Bazel drives a complete open-source flow for AMD
Xilinx 7-series FPGAs. It runs Yosys for synthesis,
nextpnr-xilinx for place and route, fpga-assembler for
bitstream generation, and openFPGALoader for programming.
You can follow this tutorial on Linux or Windows via WSL2. Even if you do
not own a physical board today, you can run every build step and inspect
the generated bitstream.
If you want an in-depth treatment of how to write custom Bazel rules for HDL simulation and synthesis, see the book Hardware Development with Bazel. This tutorial focuses on the immediate practical steps: setting up a project and building your first bitstream with open tools.
The single prerequisite: Bazelisk
You do not need to install Yosys, Python packages, or FPGA toolchains on
your host system. You only need one program: bazelisk.
Bazelisk is the official launcher for Bazel. When you run bazel build,
Bazelisk reads a configuration file named .bazelversion in your project
directory. It downloads the exact version of Bazel specified by the project
and runs it. This ensures every developer uses the same tool version.
To install Bazelisk on Ubuntu or Debian:
sudo apt install bazelisk
Alternatively, download the standalone binary directly from GitHub:
mkdir -p ~/.local/bin
curl -Lo ~/.local/bin/bazel \
https://github.com/bazelbuild/bazelisk/releases/latest/download/bazelisk-linux-amd64
chmod +x ~/.local/bin/bazel
Verify that the tool is in your PATH:
bazel version
You are now ready to create your project.
The project layout: four files
A complete FPGA project in Bazel needs four files in one folder:
my_first_fpga/
├── .bazelversion # Pins the Bazel version
├── MODULE.bazel # Declares external dependencies
├── BUILD.bazel # Defines the FPGA build targets
├── blinky.sv # The SystemVerilog RTL source
└── blinky.xdc # Pin constraints for the target board
Create an empty directory for your project and enter it:
mkdir my_first_fpga
cd my_first_fpga
We will create each file one by one.
Step 1: Configure Bazel dependencies
First, create .bazelversion to tell Bazelisk which Bazel version to run:
9.0.0
Next, create MODULE.bazel. Bazel uses this file to manage dependencies
through Bzlmod, Bazel’s package registry system:
# MODULE.bazel
module(
name = "my_first_fpga",
version = "0.1.0",
)
bazel_dep(name = "rules_openxc7", version = "0.1.0")
This stanza registers your project as a Bazel module and adds a dependency
on rules_openxc7. When you start your build, Bazel reads this line,
resolves rules_openxc7, and downloads the pinned prebuilt EDA binaries
(Yosys, nextpnr-xilinx, Project X-Ray database, and openFPGALoader). You do
not compile anything by hand.
Step 2: Write the hardware design
Create a file named blinky.sv. This design takes an input oscillator clock
and a reset signal, increments a 27-bit counter, and outputs the top bit to
an LED:
// blinky.sv
// Simple LED blinker for Xilinx 7-series FPGAs.
module blinky (
input logic clk,
input logic rst_n,
output logic led
);
logic [26:0] counter;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
counter <= 27'd0;
end else begin
counter <= counter + 1'b1;
end
end
// Blink at approximately 0.75 Hz on a 100 MHz clock.
assign led = counter[26];
endmodule
The logic is simple: when the 27th bit toggles, the LED changes state.
Step 3: Constrain physical pins
FPGAs connect internal signals to external pins through constraint files. AMD Xilinx devices use Xilinx Design Constraints (XDC).
Create blinky.xdc. The example below targets the xc7a200tfbg484-2 device
found on the Alinx AX7A200B development board. If you use a different board
(such as a Digilent Basys 3 with part xc7a35tcpg236-1), update the pin
identifiers according to your board schematic:
# blinky.xdc
# Clock input: 50 MHz or 100 MHz oscillator
set_property PACKAGE_PIN R4 [get_ports clk]
set_property IOSTANDARD LVCMOS33 [get_ports clk]
create_clock -period 20.000 -name clk [get_ports clk]
# Active-low reset button
set_property PACKAGE_PIN T6 [get_ports rst_n]
set_property IOSTANDARD LVCMOS33 [get_ports rst_n]
# Target LED
set_property PACKAGE_PIN V9 [get_ports led]
set_property IOSTANDARD LVCMOS33 [get_ports led]
rules_openxc7 includes an automated XDC translator. While nextpnr-xilinx
understands only a subset of XDC syntax, the translator expands clock
definitions and pin assignments into the format that nextpnr accepts.
Step 4: Write the build targets
Now create BUILD.bazel. This file describes what targets exist in your
project and how they connect:
# BUILD.bazel
load(
"@rules_openxc7//openxc7:defs.bzl",
"vivado_place_and_route",
"vivado_program_device",
"vivado_project",
"vivado_synthesis",
)
# 1. Project target: declares sources, constraints, and the FPGA part.
vivado_project(
name = "blinky",
srcs = ["blinky.sv"],
part = "xc7a200tfbg484-2",
top_level = "blinky",
xdcs = ["blinky.xdc"],
)
# 2. Synthesis target: translates RTL into an optimized gate-level netlist.
vivado_synthesis(
name = "blinky_synth",
project = ":blinky",
)
# 3. Place-and-route target: places logic, routes wires, and creates the bitstream.
vivado_place_and_route(
name = "blinky_pnr",
synthesis = ":blinky_synth",
)
# 4. Programming target: generates an executable script to flash the board.
vivado_program_device(
name = "blinky_prog",
deps = [":blinky_pnr"],
)
Notice how the build pipeline flows naturally:
vivado_projectcollects your source files, constraint files, the top module name, and the exact FPGA silicon part number.vivado_synthesisconsumes:blinkyand runs Yosys.vivado_place_and_routeconsumes:blinky_synthand runs nextpnr-xilinx and fpga-assembler.vivado_program_devicewraps the final bitstream into a runnable target.
Step 5: Build the bitstream
You can now build the FPGA bitstream with one command:
bazel build //:blinky_pnr
On your first run, Bazel executes the following actions automatically:
- Downloads the pinned toolchain archives for Yosys, nextpnr, and Project X-Ray.
- Creates an isolated sandbox containing only
blinky.svandblinky.xdc. - Runs Yosys synthesis to emit a JSON netlist.
- Runs nextpnr-xilinx to place slices and route interconnections.
- Runs fpga-assembler to write the hardware bitstream.
When the build finishes, you will see output similar to this:
Target //:blinky_pnr up-to-date:
bazel-bin/blinky_pnr.bit
INFO: Build completed successfully, 3 total actions
Your bitstream is ready at bazel-bin/blinky_pnr.bit.
Now run the exact same build command a second time:
bazel build //:blinky_pnr
The output appears almost immediately:
INFO: 0 processes: 3 cached actions.
INFO: Build completed successfully, 0 total actions
Because Bazel calculates cryptographic hashes of your input files, it knows
that neither blinky.sv, blinky.xdc, nor the toolchain has changed. It
retrieves the result from cache in 0.1 seconds without re-running any tools.
Step 6: Program the hardware
To write the bitstream to your board over JTAG, plug your development board into your computer’s USB port.
If your Linux user account does not already have permission to access USB
JTAG adapters, grant permission by joining the plugdev or dialout group:
sudo usermod -aG plugdev,dialout $USER
Then run the programming target:
bazel run //:blinky_prog
bazel run invokes openFPGALoader against the bitstream generated in Step
5. openFPGALoader detects the FTDI USB chip, establishes communication over
JTAG, and uploads the bitstream to the FPGA’s SRAM. Your LED will begin
blinking immediately.
Compatibility with rules_vivado
You might wonder why rules_openxc7 targets start with vivado_ instead
of openxc7_.
rules_openxc7 shares its public interface with rules_vivado.
The rule names, attribute names, and target graphs are identical.
If your design grows and requires proprietary Vivado IP (such as Xilinx
MIG memory controllers, PCIe endpoints, or encrypted vendor blocks), you do
not need to rewrite your BUILD.bazel file. You change only the load line
at the top:
# To switch to AMD Vivado, replace:
# load("@rules_openxc7//openxc7:defs.bzl", ...)
# with:
load(
"@rules_vivado//build/vivado:rules.bzl",
"vivado_place_and_route",
"vivado_program_device",
"vivado_project",
"vivado_synthesis",
)
The rest of your build targets remain unchanged. This lets teams use the lightweight open-source flow for rapid local development and automated CI, while preserving full access to Vivado when needed.
Common beginner gotchas
Here are four common issues beginners encounter, along with their solutions:
1. Label syntax errors
Bazel identifies targets with labels formatted as //package:target_name.
If your BUILD.bazel file is in the root directory of your workspace, the
package path is empty, so targets start with //:. If your file is inside a
subdirectory named hw, the target becomes //hw:blinky_pnr.
2. Undeclared files are invisible
Bazel runs compilers and EDA tools in isolated sandboxes. If you split your
Verilog design into two files (blinky.sv and counter.sv), you must list
both in the srcs attribute of vivado_project:
vivado_project(
name = "blinky",
srcs = [
"blinky.sv",
"counter.sv",
],
...
)
If a file is not listed in srcs, Bazel does not copy it into the sandbox,
and synthesis fails with an “undeclared module” error.
3. Exact part numbers
nextpnr-xilinx uses chip databases from Project X-Ray. The part string must
match the exact device and package code. For example, use
xc7a200tfbg484-2 or xc7a35tcpg236-1. A typo in the part string causes
the toolchain to report that the chip architecture is unknown.
4. Viewing detailed tool logs
If synthesis or routing fails due to a syntax error or pin conflict, Bazel
prints the failing command and saves the full terminal log. You can view
detailed step-by-step logs in the bazel-bin directory:
cat bazel-bin/blinky_synth.log
cat bazel-bin/blinky_pnr.log
Summary
With Bazel and rules_openxc7, setting up an FPGA project requires neither
hours of manual tool installation nor fragile build scripts:
- Four small files define your entire project.
- Bazelisk ensures every machine runs the same tool versions.
- Open-source synthesis and place-and-route tools are downloaded and cached on demand.
- Build times for small and medium designs are fast, with caching built in.
- Switching between open tooling and vendor Vivado requires changing a single line.
Where to go next
Explore the rules_openxc7 repository for more examples, including RISC-V SoC designs, VHDL targets, and simulation tests.
For a comprehensive guide to authoring hardware rules, setting up VHDL and Verilog simulators, and structuring hardware repositories, see the Hardware Development with Bazel book page.