Firmware Updates with fwup: Beyond the Basics

It’s easy to think of a firmware update as copying a new root filesystem onto a device. For a board sitting on your desk with an SD card reader next to it, that’s close enough to true. For a device deployed in the field with no one standing beside it, it isn’t — an update that gets interrupted halfway, or that produces a system that boots but doesn’t work, has to leave you with a device that still comes back up.
fwup is the tool Nerves uses to make that guarantee. It’s less a file-copying utility than a small state machine for the update process: it writes images to specific offsets on media, updates the bootloader environment, swaps which partition the board boots from, and maintains the metadata that makes rolling back possible. Understanding it means understanding a separation that isn’t obvious at first — between the layout of a firmware image, which is identical across every unit you ship, and the per-device configuration, which isn’t.
This post covers that separation and the update mechanics built on top of it, using a BeagleBone-based Nerves system (`pp_custom_bbb`) as a running example.

1. The Mental Model: Image Layout vs. Provisioning

The most common early confusion is treating fwup.conf and a provisioning include as the same kind of file. They’re related, but they answer different questions.

1.1 fwup.conf Describes the Image, Not the Device

fwup.conf defines the binary layout of the firmware image: partition offsets and sizes, where the bootloader environment block lives, and the set of tasks (complete, upgrade.a, upgrade.b) that can be run against media.
This is storage geometry, not application logic. Every unit running the same firmware has the identical layout, and it must stay consistent with what the board’s bootloader expects. In `pp_custom_bbb` the layout is the standard Nerves dual-rootfs design — MBR, FAT boot partition, rootfs A, rootfs B, an application data partition, and a U-Boot environment block that sits outside the partition table entirely:
				
					define(UBOOT_ENV_OFFSET, 2048)
define(UBOOT_ENV_COUNT, 256)

define(ROOTFS_A_PART_OFFSET, 63488)
define(ROOTFS_A_PART_COUNT, 286720)
define-eval(ROOTFS_B_PART_OFFSET, "${ROOTFS_A_PART_OFFSET} + ${ROOTFS_A_PART_COUNT}")
define(ROOTFS_B_PART_COUNT, ${ROOTFS_A_PART_COUNT})
				
			
Two things are worth noticing here. The environment block is outside any partition, so it survives a rootfs being overwritten entirely — which is exactly what makes it usable as durable state. And the two rootfs partitions are the same size, because either one has to be able to hold any firmware you ship.
 

1.2 A Provisioning Include Describes the Device

A provisioning file — in this project, `fwup_include/provisioning.conf` — describes runtime values that should be written into the U-Boot environment when firmware first lands on a device. It says nothing about the image layout.
It gets pulled into the factory install path from inside fwup.conf‘s task complete block:
				
					include("${NERVES_PROVISIONING}")
				
			
and contains per-device or per-board-variant settings:
				
					uboot_setenv(uboot-env, "nerves_serial_number", "\${NERVES_SERIAL_NUMBER}")

uboot_setenv(uboot-env, "enable_uboot_overlays", "1")
uboot_setenv(uboot-env, "enable_uboot_cape_universal", "1")
uboot_setenv(uboot-env, "uboot_overlay_addr4", "/lib/firmware/BB-LCD-ILI9488-ADS7846.dtbo")
				
			

2. The U-Boot Environment as a Durable Config Store

 

On a Nerves device, the U-Boot environment does a lot more work than its name suggests. It’s the small, durable key-value store that both the bootloader and the running system rely on, holding:

 

  • which firmware partition is active (`nerves_fw_active`)
  • whether the running firmware has been validated (`nerves_fw_validated`)
  • per-partition firmware metadata (version, platform, architecture, UUID)
  • the device’s serial number
  • board-specific hardware configuration, like overlay selection

 

The Nerves-generated fwup.conf writes that metadata as part of installing an image:
				
					uboot_setenv(uboot-env, "nerves_fw_active", "a")
uboot_setenv(uboot-env, "nerves_fw_devpath", ${NERVES_FW_DEVPATH})
uboot_setenv(uboot-env, "a.nerves_fw_version", ${NERVES_FW_VERSION})
uboot_setenv(uboot-env, "a.nerves_fw_uuid", "\${FWUP_META_UUID}")
				
			
Note the a. prefix — metadata is namespaced per partition, so the device can know what’s installed in A and B independently. That’s what allows an informed decision about whether to roll forward or back, rather than a guess.

 

