t4as

Firmware architecture · MCUboot · update safety · 2026

nRF52840 Radar Display: Firmware Updates That Can't Brick It

A round-screen desk display with a 24 GHz presence radar, running Zephyr on an nRF52840. I replaced the board's UF2 bootloader with MCUboot and built an update path that reverts a bad image on its own, a build step that checks the release configuration, and authenticated BLE updates. All of it was tested on hardware with deliberately broken images.

  • nRF52840
  • Zephyr / NCS 3.2.4
  • MCUboot
  • MCUmgr SMP
  • BLE
  • PSA Crypto
  • GC9A01
  • LD2410S
  • Python
from reset command to automatic revert of a deliberately bad image, measured over USB
≈ 90 s
for a new image to prove itself before the watchdog reverts it
20 s
BLE update authorisation checks passed on hardware
9 / 9
SRAM headroom with a full 240×240 RGB565 frame buffer
72.7 KiB
Radar display prototype showing the round screen in its enclosure

What it is

A small desk display: a 240×240 round GC9A01 panel, an HLK-LD2410S 24 GHz presence radar, a rotary encoder, and a Pro Micro nRF52840 running Zephyr (NCS 3.2.4). It shows presence and distance and telemetry sent from a PC. The part worth reading about is the firmware-update path. A device that sits on a desk with no debugger attached has to take updates over USB or Bluetooth, and a bad update must never turn it into a brick.

  • MCUboot instead of UF2. The board shipped with a drag-and-drop UF2 bootloader. I replaced it with MCUboot on a fixed 1 MiB map: signed images, swap-with-move, and a USB recovery mode entered with the reset pin.
  • Health-gated confirmation. A new image boots on trial. It becomes permanent only if the application proves it is healthy. Otherwise a watchdog resets it and MCUboot restores the previous image.
  • A release check in the build. A verification script checks the generated configuration and a boot log before a build can count as a release.
  • Authorised BLE updates. A challenge-response layer (HMAC-SHA256, PSA Crypto) decides who may upload, switch images or reset over the air, and refuses everyone else by default.

Replacing the bootloader

The UF2 bootloader is convenient on a bench and useless in the field: no signatures, no rollback, and updates need physical access. I laid out the whole 1 MiB of flash for MCUboot and provisioned it once over SWD. After that, every update goes over USB or BLE.

nRF52840 internal flash layout, to scale MCUboot 0x000000–0x010000, 64 KiB MCUboot Primary slot 0x010000–0x088000, 480 KiB Signed application: 226,331 B primary slot · 480 KiB signed app 226,331 B 259 KiB free Secondary slot 0x088000–0x100000, 480 KiB secondary slot · 480 KiB incoming update / previous image 0x0000000x0100000x0880000x100000 No unallocated flash: the map lives in pm_static.yml and is frozen once devices are provisioned.
Figure 1. Internal flash, to scale. The application header starts at 0x010000 and the code at 0x010200. The secondary slot is the same size as the primary, so the swap can move either image without extra space.

Provisioning. One merged HEX (MCUboot + slot 0) goes on over SWD with a script. SWD runs at 1 MHz, the speed that was reliable with this probe and wiring. The run on record erased, programmed 303,104 bytes and verified in 11 s. The device then enumerates as Radar Firmware v6 with an MCUmgr endpoint on USB CDC-ACM.

Recovery. A pulse on the reset pin starts MCUboot's own USB serial recovery (Radar Firmware Recovery). It also starts on its own if no valid application exists, so there is always a way back without a debugger.

Keys. Images are ECDSA-P256 signed. Only the public key is inside MCUboot. The key generation script refuses to overwrite an existing key, because a new key would lock out every provisioned device.

Three USB roles without conflicts. Logging moved to RTT so that the application's CDC-ACM port is used only for MCUmgr. MCUboot's recovery port is a separate USB identity. The display's SPI clock is on P0.20, and USB uses the nRF52840's dedicated D+/D− pins, so the screen and USB run at the same time.

Configuration layers with owners. Image management, USB SMP, BLE SMP and hardware workarounds each live in their own configuration fragment, with a documented merge order. A change to update transports can't silently change the radar's UART mode.


An update has to prove itself

