Zephyr DFU and MCUboot: Over-the-Air Updates with Rollback Protection

Configuring MCUboot and DFU for secure over-the-air firmware updates on Zephyr RTOS devices

Most firmware engineers get the OTA upload working on the first or second try. The image transfers, the device reboots, the new firmware runs. Then a week later, a buggy build ships over the air, the device crashes in a loop, and there’s no way to recover it without physical access. The upload was never the hard part. The rollback was.

The real engineering challenge is making sure a bad update can’t brick your fleet. That’s where MCUboot’s swap mode comes in, and where most Zephyr DFU tutorials stop short. They show you the upload, skip the confirm/revert lifecycle, and leave you with a pipeline that works in the lab but fails in production.

This walkthrough covers the full pipeline: MCUboot configuration with swap-based rollback protection, image signing, both BLE and HTTP transports, and the confirm/revert lifecycle that actually makes your OTA safe. We’re scoping this to software-based rollback (MCUboot confirm/revert), not hardware-backed anti-rollback with monotonic counters or eFuses.

How MCUboot Swap Mode Protects You

MCUboot splits your flash into two slots. Slot 0 (primary) holds the running firmware. Slot 1 (secondary) holds the staged update. A scratch area or swap-move region provides temporary storage during the swap.

Flash Memory Map (nRF52840, illustrative)
┌──────────────────────────┐ 0x00000
│       MCUboot            │
│     (Bootloader)         │
├──────────────────────────┤ 0x0C000
│    Slot 0 (Primary)      │
│   [Running Firmware]     │
├──────────────────────────┤ 0x7E000
│    Slot 1 (Secondary)    │
│   [Staged Update]        │
├──────────────────────────┤ 0xF0000
│     Scratch Area         │
├──────────────────────────┤ 0xF8000
│   Storage / NVS          │
└──────────────────────────┘ 0x100000

MCUboot supports 3 upgrade strategies: overwrite-only, swap-scratch, and swap-move. Use swap-move. Both swap modes give you rollback protection, but swap-move is more flash-efficient and doesn’t need a dedicated scratch partition.

Here’s the lifecycle:

  1. New firmware gets written to Slot 1 (via BLE or HTTP).
  2. A flag is set requesting an upgrade.
  3. On reboot, MCUboot sees the flag, swaps Slot 0 and Slot 1.
  4. The new image boots in an unconfirmed (“test”) state.
  5. The application validates itself and calls boot_write_img_confirmed().
  6. The image becomes permanent.

If step 5 never happens (the app crashes, hangs, or fails its self-test) the next reboot triggers MCUboot to automatically swap back to the previous image. That’s the entire rollback mechanism, but only if you wire it up correctly.

┌─────────────┐    upload    ┌──────────────────┐
│  Slot 0:    │ ──────────►  │  Slot 1:         │
│  FW v1.0    │  (BLE/HTTP)  │  FW v1.1 staged  │
└─────────────┘              └────────┬─────────┘
                                      │ reboot
                                      ▼
                             ┌──────────────────┐
                             │  MCUboot swaps    │
                             │  Slot 0 ↔ Slot 1 │
                             └────────┬─────────┘
                                      │
                                      ▼
                    ┌─────────────────────────────────┐
                    │  FW v1.1 boots (UNCONFIRMED)    │
                    │  Self-test / validation runs    │
                    └───────────┬───────────┬─────────┘
                         pass ──┘           └── fail/crash
                                │                    │
                                ▼                    ▼
                    ┌───────────────┐    ┌──────────────────┐
                    │ Confirm image │    │ Reboot → MCUboot │
                    │ (permanent)   │    │ auto-reverts to  │
                    └───────────────┘    │ FW v1.0          │
                                         └──────────────────┘

Project Configuration: MCUboot and Signing

Your application’s prj.conf needs these Kconfig symbols:

CONFIG_BOOTLOADER_MCUBOOT=y
CONFIG_MCUBOOT_IMG_MANAGER=y
CONFIG_IMG_MANAGER=y
CONFIG_FLASH=y
CONFIG_FLASH_MAP=y
CONFIG_STREAM_FLASH=y

When CONFIG_BOOTLOADER_MCUBOOT=y is set, west build with sysbuild automatically builds MCUboot as a child image. You don’t need to build the bootloader separately.

For signing, point to your key:

CONFIG_MCUBOOT_SIGNATURE_KEY_FILE="path/to/your-signing-key.pem"

Generate a project-specific key pair with imgtool:

imgtool keygen -k my-signing-key.pem -t ecdsa-p256