2.1 How the Running System Reads Any of This

Something the original setup makes easy to overlook: userspace needs to be told where the environment block is, because it isn’t discoverable. That’s what rootfs_overlay/etc/fw_env.config does — it gives the U-Boot userspace tools (fw_printenv / fw_setenv) the device path, offset, and size of the environment block.

 

This file must agree with UBOOT_ENV_OFFSET and UBOOT_ENV_COUNT in fwup.conf. If they drift apart — say someone adjusts the layout without updating the overlay — the symptom is confusing: the bootloader reads the environment correctly, but the running system reads garbage or nothing at all. It’s worth treating these two as a pair that changes together.

 

In Elixir, this is what backs Nerves.Runtime.KV, which is how your application reads firmware metadata at runtime:
				
					Nerves.Runtime.KV.get_active("nerves_fw_uuid")
Nerves.Runtime.KV.get("nerves_serial_number")
				
			

3. Device Identity: Don’t Bake It Into the Image

A general principle worth stating on its own, because it affects how you manufacture: **a firmware image should be identical across every unit you ship.** The moment a serial number or device-specific value is compiled into the image, you’ve turned one artifact into N artifacts, and reimaging a board means knowing which of the N it needs.

 

The Nerves pattern avoids this by writing identity into the environment at burn time instead:
				
					uboot_setenv(uboot-env, "nerves_serial_number", "\${NERVES_SERIAL_NUMBER}")

				
			
As the project’s own comment puts it: no serial numbers are stored in the `.fw` file, and a device with a blank value falls back to a built-in ID. The identity is attached to the hardware, not to a blob that might get reused across units.

The runtime side of this is `boardid`, which resolves an identity from a prioritized list of sources. In `pp_custom_bbb`:
				
					-b uboot_env -u nerves_serial_number
-b uboot_env -u serial_number
-b bbb -n 4
-b cpuinfo -n 4
-b macaddr -n 4
				
			
The U-Boot environment is checked first, then board EEPROM, CPU info, and MAC address as fallbacks. The fallback chain matters in practice: it means a board that was never formally provisioned — a dev unit, a returned board, an RMA replacement — still produces a stable identity rather than failing outright.
 

4. Overlay Selection as Boot-Time Configuration

This is especially relevant on BeagleBone-class boards, where the same root filesystem can legitimately run on several hardware variants. Rather than shipping a different image per variant, the environment selects the hardware profile:
				
					uboot_setenv(uboot-env, "enable_uboot_overlays", "1")
uboot_setenv(uboot-env, "enable_uboot_cape_universal", "1")
uboot_setenv(uboot-env, "uboot_overlay_addr4", "/lib/firmware/BB-LCD-ILI9488-ADS7846.dtbo")
				
			
The consequence is worth spelling out: on a Nerves device, some of the hardware configuration lives in the bootloader environment rather than in a static device tree blob baked into the image. One firmware image, multiple board configurations, selected at provisioning time.

 

The caveat is that this mechanism only works if the overlay actually matches how the board is wired. An overlay that conflicts with the underlying hardware — or with another overlay — produces a device that boots into a broken configuration rather than failing visibly. Choosing the right overlay in the environment isn’t an optimization; it’s a correctness requirement.
 

5. How fwup Updates Safely

5.1 The A/B Partition Model

The dual-rootfs layout exists so that one partition can be written while the other stays bootable. The flow:
  1. The device is running from partition A.
  2. An upgrade writes the new payload into partition B — A is untouched throughout.
  3. Metadata for B is updated (version, UUID, platform).
  4. The active pointer flips to B.
  5. The device reboots into B and validates.
  6. If validation never happens, the device falls back to A.

 

The safety comes from the guard clauses, not from the ordering alone:
				
					require-uboot-variable(uboot-env, "nerves_fw_active", "b")
require-uboot-variable(uboot-env, "nerves_fw_validated", "1")
				
			

5.2 A Naming Detail That Trips People Up

