t4as

Embedded Linux · 2026

Yocto for Small Boards: One Image, Remote Access, and an FPGA as the Camera

One Yocto layer builds the same headless Linux for the Raspberry Pi Zero family and the Banana Pi M2 Zero. A device enrols with a server, takes nine fixed commands over MQTT, renews its own certificate and recovers from lost contact without help, and its identity survives a root filesystem replacement. On the Banana Pi, a Tang Nano 20K FPGA drives the camera input at up to 54 MHz without a byte error.

  • Yocto (Scarthgap)
  • kas
  • BitBake
  • systemd
  • U-Boot
  • MQTT
  • mTLS
  • OpenSSH
  • V4L2
  • Verilog
  • Raspberry Pi
  • Allwinner H2+
  • Tang Nano 20K
to taking commands again once the server returned, with the certificate expired during the outage (end-to-end test)
13 s
remote commands the device accepts; none of them runs arbitrary code
9
to healthy telemetry once the server returned, after the device booted with it down for 5 minutes
25 s
byte errors from the FPGA into the camera input, across 8 clock rates from 1 to 54 MHz
0 / 1,048,576
Tang Nano 20K wired to a 24-pin camera connector adapter on perfboard, the FPGA that stands in for the Banana Pi camera

Result

This started as a small image for one board and is becoming the base for devices that get installed where nobody can reach them. That sets the one requirement everything here is measured against: a deployed device must never need a visit. It has to come back from a fault on its own, stay updatable, and still be the same device afterwards.

  • One layer, several boards. The distro policy, package list and services live in a single project layer. A board is a short kas file that names a machine. Images for the Pi Zero W, the Pi Zero 2 W and the Banana Pi M2 Zero come out of the same recipes.
  • Remote access without an open port. The device makes outbound connections only. It enrols once over HTTPS, then takes commands over MQTT with a client certificate. A shell is a reverse SSH tunnel that the device opens on request and closes on a deadline.
  • It renews and recovers by itself. The device renews its certificate before it expires, and after it has expired, using the key it enrolled with. A health check proves once a minute that commands can still reach it, and escalates step by step when they can’t. A hardware watchdog covers the case where nothing runs at all.
  • Identity outside the root filesystem. Keys, certificates and Wi-Fi credentials sit on their own partition. I replaced the root partition on an enrolled device and it came back as the same device, with no re-enrolment.
  • An FPGA in place of the camera. A Tang Nano 20K drives the Banana Pi’s parallel camera input through the kernel’s normal capture path. Every byte of the test stream was checked at eight clock rates up to 54 MHz.

One layer, several boards

The project layer defines a distro (systemd, systemd-networkd, no X11, Wayland or audio), one package group and one image recipe. Board-specific things stay in the BSP layers: meta-raspberrypi for the Pis and meta-sunxi with mainline Linux and U-Boot for the Banana Pi.

Target MACHINE Notes
Pi Zero W raspberrypi0-wifi the fleet device used for all hardware tests
Pi Zero 2 W raspberrypi0-2w-64 64-bit
Banana Pi M2 Zero bananapi-m2-zero Allwinner H2+, mainline kernel and U-Boot
QEMU qemuarm64 boots the same image for smoke tests: systemd, DHCP, DNS, TLS, SSH

Every upstream layer is pinned to a full commit in kas, so a build next year fetches the same sources (kas/base.yml):

repos:
  poky:
    url: https://git.yoctoproject.org/poky.git
    tag: yocto-5.0.19
    commit: bb98354685781296e3b3737e7762412100f359c2
    layers:
      meta:
  meta-openembedded:
    url: https://git.openembedded.org/meta-openembedded.git
    branch: scarthgap
    commit: bec755063a8b5da65df626f5749496aadaa4f4bb
    layers:
      meta-oe:
      meta-python:

The release is Scarthgap (5.0 LTS) rather than the newest one for a concrete reason: the Banana Pi BSP layer has a Scarthgap branch and none for the newer release. I would rather run one release behind than edit LAYERSERIES_COMPAT in someone else’s layer and pretend it was tested.

Features are kas fragments that are added to a board: the fleet client, the OV5647 camera, the U-Boot boot path. The plain image has none of them, and no credentials. The build host is an Orange Pi 4 Pro, so everything compiles natively on aarch64, with BitBake held to four threads because the board has 5.7 GiB of RAM. Downloads and shared state are kept on the NVMe drive and shared by all targets.

