Bazel all the way down: how I build programmable hardware

#bazel#FPGA#HER#Vivado#Cocoapuffs#RISC-V#EDA

This is a description of how I build programmable hardware. Everything that goes into Cocoapuffs (my RISC-V system-on-chip on an Artix-7 FPGA), including the RTL, firmware, simulations, synthesis, bitstream, and board programming, comes out of a single bazel build on a machine that has nothing installed on it but bazel. The build is hermetic, ephemeral, and reproducible, and it behaves identically whether it runs on my laptop, on a virtual machine in the cloud, or in continuous integration. Below is why I do it this way, what the approach looks like, and which Bazel rules do the heavy lifting.

Why?

I have explained before why I bother with Bazel at all. Programmable hardware makes every one of those reasons more pressing, and adds a few of its own.

Hardware builds are slow. A synthesis and place-and-route pass for the 64-bit NOEL-V core in Cocoapuffs takes about an hour and a half. If the build system does not know exactly which inputs went into that bitstream, it cannot tell you whether you need to run it again. So you run it again, just in case. That is an entire afternoon gone, and the lost time adds up quickly.

Hardware toolchains are enormous, and hostile. A vendor installation weighs hundreds of gigabytes, comes with its own idea of what a filesystem is for, and is not shy about creating files wherever it likes. If your build depends on whatever happens to be installed on the machine, then your build depends on that specific machine. Two machines, two different builds.

A bitstream is a promise. When you program a device and it does not do what you expected, the first question is always the same: what, exactly, did I just load into it? During the Zircon bringup, I spent weeks debugging without a working console. The one thing I did not have to doubt was which sources, which firmware, and which tool versions had produced the bitstream and the boot image on the board. That certainty is not a luxury when the only signal you have is a single LED.

A project outlives its machines. Cocoapuffs has been years in the making across several computers, one of which is a cloud virtual machine that gets resized as the design grows. A setup that took a week of pain the first time should take a single command the second time.

Software and hardware live in the same tree. A system-on-chip is not just RTL. It is also the boot ROM, supervisor firmware, device tree, serial loader, board-communication utilities, and documentation describing the system. Building all of that with one tool, inside one dependency graph, is the only way I know to keep them consistent with each other.

If any of this sounds familiar from software engineering, it is because it is the exact same problem. Joel Spolsky asked “can you make a build in one step?” a quarter of a century ago. Hardware developers mostly still cannot.

What?

The three words in the title of my HER note define the specification, so I will restate them briefly in the context of hardware.

Hermetic means that every build step sees only the inputs explicitly declared for it. For a synthesis step, that means the RTL, constraints, IP definitions, and the synthesis tool, and nothing else. No stray file on the host machine can change the result without the build system noticing.

Ephemeral means that the build provisions its own tools. The RISC-V compiler, VHDL simulators, device tree compiler, Python test runner, and even Vivado itself are provisioned by the build into a location the build controls. The host machine requires bazel and nothing else.

Reproducible means that identical inputs produce identical outputs. For the parts of the flow that are ordinary software, Bazel enforces this guarantee directly. For synthesis and place-and-route, I rely on the vendor promise that a given tool version produces identical results for identical inputs; what the build system guarantees is that tool versions and inputs remain strictly identical, recorded, and verifiable on every run.

How?

One dependency graph

The whole of Cocoapuffs is a single Bazel module. Running bazel build //... compiles all of it, and bazel test //... runs every simulation configured as a test target. The components of the graph, roughly in the order they execute:

  • HDL libraries. VHDL and Verilog sources are grouped into library targets with explicit dependencies between them, including vendor simulation primitives and the GRLIB IP library that provides the NOEL-V core. The same library targets feed both simulation and synthesis, providing a single authoritative definition of the design.
  • Simulation as tests. Testbenches are standard bazel test targets. Some run under NVC or GHDL, with VUnit and OSVVM available as libraries; others run under Vivado’s integrated simulator, extending to targets that boot OpenSBI, the boot shim, and Zircon in RTL simulation without physical hardware.
  • Firmware and boot images. OpenSBI compiles from source with a hermetically fetched RISC-V GCC toolchain. The device tree compiles from a .dts in the same repository. The boot image streamed to the board (OpenSBI, the boot shim, and the kernel image) is assembled into an Intel HEX file by a build rule. Changing a line in the device tree rebuilds only that image, leaving the bitstream untouched.
  • Synthesis, place-and-route, and bitstream generation. These execute as standard build actions with RTL libraries and constraint files as inputs and the bitstream as output. Because they are regular actions, Bazel caches them: updating firmware does not trigger the 90-minute place-and-route run.
  • Device programming. Running bazel run on the programming target builds stale artifacts and connects to a Vivado hardware server, which may run on another continent behind an SSH tunnel.
  • Host utilities. The Intel HEX uploader that streams images to the SERV bootloader on the board, shared shell libraries, and command-line flag parsers are all built from source by the same build. There is no manual “install helper scripts” step.
  • Documentation. The bringup reports and architectural diagrams build directly under Bazel from LaTeX and diagram source, ensuring published documentation matches the hardware it describes.

The critical advantage is not any individual target, but that all artifacts share one unified graph. Nothing is rebuilt unnecessarily, and everything requiring an update rebuilds automatically.

Bringing the tools in

This is where most of my engineering effort went over the years, and where I have iterated through several designs. The core requirement remains simple: the build must fetch, verify, and configure every tool it needs without relying on host system packages.