The default MCUboot signing key (root-ec-p256.pem in the MCUboot repo) is public. Every developer in the world has it. If you ship with it, anyone can sign an image your devices will accept.

Sign your built image with west sign or imgtool directly:

west sign -t imgtool -- --key my-signing-key.pem --version 1.1.0

Or manually:

imgtool sign --key my-signing-key.pem --align 4 --version 1.1.0 \
  --header-size 0x200 --slot-size 0x72000 \
  build/zephyr/zephyr.bin signed-v1.1.0.bin

The --slot-size must match your partition table. Get it wrong and MCUboot silently rejects the image.

Transport Option A: BLE DFU via SMP

The SMP (Simple Management Protocol) is Zephyr’s standard for device management over BLE. It’s what the mcumgr tools speak.

Add these to your prj.conf:

CONFIG_MCUMGR=y
CONFIG_MCUMGR_TRANSPORT_BT=y
CONFIG_MCUMGR_GRP_IMG=y
CONFIG_MCUMGR_GRP_OS=y
CONFIG_BT=y
CONFIG_BT_PERIPHERAL=y
CONFIG_BT_SMP=y

In your application code, register the SMP BLE transport. As of Zephyr 3.5+, much of this is auto-initialized, but you still need BLE advertising active:

#include <zephyr/bluetooth/bluetooth.h>
#include <zephyr/mgmt/mcumgr/transport/smp_bt.h>

void main(void) {
    bt_enable(NULL);

    /* Start advertising so clients can discover the SMP service */
    const struct bt_data ad[] = {
        BT_DATA_BYTES(BT_DATA_FLAGS, (BT_LE_AD_GENERAL | BT_LE_AD_NO_BREDR)),
        BT_DATA_BYTES(BT_DATA_UUID128_ALL, 0x84, 0xaa, 0x60, 0x74,
                       0x52, 0x8a, 0x8b, 0x86, 0xd3, 0x4c,
                       0xb7, 0x1d, 0x1d, 0xdc, 0x53, 0x8d),
    };
    bt_le_adv_start(BT_LE_ADV_CONN, ad, ARRAY_SIZE(ad), NULL, 0);

    /* ... rest of your application ... */
}

The UUID in that advertising data is the SMP service UUID. Clients (like the nRF Connect mobile app or mcumgr CLI) scan for it.

On the client side, use mcumgr CLI:

# Upload the signed image
mcumgr --conntype ble --connstring ctlr_name=hci0,peer_name="MyDevice" \
  image upload signed-v1.1.0.bin

# List images in both slots
mcumgr --conntype ble --connstring ctlr_name=hci0,peer_name="MyDevice" \
  image list

# Mark the uploaded image for test (triggers swap on reboot)
mcumgr --conntype ble --connstring ctlr_name=hci0,peer_name="MyDevice" \
  image test <hash-from-image-list>

# Reset the device to trigger the swap
mcumgr --conntype ble --connstring ctlr_name=hci0,peer_name="MyDevice" \
  reset

Expect 1 to 5 KB/s throughput over BLE, depending on MTU and connection interval. A 200 KB image can take a few minutes. Negotiate the highest MTU your hardware supports (247 bytes is common on nRF52840) to speed things up.

If you’re building on Zephyr with BLE for a Hubble-connected device, the Zephyr RTOS quick-start guide covers the base BLE integration you’d layer this on top of.

Transport Option B: HTTP DFU over WiFi

You have two paths here.

Path 1: SMP over UDP. Same protocol, different wire. Add to prj.conf:

CONFIG_MCUMGR=y
CONFIG_MCUMGR_TRANSPORT_UDP=y
CONFIG_MCUMGR_TRANSPORT_UDP_IPV4=y
CONFIG_MCUMGR_TRANSPORT_UDP_PORT=1337
CONFIG_MCUMGR_GRP_IMG=y
CONFIG_MCUMGR_GRP_OS=y

Then use mcumgr over UDP:

mcumgr --conntype udp --connstring=[192.168.1.42]:1337 image upload signed-v1.1.0.bin

This is the simplest option if you already have SMP working over BLE and just want faster transfers. Same commands, same lifecycle.

Path 2: Custom HTTP endpoint. Your device runs an HTTP server, accepts the firmware binary via a POST request, and writes it to the secondary slot. This takes more work but integrates with existing cloud infrastructure (your own update server, AWS IoT Jobs, etc.).

The key API calls:

#include <zephyr/dfu/flash_img.h>
#include <zephyr/dfu/mcuboot.h>