Remote access without an open port

The three connections between a device and the fleet server, all opened by the device Device Pi Zero W, Yocto image generates its own ID, TLS key and tunnel key fleet-dispatch: 9 allowed commands sshd on 127.0.0.1:22 Fleet server Orange Pi on the LAN enrolment API, signs the device certificate MQTT broker, per-device topic permissions SSH gateway, loopback port 31000 to 31999 1. HTTPS, once: single-use token + CSR + public keys the private keys never leave the device 2. MQTT over TLS with the client certificate commands in; replies, telemetry and online state out 3. Reverse SSH, only after shell.open, 60 s to 1 h the server picks the port; the device forwards it to its own sshd
Figure 1. All three connections start at the device, so it works behind NAT and needs no inbound port. The tunnel (dashed) does not exist until an operator asks for it.

Enrolment. A fresh device generates a UUID, a TLS key with a signing request, and a separate SSH key for the tunnel. It sends the public parts with a single-use token; the server stores only the token’s hash and binds it to the first key material it sees, so a retry with the same keys is idempotent and a replay with different keys is refused. The device gets back a client certificate whose MQTT permissions cover its own five topics and nothing else.

Commands. MQTT is only the transport. Each message goes through one dispatcher that checks the envelope, the expiry time and an allowlist: camera start, stop, restart, status and profile, telemetry, shell open and close, reboot. There is no “run this” command. The arguments of shell.open are checked the same way (fleet-dispatch):

fleet_is_uint "$duration" || { reply_error invalid_duration; exit 0; }
[ "$duration" -ge 60 ] && [ "$duration" -le 3600 ] || {
    reply_error invalid_duration
    exit 0
}
fleet_is_uint "$server_port" || { reply_error invalid_server_port; exit 0; }
[ "$server_port" -ge 31000 ] && [ "$server_port" -le 31999 ] || {
    reply_error invalid_server_port
    exit 0
}
"$FLEET_TUNNEL" open "$duration" "$server_port" >/dev/null

A command can choose how long the tunnel lives and which server port it uses, within bounds. It cannot choose the host, the user, the key or the forwarding target: those come from the enrolled configuration, and the target is always the device’s own 127.0.0.1:22. Replies are cached by command ID, so an MQTT QoS 1 duplicate does not reboot the device twice. Commands that change state are refused while the clock is unsynchronised, because expiry cannot be checked without one.

Shell and camera. The operator logs in through the server’s loopback port with a short-lived SSH certificate. The same tunnel carries the camera: an OV5647 on the Pi Zero W, H.264 over TCP, started and stopped by command. Through the tunnel, ffprobe read 1280×720 at 15 fps.

Tests on the Pi Zero W, with the device untouched after each fault:

Fault Outcome
Device rebooted while the server was down for 5 minutes same device ID, reconnected by itself, healthy telemetry 25 s after the server returned
Wi-Fi taken down and restored reconnected without a reboot, healthy telemetry after about 39 s
Agent process killed with SIGKILL restarted by systemd within 10 s, telemetry resumed
Tunnel opened with a 60 s lifetime closed at the deadline, server listener gone, 0 failed units

Identity that survives a reflash

On the first fleet image, flashing a new build wiped the device’s identity, and it had to be enrolled again as a stranger. That makes updates impossible, so before any update mechanism I moved everything that makes a device that device onto its own partition (myproject-raspberrypi-state.wks):

bootloader --ptable msdos

part /boot --source bootimg-partition --fstype=vfat --label boot --active --align 4096 --size 130
part /     --source rootfs --fstype=ext4 --label root --align 4096
part state --source rawcopy --sourceparams="file=myproject-state-image-${MACHINE}.rootfs.ext4"
           --fstype=ext4 --label MYPROJECT_STATE --use-label --align 4096 --fixed-size 256

The state partition holds the device ID, the TLS and tunnel keys, the trust anchors, the Wi-Fi configuration, the SSH host keys and the duplicate-command cache. Two rules keep it honest. A marker separates factory-empty from provisioned, and a provisioned device whose state is missing or unreadable stops in a degraded mode: it never quietly generates a new identity. And the fleet services declare RequiresMountsFor on the mount, so they cannot start on an empty directory in the root filesystem.

The test was to overwrite only the root partition of an enrolled device and boot it. A guarded script does the write: it resolves the target with lsblk, refuses any disk the host itself runs from, and touches partition 2 only.

