rules_vivado: FPGA Synthesis and Place-and-Route in Bazel

#bazel#FPGA#Vivado#RISC-V#Cocoapuffs#auto

rules_vivado drives the AMD/Xilinx Vivado FPGA toolchain from Bazel: compile, simulate, synthesize, place-and-route, generate a bitstream, and program the device, all as ordinary build actions instead of clicks in a GUI or a pile of ad-hoc TCL. This post kicks off a series on the Bazel modules behind cocoapuffs-fpga, the SoC repo from the Zircon-on-FPGA bring-up. I have covered some modules before (rules_ghdl, rules_shar, fshlib, bazel-ebook, and build-in-docker); the coming posts cover the rest: this one, then grlib, rules_fusesoc, rules_vunit, rules_dtc, vhdl_ls_gen, OpenSBI, and rules_osvvm.

Why fight this fight at all

A place-and-route pass for the Cocoapuffs design takes about an hour and a half. When a build step costs that much, you want two guarantees before you start it: that its inputs are exactly what you think they are, and that you never run it when nothing changed. The Vivado project flow gives you neither. A .xpr project accumulates state as you click; two engineers with the “same” project routinely hold different in-memory settings; and the standard recovery advice for a confusing failure is to delete the project directory and rebuild everything from scratch.

Bazel’s contract is exactly the missing piece: declared inputs, cached outputs, nothing rebuilt without a reason. The catch is that Vivado violates every assumption a build system makes about a well-behaved compiler, so the rules spend most of their effort on containment.

Containing a tool that hates sandboxes

Vivado wants a writable $HOME (it drops ~/.Xilinx state on every run), writes logs and journals next to wherever it was started, expects absolute paths, and comes as a multi-hundred-gigabyte licensed installation that cannot be redistributed. The original answer, borrowed from my build-in-docker rules, was to run every Vivado command inside a locally built Docker container: the action’s working directory is mounted at a fixed path, $HOME points inside the sandbox, and whatever Vivado scribbles stays in the action.

That answer later generalized into a Bazel toolchain with four modes, selected by one flag:

  • docker (the default) runs the locally built xilinx-vivado image;
  • host runs a Vivado installed on the machine, with $HOME redirected into the action’s working directory so the build stays self-contained;
  • hermetic has Bazel install Vivado itself (next section);
  • custom selects a toolchain registered by you or by a dependency module, so an infrastructure module can ship a ready-made Vivado setup for all of its dependents.

The Bazel-managed Vivado installation

The newest mode is my favorite. In hermetic mode, Bazel downloads AMD’s unified installer archive (on the order of 100 GB; you mirror it locally, since AMD downloads sit behind a login), runs an unattended batch install of only the device families you list, and stores the result in a content-addressed install cache keyed by the archive checksum and the component selection. Every machine that builds the workspace gets the same Vivado, provisioned on first use.

Two details took real effort. First, the installer’s component menu differs per version, so the rules match your modules list against the menu and fail with the full menu printed when a name is ambiguous or unknown; requesting a nonexistent module is the documented way to discover what an archive offers. Second, a naive implementation reinstalls 100 GB every time Bazel decides to refetch the external repository, which happens far more often than you would like (an edited .bzl file is enough). The installation therefore lives outside the workspace’s output base, in Bazel’s per-user cache: a refetch that finds the completed install regenerates two small files and finishes in seconds, and even bazel clean --expunge leaves the installation alone.

What the rules cover

The surface area is what you would expect from a full flow: vivado_library for compiling VHDL and Verilog into named logical libraries, vivado_simulation and vivado_test for XSim runs (the latter as a self-checking bazel test), vivado_synthesis and vivado_place_and_route down to the bitstream, and vivado_program_device / vivado_program_flash to put it on the board. vivado_ip configures a parameterized AMD IP block from its vlnv and a config dict, and vivado_unisims_library builds the Xilinx simulation primitives.

Two conveniences deserve a mention. vivado_repl and vivado_gui open an interactive TCL prompt or the full GUI inside the same containment as the build, so “poke at it interactively” and “build it reproducibly” use the same Vivado and the same environment. And vivado_ila with vivado_read_ila automate the Integrated Logic Analyzer: inserting an ILA core and reading it back are usually GUI operations, and during a bring-up (when the serial console is exactly the thing that does not work yet) a scripted, headless path to on-chip visibility matters. The Cocoapuffs bring-up ran for weeks in the “no working console” state; the debugging machinery earned its permanent place in the rules.

In cocoapuffs-fpga

This module is the synthesis and simulation backend for the Artix-7 xc7a200t. The board flow lives in boards/noelv/tool.vivado: vivado_library targets assemble the SoC from grlib and local IP, then :sim, :synth, :pnr, and :prog form the chain from simulation to bitstream to programming. Board-level generics splice the firmware images (the SERV loader and NOEL-V boot images) into the RTL. Across the repo there are dozens of vivado_library targets and a couple dozen vivado_simulation runs, many of them boot-debug traces from the Zircon work: with an hour-long place-and-route, the working rule is to reproduce every problem in simulation first and touch the bitstream last.

Prior art and references

  • agoessling/rules_vivado predates my rules and requires a host Vivado install; its container setup was the starting inspiration.
  • hw-bzl/rules_vivado is an independent set of Vivado Bazel rules.
  • bazel_rules_hdl is the broad “EDA under Bazel” precedent (Verilator, Yosys, OpenROAD).
  • FuseSoC and Edalize solve the same “assemble and drive an FPGA flow” problem outside Bazel.

For the payoff, see From zero to RISC-V in hardware in 6 minutes and the Cocoapuffs post. The source is at filmil/bazel_rules_vivado, published through my Bazel registry.