static struct flash_img_context flash_ctx;

/* Call once when the upload starts */
flash_img_init(&flash_ctx);

/* Call for each chunk of the incoming firmware binary */
flash_img_buffered_write(&flash_ctx, chunk_data, chunk_len, false);

/* Call with flush=true on the last chunk */
flash_img_buffered_write(&flash_ctx, last_chunk, last_len, true);

/* Request the upgrade on next reboot */
boot_request_upgrade(BOOT_UPGRADE_TEST);

WiFi throughput is dramatically better than BLE. A 500 KB image that takes 3 minutes over BLE might transfer in under 2 seconds over WiFi.

Confirming the Image (This Is Where Most People Get It Wrong)

boot_write_img_confirmed() is a single function call. It’s also the most important line in your entire DFU pipeline.

#include <zephyr/dfu/mcuboot.h>

void main(void) {
    /* Don't confirm here. Not yet. */

    bool healthy = run_self_test();

    if (healthy) {
        boot_write_img_confirmed();
        printk("Image confirmed.\n");
    } else {
        printk("Self-test failed. Rebooting to revert.\n");
        sys_reboot(SYS_REBOOT_COLD);
    }
}

Where you put this call is everything. A basic self-test pattern:

bool run_self_test(void) {
    /* Can we reach our cloud backend? */
    if (!check_network_connectivity()) return false;

    /* Are critical peripherals responding? */
    if (!check_sensor_init()) return false;

    /* Is our config partition readable? */
    if (!check_nvs_health()) return false;

    return true;
}

If you confirm at the top of main(), you’ve defeated the entire purpose of rollback protection. A bad image that boots but crashes 10 seconds later is already permanent.

If you never confirm, or if you confirm too late and a watchdog fires first, every reboot reverts to the old image. Your update will never stick.

The sweet spot: confirm after your application has proven it can do its core job (connect to the network, read its sensors, whatever “healthy” means for your product). That’s usually 5 to 30 seconds after boot.

Testing the Full Pipeline

A step-by-step test procedure you should run before shipping:

  1. Flash your initial firmware (v1.0.0) along with MCUboot using west flash.
  2. Build and sign v1.1.0 with imgtool.
  3. Upload via BLE (mcumgr image upload) or HTTP.
  4. Reboot and confirm the device is running v1.1.0 (mcumgr image list).
  5. Check that the image is confirmed.
  6. Build a v1.2.0 that deliberately omits boot_write_img_confirmed(). Upload it, reboot, then reboot again. Verify the device reverts to v1.1.0.

Step 6 is the one people skip, and it’s the most important test you’ll run. If rollback doesn’t work in the lab, it won’t work in the field.

For CI, wire up a physical device (or QEMU target) and script mcumgr commands. The CLI is built for automation.

Common Pitfalls

  • Shipping with the default MCUboot signing key. It’s in every MCUboot repo on earth. Generate your own with imgtool keygen.
  • Missing CONFIG_MCUBOOT_IMG_MANAGER. Without it, boot_write_img_confirmed() won’t compile, and you’ll spend an hour wondering why.
  • Secondary slot too small. Your new image must fit in Slot 1. Check your board’s partition table (the .dts overlay) before you build; if the signed binary exceeds the slot size, MCUboot rejects it silently.
  • No image version set. Depending on MCUboot config, it may reject an upload with the same version number. Always bump the --version flag.
  • BLE MTU stuck at 23 bytes. This is the default, and it’s painfully slow. Negotiate a higher MTU (up to 247) or your 200 KB upload will take 10+ minutes.
  • Calling boot_write_img_confirmed() on line 1 of main(). Congratulations, you’ve made every image permanent, including the broken ones.

Connecting the Pieces Into a Production Pipeline

MCUboot handles the swap. imgtool handles signing. SMP handles transport. boot_write_img_confirmed() handles confirmation. They’re documented in 4 different places, and none of the docs connect them into a single workflow.

You now have the full path: configure MCUboot in swap mode, sign every image with your own key, push over BLE or HTTP using SMP, and confirm only after your firmware proves it’s healthy. That’s a production-grade OTA pipeline with automatic rollback protection.

From here, the natural next steps are adding image encryption (MCUboot supports it with an additional key), integrating with a device management platform like Golioth or Memfault, or tying the whole thing into your CI pipeline. The confirm/revert lifecycle is the same whether you’re updating 1 device on your bench or 10,000 in the field.


Hubble Network enables firmware updates to devices anywhere on Earth—without ground infrastructure or proximity constraints. See how it works →