Keeping a device reachable without help

Device certificates last 90 days, and MQTT is the only way in: the shell is opened through it. So an unrenewed certificate is a lock-out with a date on it. The bench unit enrolled in September would have gone silent in early December. Renewal was the first thing to fix.

Renewal that still works after expiry. The device asks for a new certificate at two thirds of the lifetime. It proves who it is with the signing request itself: the request is signed by the device’s private key, and the server checks that key against the fingerprint it stored at enrolment. The old certificate is deliberately not required (fleet_server/devices.py):

bound_key = (record.get("binding") or {}).get("tls_public_key_sha256")
if not bound_key or public_key_fingerprint_from_csr(csr) != bound_key:
    raise EnrollmentError("CSR key does not match the enrolled key", 403)

A device that stayed offline past its certificate’s expiry comes back, renews with the key it has always had, and is reachable again. A revoked device is refused. A replayed request only yields a certificate for a key the replaying party doesn’t hold.

On the device, a new certificate replaces the old one only after it has been used once to reach the broker. A bad certificate from the server therefore can’t cut the device off. The exception is a certificate that has already expired: then there is nothing left to protect (fleet-renew):

fleet_mqtt_pub "$(fleet_topic events)" 1 0 "$event" && broker_ok=1
MQTT_CERT_FILE=$current_cert_file
if [ "$broker_ok" -eq 1 ]; then
    log "new certificate accepted by the broker"
elif [ "$expired" -eq 1 ]; then
    log "broker test failed, but the current certificate is expired; installing the new one"
else
    log "broker refused or was unreachable with the new certificate; keeping the current one"
    exit 1
fi

A health check that tests the real path. A process that exists isn’t proof that commands arrive. Once a minute the device publishes a ping to its own topic, and its command subscriber has to receive it. A broker acknowledgement counts as contact. When contact goes stale, the device escalates, one step at a time and each step rate-limited:

No contact for Action
ping not echoed for 5 min, broker reachable restart the agent (back-off up to 6 h)
15 min restart networking
1 h reboot, then 2 h, 4 h, … at most once a day

The reboot counter lives on the state partition, so a device whose server is gone for weeks reboots once a day instead of looping, and any contact resets it. All the timing uses seconds of uptime rather than the wall clock, which a board without a battery-backed clock can’t trust at boot. Underneath this, systemd drives the SoC’s hardware watchdog with a 10 s timeout, and a kernel panic reboots after 10 s. The time is saved to the state partition and restored at the next boot, only ever forwards.

since() {
    marker=$(fleet_marker_read "$1")
    if [ -z "$marker" ]; then
        echo -1
    elif [ "$marker" -ge "$now" ]; then
        echo 0
    else
        echo $((now - marker))
    fi
}

Results, first against the real server software with a simulated device and certificates that live two minutes, then on the Pi Zero W:

Test Outcome
6 min soak, a command every few seconds 4 renewals, 44 commands, none lost
server stopped until the device’s certificate expired, then restarted renewed and taking commands 13 s after the server returned
revoked device asks for renewal refused, installed certificate unchanged
Pi Zero W: renewal against the live server 2.7 s; new certificate accepted by the broker before it was installed
Pi Zero W: ping through its own subscriber echoed within 2 s
Pi Zero W: hardware watchdog armed by systemd with a 10 s timeout

An FPGA as the camera

The Banana Pi M2 Zero has a parallel camera input (8 data lines, PCLK, HREF, VSYNC). I wired a Tang Nano 20K to it with eleven hand-soldered wires, with one goal: get data from FPGA logic into Linux through the SoC’s real capture path, sun6i-csi with DMA and V4L2. Sampling GPIOs would have been easier and would have proved nothing about speed.

Tang Nano 20K soldered to the camera-connector adapter with eleven wires

Wiring first. Before any fast signal, the FPGA drove one line at a time while the others floated, and the Banana Pi read all eleven pins, once with pull-downs and once with pull-ups. All 11 lines arrived on the intended pin, with no swaps or bridges.

A sensor that isn’t there. The capture driver only starts when a sensor sub-device is linked to it in the media graph. I wrote a small V4L2 sub-device that negotiates the format and does nothing else (kernel/fpga_dvp_testsrc.c), and a device-tree overlay that links it to the CSI:

