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

**URL:** <https://hub.mender.io/t/building-and-packaging-fpga-bitstreams-for-the-zynq-7000-without-vivado/8323>\
**Category:** Tutorials\
**Created:** [July 20, 2026, 10:17am UTC](https://hub.mender.io/t/building-and-packaging-fpga-bitstreams-for-the-zynq-7000-without-vivado/8323 "2026-07-20T10:17:25Z")\
**Posts on this page:** 1\
**Page:** 1

<div class="post-metadata">

**Author:** ![TheYoctoJester](https://yyz2.discourse-cdn.com/flex036/user_avatar/hub.mender.io/theyoctojester/32/1444_2.png) [@TheYoctoJester](https://hub.mender.io/u/TheYoctoJester)\
**Post date:** [July 20, 2026, 10:17am UTC](https://hub.mender.io/t/building-and-packaging-fpga-bitstreams-for-the-zynq-7000-without-vivado/8323/1 "2026-07-20T10:17:25Z")

</div>

## 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](https://github.com/openXC7) toolchain, running in a single, digest-pinned container: [yosys](https://github.com/YosysHQ/yosys) for synthesis, [nextpnr-xilinx](https://github.com/openXC7/nextpnr-xilinx) for place-and-route, and [Project X-Ray](https://github.com/f4pga/prjxray) 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](https://digilent.com/reference/programmable-logic/arty-z7/start) (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`](https://docs.mender.io/artifact-creation/create-an-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.

 ![The openXC7 build pipeline, from Verilog to a byte-swapped .bin](https://canada1.discourse-cdn.com/flex036/uploads/mender/original/2X/a/aa28f6e5a7caf73e6ee22e9a6e3b550a79fc8c51.png)

All of these tools ship, ready to run, in the [`regymm/openxc7`](https://hub.docker.com/r/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](https://github.com/f4pga/prjxray-db) and kept on the host:

```auto
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:

```verilog
`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:

```auto
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:

```make
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](https://github.com/f4pga/prjxray) (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)](https://docs.amd.com/v/u/en-US/ug470_7Series_Config), which appears on the wire, and in the file, as the byte sequence `66 55 99 aa`.

 ![Parsing the .bit header and byte-swapping to the .bit.bin the fpga_manager accepts](https://canada1.discourse-cdn.com/flex036/uploads/mender/original/2X/c/c08897c3d4f512e360e95fd7040e95cc0043daad.png)

`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:

```python
#!/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`](https://docs.mender.io/artifact-creation/create-a-custom-update-module) once:

```bash
#!/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](https://docs.mender.io/artifact-creation/software-versioning) key `data-partition.fpga-bitstream.version` rather than tying it to the rootfs image, so it survives A/B rootfs updates.

 ![The .bit.bin and its metadata packaged into a .mender artifact](https://canada1.discourse-cdn.com/flex036/uploads/mender/original/2X/2/23142eb3fbb0e2cf320cce71e26af8dbcb0eb7c1.png)

Producing the v1 artifact is then a one-liner:

```auto
./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:

```auto
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:

```auto
$ 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.