task upgrade.a does not mean “upgrade from partition A.” It means “write the new image into partition A” — which is only valid when you’re currently running from B. That’s why the task requires `nerves_fw_active` to be `b`.

 

In practice you rarely name the task explicitly. fwup does prefix matching on task names and selects the first task whose `require-*` conditions are satisfied, so the normal invocation is just:
				
					fwup --apply --task upgrade --device /dev/mmcblk0 firmware.fw
				
			
and fwup figures out whether that means upgrade.a or upgrade.b based on the device’s current state. Hardcoding upgrade.a in a deploy script is a good way to produce an update that works on half your fleet and errors out on the other half.

 

5.3 What “Validated” Actually Means

This is the part of the model that’s easiest to configure incorrectly, because nothing complains if you get it wrong until an update fails in the field.
 
Flipping the active pointer to a new partition isn’t a commitment — it’s a trial. The new firmware is expected to declare itself healthy after it boots, by setting `nerves_fw_validated`. In Elixir that’s:
				
					Nerves.Runtime.validate_firmware()
				
			
If that never happens — the app crash-loops, the network never comes up, a required device never enumerates — the device should end up back on the previous partition rather than sitting in a broken state forever.

 

Two things make that automatic rather than aspirational:
  • nerves_heart feeds the hardware watchdog from inside the running system. If the VM wedges or the system never reaches a healthy state, the watchdog resets the board rather than leaving it hung.
  • Bootloader-side revert logic (or nerves_runtime‘s revert behavior, depending on the system) uses the validated flag to decide whether an unvalidated partition should be trusted on the next boot.
  • If your application never calls validate_firmware/0, you’ve built the A/B machinery without the mechanism that makes it useful. Conversely, calling it too early — immediately at application start, before anything has actually been verified working — means you’ll happily validate firmware that’s about to fail. The useful place is after your device has demonstrated it can do its job: network is up, hardware enumerated, whatever “working” means for your product.

 

6. Firmware Signing

Worth covering because it’s easy to postpone and painful to retrofit once devices are deployed.

 

fwup supports cryptographically signing firmware archives and verifying them before they’re applied. You generate a key pair, sign your .fw at build time, and ship the public key in the device’s firmware so the device refuses anything not signed by the matching private key:
				
					fwup-genkeys                                   # generates fwup-key.pub / fwup-key.priv
fwup --sign --private-key <key> -i in.fw -o signed.fw
fwup --public-key <pubkey> --apply --task upgrade -d /dev/mmcblk0 signed.fw
				
			
In a Nerves project this is wired in via NERVES_FW_PRIV_KEY at build time and the public key in the system configuration.

 

The reason to do this before deployment rather than after: a device that already trusts unsigned firmware has to receive a signed-firmware-capable update through the unsigned path first. That’s possible, but it’s a fleet-wide migration you’d rather not run. If there’s any chance your update path will ever traverse a network you don’t fully control, set signing up at the start.
 

7. The Factory First-Boot Flow

task complete is the initial-deployment path for a blank board. It’s the only point where a device gets genuinely device-specific state:
				
					mbr_write(mbr)
fat_mkfs(${BOOT_PART_OFFSET}, ${BOOT_PART_COUNT})
raw_write(${UBOOT_ENV_OFFSET})
include("${NERVES_PROVISIONING}")
				
			
That writes the bootloader and kernel, the root filesystem, the environment area, and the provisioning values — serial number, overlay selection, and anything else per-device. Which is why a blank board comes up in the right hardware configuration with no one editing a config file on the target: the configuration was written at install time, deliberately.

The practical form of this when provisioning a batch:
				
					sudo NERVES_SERIAL_NUMBER=12345678 fwup path/to/firmware.fw
				
			
Same image, different environment values, one unit at a time.
 

8. Revert: The Operational Safety Net

fwup-revert.conf is the companion to normal updates — meant for rollback and recovery, not for routine upgrades. Its tasks verify the current state before pointing the board back at the known-good image:
				
					task revert.a {
    require-uboot-variable(uboot-env, "nerves_fw_active", "b")
    require-uboot-variable(uboot-env, "a.nerves_fw_platform", "${NERVES_FW_PLATFORM}")

    on-init {
        uboot_setenv(uboot-env, "nerves_fw_active", "a")
        uboot_setenv(uboot-env, "nerves_fw_validated", "1")
    }
}
				
			
Note the platform check — it’s not enough that partition A holds something; it has to hold something built for this board. Reverting onto a stale image from a different platform would be worse than staying put.