static int fpga_dvp_s_stream(struct v4l2_subdev *sd, int enable)
{
	/* The FPGA is already free-running; the callback only satisfies the
	 * normal bridge contract. */
	return 0;
}

With that, /dev/video1 behaves like a camera, and captures use the stock driver.

A stream that can be checked. The FPGA sends 32×32 frames with a 16-byte header (magic, pattern ID, clock rate, frame counter) and eight payload patterns in rotation: a counter, walking ones and zeros, 55/AA, 00/FF for maximum simultaneous switching, static levels and a pseudo-random sequence. The host regenerates each frame from its counter and compares every byte, with an error count per data line. Blanking is long on purpose: the edges run at the full pixel clock while the average data rate stays low, which separates wire and timing problems from throughput problems.

Start of a line: byte 0 is on the bus for one full clock before HREF rises PCLK HREF D[7:0] blanking byte 0 byte 1 byte 2 … 64 bytes per line setup cycle: byte 0 already on the bus, HREF still low not sampled byte 0 byte 1 byte 2
Figure 2. Start of a line as the FPGA now sends it. Dashed lines are the receiver's sampling edges; data changes on the opposite edge. Byte 0 is driven one clock early, so it has been stable for a cycle and a half when HREF opens the line.

At 45 MHz the first full sweep showed 2,112 byte errors, and they were not spread out: every one was at the start of a line, and the rest of each line was clean. That pattern points at the first sample after HREF rises, not at the wires. The fix is in the FPGA: put byte 0 on the bus for one full clock with HREF still low, then open the line (stress/stress_top.v):

// Put byte zero on the bus for a complete low-HREF PCLK before
// opening the active window. This gives the CSI input pad a full
// cycle of setup at the line/frame boundary.
state <= STATE_ACTIVE_START;
href_r <= 1'b0;
byte_index <= 6'd0;
line_index <= 6'd0;
data_r <= stream_byte(frame_count, 6'd0, 6'd0);

After rebuilding all eight images, the sweep ran with the stock kernel driver:

PCLK Frames Bytes checked Byte errors Sync or dropped frames
1 MHz 64 131,072 0 0
5 MHz 64 131,072 0 0
10 MHz 64 131,072 0 0
20 MHz 64 131,072 0 0
27 MHz 64 131,072 0 0
36 MHz 64 131,072 0 0
45 MHz 64 131,072 0 0
54 MHz 64 131,072 0 0

The slow clocks come from a fractional divider on the board’s 27 MHz oscillator, the fast ones from the FPGA’s PLL. The whole flow is open source: Yosys, nextpnr and openFPGALoader, loading the FPGA’s SRAM only, with a Python harness that programs each image, captures over SSH and writes the report.

Where it started

The first version was a single image for the Pi Zero W on Yocto Kirkstone, built in Docker: join a known Wi-Fi network, accept SSH, nothing else. It taught me the two things the current project is built around. On a minimal systemd image nothing brings the interface up unless a unit says so, and the DHCP client has to wait for wpa_supplicant to associate. And credentials baked into an image at build time are the wrong place for them, which is why the current default image contains none and devices are provisioned at runtime. That repository is public and linked above.

The same image ran an IP camera on a two-servo pan-tilt mount, shown on the 3D prints & CAD page.

Raspberry Pi Zero W with the servo-driven camera frame

Where it goes next

The aim is a base that other projects build on and then leave unattended, so the remaining work is ordered by what could strand a device.

  • A test rig that does the visiting. A Raspberry Pi 3 as the tester: it switches each board’s power, reads its serial console and acts as its Wi-Fi access point, so it can cut power at any moment, drop the network or block the clock source, thousands of times, and count the boots that fail to come back. Each test has a pass criterion a script can check.
  • Storage that survives power cuts. A read-only root filesystem and checked state files, measured on that rig. The target is zero failed boots.
  • Updates with rollback. Two system slots behind U-Boot, with signed bundles. A new system is confirmed only after it has reached the server by itself; one that cannot is rolled back, which becomes the last step of the escalation above. The U-Boot image builds and passes its artefact checks, and booting it on the board is the next step.
  • A release gate. The rig’s fault tests before every release, plus devices that run for weeks with nightly updates.

Board specifics stay in the BSP layers and a short machine file, so the same base can move to an industrial board when a deployment calls for one. On the FPGA side the sweep covered edge timing with short frames; the next runs raise the duty cycle towards real video frames, and the receiver module moves into the Yocto build for the Banana Pi.