MCUboot can revert an image, but only if the image never confirms itself. The obvious approach, confirming a few seconds after boot, would make a hung or half-working image permanent. So confirmation depends on evidence that the image can still be updated again.

  1. Upload a signed image to the secondary slot over USB or BLE, and mark it for test.
  2. Trial boot. MCUboot swaps it in. An 8 s hardware watchdog is armed in SYS_INIT, before Bluetooth starts, so a stalled BLE bring-up is also caught.
  3. Health gate. The radar, display and input threads run for 5 s, and BLE must be advertising the MCUmgr service, because an image that can't take the next update must not be kept. Only then does the image confirm itself.
  4. Or revert. If the image hasn't confirmed within 20 s, it stops feeding the watchdog. The reset hands control back to MCUboot, which restores the confirmed image.

The gate also has to stand up to the host. MCUmgr lets a client mark images as permanent or confirm them remotely. That would let a PC confirm a bad image before the device has judged it. I disabled confirmation of non-active slots and added a hook that refuses any image-state write while the running image is unconfirmed. Only the device's own health check can accept a test image.

To prove it, I built a signed test image that is identical except that it refuses to confirm. The release checks reject any build with that option enabled.

/* src/firmware_update.c (trimmed) */
static enum mgmt_cb_return image_state_write_guard(
        uint32_t event, ..., void *data, size_t data_size)
{
    const struct mgmt_evt_op_cmd_arg *command = data;

    if (command->group == MGMT_GROUP_ID_IMAGE &&
        command->id == IMAGE_MGMT_STATE_COMMAND_ID &&
        command->op == MGMT_OP_WRITE &&
        !boot_is_img_confirmed()) {
        /* Only the local BLE health gate may confirm
         * a running test image. */
        *rc = MGMT_ERR_EACCESSDENIED;
        return MGMT_CB_ERROR_RC;
    }
    return MGMT_CB_OK;
}

static void watchdog_feed(struct k_work *work)
{
    if (test_image && !atomic_get(&health_accepted) &&
        k_uptime_get() >= health_deadline_ms) {
        LOG_ERR("boot health deadline expired; "
                "watchdog will reset test image");
        return;                 /* stop feeding */
    }
    wdt_feed(watchdog, watchdog_channel);
    k_work_reschedule(&watchdog_feed_work, K_MSEC(2000));
}

Measured on hardware

Revert test timeline observed over USB MCUmgr polling 02040608095 s 0–16 s: old image, new one pending old, pending reset sent (3 s) 18–38 s: no USB response (swap, boot) swap + boot 40–65 s: bad test image running, refuses to confirm bad image on trial 67–89 s: watchdog reset, MCUboot revert watchdog → revert 91 s: previous image active and confirmed 91 s: old image back Observed through USB MCUmgr polling every 2 s (±2 s), with no debugger attached during the cycle.
Figure 2. The deliberately non-confirming image (6.0.0+1) was uploaded over USB (234 KB), marked for test and reset. It ran for about 25 s, which matches the 20 s deadline plus the 8 s watchdog, and MCUboot put 6.0.0 back as active and confirmed. USB, BLE advertising, radar and display all came back after the revert.

The build checks its own configuration

Kconfig makes it easy to lose a safety property without noticing. Another fragment overrides a symbol, and the update path quietly changes. The build therefore runs a verification script, verify_stage6.sh, on the generated configuration and fails if the release contract doesn't hold.

  • Required: MCUboot, USB and BLE MCUmgr transports with encrypted BLE permissions, the watchdog, the command hooks the health gate depends on, and MCUboot serial recovery on CDC-ACM with pin reset.
  • Forbidden: confirming non-active slots, the rollback-test option, and the bench authentication key. The release verifier rejects the proof-of-concept build.
  • Optional boot-log check: given an RTT capture, the script fails on any error, fault or stack overflow line. It requires lines showing USB up, BLE OTA ready, the watchdog armed, radar RX running, display init with errors=0 and renders with write_errors=0.

Hardware workarounds live in one file, and each entry records the failure signature and the root cause:

# config/stage6_hardware_compat.conf
# CDC-ACM mcumgr enables the global interrupt-driven
# UART API. Without these per-instance overrides,
# UART1 defaults to interrupt-driven and
# uart_callback_set() returns -ENOTSUP. Radar UART1
# must remain asynchronous.
CONFIG_UART_1_INTERRUPT_DRIVEN=n
CONFIG_UART_1_ASYNC=y

# The nRF UDC worker overflowed its 512-byte default
# while handling USB events (thread object 0x200033f8,
# MMFAR 0x20024b70 in drv_stack). A 1024-byte worker
# ran concurrently with BLE, radar, and 96+ display
# renders.
CONFIG_UDC_NRF_THREAD_STACK_SIZE=1024

