How to Add BLE Over-the-Air DFU to a Zephyr Project with MCUboot

Adding wireless firmware updates to a Zephyr device using MCUboot and Bluetooth

Most embedded teams discover the same thing 6 months after shipping: the firmware bug you can’t reproduce in the lab is sitting on 4,000 devices in customers’ hands, and you have no way to fix it. BLE DFU is the difference between a recall and a Tuesday afternoon push.

The good news: pure upstream Zephyr has everything you need. MCUboot for the bootloader, the MCUmgr SMP server for transport, mcumgr CLI for the host side. The bad news: the docs are spread across 3 projects. Half the Kconfigs got renamed in the last year. The “official” sample skips the production hardening you need.

This walkthrough takes you from a stock Zephyr app to a signed, dual-slot, rollback-capable OTA update over BLE on the nRF52840 DK. Upstream Zephyr only, no NCS. Targets Zephyr LTS v3.7 with gotchas flagged for older trees.

Architecture: What You’re Building

  ┌─────────────────┐         BLE          ┌──────────────────┐
  │  mcumgr CLI     │◄────── SMP ────────► │  Zephyr App      │
  │  (host)         │                      │  + SMP server    │
  └─────────────────┘                      └──────────────────┘
                                                    ▲
                                                    │ boots
                                           ┌──────────────────┐
                                           │  MCUboot         │
                                           │  (verifies sig,  │
                                           │   picks slot)    │
                                           └──────────────────┘
                                            Slot 0 │ Slot 1

MCUboot owns the first few kilobytes of flash. On every boot it checks both slots, verifies the signature on the image marked pending, and either jumps to it or falls back to the last known-good image. Your app links the SMP server, which speaks the Simple Management Protocol over a BLE GATT service. The mcumgr CLI on your laptop talks to that service, streams chunks of the new image into the secondary slot, and asks MCUboot to swap on the next reset.

Two properties matter. Signatures ensure only your images run. Dual slots mean a bricked update reverts itself on the next reset.

Prerequisites

Skip ahead if you have these:

  • Zephyr SDK 0.16+ and a west workspace on v3.7 LTS
  • nRF52840 DK with J-Link
  • mcumgr CLI: go install github.com/apache/mynewt-mcumgr-cli/mcumgr@latest
  • imgtool (ships in bootloader/mcuboot/scripts/)

⚠️ Board name gotcha: Zephyr v3.7 uses the HW model v2 form, so it’s nrf52840dk/nrf52840, not the old nrf52840dk_nrf52840. If you’re on Zephyr 3.5 or earlier, use the underscore form.

Partition Layout and Signing Keys

Dual-slot isn’t optional. You need a primary slot (where the running image lives), a secondary slot (where the new image gets staged), and ideally a storage partition for settings that survive updates.

  Before update:        After upload:         After confirm:
  ┌──────────┐          ┌──────────┐          ┌──────────┐
  │ Slot 0   │ ACTIVE   │ Slot 0   │ ACTIVE   │ Slot 0   │ (old)
  │ v1.0     │          │ v1.0     │          │ v1.1     │ ACTIVE
  ├──────────┤          ├──────────┤          ├──────────┤
  │ Slot 1   │ EMPTY    │ Slot 1   │ PENDING  │ Slot 1   │ (swapped)
  │          │          │ v1.1     │          │ v1.0     │
  └──────────┘          └──────────┘          └──────────┘

For the nRF52840’s 1 MB internal flash, a sane layout via a board overlay:

&flash0 {
    partitions {
        compatible = "fixed-partitions";
        #address-cells = <1>;
        #size-cells = <1>;

        boot_partition: partition@0 {
            label = "mcuboot";
            reg = <0x000000000 0x0000C000>;
        };
        slot0_partition: partition@c000 {
            label = "image-0";
            reg = <0x0000C000 0x00072000>;
        };
        slot1_partition: partition@7e000 {
            label = "image-1";
            reg = <0x0007E000 0x00072000>;
        };
        storage_partition: partition@f0000 {
            label = "storage";
            reg = <0x000F0000 0x00010000>;
        };
    };
};