I use three distinct approaches across the stack, selecting the one that best fits each tool:

  • Build in Docker (rules_bid, overview post). Build actions run inside a Docker container packaging the necessary toolchain. This is straightforward to configure and represents the most practical solution for monolithic installations the size of Vivado. The trade-off is requiring a Docker daemon on the build host and managing container image layers that are hermetic but not strictly reproducible on their own.
  • Nix packages via Bazel (bazel_local_nix, overview post). This approach provisions tools using Nix store closures managed directly within Bazel. It yields fully reproducible environments without requiring container runtimes or root privileges on the build machine.
  • Root filesystem extraction (bazel_rootfs). Minimal root filesystem archives unpack directly into Bazel external repositories, allowing tools to execute against clean filesystem trees without host contamination.

On top of these foundations sit rules for individual tools. Where a tool builds cleanly from source under Bazel, I compile it from source: NVC builds that way, as does the FuseSoC and Edalize infrastructure behind rules_fusesoc. Where prebuilt binaries are more practical, rules download and verify checksummed archives, as in rules_ghdl.

Vivado warrants special mention because hardware developers often assume it cannot be tamed within a modern build system. rules_vivado wraps Vivado as a Bazel toolchain supporting multiple modes: inside a locally built Docker image, directly from an existing host installation, or in fully ephemeral mode where Bazel ingests the AMD installer archive, runs a silent batch installation into a managed external repository, and registers the output as a toolchain (toolchains_vivado). This batch installation requires several hundred gigabytes of temporary disk space and a coffee break once. Afterwards, it behaves like any other cached tool. While licensing prevents redistributing the installation directory, the reproducible recipe is published and open source.

The rules

These Bazel modules power the Cocoapuffs build. All of them are available from my Bazel registry; several are mirrored in the Bazel Central Registry, with remaining packages submitted on an ongoing basis:

  • rules_vivado: libraries, simulation, synthesis, place-and-route, bitstream generation, IP cores, ILA capture, and device programming for AMD Vivado. This is the backbone of the FPGA workflow, described in detail in its own post.
  • rules_ghdl: GHDL analysis, elaboration, and simulation, alongside VHDL-to-Verilog conversion for open source tooling. This was historically the first of these hardware rule sets, now maintained in the hw-bzl GitHub organization.
  • rules_nvc: the NVC VHDL compiler and simulator, built from source under Bazel, with test runners for VHDL testbenches. This is my primary workhorse simulator for daily unit testing.
  • rules_vunit and rules_osvvm: two standard VHDL verification frameworks, prebuilt as Bazel libraries so testbenches can depend on them directly under bazel test.
  • rules_fusesoc: consumes FuseSoC .core definitions as Bazel dependencies, allowing open-source IP cores to drop directly into the build.
  • grlib: Gaisler’s GRLIB IP library, including the NOEL-V processor, packaged as a Bazel module with modular library targets.
  • rules_dtc: the device tree compiler, transforming .dts sources into .dtb binary blobs within the dependency graph.
  • rules_opensbi: OpenSBI compiled from source using a hermetic RISC-V toolchain, with board-specific platform patches applied during the build.
  • vhdl_ls_gen: generates language server configuration for VHDL directly from the Bazel graph, ensuring editor navigation sees identical library paths and generated files.
  • rules_bid, bazel_local_nix, bazel_rootfs: the three tool-provisioning frameworks described above.
  • rules_shar, fshlib, gotopt2: the integration glue: self-extracting shell archives, shell function libraries, and declarative CLI flag parsing.

The one-command experience

Taken together, bringing up a new machine and programming a physical FPGA board requires three steps: install bazelisk under the alias bazel, clone cocoapuffs-fpga, and invoke the programming target. The initial invocation fetches and builds every dependency. Subsequent builds perform only incremental work. I demonstrated this on video with an earlier, smaller design, starting from an unprovisioned cloud VM and reaching a UART “hello world” six minutes later, with the physical board located hundreds of miles away from the compute instance. Cocoapuffs is a significantly larger design with longer compile times, but the underlying build architecture is identical.

What it costs

I would do you a disservice by presenting this approach as free of friction.

You will write build rules. Nobody has written Bazel rules for your favorite niche EDA tool yet, and if they have, they may not have made them hermetic. Authoring and maintaining rules requires a real upfront investment, which is why I publish the packages listed above.

Vendor tools fight back. Some workarounds are complex. Certain flows require Docker, others require several hundred gigabytes of disk, and none of them feel as lightweight as compiling C code.

Bazel itself evolves. The migration to Bazel modules turned me into the maintainer of an external package registry, which was not my original plan. The benefit is a shared catalog of modules that other hardware engineers can reuse directly.

The philosophy is opinionated. If your team is satisfied with manual Makefiles and a shared workstation with preinstalled Vivado, this machinery may be unnecessary. For reproducible, isolated hardware development, however, the investment pays for itself.

Conclusion

For me, the trade-off is clearly worth it. I have a complete system-on-chip, its firmware, verification testbenches, technical documents, and board programming flow captured in one reproducible build that I can run on any machine years from now and obtain the exact same bitstream. When the design finally booted a real kernel, I knew exactly what had been built. That level of confidence is what reproducibility means in hardware development, and I know of no other way to achieve it.

If you want to explore the complete design, the source code is available at cocoapuffs-fpga. To try individual components, start with rules_vivado and the Bazel registry. If you have thoughts or feedback, let me know.

References

Other projects embracing similar approaches to hardware builds:

  • Uros Popovic maintains several hardware projects utilizing Bazel.
  • Oxide Computer Company builds programmable hardware using buck2, an open source build system sharing core architecture principles with Bazel.