Invoked directly:
				
					fwup -t revert.a -f fwup-revert.conf -d /dev/mmcblk0
				
			
The value of this over reflashing: the previous image is still sitting there intact, so recovery is a pointer flip rather than a full re-provisioning cycle — which also means it doesn’t wipe the application data partition.
 

9. Getting Updates Onto Devices

The mechanics above describe what happens on the device. How the `.fw` arrives is a separate decision, and the options scale roughly with fleet size:
  • mix burn — writes directly to an SD card or eMMC over a card reader. Development only.
  • mix upload — pushes firmware to a device over SSH on your local network and applies the upgrade task remotely. Good for a handful of devices you can reach directly.
  • NervesHub — an OTA update server that handles firmware distribution, device grouping, staged rollouts, and update status reporting across a fleet. This is what you want once “how many devices got the update?” becomes a question you can’t answer from memory.

All three ultimately run the same fwup tasks against the same image — the difference is how the archive gets there and how much visibility you have into the result.

9.1 Delta Updates

Worth knowing about if bandwidth matters: fwup supports delta firmware updates, shipping only the difference between a known source image and the new one rather than the full archive. For bandwidth-constrained deployments — cellular, metered links, large fleets — this can be a substantial reduction.
It’s not free complexity, though: it requires knowing which image a device is currently running, which in turn means the fleet-management side has to track firmware UUIDs accurately. Practical once you already have NervesHub or equivalent in place; not the first thing to reach for.
 

10. Common Pitfalls

  • Mixing provisioning into image layout: fwup.conf can’t hold runtime configuration. The layout is static and must match the hardware partition map; per-device board setup belongs in the provisioning environment.
  • Treating activation as automatic rather than metadata-driven: If nerves_fw_active or nerves_fw_validated aren’t maintained correctly, a board can boot the wrong image or reject a perfectly good update. The task guard clauses are cautious deliberately.
  • Never calling validate_firmware/0: The most consequential omission on this list, and an invisible one — everything works fine until the first update that would have needed to roll back.
  • fw_env.config drifting out of sync with fwup.conf: The bootloader and userspace disagree about where the environment lives, and the failure looks like corrupted or missing variables rather than a configuration error.
  • Hardcoding board identity into the image: Serial number, board variant, and overlay path belong in the environment slot, not compiled into a generic .fw.
  • Overlays without pin validation: An overlay that conflicts with the board’s actual wiring produces a device that boots into a broken configuration rather than failing loudly.
  • Deferring signing: Cheap to set up before deployment, awkward to retrofit onto a fleet already in the field.

 

11. A Rule of Thumb

The separation that keeps all of this manageable:
Layer What belongs there
fwup.conf
Image structure, boot data, partition management
Provisioning include
Per-device operational settings
U-Boot environment
Durable board state: serial number, overlay selection, boot metadata
rootfs / application
Runtime software state — never firmware identity

12. Takeaways

  1. fwup.conf defines where firmware lives on disk; the provisioning include defines what the board should be configured as. They’re not interchangeable.
  2. The U-Boot environment is a durable config store surviving both reboots and firmware upgrades — which is exactly why identity and hardware selection belong there rather than in the image.
  3. The A/B model makes updates safe, but only in combination with validation. Flipping the active partition is a trial, not a commitment, and something has to declare the trial successful.
  4. Revert is the operational safety net: a pointer flip back to an image that’s still intact, not a re-provisioning cycle.
  5. Keep the firmware image identical across every unit. Device-specific values go in the environment at burn time.
  6. Set up signing before deployment, not after.

 

The real power of fwup isn’t copying files — it’s managing trusted state transitions between partitions and board configurations. That’s what makes Nerves updates repeatable, recoverable, and safe for devices you can’t physically reach.
Facebook
Twitter
LinkedIn
Email

Recent Posts

4 Responses

Leave a Reply

Your email address will not be published. Required fields are marked *

Table Header Table Header Table Header Table Header
Content
Content
Content
Content