Generate a signing key:

python3 bootloader/mcuboot/scripts/imgtool.py keygen \
    -k mcuboot_priv.pem -t ecdsa-p256

⚠️ Watch out here. MCUboot ships with a built-in default key. If you build without specifying your own, you’re signing with a key that’s public on GitHub. Generate your own, commit only the public half, and store the private key in a real secrets manager (Vault, AWS Secrets Manager, whatever your team already runs).

Building and Flashing MCUboot

Point MCUboot at your key with a Kconfig overlay (mcuboot.conf):

CONFIG_BOOT_SIGNATURE_TYPE_ECDSA_P256=y
CONFIG_BOOT_SIGNATURE_KEY_FILE="/abs/path/to/mcuboot_priv.pem"
CONFIG_BOOT_SWAP_USING_MOVE=y

Build and flash:

west build -b nrf52840dk/nrf52840 bootloader/mcuboot/boot/zephyr \
    -- -DEXTRA_CONF_FILE=/abs/path/to/mcuboot.conf
west flash

On the serial console you should see MCUboot print its version, fail to find a valid image (expected: you haven’t flashed one yet), and halt.

Wiring Up the SMP Server Over Bluetooth

In your app’s prj.conf:

# MCUboot integration
CONFIG_BOOTLOADER_MCUBOOT=y
CONFIG_IMG_MANAGER=y
CONFIG_STREAM_FLASH=y
CONFIG_FLASH=y

# MCUmgr + SMP
CONFIG_MCUMGR=y
CONFIG_MCUMGR_TRANSPORT_BT=y
CONFIG_MCUMGR_GRP_IMG=y
CONFIG_MCUMGR_GRP_OS=y

# Bluetooth
CONFIG_BT=y
CONFIG_BT_PERIPHERAL=y
CONFIG_BT_DEVICE_NAME="zephyr_dfu"
CONFIG_BT_DEVICE_NAME_DYNAMIC=y
CONFIG_BT_DEVICE_APPEARANCE=833

# Throughput: default MTU is 23 bytes. You'll wait forever.
CONFIG_BT_L2CAP_TX_MTU=498
CONFIG_BT_BUF_ACL_RX_SIZE=502
CONFIG_BT_BUF_ACL_TX_SIZE=251
CONFIG_BT_CTLR_DATA_LENGTH_MAX=251

⚠️ Rename gotcha: Pre-Zephyr 3.3, these were CONFIG_MCUMGR_CMD_IMG_MGMT, CONFIG_MCUMGR_CMD_OS_MGMT, and CONFIG_MCUMGR_SMP_BT. If you copy-paste old samples onto a current tree, nothing breaks loudly. The features quietly fail to compile in. Check west build -t menuconfig if you suspect this.

In recent Zephyr, the SMP BT service auto-registers when CONFIG_MCUMGR_TRANSPORT_BT=y, so your app code only needs standard BLE advertising:

#include <zephyr/bluetooth/bluetooth.h>

static const struct bt_data ad[] = {
    BT_DATA_BYTES(BT_DATA_FLAGS, BT_LE_AD_GENERAL | BT_LE_AD_NO_BREDR),
    BT_DATA(BT_DATA_NAME_COMPLETE, "zephyr_dfu", 10),
};

void main(void)
{
    bt_enable(NULL);
    bt_le_adv_start(BT_LE_ADV_CONN_NAME, ad, ARRAY_SIZE(ad), NULL, 0);
}

That’s it on the device side. No image manager init, no SMP boilerplate. Build with your signing key:

west build -b nrf52840dk/nrf52840 app/ \
    -- -DCONFIG_MCUBOOT_SIGNATURE_KEY_FILE=\"/abs/path/to/mcuboot_priv.pem\"

You’ll find zephyr.signed.bin and zephyr.signed.hex in build/zephyr/. Flash the hex with west flash. MCUboot should verify it on next boot and jump in. The DK now advertises as zephyr_dfu.

