Building and packaging FPGA bitstreams for the Zynq-7000 (without Vivado!)

Introduction

The programmable logic of a Zynq-7000 is configured from a bitstream, and the vendor-blessed way to produce one runs through Vivado: a multi-gigabyte tool installation, a licensed flow, and a .bit file at the end of it. For a small design on a well-documented part, that is a lot of ceremony, and none of it fits a reproducible, headless build.

This tutorial takes the open-source path. We build a bitstream for the Digilent Arty Z7-20 entirely with the openXC7 toolchain, running in a single, digest-pinned container: yosys for synthesis, nextpnr-xilinx for place-and-route, and Project X-Ray for the bitstream back end. We then convert the result into the exact byte layout the Linux FPGA manager expects, and package it as a Mender Artifact so it can be shipped over the air.

The demo board is the Arty Z7-20 (Zynq-7000 xc7z020), but everything except the pin constraints carries over to any 7-series part that openXC7 supports. Deploying the resulting artifact is a separate concern (the runtime side, with a custom fpga-bitstream Update Module); this tutorial ends when you hold a .mender file ready to upload.

Version notes

This tutorial was verified with the following setup:

Component Version
Yocto Project scarthgap (5.0 LTS)
openXC7 regymm/openxc7 container image, pinned by digest (see below)
mender-artifact 4.4.1
Board Digilent Arty Z7-20 (Zynq-7000 xc7z020clg400-1)

Prerequisites

  • A build host with docker (for the openXC7 toolchain) and mender-artifact on the PATH.
  • The design sources shown below: a Verilog source, a constraints file, the Makefile, and bit2bin.py. They are collected in the fpga/ directory of the board’s build repository.
  • No Vivado, no Xilinx account, no vendor tool of any kind.

Goal

Produce a Mender Artifact (.mender) carrying a Zynq-7000 PL bitstream, built from Verilog with open-source tools only, in the byte format the kernel’s zynq_fpga driver accepts. The build is reproducible: the same sources always yield the same bytes.

The open-source toolchain

openXC7 stitches together the pieces of the open 7-series flow: yosys synthesizes the Verilog to a netlist, nextpnr-xilinx places and routes it against a chip database, and Project X-Ray’s tools turn the routed design into a bitstream. The board’s Makefile chains these stages; the diagram below shows the data flowing through them.

All of these tools ship, ready to run, in the regymm/openxc7 container image. It is the one third-party input in this flow, so pin it by digest; a moving latest tag has no place in a reproducible build. The only per-part preparation is a chip database, generated once from the prjxray database and kept on the host:

cd fpga
IMG=regymm/openxc7@sha256:eced1cdd4727549f2d983328e0cf170fb6f6f67d87f19b2bf24365163368c70c
docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp \
    -v "$PWD:/work" -w /work $IMG make chipdb
docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp \
    -v "$PWD:/work" -w /work $IMG make

Running as the calling user (-u) keeps the generated files owned by you rather than by root, and HOME=/tmp gives the tools a writable home inside the container.

The demo design

The payload is a plain LED blinky: a free-running counter whose high bits drive the four user LEDs, clocked from the 125 MHz reference the Arty Z7-20 routes to pin H16. It is deliberately trivial; the point is the flow, not the logic. Here is the complete design:

`default_nettype none
module blinky (
    input  wire clk,
    output wire led0,
    output wire led1,
    output wire led2,
    output wire led3
);
    // The PS7 primitive must be present in every Zynq PL design that is
    // loaded while the PS is running: a bitstream whose PS7 site is left at
    // default configuration wedges the PS during PCAP programming.
    wire [3:0] w_fclk_unused;
    (* keep *) PS7 ps7_i (
        .FCLKCLK(w_fclk_unused)
    );

    reg [27:0] r_count = 28'd0;

    always @(posedge clk)
        r_count <= r_count + 28'd1;

    assign {led3, led2, led1, led0} = r_count[27:24];
endmodule

The clock and the four LEDs are the only pins the design uses, and the constraints file maps them to the package pins the board wires up:

set_property PACKAGE_PIN H16 [get_ports clk]
set_property IOSTANDARD LVCMOS33 [get_ports clk]
set_property PACKAGE_PIN R14 [get_ports led0]
set_property IOSTANDARD LVCMOS33 [get_ports led0]
set_property PACKAGE_PIN P14 [get_ports led1]
set_property IOSTANDARD LVCMOS33 [get_ports led1]
set_property PACKAGE_PIN N16 [get_ports led2]
set_property IOSTANDARD LVCMOS33 [get_ports led2]
set_property PACKAGE_PIN M14 [get_ports led3]
set_property IOSTANDARD LVCMOS33 [get_ports led3]

The PS7 pitfall

The comment in the design deserves emphasis, because the failure mode is drastic. A Zynq-7000 combines the programmable logic (PL) with a hard processing system (PS), and the two meet at the PS7 primitive. Leave PS7 out and that boundary stays at its default configuration. Load such a bitstream through the FPGA manager while Linux is running, and the processing system locks up the instant the PCAP transfer starts: the console goes silent mid-line, the network drops, and only a power cycle brings the board back. Nothing warns you. The design synthesizes, routes and converts cleanly, and the problem only shows on the running target.

The fix is the (* keep *) PS7 instance shown above. It costs nothing in the design and needs no connections beyond what you actually use; the keep attribute stops synthesis from optimizing the unconnected instance away. If you take one thing from this tutorial, take this: every Zynq-7000 PL design that will be loaded at runtime must instantiate PS7.

Building the bitstream

The Makefile ties the stages together and finishes with the conversion to the byte-swapped .bin (covered in the next section). Each rule is one tool from the pipeline diagram:

DB_DIR    ?= /nextpnr-xilinx/xilinx/external/prjxray-db
BBAEXPORT ?= /nextpnr-xilinx/xilinx/python/bbaexport.py
PYPY3     ?= pypy3

FAMILY = zynq7
PART   = xc7z020clg400-1
XDC    = arty-z7-20.xdc
CHIPDB ?= $(CURDIR)/chipdb

BITSTREAMS = build/blinky-v1.bit.bin build/blinky-v2.bit.bin

.PHONY: all chipdb clean
# a failed recipe must not leave a fresh-but-truncated target behind
# (build/%.frames is written via shell redirection)
.DELETE_ON_ERROR:
all: $(BITSTREAMS)

chipdb: $(CHIPDB)/$(PART).bin

$(CHIPDB)/$(PART).bin:
	mkdir -p $(CHIPDB)
	$(PYPY3) $(BBAEXPORT) --device $(PART) --bba $(PART).bba
	bbasm -l $(PART).bba $@
	rm -f $(PART).bba

# the top module is fixed: every design here must name its top "blinky"
build/%.json: %.v
	mkdir -p build
	yosys -p "synth_xilinx -flatten -abc9 -arch xc7 -top blinky; write_json $@" $<

build/%.fasm: build/%.json $(CHIPDB)/$(PART).bin $(XDC)
	nextpnr-xilinx --chipdb $(CHIPDB)/$(PART).bin --xdc $(XDC) --json $< --fasm $@

build/%.frames: build/%.fasm
	fasm2frames --part $(PART) --db-root $(DB_DIR)/$(FAMILY) $< > $@

build/%.bit: build/%.frames
	xc7frames2bit --part_file $(DB_DIR)/$(FAMILY)/$(PART)/part.yaml \
	    --part_name $(PART) --frm_file $< --output_file $@

build/%.bit.bin: build/%.bit
	python3 bit2bin.py $< $@

clean:
	rm -rf build

The stages map one-to-one onto the diagram: yosys’ synth_xilinx produces a JSON netlist; nextpnr-xilinx places and routes it against the chip database and the constraints, emitting FASM (FPGA assembly, Project X-Ray’s human-readable feature list); fasm2frames expands that into configuration frames; and xc7frames2bit packs the frames into a .bit. Because every stage is deterministic and the toolchain is pinned, rebuilding the same sources yields byte-identical output.

The Zynq bitstream format

A .bit produced by xc7frames2bit is not what the Linux zynq_fpga driver loads. Two things stand between them.

First, the file is wrapped in the standard Vivado-style header: a fixed magic number, a handful of length-prefixed metadata fields (design name, part, date, time), and finally the raw configuration words. Second, and easy to miss, the configuration words in a .bit are big-endian, but the Zynq FPGA manager, driving the PCAP interface, expects them byte-swapped within each 32-bit word. After the swap the stream begins with the configuration sync word, 0xAA995566 in the 7-series configuration guide (UG470), which appears on the wire, and in the file, as the byte sequence 66 55 99 aa.

bit2bin.py does exactly this: it parses the header, takes the configuration payload, swaps each 32-bit word, and, as a safety net, refuses to write a file if the sync word is not present afterwards, so a botched conversion can never reach the device:

#!/usr/bin/env python3
"""Convert a Xilinx/prjxray .bit file into the byte-swapped .bin the Linux
zynq-fpga fpga_manager driver requires (sync word 66 55 99 aa).

Handles the standard Vivado-style TLV header that prjxray's xc7frames2bit
emits: 13-byte magic, fields 'a'-'d' (u16 length + data), field 'e'
(u32 big-endian length + raw big-endian bitstream words).
"""
import struct
import sys

BIT_MAGIC = bytes([0x00, 0x09, 0x0F, 0xF0, 0x0F, 0xF0, 0x0F, 0xF0,
                   0x0F, 0xF0, 0x00, 0x00, 0x01])


def main(inp, outp):
    data = open(inp, "rb").read()
    if not data.startswith(BIT_MAGIC):
        sys.exit("%s: not a .bit file (bad magic)" % inp)
    pos = len(BIT_MAGIC)
    body = None
    while pos < len(data):
        key = data[pos]
        pos += 1
        if key in (0x61, 0x62, 0x63, 0x64):        # 'a'..'d' meta fields
            (length,) = struct.unpack(">H", data[pos:pos + 2])
            pos += 2
            value = data[pos:pos + length]
            pos += length
            sys.stderr.write("bit2bin: %c: %s\n"
                             % (key, value.rstrip(b"\0").decode(errors="replace")))
        elif key == 0x65:                          # 'e': bitstream data
            (length,) = struct.unpack(">I", data[pos:pos + 4])
            pos += 4
            body = data[pos:pos + length]
            if len(body) != length:
                sys.exit("%s: truncated bitstream data" % inp)
            break
        else:
            sys.exit("%s: unknown .bit field 0x%02x" % (inp, key))
    if body is None or len(body) % 4:
        sys.exit("%s: missing or unaligned bitstream payload" % inp)

    swapped = bytearray(len(body))                 # BE .bit -> byte-swapped .bin
    swapped[0::4] = body[3::4]
    swapped[1::4] = body[2::4]
    swapped[2::4] = body[1::4]
    swapped[3::4] = body[0::4]

    if b"\x66\x55\x99\xaa" not in bytes(swapped[:256]):
        sys.exit("%s: no sync word after swap; refusing to write" % inp)
    open(outp, "wb").write(swapped)
    sys.stderr.write("bit2bin: wrote %s (%d bytes)\n" % (outp, len(swapped)))


if __name__ == "__main__":
    if len(sys.argv) != 3:
        sys.exit("usage: bit2bin.py input.bit output.bit.bin")
    main(sys.argv[1], sys.argv[2])

The make invocation above already runs this as its final step, so the build leaves you with build/blinky-v1.bit.bin, a bitstream in exactly the form the kernel will accept.

Packaging as a Mender Artifact

A bitstream on the build host is not yet deployable. To ship it over the air it has to become a Mender Artifact: a self-describing package the server distributes and the device’s Update Module installs. We build a module-image artifact of type fpga-bitstream. The repository wraps the whole step in a small helper that validates its arguments and calls mender-artifact once:

#!/bin/bash
# Generate a Mender artifact for the fpga-bitstream update module.
# Modeled on mender-update-modules' persistent-app-artifact-gen.
set -e

show_help() {
  cat << EOF
Usage: $0 -n ARTIFACT_NAME -t DEVICE_TYPE -v SOFTWARE_VERSION [-o OUT.mender] BITSTREAM.bit.bin

    -n, --artifact-name     Artifact name (required, unique on the server)
    -t, --device-type       Compatible device type (required, repeatable)
    -v, --software-version  Version for data-partition.fpga-bitstream.version (required)
    -o, --output-path       Output file (default: <artifact-name>.mender)

Anything after '--' is passed to mender-artifact.

Example:
    $0 -n fpga-blinky-v1 -t arty-z7-20 -v 1 build/blinky-v1.bit.bin
EOF
}

command -v mender-artifact >/dev/null || {
  echo "Error: mender-artifact not found in PATH." >&2; exit 1; }

device_types=()
artifact_name=""
software_version=""
output_path=""
bitstream=""
passthrough_args=()
passthrough=0

while (( "$#" )); do
  if [ $passthrough -eq 1 ]; then
    passthrough_args+=("$1"); shift; continue
  fi
  case "$1" in
    -n|--artifact-name)    artifact_name="$2"; shift 2 ;;
    -t|--device-type)      device_types+=("--compatible-types" "$2"); shift 2 ;;
    -v|--software-version) software_version="$2"; shift 2 ;;
    -o|--output-path)      output_path="$2"; shift 2 ;;
    -h|--help)             show_help; exit 0 ;;
    --)                    passthrough=1; shift ;;
    -*)                    echo "Unknown option: $1" >&2; show_help; exit 1 ;;
    *)                     bitstream="$1"; shift ;;
  esac
done

[ -n "$artifact_name" ]       || { echo "Error: artifact name required." >&2; exit 1; }
[ ${#device_types[@]} -gt 0 ] || { echo "Error: device type required." >&2; exit 1; }
[ -n "$software_version" ]    || { echo "Error: software version required." >&2; exit 1; }
[ -f "$bitstream" ]           || { echo "Error: bitstream file '$bitstream' not found." >&2; exit 1; }
[ -n "$output_path" ]         || output_path="${artifact_name}.mender"

# --software-filesystem data-partition => provides key
# data-partition.fpga-bitstream.version, which survives rootfs A/B updates.
mender-artifact write module-image \
  -T fpga-bitstream \
  "${device_types[@]}" \
  -n "$artifact_name" \
  -o "$output_path" \
  --software-filesystem data-partition \
  --software-name fpga-bitstream \
  --software-version "$software_version" \
  -f "$bitstream" \
  "${passthrough_args[@]}"

echo "Wrote $output_path"

The heart of it is the mender-artifact write module-image call at the end; everything above is argument handling. Three of its parameters carry the meaning. -T fpga-bitstream names the Update Module type the device must run to install the payload; it ties the artifact to the module that knows how to program the PL. --compatible-types (built up in the device_types array, one pair per -t) restricts the artifact to the boards it fits. And the --software-filesystem data-partition / --software-name / --software-version triple makes the bitstream version independent of the root filesystem: Mender tracks it under the software versioning key data-partition.fpga-bitstream.version rather than tying it to the rootfs image, so it survives A/B rootfs updates.

Producing the v1 artifact is then a one-liner:

./fpga-bitstream-artifact-gen -n fpga-blinky-v1 -t arty-z7-20 -v 1 build/blinky-v1.bit.bin

The wrapper expands those arguments into the full mender-artifact invocation. -t arty-z7-20 becomes --compatible-types arty-z7-20 (repeat -t for a multi-board artifact and each one appends another --compatible-types pair to device_types); -n fpga-blinky-v1 sets the artifact name and, since no -o was given, the default output path fpga-blinky-v1.mender; -v 1 becomes --software-version 1; and the positional build/blinky-v1.bit.bin becomes the -f payload. The trailing "${passthrough_args[@]}" stays empty unless you add flags after a --. So the one-liner runs:

mender-artifact write module-image \
  -T fpga-bitstream \
  --compatible-types arty-z7-20 \
  -n fpga-blinky-v1 \
  -o fpga-blinky-v1.mender \
  --software-filesystem data-partition \
  --software-name fpga-bitstream \
  --software-version 1 \
  -f build/blinky-v1.bit.bin

You can confirm the result before uploading it. mender-artifact read shows the type, the compatible devices, and the version provides that the device will report back after a successful deployment:

$ mender-artifact read fpga-blinky-v1.mender
Mender Artifact:
  Name: fpga-blinky-v1
  Format: mender
  Version: 3
  Compatible types: [arty-z7-20]

Updates:
  - Type: fpga-bitstream
    Provides:
      data-partition.fpga-bitstream.version: 1
    Clears Provides: [data-partition.fpga-bitstream.*]
    Files:
      - name: blinky-v1.bit.bin
        size: 4045564

Iterating a design is now just a matter of rebuilding and bumping the version: a fpga-blinky-v2 artifact built from a second Verilog source and packaged with -v 2 deploys over the first without touching the root filesystem.

Conclusion

Starting from a Verilog source and a constraints file, we produced a Zynq-7000 bitstream with an entirely open-source toolchain, converted it to the byte layout the kernel’s FPGA manager requires, and packaged it as a versioned Mender Artifact, with no Vivado anywhere and reproducible end to end. The .mender file is now ready to upload to a Mender server and roll out as a deployment. The device-side counterpart that installs it, the fpga-bitstream Update Module (it programs the PL, reloads it at boot, and rolls back a bad bitstream), lives in the meta-mender-xilinx layer; from the server’s point of view, deploying this artifact is an ordinary deployment.