Skip to content

Releases: matsvandamme/fishball7020-fpga-devkit

v2.3

Choose a tag to compare

@github-actions github-actions released this 02 Oct 22:24

Linux 6.12.0-g61ef303acadc-dirty and a Debian userspace for this board.

What's new in v2.3

A large cyclic transmit buffer is no longer muted before it arrives (patch
0022). The starve watchdog started its 250 ms countdown when a buffer was
enabled, and libiio uploads the data after that, so a big cyclic pattern
(4.4 MB for two channels at 40 MS/s) was muted before it landed. The mute also
cut the DAC off from the DMA, so the sample-locked GPIO markers vanished too.
The first block now gets time in proportion to its size; two channels run at
40, 50 and 60 MS/s. A killed transmitter is still muted within 0.3 s. Reported
by Akil0515.

Transmit-watchdog settings survive a reboot: fw_setenv tx_starve_ms <ms>
(0 = off) and fw_setenv tx_cyclic_bound <ms>.

Board side:

  • the serial console login is pinned to 115200 baud;
  • kmod and wireless-regdb are installed, with the regulatory database
    this kernel can verify.

./devkit (in the repository):

  • builds and flashes this modern firmware by default (--target factory for
    v1.x);
  • one-screen help, typo hints, and a clear message when no board answers;
  • status shows what the board runs;
  • the transmit gate (tools/tx_gate.py) works on Windows without bash.

SDR++: a patched build with RX2, the FPGA ÷8 decimator, the AD9361's
correction loops and a status line, documented with measured USB rates
(5 MS/s complete over USB).

Docs: https://matsvandamme.github.io/fishball7020-fpga-devkit/

Upgrading from v2.2: the boot files and the kernel change; the Debian root
gains tx_starve_ms and the console fix. A board on v2.2 can take the kernel
over the network (./devkit flash --kernel-only); the root needs
write-card.

The files, and where each one goes

The card has two partitions, so this is not a copy-five-files release.
./devkit write-card, in a clone of this repository, makes both, takes every
boot file from the download (checked against SHA256SUMS first) and
unpacks the root filesystem:

# run from: the repo root   - DESTROYS everything on the card
gunzip -c "$HOME/Downloads/debian-rootfs.tar.gz" > firmware-modern/debian/rootfs.tar
sudo ./devkit write-card --target modern --from "$HOME/Downloads" /dev/sdX

On Windows, with no clone and nothing installed: download write-card.cmd too, put it in the folder with the other files, and double-click it. It checks them against SHA256SUMS, backs up the card, and writes both partitions. Step by step: writing the card on Windows.

BOOT.bin FSBL + FPGA bitstream + U-Boot, the bitstream from v1.7's system_top.xsa (below)
uImage the kernel, ADI main pinned at 947298737646475ebb557be24d6ee9fc7901e0a9, with 10 driver patches
devicetree.dtb its device tree - probes the transmitter at 89.75 dB, not 10 dB
uEnv.txt boots the ext4 root on p2 (this release carries no Buildroot ramdisk, so there is nothing for rootfs_mode ramdisk to fall back to)
debian-rootfs.tar.gz Debian 13 armhf + systemd, for the second partition
system_top.xsa the FPGA design BOOT.bin was built from: byte-identical to the v1.7 factory release's
write-card.cmd writes the card on Windows 10 or 11 (added after release; not in SHA256SUMS, which covers the firmware files it checks)
SHA256SUMS for the six firmware files above

The FPGA design is v1.7's. This target never runs Vivado; its
BOOT.bin is built from the XSA of the factory release that
firmware-modern/factory-xsa.pin names, and the release job refuses any other.

What was verified before this was published

The workflow refuses to publish unless all of it holds:

  • BOOT.bin was built from v1.7's XSA - its hash matches the
    pin and that release's asset, and the bitstream inside BOOT.bin is
    byte-identical to the XSA's.
  • every file here is byte-identical to what the board booted -
    BOOT.bin, uImage, devicetree.dtb and uEnv.txt sha256-compared against /boot on the
    running board. Not "the build passed"; the artifacts ran.
  • both transmitters read −89.75 dB at probe, before any userspace.
  • the rootfs carries no board identity - zero SSH host keys, an
    empty /etc/machine-id, and the keygen unit enabled so each board
    makes its own on first boot. Shipping the build container's keys would
    put one private host key on every board flashed from this download.

Checksums in SHA256SUMS.

v2.2

Choose a tag to compare

@github-actions github-actions released this 01 Oct 10:14

Linux 6.12.0-g70fa2c6d3bdd-dirty and a Debian userspace for this board.

What's new in v2.2