The verifier requires both of these lines as well, so the workarounds can't be dropped by accident.


Who may update it over Bluetooth

Encryption alone doesn't solve BLE updates for a device with no screen and no buttons for pairing. With no I/O on the device, BLE pairing falls back to unauthenticated modes, and the Linux host I tested with refused the combination. So I added authorisation at the MCUmgr layer, under a stated threat model: few trusted users, a custom updater, keys delivered out of band.

  • Mutual challenge-response in a custom MCUmgr group (64). Device and client each prove knowledge of a 32-byte pre-shared key with HMAC-SHA256 over a fixed 100-byte transcript: version, 8-byte device ID, both 32-byte nonces, client ID, scope, and separate 19-byte labels for each direction. Nonces come from PSA Crypto, and proofs are compared in constant time.
  • Deny by default. Before authorisation, only the auth group and image list are allowed. Upload, erase, image-state writes and reset are refused, and so is any unknown group or command. The gates hook MCUmgr's own command, chunk and reset callbacks, which I checked against the installed NCS 3.2.4 source.
  • One connection, one purpose. Authorisation is scoped to the BLE connection. A disconnect wipes the session, and an image-state write or reset consumes it. USB MCUmgr is off in this profile, so a second transport can't reuse the session.
  • Independent layers. The PSK never replaces MCUboot's signature check, and authorisation never confirms an image. The health gate still decides.
  • Key hygiene. The key goes into a generated C file inside an ignored build tree, never into Kconfig, command lines or logs. The build runs with umask 077 because the key ends up in the objects and the firmware.
# bench self-test, BLE, real device
PASS no-auth image list: 2 image entries
PASS no-auth upload: access denied
PASS no-auth image-state write: access denied
PASS no-auth erase: access denied
PASS no-auth reset: access denied
PASS wrong-key proof: access denied
PASS replayed proof after new challenge: access denied
PASS correct-key mutual authentication
PASS privileged command after disconnect/reconnect: access denied

End to end over BLE: a good 6.0.1 image uploaded (hash checked), booted and confirmed itself. Then a deliberately bad 6.0.2 went up the same way, booted, refused to confirm, and the watchdog and MCUboot restored 6.0.1. The Python client uses Bleak and CBOR. It checks the image hash before selecting it, and re-authenticates after each operation that consumes the session. Five unit tests cover the transcript, the HMAC and the image parser.


The rest of the firmware

Staged bring-up. Separate build profiles for GPIO, the radar UART, display SPI, BLE and finally MCUmgr. Each interface was proven on the board before the next was added, so a fault always had a small search space.

Three threads, one direction of data. The radar UART uses Zephyr's async API. The callback fills a ring buffer, and one radar thread owns parsing. It synchronises on the LD2410S five-byte report (6E state dist_lo dist_hi 62), checks the terminator and publishes a read-only snapshot. The display and the BLE debug service only read that snapshot, so they never become a second consumer of the UART. A 43-minute run logged 18,000+ frames with zero overflows.

Own renderer. The GC9A01 is driven write-only over SPI at 8 MHz from an RGB565 frame buffer, with my own font, arcs, status rings and page indicators. A full render takes about 215 ms. Page changes redraw everything, and animations redraw dirty regions only. PC telemetry arrives as newline-delimited JSON through a 512-byte GATT write buffer, and is validated completely before any UI state changes.

Measured buildValue
Signed application226,331 B
Primary slot capacity491,520 B
Free in slot259 KiB
SRAM used187,743 B
SRAM headroom72.7 KiB
USB SMP upload~3.1 KB/s
Full-screen render~215 ms

The frame buffer (115,200 B) is most of the RAM. It keeps the renderer free of a graphics framework, and it still leaves headroom.

Completed radar display prototype with covers fitted
The assembled prototype.
Radar display prototype electronics visible without covers: nRF52840 board, radar module and wiring
Electronics before the covers go on.

A display fault that wasn't firmware

During bring-up the panel showed intermittent corruption that looked like an SPI timing bug. Some boots were clean, which pointed at firmware timing, so I checked the driver side first: SPI write status, and the GPIO and SPIM registers over J-Link. The noise came from a cheap BMS board on the supply, and it was there even when running from USB. The rule I took from it: when a write-only peripheral misbehaves intermittently, check the supply before the driver.

Corruption caused by supply noise from the BMS board.
The working prototype.