Performing the OTA Update

Bump the version in your app, rebuild, and you’ve got a new zephyr.signed.bin. Push it:

# Define a connection profile (once)
mcumgr conn add ble type=ble connstring="peer_name=zephyr_dfu"

# Upload the new image to the secondary slot
mcumgr -c ble image upload build/zephyr/zephyr.signed.bin

# Verify it landed and grab its hash
mcumgr -c ble image list

image list shows both slots. The new one is pending: false, confirmed: false. Tell MCUboot to try it on next boot:

mcumgr -c ble image test <hash-from-image-list>
mcumgr -c ble reset

The device reboots. MCUboot verifies the signature, swaps slots, and runs the new image. The state machine:

  ┌──────────┐    upload    ┌──────────┐    test+reset    ┌──────────┐
  │ ACTIVE   │─────────────►│ PENDING  │─────────────────►│ RUNNING  │
  │ v1.0     │              │ v1.1     │                  │ v1.1     │
  └──────────┘              └──────────┘                  │ (unconf) │
       ▲                                                  └────┬─────┘
       │                                                       │
       │     reset without confirm (rollback)         confirm  │
       └───────────────────────────────────────────────────────┘
                                                               │
                                                          ┌────▼─────┐
                                                          │ ACTIVE   │
                                                          │ v1.1     │
                                                          └──────────┘

After confirming the new image is healthy (sensors reading, connectivity up, whatever your app needs), lock it in:

mcumgr -c ble image confirm

If you never confirm, the next reset rolls back to v1.0 automatically. A v1.1 that crashes before it can confirm gets undone by the watchdog reset, no human intervention needed. Skip the confirm step in production code and you have no rollback. That’s the most common shipping mistake.

Hardening Checklist Before You Ship

  • Replace the default signing key, and store the private half in a real secrets manager.
  • Keep CONFIG_MCUBOOT_BOOTLOADER_MODE_OVERWRITE_ONLY=n. Overwrite-only mode is smaller but kills rollback.
  • Encrypt and authenticate the BLE transport: CONFIG_MCUMGR_TRANSPORT_BT_AUTHEN=y plus bonding. Otherwise anyone in BLE range can attempt uploads (the signature still protects you, but DoS is cheap).
  • Wire boot_write_img_confirmed() into a health check after boot, not into main() unconditionally. A confirm that always runs is the same as no rollback.
  • Embed semver into every build via imgtool --version; image list will show it.
  • Enable APPROTECT on the nRF52840 before shipping. An attacker with SWD access skips every other defense.
  • Test the rollback path on real hardware. Force a crash in v1.1, confirm v1.0 comes back.

Going from 1 Device to 10,000

The day you ship, the next problems show up: how do you get the signed .bin to devices that only see the internet through a phone or a gateway? How do you stage rollouts to 5%, then 25%, then everyone? How do you know which devices took the update, which failed signature verification, and which are stuck unconfirmed?

That’s where a delivery layer earns its keep. Hubble sits on top of the same MCUboot/SMP foundation you just built and handles the fleet-side problems: BLE gateway routing, cohort targeting, staged rollouts, and update telemetry, without asking you to change the device firmware contract. If you’re shipping more than a few hundred units, building this layer yourself is a year of work you don’t need to do.

Picking Your Next Move

You’ve got signed dual-slot images flashing over BLE on upstream Zephyr with working rollback.

From here, two paths. If you’re a hobbyist or a one-off deployment, the Zephyr samples/subsys/mgmt/mcumgr/smp_svr directory has more transport examples (UART, UDP, shell) you can bolt on. If you’re heading toward fleet scale, look at the Hubble device integration guides and the Zephyr reference application on GitHub for how the same SMP foundation extends to managed OTA delivery.

Either way, the bootloader and signing story you set up today is the one you’ll keep.


Hubble Network handles BLE-based OTA delivery at fleet scale, no gateway infrastructure or custom mobile apps required. See how it works →