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) andmender-artifacton thePATH. - The design sources shown below: a Verilog source, a constraints file, the
Makefile, andbit2bin.py. They are collected in thefpga/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.


