Bazel all the way down: how I build programmable hardware

August 17, 2026

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: the RTL, the firmware, the simulations, the synthesis, the bitstream, and the programming of the board, comes out of a single bazel build, from a machine that has nothing installed on it but bazel. The build is hermetic, ephemeral, and reproducible, and it is the same build 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 of the Bazel rules I wrote along the way 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 can not tell you whether you need to run it again. So you run it again, just in case. That is an afternoon gone, and it adds up.

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 the machine. Two machines, two 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. The setup that took a week of pain the first time should take one 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, the supervisor firmware, the device tree, the loader that streams images over a serial line, the tools that talk to the board, and the documents that describe the whole thing. Building all of that with one tool, in one dependency graph, is the only way I know to keep them consistent with each other.

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

What?

The three words in the title of my HER note are the specification, so let me restate them briefly for hardware.

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

Ephemeral means that the build brings its own tools. The RISC-V compiler, the VHDL simulators, the device tree compiler, the Python that runs the test framework, and yes, even Vivado, are all provisioned by the build itself, once, into a place the build controls. The machine needs bazel and nothing else.

Reproducible means that identical inputs produce identical outputs. For the parts of the flow that are ordinary software this is a fact that Bazel enforces. For synthesis and place-and-route, I have to lean on the vendor’s promise that the same tool version, given the same inputs, produces the same result; what the build system contributes is that the tool version and the inputs really are the same, every time, and that they are recorded.

How?

One dependency graph

The whole of Cocoapuffs is a single Bazel module. bazel build //... builds all of it, and bazel test //... runs every simulation that is set up as a test. The pieces of the graph, roughly in the order they are needed:

  • HDL libraries. VHDL and Verilog sources are grouped into library targets with explicit dependencies between them, including the vendor’s simulation primitives and the GRLIB IP library that the NOEL-V comes from. The same library targets feed both simulation and synthesis, so there is one definition of what the design is.
  • Simulation as tests. Testbenches are bazel test targets. Some run under NVC or GHDL, with VUnit and OSVVM available as libraries; some run under Vivado’s own simulator, all the way up to a target that boots the complete OpenSBI, boot shim, and Zircon chain in RTL simulation, without a board.
  • Firmware and boot images. OpenSBI is built from source with a hermetically fetched RISC-V GCC. The device tree is compiled from a .dts in the same tree. The boot image that gets streamed to the board, OpenSBI plus the boot shim plus the kernel image, is assembled by a build rule into a single Intel HEX file. Change a line in the device tree, and exactly the image is rebuilt, not the bitstream.
  • Synthesis, place-and-route, bitstream. These are build actions like any other, with the RTL libraries and the constraints as inputs and the bitstream as the output. Because they are actions, they are cached: touch the firmware and the hour-and-a-half step is not rerun.
  • Programming. bazel run on the programming target builds whatever is stale, then talks to a Vivado hardware server, which may well be on another continent behind an SSH tunnel.
  • Tools. The Intel HEX uploader that streams images to the SERV loader on the board, the shell libraries the scripts share, the flag parser they use, are all built by the same build. There is no “install the helper scripts” step.
  • Documents. The bringup report and the diagrams are built by Bazel too, from LaTeX and from source, so the document you read matches the design it describes.

The important property is not any one of these, but that they are all in one graph. Nothing is rebuilt that does not need to be, and everything that does need to be, is.

Bringing the tools in

This is where most of the effort went over the years, and where I have changed my mind more than once. The rules of the game: the build must be able to fetch, verify, and set up every tool it needs, and it must not depend on what is on the host.

I have gone through three approaches, and I still use all three where each fits best.

  • Build in Docker (rules_bid, post). A build action runs inside a Docker container that carries the tool. Easy to set up, and the only realistic option for something the size of Vivado. The downside is that not everyone can, or wants to, run Docker in their build, and that a container image is hermetic but not, by itself, reproducible.

On top of these sit the rules for the individual tools. Where a tool builds cleanly from source under Bazel, I build it from source: NVC is built that way, and so is the FuseSoC and Edalize distribution behind rules_fusesoc. Where a prebuilt binary is the sane choice, the rules download and verify one, as rules_ghdl does.