Same kernel source, driver patches and FPGA design (v1.7's) as v2.1. What
changed is how you build it and what the build checks; the files here are
built and verified the same way.

  • Either ARM Linux compiler works, on both targets: gcc-arm-linux-gnueabi or
    the hard-float arm-linux-gnueabihf-gcc. U-Boot is compiled with
    -mfloat-abi=soft, which fixes the old "unrecognized -march target: armv5".
    On Arch, see "An ARM cross-compiler on Arch" in docs/building.md.
  • The Debian root builds from ./devkit: ./devkit build --target modern --rootfs-only,
    or --all for the boot files and the root together. ARM emulation is set up
    automatically where the container runtime allows it (contributed by @MrMati).
  • The root is pinned: the base image by digest, packages from a fixed
    snapshot.debian.org date, both recorded in /opt/VERSIONS. Rebuilding a commit
    installs the same versions.
  • The root is checked in CI: built from scratch and checked by
    firmware-modern/debian/check-rootfs.sh whenever it changes.
  • Preflight checks bc, flex, bison and make before building.
  • The documentation is rewritten as quick starts and reference (#9), with
    several wrong statements corrected. Among them: MATLAB examples 04 and 06 also
    transmit, and the 60 s bound on unattended cyclic transmits is armed only on
    this target, not on the factory firmware.

Tested on the board with exactly these files: bootloader contract identical to
the baseline, self-test 25 passed / 0 failed, gpio-check PASS, and the RF
loopback on both channels (20 dB pad and 30 dB pad: 34 passed, 1 warning,
0 failed each; the warning is the attenuation guard forcing the transmitter back
to −89.75 dB, the safe direction).

The files, and where each one goes

The card has two partitions, so this is not a copy-five-files release.
./devkit write-card, in a clone of this repository, makes both, takes every
boot file from the download (checked against SHA256SUMS first) and
unpacks the root filesystem:

# run from: the repo root   - DESTROYS everything on the card
gunzip -c "$HOME/Downloads/debian-rootfs.tar.gz" > firmware-modern/debian/rootfs.tar
sudo ./devkit write-card --target modern --from "$HOME/Downloads" /dev/sdX
BOOT.bin FSBL + FPGA bitstream + U-Boot, the bitstream from v1.7's system_top.xsa (below)
uImage the kernel, ADI main pinned at 947298737646475ebb557be24d6ee9fc7901e0a9, with 9 driver patches
devicetree.dtb its device tree - probes the transmitter at 89.75 dB, not 10 dB
uEnv.txt boots the ext4 root on p2 (this release carries no Buildroot ramdisk, so there is nothing for rootfs_mode ramdisk to fall back to)
debian-rootfs.tar.gz Debian 13 armhf + systemd, for the second partition
system_top.xsa the FPGA design BOOT.bin was built from: byte-identical to the v1.7 factory release's
SHA256SUMS for the six above

The FPGA design is v1.7's. This target never runs Vivado; its
BOOT.bin is built from the XSA of the factory release that
firmware-modern/factory-xsa.pin names, and the release job refuses any other.

What was verified before this was published

The workflow refuses to publish unless all of it holds:

  • BOOT.bin was built from v1.7's XSA - its hash matches the
    pin and that release's asset, and the bitstream inside BOOT.bin is
    byte-identical to the XSA's.
  • every file here is byte-identical to what the board booted -
    BOOT.bin, uImage, devicetree.dtb and uEnv.txt sha256-compared against /boot on the
    running board. Not "the build passed"; the artifacts ran.
  • both transmitters read −89.75 dB at probe, before any userspace.
  • the rootfs carries no board identity - zero SSH host keys, an
    empty /etc/machine-id, and the keygen unit enabled so each board
    makes its own on first boot. Shipping the build container's keys would
    put one private host key on every board flashed from this download.

Checksums in SHA256SUMS.

v2.1 - the modern target ships its own BOOT.bin

Choose a tag to compare

@github-actions github-actions released this 30 Sep 09:40

Linux 6.12.0-g70fa2c6d3bdd-dirty and a Debian userspace for this board.

New since v2.0

  • BOOT.bin is included. One download now boots a blank card. It carries
    v1.7's FPGA design: the modern target never runs Vivado and builds its
    BOOT.bin from a factory release's XSA, pinned in firmware-modern/factory-xsa.pin
    (#9).
  • write-card.sh --from DIR writes a card straight from this download,
    every boot file checked against SHA256SUMS first.
  • iiod fails closed. No network SDR service unless the boot-time transmitter
    mute was proven; the USB network and console still come up so you can see why.
  • Cyclic transmits are bounded at 60 s by default (fw_setenv tx_cyclic_bound
    changes it), so a crashed program cannot leave a carrier on.
  • A quieter boot: iiod no longer pulls in the deprecated systemd-udev-settle.

Tested on the board with exactly these files: self-test 24/0/0, gpio-check PASS.
The RF loopback (both channels, 20 and 30 dB pads, 33 passed / 0 failed each) ran
on the same kernel source with main's newer FPGA design, not v1.7's.

The files, and where each one goes

The card has two partitions, so this is not a copy-five-files release.
firmware-modern/debian/write-card.sh, in a clone of this repository, makes
both, takes every boot file from the download (checked against
SHA256SUMS first) and unpacks the rootfs:

# run from: firmware-modern/debian/   - DESTROYS everything on the card
gunzip -c "$HOME/Downloads/debian-rootfs.tar.gz" > rootfs.tar
sudo ./write-card.sh --from "$HOME/Downloads" /dev/sdX
BOOT.bin FSBL + FPGA bitstream + U-Boot, the bitstream from v1.7's system_top.xsa (below)
uImage the kernel, ADI main pinned at 947298737646475ebb557be24d6ee9fc7901e0a9, with 9 driver patches
devicetree.dtb its device tree - probes the transmitter at 89.75 dB, not 10 dB
uEnv.txt boots the ext4 root on p2 (this release carries no Buildroot ramdisk, so there is nothing for rootfs_mode ramdisk to fall back to)
debian-rootfs.tar.gz Debian 13 armhf + systemd, for the second partition
system_top.xsa the FPGA design BOOT.bin was built from: byte-identical to the v1.7 factory release's
SHA256SUMS for the six above

The FPGA design is v1.7's. This target never runs Vivado; its
BOOT.bin is built from the XSA of the factory release that
firmware-modern/factory-xsa.pin names, and the release job refuses any other.

What was verified before this was published

The workflow refuses to publish unless all of it holds:

  • BOOT.bin was built from v1.7's XSA - its hash matches the
    pin and that release's asset, and the bitstream inside BOOT.bin is
    byte-identical to the XSA's.
  • every file here is byte-identical to what the board booted -
    BOOT.bin, uImage, devicetree.dtb and uEnv.txt sha256-compared against /boot on the
    running board. Not "the build passed"; the artifacts ran.
  • both transmitters read −89.75 dB at probe, before any userspace.
  • the rootfs carries no board identity - zero SSH host keys, an
    empty /etc/machine-id, and the keygen unit enabled so each board
    makes its own on first boot. Shipping the build container's keys would
    put one private host key on every board flashed from this download.

Checksums in SHA256SUMS.

v1.7

Choose a tag to compare

@github-actions github-actions released this 28 Sep 21:45

Note (2026-10-02): ./devkit now builds and flashes the modern firmware by default. Commands for this factory release take --target factory, as below.

Prebuilt SD-card files. Copy all five onto a FAT32 card, or
update a running board with ./devkit flash --target factory --all.

system_top.xsa - build your own firmware without Vivado

The sixth file is the FPGA design, finished: the bitstream plus the
processor bring-up code the boot loader is compiled from. With it you can
change the kernel, a driver or the root filesystem and rebuild, without
installing Vivado
(~50 GB):

# run from: firmware/
./scripts/build_all.sh --xsa /path/to/system_top.xsa

Needs nothing from AMD: the boot loader is built from AMD's embeddedsw
with gcc-arm-none-eabi using the ps7_init.c inside this file, and
bootgen is built from AMD's Apache-2.0 source. Full instructions:
Building without Vivado.

This is the platform the five files above were built from, so a rebuild
starts from exactly the same hardware design.

Built and verified on hardware before publishing: ./devkit verify --target factory
passed, and ./devkit verify --target factory --require-board read the card and
confirmed the board was running exactly these files.

Slice LUTs 11896
DSP48s 72 / 220
Timing WNS 0.205 ns, no failing endpoints

Checksums in SHA256SUMS. Draft - edit the notes, then publish.

v1.6

Choose a tag to compare

@github-actions github-actions released this 28 Sep 08:44

Note (2026-10-02): ./devkit now builds and flashes the modern firmware by default. Commands for this factory release take --target factory, as below.

Prebuilt SD-card files. Copy all five onto a FAT32 card, or
update a running board with ./devkit flash --target factory --all.

system_top.xsa - build your own firmware without Vivado

The sixth file is the FPGA design, finished: the bitstream plus the
processor bring-up code the boot loader is compiled from. With it you can
change the kernel, a driver or the root filesystem and rebuild, without
installing Vivado
(~50 GB):

# run from: firmware/
./scripts/build_all.sh --xsa /path/to/system_top.xsa

You need nothing else from AMD. The boot loader is compiled from the
ps7_init.c inside this file, using AMD's public
embeddedsw sources and an ordinary
gcc-arm-none-eabi, and bootgen - which packages BOOT.bin - is built from
AMD's Apache-2.0 source. Full instructions:
Building without Vivado.

Correction, 2026-09-28, added after publishing. This paragraph originally
read "You still need Vitis". That was true when v1.6 was cut and stopped
being true later the same day, when the xsct/Vitis path was removed and
bootgen was moved to a from-source build. The note is corrected because it
linked to a document that now says the opposite.

The attached files are untouched and their checksums still match
SHA256SUMS. Their BOOT.bin contains the boot loader as Vitis built it at
the time; a rebuild from today's main produces a functionally identical one
that differs in a few bytes of newlib startup code.

This is the platform the five files above were built from, so a rebuild
starts from exactly the same hardware design.

Built and verified on hardware before publishing: ./devkit verify --target factory
passed, and ./devkit verify --target factory --require-board read the card and
confirmed the board was running exactly these files.

Slice LUTs 11896
DSP48s 72 / 220
Timing WNS 0.205 ns, no failing endpoints

Checksums in SHA256SUMS. Draft - edit the notes, then publish.

v2.0 — Linux 6.12 LTS and a Debian userspace

Choose a tag to compare

@matsvandamme matsvandamme released this 27 Sep 16:46

⚠️ debian-rootfs.tar.gz was replaced on 2026-09-28

If you downloaded this release before 2026-09-28 22:00 UTC, download the root
filesystem again.
The original tarball was built on 2026-09-27 and was missing
three files that landed the next day:

missing file what it fixes
/etc/systemd/system/systemd-logind.service (mask) a systemd-logind restart loop
/etc/systemd/system.conf.d/fishball.conf DefaultTimeoutStopSec, RebootWatchdogSec
/etc/systemd/system/systemd-logind.service.d/fishball.conf the restart-loop limiter

Without them the board takes ~75 s to boot and, on shutdown, can sit for
around 29 minutes and then fail to reboot at all — the SoC never resets and
the board has to be power-cycled. Those power cuts are also what corrupt the
journal, so each occurrence destroyed its own evidence.

Measured on a card written from the old tarball, then from the new one:
boot 1 min 14.8 s → 13.9 s, reboot ~29 min-and-dead → 41 s.

The cause was not the fix itself but how it shipped: rootfs.tar is a build
artefact and overlay/ is the source, and nothing rebuilt the tar when the
overlay changed. write-card.sh now refuses a rootfs older than the overlay
and names the files that would be missing.

uImage, devicetree.dtb and uEnv.txt are unchanged and are byte-identical
to the originals. SHA256SUMS was reissued to match the new tarball; the new
checksum is 7e114a18….

Linux 6.12 LTS and a Debian userspace on the Fishball7020, with the same measured RF behaviour as the factory firmware.

This is the first release of firmware-modern/, now the recommended firmware target. It came out of issue #4, opened by MrMati, who owns the same board and was right that the Linux side was a fork of a fork of a fork.

The bitstream is deliberately unchanged. That was a hard invariant of the whole exercise: freeze the FPGA so every measured difference is attributable to the kernel. It is, and the measured answer is that there isn't one.

What changed

v1.5 this
kernel 5.15.0, vendor fork 6.12.0 LTS, Analog Devices main, pinned at 9472987
userspace Buildroot + busybox, a RAM disk Debian 13 (trixie) armhf + systemd, ext4
root filesystem volatile — changes lost at reboot persistent, apt install works, journalctl keeps logs
card layout one FAT partition, five files 128 MB FAT + an ext4 root
driver patches 16 against the vendor tree 9 against 6.12: eight rebased, one the rebase uncovered

The ninth patch

Rebasing the eight transmitter-safety patches onto 6.12 exposed a defect that had been in the tree all along. The kernel's unmute restored a cached attenuation held in ad9361_rf_phy_state — a struct that ad9361_clear_state() memsets — and 0 mdB is full output. So a debugfs initialize followed by any transmit stream keyed the transmitter flat out. Measured at 0.000000 dB with an antenna fitted and no gain ever written.

0019 moves the cache out and seeds it at maximum attenuation on probe. It is still live on the 5.15 target, where an initialize needs re-muting afterwards.

That is the third safety-relevant field to be moved out of that struct, after 0016's latch and 0018's limit. If you add a fourth, do not put it there.

Measured, not asserted

Interleaved A/B on one board, 6.12 → 5.15 → 6.12, same tool, buffer, rate and counts, three repeats each:

33.6 M, 1 ch 33.6 M, 2 ch 134.4 M, 1 ch 134.4 M, 2 ch
6.12 183.1 MB/s 346.4 MB/s 220.0 MB/s 430.8 MB/s
5.15 183.1 MB/s 346.4 MB/s 220.0 MB/s 430.8 MB/s
6.12 again 183.1 MB/s 341.8–346.4 220.0 MB/s 429.0–430.8

Every figure matched; the scatter within one kernel is larger than any difference between them. Debian costs nothing either: identical RX throughput, and the DAC underflow counter stayed flat over 10 s at 117 MB/s. With Debian up, 955 MB of 1001 are free.

./devkit selftest --ssh: 23 passed, 0 warnings, 0 failed. --loopback --pad 20, TX0→RX0: 32 passed, 1 warning, 0 failed — the warning is patch 0015 doing its job, muting a starved stream.

Files

A fresh card, from a clone plus these assets:

# run from: firmware-modern/debian/   - write-card.sh reads ./rootfs.tar
gunzip -c ~/Downloads/debian-rootfs.tar.gz > rootfs.tar
sudo BOOT_BIN=/path/to/BOOT.bin ./write-card.sh /dev/sdX   # DESTROYS the card

write-card.sh makes both partitions, copies the four boot files onto the FAT one
and unpacks the rootfs into the ext4 one. It takes BOOT.bin from the most recent
card backup in the tree, so on a machine that has never flashed this board, point
BOOT_BIN at the one you downloaded. Check the device name yourself.

A running board, over the network — note that both the kernel and the
device tree have to go, and --kernel-only sends only uImage:

# run from: the repo root, with uImage and devicetree.dtb in firmware-modern/output/
FW_OUTPUT=$PWD/firmware-modern/output ./tools/flash.sh --kernel-only --no-reboot
FW_OUTPUT=$PWD/firmware-modern/output ./tools/flash.sh --dtb-only

A 6.12 uImage against the 5.15 device tree is a boot that half-works and is
confusing to diagnose — the GPIO base moves, among other things.

uImage the 6.12.0 kernel, with the nine patches
devicetree.dtb its device tree — probes the transmitter at 89.75 dB, not 10 dB
uEnv.txt boots the ext4 root on p2; fw_setenv rootfs_mode ramdisk switches back to Buildroot
debian-rootfs.tar.gz Debian 13 armhf, 295 MB unpacked, with the five systemd units this board needs
SHA256SUMS checksums for the four above

BOOT.bin is not here, because the bitstream did not change. Take it from
v1.5,
along with system_top.xsa if you want to rebuild the FPGA design without
installing Vivado. v1.5 ships two:

sha256
BOOT.bin 7c44f358… the stock design — 72/220 DSP48s, WNS +0.205 ns
BOOT.bin.both-channels-filtered 0bb8ebe6… with optional/0004: RX1 gets an anti-alias filter, 94 DSP48s, +0.215 ns

Which bitstream the numbers were measured on — now settled. The figures above
were originally taken with the board running the both-receive-channels-filtered
bitstream rather than the stock one. The board has since been put back on
v1.5's stock BOOT.bin (sha256 7c44f358…, verified against v1.5's own
SHA256SUMS) and everything re-measured, with the kernel, device tree and rootfs
held constant so only BOOT.bin changed.

Nothing moved. 32 passed, 1 warning, 0 failed both times, gpio-check PASS
both times, and every stable field identical: digital interface eye 157 on
both, digital loopback error 0.0 dB on both, TX power 19.0 dBm on both. The fields
that did move — image rejection 76.6 → 66.6 dBc, DC offset, the two die
temperatures — are the ones this repo already documents as varying run to run.

That is the explanation rather than a null result: the self-test deliberately
bypasses the FPGA decimator
, and optional/0004 only does anything when the
decimator is engaged. So the self-test structurally cannot tell the two bitstreams
apart, which is how the board came to run the wrong one unnoticed. The full
comparison, and why closing that gap is a trade-off rather than an oversight, is in
firmware-modern/baseline/README-bitstream-swap.md.

So the docs' stock FPGA figures — 72 DSP48s, WNS +0.205 ns — now describe the
build the board is actually running.

If you want the documented stock behaviour, use v1.5's BOOT.bin. If you have two
receive channels and use the fabric decimator, you want the filtered one — see
docs/both-receive-channels.md.

No board identity is baked into debian-rootfs.tar.gz. It ships with no SSH host keys and an empty /etc/machine-id, so each board generates its own on first boot. Shipping the build container's keys would have given every board flashed from this download the same private host key, publicly available here — which makes impersonating any of these boards trivial and makes ssh's key-change warning useless, since it could never fire. Verified on hardware: with all six keys removed, the unit regenerates them, sshd -t passes and a fresh login succeeds.

How this was verified, and what was not run

release.yml did not produce this release, and the notes should say so rather than imply a gate that did not run. That workflow builds on the self-hosted runner and gates on ./devkit verify --board against firmware/output/'s five files — which cannot pass for a target that is a four-file FAT partition plus a rootfs tarball, and which in any case requires the hardware runner to be registered. It is not. So this release was cut by hand.

What was checked instead:

  • every file here is byte-identical to what the board booted. uImage, devicetree.dtb and uEnv.txt were sha256-compared against /boot on the running board; all three match. The board's BOOT.bin is now v1.5's stock one, sha256 7c44f358…, which is an asset of that release.
  • the board reports 6.12.0-g70fa2c6d3bdd-dirty, gpiochip512 base=512, both transmit channels at −89.750000 dB at power-on with no init script having run, and 0 failed systemd units.
  • CI green on main: *Verify the modern fi...
Read more

v1.5 — both receivers survive decimation

Choose a tag to compare

@matsvandamme matsvandamme released this 23 Sep 02:11

Built from b4f72b4. The five SD-card files are the stock build — patches
0001–0009 and 0012, exactly what a fresh clone of this tag produces.

New: both receivers survive decimation

The FPGA carries a ÷8 decimating filter that lets this radio reach rates the
AD9361 cannot manage alone — down to 260 kSPS, where FM, GSM and narrowband IoT
live. Upstream wires it to channel 0 only, and cpack captures every
channel on channel 0's timing. So the moment you engage it, the second receiver
is sampled at one eighth rate with no anti-alias filter of its own.

Measured on hardware — TX2A through a 20 dB pad into RX2A, converter at
61.44 MSPS, decimator engaged, a tone placed at 10 MHz, well outside the
resulting ±3.84 MHz window:

Channel 1, decimator engaged Strongest bin Level
Stock +2.320 MHz — an alias (10 − 7.68) 70.1 dB
Patched none below the noise

Better than 70 dB of suppression, on a channel that previously had none.
With the filter bypassed both builds behave identically, so the default case
loses nothing.

The fix is small because ADI's own Tcl helper already loops over its channel
count: ask for four channels instead of two, route channel 1 through, and take
cpack's inputs from the filter outputs. Both channels then share one enable,
one coefficient set and one group delay — so they stay sample-aligned, which is
the whole point of having two.

Cost: DSP48s 72 → 94 of 220, LUTs 11,896 → 12,521, and timing actually
improves slightly, +0.215 ns against +0.205 ns stock.

It is opt-in. setup.sh does not apply patches/optional/, so this release's
five files remain upstream's block design.

cd firmware/src && git apply ../patches/optional/0004-filter-both-receive-channels.patch && cd ..
rm -rf src/hdl/projects/pluto/pluto.{xpr,cache,gen,hw,ip_user_files,runs,sim,srcs,sdk}
cd .. && ./devkit build --hdl-only && ./devkit verify && ./devkit flash --boot-only

Full write-up: docs/both-receive-channels.md.

Also new since v1.4

docs/modulation-and-throughput.md — what the board does with real
modulated signals. The headline is that the widely-repeated "it tops out around
5 MSPS" is a property of continuous host streaming, not of the radio: load the
waveform into the transmit DMA once and let CYCLIC=1 repeat it, and QPSK holds
2.17% EVM and 16-QAM 2.24% at the full 61.44 MSPS across 18 MHz of occupied
bandwidth. Includes capture-integrity figures cross-checked against the AD9361's
internal BIST tone, and why OFDM measures worse without anything being wrong.

Assets

The five files are a normal stock build. One extra is included for convenience:

  • BOOT.bin.both-channels-filtered — the same build with the optional patch
    applied, so you can try it without a 20-minute Vivado run. Rename it to
    BOOT.bin on the card. It is not what a clean clone builds; that is the
    point of the separate name.

uramdisk.image.gz has a different checksum from v1.4 but the payload is
byte-identical
— only the U-Boot header's embedded build timestamp differs.
Verified by stripping the 64-byte header and comparing.

Verified before publishing

  • ./devkit verify: WNS 0.205 ns over 48,263 endpoints, no failing setup
    endpoints, bitstream compressed, DSP48 72/220, LUTs 11,896/53,200 —
    matching v1.4 exactly, which is the evidence these are genuinely stock.
  • The patched bitstream measured on hardware as above, and left running on the
    development board.
  • CI green on b4f72b4, including the check that every optional patch still
    applies to a clean tree.

Install

Copy the five files onto the SD card's FAT32 partition, or from a running board:

./devkit flash --all

Figures measured on one board. Indicative, not specification.

v1.4 — the USER LED follows the transmitter

Choose a tag to compare

@matsvandamme matsvandamme released this 22 Sep 23:13

Built from 0b1c6d2, the full patch series 0001–0009 plus 0012. A fresh
clone of this tag rebuilds these exact inputs.

Why you want it

The USER LED now tells you something worth knowing. It used to run the
kernel's heartbeat trigger, which says the CPU is alive and nothing else. On a
board that reaches about +19 dBm, the more useful question is whether RF can
leave the port — so that is what it answers now:

Lit whenever either transmit chain is out of full attenuation.
Dark when both sit at the −89.75 dB mute floor.

It is a real kernel LED trigger (tx-active), driven from
ad9361_set_tx_atten() — the one point every attenuation change passes
through, whether that is the driver's own mute when a DMA stream is torn down
or a plain sysfs write. No polling loop.

Hooking the attenuator rather than the DMA buffer is deliberate: attenuation
can be raised with no buffer open at all, and a stream-only indicator would
sit dark while the LO leaks out of the SMA.

Keep the heartbeat instead with fw_setenv tx_led 0 and reboot.

The bit-map flag's clock crossing is now actually constrained. Patch 0009
landed after v1.3 was built, so this is the first release whose bitstream
carries it. set_max_delay -datapath_only silently drops the constraint when
it has no -from; v1.2 and v1.3 shipped with that crossing unconstrained.

What changed on the card

All five files, because the bitstream was rebuilt for 0009. The device tree
is unchanged in content and still recompiles byte-for-byte identical to the
factory one — the LED trigger is selected from S21misc, not from
linux,default-trigger, precisely to keep that true.

Verified before publishing

  • ./devkit verify: WNS 0.205 ns over 48,263 endpoints, no failing setup
    endpoints; bitstream compressed (2,371,856 B); DSP48 72/220, LUTs
    11,896/53,200.
  • On hardware, from a clean boot, with the TX LO powered down so nothing
    radiated: TX1 alone, TX2 alone, both together, and a gain set before a DMA
    stream each light the LED; returning to the −89.75 dB floor puts it out; the
    trigger comes up selected at boot with nothing done by hand.
  • CI green on 0b1c6d2.

Install

Copy all five files onto the SD card's FAT32 partition, or from a running
board:

./devkit flash --all

Figures above were measured on one physical board — indicative, not
specification.

v1.2 — sample-locked GPIO outputs, and a survivable first hour

Choose a tag to compare

@matsvandamme matsvandamme released this 17 Sep 10:16

Prebuilt SD-card files, verified on a real board. Copy all five onto a FAT32
card, or update a running board over the network with ./devkit flash --all.

New: sample-locked GPIO outputs

Four expansion-header pins now carry the four bits of every transmit sample
that the 12-bit DAC throws away, so their edges are locked to the RF sample
that carried them — a master clock, a frame marker or a sync line for external
hardware. It costs +3 LUTs and +7 flip-flops and no analog performance at all,
because nothing downstream ever read those bits.

Signal Header net JP5 pin FPGA ball
sample_gpio[0] 3V3_IO1 7 V10
sample_gpio[1] 3V3_IO2 9 U9
sample_gpio[2] 3V3_IO3 11 U10
sample_gpio[3] 3V3_IO4 13 T9

Off by default, so nothing about the radio changes until you ask:

echo 1 > /sys/bus/iio/devices/iio:device2/tx_sample_gpio_en

With it off the same pins are ordinary Linux GPIO. Full reference:
docs/tx-gpio-bitmap.md.

Verified on hardware: every one-hot nibble appears on its own pin, the enable
releases them, and an authored square wave tracks N/fs to 0.1 %. The pins are
pulled down, so an idle pin reads a defined low rather than floating.

New tooling

  • ./devkit — one entry point: doctor, setup, sim, build, verify, flash, selftest, status
  • ./devkit doctor — checks Vivado, host packages, headers and disk space in a second, rather than failing at minute 40
  • ./devkit flash — flashes the running board over the network, with backup, checksum verification before the swap, and a clean unmount
  • ./devkit verify --board — is the board actually running what you built? A card holding a different build of the same size looks entirely normal
  • tools/tx-gpio-bitmap-check.py — verifies the GPIO feature on hardware with no scope, no antenna and the transmitter muted

Fixed

  • setup.sh failed when run twice. Patches stack, so a per-patch "is it already applied" test reported a perfectly good tree as broken and aborted with a message about upstream drift.

Measured

Timing WNS +0.231 ns, 0 failing endpoints of 48 263
Logic 11 896 LUTs, 72 / 220 DSP48s
devicetree.dtb byte-for-byte identical to the factory board's
HDL simulation 2 565 checks against golden models, all 10 mutants caught

The firmware layers are still factory-identical — device tree, kernel config,
rootfs. The bitstream now contains the GPIO feature, which the factory one does
not.

Checksums in SHA256SUMS.

v1.1 — prebuilt firmware, TX safety, and a board self-test

Choose a tag to compare

@matsvandamme matsvandamme released this 14 Sep 16:06

Everything since v1.0, which was source-only and 50 commits ago. This release
carries prebuilt SD-card files
, so the board is usable without an ~80 GB
Vivado install — see Just flash it below.

The firmware actually changed

The board now has a persistent serial number. Its Winbond W25Q128 flash
never emits the SPI-NOR-UniqueID line the boot script greps for, so the
serial came out empty — and SDRangel, which identifies Plutos by serial, listed
PlutoSDR0 TBD and refused to open it. A serial is now minted on first boot
and kept, without changing the USB gadget MAC or the interface name.

The transmitter is quiet when nothing is transmitting. The AD9361 boots
into ENSM fdd with its TX chain biased, leaking LO from power-on with nothing
in the DAC. patches/0004 mutes it whenever no DMA buffer is streaming and
unmutes when one starts; the teardown hook runs even if the application
crashed, because it happens on file close. Measured over a 50 dB attenuated
loopback, the mute costs no output power at all.

And the unmute no longer overwrites your gain. patches/0005. Restoring
the cached attenuation unconditionally meant that setting a gain and then
starting the stream — the obvious order, and the one most tools use — was
silently overridden a moment later by the value cached at the end of the
previous transmission: asking for −10 dB could put −60 dB on the wire. Both
orders now work. If you keep a watchdog script that re-applies a gain after the
stream starts, this is what it was working around, and you can delete it.

BOOT.bin is compressed again. BITSTREAM.GENERAL.COMPRESS is only applied
inside adi_project_run, which the incremental build path skips — so a rebuilt
bitstream came out uncompressed, overflowed the FSBL's OCM, and failed to boot
with no message at all. Fixed with a write_bitstream pre-hook.

The build's self-repair actually repairs. Three defects, all found by
building this release from a clean clone: it patched the alphabetically-first
of the 1120 packages containing a COPYING instead of the one that failed; it
recorded the hash of a zero-byte download as if valid, disabling the check that
caught the corruption; and it cleared dl/ without clearing the stamps that
stop buildroot re-fetching. Any one of them stopped a fresh build.

Tools that did not exist before

  • tools/selftest/ — is the board damaged? Reads the six Zynq supply rails,
    both die temperatures, the AD9361's digital-interface eye and internal digital
    loopback, then the receiver. With a cable and a pad it measures attenuator and
    gain linearity, image rejection, harmonics, path loss from 100 MHz to 5 GHz,
    and how far the transmitter really falls when stopped. Standard library only.
    It never transmits without --loopback.
  • firmware/sim/ — simulate the custom HDL against a golden model in about
    a second with iverilog, instead of a 20-minute Vivado build. --mutate
    proves the testbench can still fail.
  • firmware/scripts/verify_output.sh — check a build before spending
    minutes flashing it.
  • .claude/skills/ — the workflow rules as an Agent Skill.

Documentation

docs/block-design.md walks the stock Vivado project IP by IP, and
docs/wbfm-channelizer.md is a full worked example of putting real DSP in the
fabric. The README now covers the boot DIP switch, all three flashing routes,
the USER LED, the names the board is sold under, and the end-to-end test.

Careful with a loopback

This board is sold in a variant carrying a Mini-Circuits PGA-102+ power
amplifier — 17.7 dB of gain at 50 MHz falling to 10.4 dB at 6 GHz. Measured at
900 MHz, a PA-equipped unit delivers about +18.3 dBm, roughly 16 dB above
what its own receive port survives
. Fit at least 20 dB of attenuation in any
TX→RX loop; 40–50 dB is comfortable.

Just flash it

Download the five files below onto a FAT32 SD card and boot. Verify them first:

sha256sum -c SHA256SUMS

Provenance

Built from this tag by firmware/scripts/build_all.sh, Vivado/Vitis 2022.2 on
Ubuntu 22.04, from a clean git clone with nothing cached — then flashed to a
real board over the SD partition and checked:

  • devicetree.dtb is byte-for-byte identical to the factory board's
  • 72 / 220 DSP48s, WNS +0.214 ns, 0 failing endpoints of 48 248
  • both buildroot passes succeeded on their first iteration, no repairs needed
  • 32 of 32 self-test checks pass on hardware, including a 50 dB RF loopback

If you would rather not take binaries on trust, that is what the rest of the
repository is for — setup.sh, build_all.sh, and verify_output.sh reproduce
them.