Vivado deserves its own paragraph, because it is the tool everyone assumes can not be tamed. rules_vivado runs it through a Bazel toolchain with a choice of modes: inside a locally built Docker image, directly from a host installation if you have one, or in a fully ephemeral mode where Bazel takes the AMD installer archive, performs a batch install into a Bazel-managed external repository, and registers the result as the toolchain (toolchains_vivado). The last one needs a couple of hundred gigabytes of transient disk and a long coffee break, once. After that it is just another cached tool. I can not redistribute the resulting installation, for licensing reasons, but I can and do publish the recipe.

The rules

These are the Bazel modules that carry the Cocoapuffs build. All of them are available from my Bazel registry; some are also in the Bazel Central Registry, and the rest get submitted there on a best-effort basis.

  • rules_vivado: libraries, simulation, synthesis, place-and-route, bitstream, IP generation, ILA capture, and device programming for AMD Vivado. The backbone of the board flow; described in more detail in its own post.
  • rules_ghdl: GHDL analysis, elaboration, and simulation, plus VHDL to Verilog conversion for the open source Verilog ecosystem. Historically the first of these rule sets, now living in the hw-bzl organization alongside other hardware rule sets.
  • rules_nvc: the NVC VHDL compiler and simulator, built from source under Bazel, with test rules for VHDL testbenches. My workhorse simulator for unit tests.
  • rules_vunit and rules_osvvm: the two VHDL verification frameworks, prebuilt as Bazel libraries so a testbench can depend on them like any other library, and run under bazel test.
  • rules_fusesoc: consumes FuseSoC .core files as Bazel dependencies, so cores packaged for the rest of the open source hardware world drop straight into the build.
  • grlib: Gaisler’s GRLIB IP library, including the NOEL-V, packaged as a Bazel module with library targets you can depend on.
  • rules_dtc: the device tree compiler, so .dts sources are build inputs and .dtb blobs are build outputs.
  • rules_opensbi: OpenSBI built from source with a hermetic RISC-V toolchain, with the board’s platform patches applied as part of the build.
  • vhdl_ls_gen: generates the configuration for the VHDL language server from the Bazel graph, so the editor sees exactly the libraries the build sees, generated files included.
  • rules_bid, bazel_local_nix, bazel_rootfs: the three tool-provisioning approaches above.
  • rules_shar, fshlib, gotopt2: the glue scripts: self-extracting archives of a script and its dependencies, a shell library, and declarative flag parsing.

The one-command experience

Put together, a new machine goes from nothing to a programmed board like this: install bazelisk under the name bazel, clone cocoapuffs-fpga, and run the programming target. The first run fetches and sets up every tool. Every run after that does the minimum. I showed this on video with an earlier, smaller design, starting from a bare cloud machine and ending with a “hello world” on a UART, six minutes later, with the board hundreds of miles away from the machine doing the work. Cocoapuffs is a much larger design, so the numbers are larger, but the shape of the process is exactly the same.

What it costs

I would be doing you a disservice if I left it at that.

You will write build rules. Nobody has written the rules for your favorite tool yet, and if they have, they may not have written them hermetically. This is a real investment, and it is the reason the list above exists.

Vendor tools fight back. Some of the workarounds are not pretty. Some require Docker. Some require a couple of hundred gigabytes of disk. None of them are as clean as compiling a C file.

Bazel itself moves. The migration to Bazel modules made me the manager of a software repository, which I did not set out to be. The upside is a registry of modules that anyone can use.

It is opinionated. If your team is happy with a Makefile and a shared server with Vivado on it, you may not want any of this. That is fine.

Conclusion

For me, the tradeoff is clearly worth it. I have a system-on-chip, its firmware, its tests, its documents, and the process that puts it on a board, all captured in one build that I can rerun, on any machine, years later, and get the same thing. When the design finally booted a real kernel, I knew exactly what I had built. That is what reproducibility means to me in hardware, and I do not know of another way to get it.

If you want to look at the whole thing, the source is at cocoapuffs-fpga. If you want to try the pieces, start with rules_vivado and the registry. And if you have comments, let me know.

References

Here are examples of other people or entities with similar approaches: