Sec1.dk
← Back to Blog
Firmware/ Bootloader/ nRF52840/ UF2May 2026
Deep Dive — Development Log

UF2 Bootloader for ChameleonUltra

Replacing the signed-CDC DFU with drag-and-drop firmware updates — and the debugging story of getting it actually onto the device when Windows refuses to tell the truth, finishing with a self-bootstrap update path that doesn't need SWD.

Bootloader USB MSC GhostFAT SoftDevice S140 Drive Confirmed ✓ Bricking Window 1.4s

// Project Profile

Author
Niel Nielsen
Platform
ChameleonUltra (nRF52840, S140 7.2.0)
Old transport
Nordic Secure DFU over USB CDC
New transport
UF2 over USB Mass Storage (GhostFAT 4 MB)
Family ID
0x1B57745F (nRF52840)
BL size
~40 KB of 44 KB available
Recovery path
bl_updater — embedded BL + raw NVMC writes from the application

// Contents

  1. Why UF2 on a closed-bootloader device
  2. Memory map and the flash budget
  3. Registering a UF2 DFU transport
  4. GhostFAT in five kilobytes
  5. Sniffing UF2 blocks out of the write stream
  6. Debugging — when Windows lies and dmesg doesn't
  7. The descriptor bug and its downstream effects
  8. Locked out — no CDC to push the fix
  9. bl_updater — embedding a bootloader in the app
  10. The SoftDevice teardown and raw NVMC dance
  11. Bricking analysis — why this is harder to brick than it looks

01Why UF2 on a closed-bootloader device

The Chameleon Ultra ships with Nordic's Secure DFU bootloader: a signed-zip update path over USB CDC, driven by nrfutil pkg generate and a custom flash-dfu-full.sh wrapper. It works, but every iteration of firmware development means a build, a sign, a zip, a CDC connection, an enter-DFU command, a transfer, a verify, a reboot. Each step is its own potential failure mode and silent rejections of the bootloader image are a known pain point.

UF2 — Microsoft's microcontroller firmware update format used by Adafruit, Microchip, and most BBC micro:bit / Raspberry Pi Pico projects — does the same job with a USB mass-storage drive and a drag-and-drop. The host sees a removable disk labeled CHAMELEON. The user copies a .uf2 file onto it. The firmware updates. No driver install, no nrfutil, no signing infrastructure required for development builds.

The drive isn't a real filesystem: it's a synthesized one (commonly called GhostFAT) that pretends to be FAT12 to the host while sniffing every write looking for UF2-magic blocks and translating them straight to flash. From the kernel's perspective it's a vanilla USB Mass Storage device. From the bootloader's perspective every SCSI WRITE_10 is potential firmware data.

02Memory map and the flash budget

The Chameleon's flash layout is fixed by S140's footprint and the existing bootloader assignment. No room to grow either region without breaking compatibility with existing application binaries:

  0x00000 ┌─────────────────────────────────────────────┐  4 KB
            MBR (master boot record, untouchable)     
  0x01000 ├─────────────────────────────────────────────┤  152 KB
            SoftDevice S140 7.2.0                       
            (BLE stack, owns its region)                
  0x27000 ├─────────────────────────────────────────────┤  816 KB
            Application                                
            (NFC frontend, RFID firmware, CDC CLI)      
  0xF3000 ├─────────────────────────────────────────────┤  44 KB
            Bootloader  ← this is what I'm replacing 
  0xFE000 ├─────────────────────────────────────────────┤  4 KB
            MBR parameters                              
  0xFF000 ├─────────────────────────────────────────────┤  4 KB
            Bootloader settings (version, banks, CRCs) 
 0x100000 └─────────────────────────────────────────────┘

44 KB is tight. The stock CDC bootloader uses approximately 32 KB. Adding a USB MSC class plus a virtual filesystem on top of that puts you well over budget by default. Something had to be removed:

makefirmware/bootloader/Makefile (diff)
- $(SDK_ROOT)/components/libraries/bootloader/ble_dfu/nrf_dfu_ble.c          \
- $(SDK_ROOT)/components/libraries/bootloader/serial_dfu/nrf_dfu_serial.c    \
- $(SDK_ROOT)/components/libraries/bootloader/serial_dfu/nrf_dfu_serial_usb.c\
+ $(PROJECT_DIR)/src/uf2_blockdev.c   \
+ $(PROJECT_DIR)/src/uf2_ghostfat.c   \
+ $(PROJECT_DIR)/src/nrf_dfu_uf2.c    \

OPT += -flto

Dropping BLE DFU is harmless (nobody updates an nRF over Bluetooth when USB is available). Dropping CDC DFU is consequential — it means the new bootloader can no longer accept a signed .zip push over a serial port. UF2 replaces it, but it also means there's no fallback if something goes wrong with the new transport. That property will become important in section 08.

Architecture
The flash-budget tradeoff cuts the safety net

Once CDC DFU is gone from the bootloader, all future bootloader updates have to come through UF2 itself — which has a built-in bounds check that refuses writes to the bootloader region. The safety mechanism is the same one that locks you out during recovery. Plan for this.

03Registering a UF2 DFU transport

Nordic's bootloader has a clean abstraction for adding update transports. Each is a small struct with init and close function pointers, placed into a dedicated linker section .dfu_trans. At startup the framework iterates that section and runs each transport's init.

Cnrf_dfu_uf2.c
/* Transport registration — placed in .dfu_trans by macro */

DFU_TRANSPORT_REGISTER(const nrf_dfu_transport_t uf2_dfu_transport) = {
    .init_func    = uf2_transport_init,
    .close_func   = uf2_transport_close,
};

static uint32_t uf2_transport_init(nrf_dfu_observer_t observer)
{
    m_observer = observer;
    app_usbd_class_inst_t const *msc = app_usbd_msc_class_inst_get(&m_app_msc);
    app_usbd_class_append(msc);
    app_usbd_power_events_enable();
    return NRF_SUCCESS;
}

Inside the init function I bring up Nordic's USB device power manager, register the SDK's app_usbd_msc class, and start enumeration. The MSC class drives whatever block device I provide — in this case uf2_blockdev, which exposes a 4 MB removable disk backed by GhostFAT.

The __attribute__((used)) annotations and non-static qualifiers matter here: LTO will happily eliminate transport registration entries if it can't see them being called from anywhere. The DFU_TRANSPORT_REGISTER macro is the linker section trick, but LTO needs an explicit signal that the symbol is reachable through mechanisms it can't see.

04GhostFAT in five kilobytes

A FAT12 disk needs surprisingly little structure to convince a host. The bootloader's GhostFAT lays out a virtual 4 MB volume:

SectorContentsNotes
0 BIOS Parameter Block Standard FAT12 BPB with cluster size, FAT size, root entries. "MSDOS5.0" OEM string Windows sniffs for.
1 – 24 FAT (24 sectors) Mostly zero. Reserved entries 0 and 1 hold canonical magic values.
25 – 48 Second FAT copy FAT12 spec requires two FATs. Modern hosts never read it but some get cranky if absent.
49 Root directory One entry: volume label CHAMELEON. No INFO_UF2.TXT — flash budget.
50+ Data area Reads return 0xFF (erased flash). Writes are inspected.

Most UF2 implementations include INFO_UF2.TXT and INDEX.HTM files in the root directory so the user gets a bit of feedback when they open the drive. I dropped them. Every byte of root-directory payload costs flash the bootloader doesn't have.

05Sniffing UF2 blocks out of the write stream

A UF2 file is a stream of 512-byte blocks, each with a tiny header containing two magic constants (0x0A324655 and 0x9E5D5157) sandwiching a 256-byte payload and a target flash address. The host writes the file linearly to the fake disk; I ignore the file's notional offset and just inspect each 512-byte sector:

Cuf2_ghostfat.c — write handler
/* Called for every SCSI WRITE_10 sector */

if (b->magic_start_0 == UF2_MAGIC_0 &&
    b->magic_start_1 == UF2_MAGIC_1 &&
    b->magic_end     == UF2_MAGIC_END)
{
    if ((b->flags & UF2_FLAG_FAMILYID) &&
        b->reserved == UF2_FAMILY_ID_NRF52840)
    {
        /* Bounds: app region only. The bootloader region is OFF-LIMITS */
        if (b->target_addr >= UF2_APP_START &&
            b->target_addr + b->payload_size <= UF2_APP_END)
        {
            uf2_flash_write(b->target_addr, b->data, b->payload_size);
        }
    }
    if (b->block_no + 1 == b->num_blocks) {
        uf2_dfu_complete();   /* mark app valid, reset */
    }
}

That bounds check is the safety belt. The UF2 path can write anywhere in the application region but cannot touch the bootloader region. Without this constraint, a malicious or buggy UF2 could overwrite the running bootloader itself. With it, the system is fundamentally unbrickable from userspace — at the cost of preventing exactly the kind of recovery operation I'd need later.

06Debugging — when Windows lies and dmesg doesn't

Build complete. Bootloader linked at 0xF3000 exactly. Symbol table confirms uf2_dfu_transport and app_usbd_msc_class_methods are present, and usb_dfu_transport / m_payload_pool (the SDK's CDC transport symbols) are absent. The build is verifiably CDC-free.

Pushed via the existing flash-dfu-full.sh. Asked the bootloader to enter DFU mode. Ran pnputil on Windows. Saw — somehow — a CDC serial port.

PowerShell · Windows host
> pnputil /enum-devices /connected | findstr /i "VID_1915"
USB\VID_1915&PID_521F\CCB5DB7D5207           USB Composite Device
USB\VID_1915&PID_521F&MI_00\6&22f1d828&...   USB Serial Device (COM15)
                                             Service: usbser

A CDC serial port on a bootloader that does not contain CDC code. That isn't unexpected — that's impossible. I spent several hours believing it anyway, working through a wrong hypothesis: the bootloader update must have silently failed, the old CDC-equipped bootloader must still be on the device. Bumped the version. Rebuilt. Reflashed. Same result. Bumped again. Same result.

Lesson
Build artifacts are not ground truth

nm tells you what's in your binary. It does not tell you what's on the chip. Until you've independently verified the chip's contents — by reading flash via SWD, or by observing physical-layer behaviour that only the new code can produce — you are debugging a phantom. This single oversight cost most of an afternoon.

Eventually gave up and unplugged into Linux. sudo dmesg -w, hold B, plug. The kernel had a completely different story:

dmesg · Kali
[3509.870] usb 1-6: new full-speed USB device number 41 using xhci_hcd
[3510.020] usb 1-6: config 1 has an invalid interface number: 2 but max is 0
[3510.020] usb 1-6: config 1 has no interface number 0
[3510.021] usb 1-6: idVendor=1915, idProduct=521f, bcdDevice= 1.00
[3510.021] usb 1-6: Product: ChameleonUltra: hw_v1, fw_v256
[3510.023] usb-storage 1-6:1.2: USB Mass Storage device detected
[3510.024] scsi host0: usb-storage 1-6:1.2
[3511.096] scsi 0:0:0:0: Direct-Access RRG ChameleonUltra 1.0
[3511.098] sd 0:0:0:0: [sda] 8192 512-byte logical blocks: (4.19 MB)
[3511.539] sd 0:0:0:0: Sense Key : Hardware Error [current]

Three things at once. First, my bootloader was on the device — Linux saw the exact RRG / ChameleonUltra / 1.0 SCSI inquiry strings I'd compiled in. Second, MSC was enumerating — usb-storage attached and /dev/sda appeared with the right geometry. Third, something was wrong with the USB descriptor, and reads were failing with Hardware Error.

Why was Windows showing CDC then? Because Windows caches PnP records under (VID, PID, DeviceSerialNumber) tuples, and the ChameleonUltra's serial is derived from FICR — same value across bootloader and application. The first time I'd ever plugged this device in with a CDC-equipped bootloader, Windows captured a descriptor record under that serial. Every subsequent enumeration, Windows preferred its cached record over re-querying the device. The composite/usbser entries weren't physical reality. They were what Windows remembered the device used to be.

Architecture
Windows PnP cache is keyed on USB serial

Two device personas sharing the same FICR-derived serial number will collide in Windows' PnP database. Workaround: use distinct PIDs for the bootloader and the application, so Windows treats them as fundamentally separate devices. On Linux this isn't an issue — the kernel re-reads descriptors honestly on every replug.

07The descriptor bug and its downstream effects

The dmesg told me exactly what was wrong: "config 1 has an invalid interface number: 2 but max is 0". USB Configuration descriptors declare bNumInterfaces, and individual Interface descriptors number themselves with bInterfaceNumber. They must be contiguous, starting from zero.

When I'd removed the CDC interfaces (which had occupied 0 and 1), I'd left my MSC interface declaring itself as #2. The configuration now declared one interface, but that one interface called itself interface number 2. Linux tolerated this enough to bind usb-storage to it, degraded the bulk endpoint framing as a side effect, and that's why SCSI was failing on commands more complex than INQUIRY.

Bug
USB interface numbers must be contiguous starting at zero

Dropping a class from a composite device requires renumbering the remaining interfaces. bNumInterfaces is computed by the USBD class layer from the registered count, but the individual bInterfaceNumber is whatever the class instantiation specifies. Mismatched values pass nrf SDK build checks but break enumeration in subtle ways.

The fix was a one-line change to nrf_dfu_uf2.c:

Cnrf_dfu_uf2.c (diff)
- #define MSC_INTERFACE  2
- #define MSC_EPIN       NRF_DRV_USBD_EPIN3
- #define MSC_EPOUT      NRF_DRV_USBD_EPOUT2
+ #define MSC_INTERFACE  0
+ #define MSC_EPIN       NRF_DRV_USBD_EPIN1
+ #define MSC_EPOUT      NRF_DRV_USBD_EPOUT1

Endpoint reassignment was conservative cleanup — when CDC was present I'd placed MSC on EP3/EP2 to avoid collision. With CDC gone, the conventional MSC-on-EP1 layout matches every reference example in Nordic's SDK and keeps the bulk endpoint configuration simple.

08Locked out — no CDC to push the fix

Trivial fix. Rebuild. Push. Except — the push failed:

flash-dfu-full.sh output
Flashing ultra
[00:00:17]       0% [2/2 CCB5DB7D5207] Failed, Internal sdfu error:
Slip decoder error: ReadError(Custom { kind: TimedOut })
Error: One or more program tasks failed

The [2/2] is nrfutil's image counter. A full DFU package contains two images: the combined bootloader+softdevice image (always image 1, because BL and SD must update atomically) and the application (image 2). The error says it timed out at 0% of image 2 — meaning image 1 already transferred, the device rebooted, and then nrfutil tried to push image 2 over CDC and got no response.

Image 1 had landed: my new bootloader was now on the device. That new bootloader has no CDC. So image 2 had no transport to push over, and nrfutil sat there waiting for SLIP frames that would never come.

Field note
Hold B during the inter-stage reboot

A second non-obvious operational detail: when pushing a full zip with both images, the device reboots between stages. Image 1 (BL+SD) transfers, the device resets to apply it, and then nrfutil tries to push image 2 (app) into whatever comes up next. If B isn't held during that reboot, the device boots into normal application mode and the application's CDC trigger interface isn't the one nrfutil expects — image 2 fails. Hold B from the moment image 1 hits 100% until you see image 2 actually start transferring. Easy to miss because the timing is a single device cycle deep into the push.

⚠ Critical
The recovery path I'd been relying on no longer exists

The Chameleon's update flow is: app boots, app accepts CDC commands, "enter DFU" reboots into bootloader, bootloader accepts CDC packages. With my new bootloader having no CDC, step three is broken. And my own UF2 transport refuses writes to the bootloader region. I'd locked myself out of updating the only piece of software I needed to update.

The only working channel left was the application's USB CDC — which uses a custom command protocol independent of the bootloader. The application still works fine. So if I could put code into the application that wrote to the bootloader region from inside the app's execution context, I could orchestrate a one-shot bootloader update through that channel.

09bl_updater — embedding a bootloader in the app

The plan: bundle a freshly-built bootloader binary into the application binary as a const array in .rodata. Add a new CLI command that, when invoked, validates the embedded blob's CRC, disables the SoftDevice (raw NVMC access to BL pages requires SoftDevice to be offline), erases the BL region, writes the embedded bytes, and resets.

A small Python script parses the bootloader's Intel HEX file, extracts only the bytes whose addresses fall inside [0xF3000, 0xFE000), and emits a C header. The first ihex extraction attempt got the address range wrong because the BL hex uses type 02 (Extended Segment Address) records, not just type 04 (Extended Linear Address) — both must be handled.

Cembedded_bootloader.h (auto-generated)
#define EMBEDDED_BOOTLOADER_BIN_SIZE   39948u
#define EMBEDDED_BOOTLOADER_BIN_CRC32  0x4826BA0Cu

static const uint8_t EMBEDDED_BOOTLOADER_BIN[EMBEDDED_BOOTLOADER_BIN_SIZE]
    __attribute__((aligned(4))) = {
    0x00, 0x80, 0x03, 0x20,   /* initial stack pointer  0x20038000 */
    0x81, 0x50, 0x0f, 0x00,   /* Reset_Handler          0x000F5081 */
    0xa9, 0x50, 0x0f, 0x00,   /* NMI_Handler            0x000F50A9 */
    /* ... 9985 more 4-byte words ... */
};

A sanity check at the top: the first word is the initial stack pointer (top of RAM), the second is the reset handler (thumb-mode bit set, pointing into the BL region). That's the shape of a healthy ARM Cortex-M image. Anything else means the extractor got the wrong region or the binary is corrupt.

CRC32 is computed at generation time using zlib's standard polynomial, and re-computed at runtime by the updater before touching any flash:

Cbl_updater.c — pre-flight validation
bl_updater_status_t bl_updater_validate(void)
{
    if (EMBEDDED_BOOTLOADER_BIN_SIZE == 0u)
        return BL_UPDATER_ERR_EMPTY;

    if (EMBEDDED_BOOTLOADER_BIN_SIZE > BL_REGION_BYTES)
        return BL_UPDATER_ERR_TOO_LARGE;

    if (crc32_compute(EMBEDDED_BOOTLOADER_BIN,
                      EMBEDDED_BOOTLOADER_BIN_SIZE)
        != EMBEDDED_BOOTLOADER_BIN_CRC32)
        return BL_UPDATER_ERR_CRC;

    return BL_UPDATER_OK;
}

A corrupted blob fails validation, the command returns an error, the bootloader region is untouched. The destructive operation is gated behind this check.

10The SoftDevice teardown and raw NVMC dance

Raw NVMC access to flash regions outside the application's own assignment is forbidden while the SoftDevice is running. SoftDevice maintains an internal model of which pages it owns and which it cooperatively shares; writes outside the sharing contract cause a HardFault. nrf_sdh_disable_request() is asynchronous — it tells the SDH manager to start tearing down, and SoftDevice observers (USB, BLE, power management) get a chance to clean up before SD actually disables.

Cbl_updater.c — main update path
bl_updater_status_t bl_updater_run(void)
{
    bl_updater_status_t st = bl_updater_validate();
    if (st != BL_UPDATER_OK) return st;

    /* Take SoftDevice offline (async — wait until actually down) */
    if (nrf_sdh_is_enabled()) {
        if (nrf_sdh_disable_request() != NRF_SUCCESS)
            return BL_UPDATER_ERR_SD_DISABLE;
        while (nrf_sdh_is_enabled()) { /* spin */ }
    }

    __disable_irq();

    /* Erase 11 pages of bootloader region */
    for (uint32_t i = 0; i < BL_REGION_PAGES; i++) {
        nvmc_page_erase(BL_REGION_START + i * BL_PAGE_SIZE);
    }

    /* Write embedded bootloader, word at a time */
    nvmc_write_bytes(BL_REGION_START,
                     EMBEDDED_BOOTLOADER_BIN,
                     EMBEDDED_BOOTLOADER_BIN_SIZE);

    nrf_delay_ms(50);
    NVIC_SystemReset();   /* MBR jumps to new BL */
}

NVMC is driven inline rather than via the SDK helper because the application doesn't otherwise link nrf_nvmc.c — every other flash operation in the app goes through the SoftDevice. Two short functions handle erase and write:

Cbl_updater.c — raw NVMC
static void nvmc_page_erase(uint32_t page_addr)
{
    nvmc_wait_ready();
    NRF_NVMC->CONFIG    = NVMC_CONFIG_WEN_Een;
    nvmc_wait_ready();
    NRF_NVMC->ERASEPAGE = page_addr;
    nvmc_wait_ready();
    NRF_NVMC->CONFIG    = NVMC_CONFIG_WEN_Ren;
    nvmc_wait_ready();
}

static void nvmc_write_bytes(uint32_t dst,
                             const uint8_t *src,
                             uint32_t len)
{
    nvmc_wait_ready();
    NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Wen;
    nvmc_wait_ready();

    while (len >= 4) {
        uint32_t word;
        memcpy(&word, src, 4);
        *(volatile uint32_t *)dst = word;
        nvmc_wait_ready();
        dst += 4; src += 4; len -= 4;
    }
    /* trailing partial word padded to 0xFF (erased-flash value) */
    if (len) {
        uint32_t word = 0xFFFFFFFFu;
        memcpy(&word, src, len);
        *(volatile uint32_t *)dst = word;
        nvmc_wait_ready();
    }
    NRF_NVMC->CONFIG = NVMC_CONFIG_WEN_Ren;
    nvmc_wait_ready();
}

Eleven pages erased (~90 ms each), ~40,000 bytes written (~40 µs per 32-bit word). Total NVMC operation time roughly 1.4 seconds. After the last word, NVIC_SystemReset(). The MBR sees the new bootloader at 0xF3000, jumps to its reset handler, and the new code takes over.

✓ Confirmed
Drive enumerates cleanly after update

Post-bl_updater, dmesg shows the bootloader's MSC enumerating without descriptor warnings. scsi 0:0:0:0: Direct-Access RRG ChameleonUltra 1.0, sd 0:0:0:0: [sda] 8192 512-byte logical blocks, no Hardware Errors during the partition scan. Mountable FAT12 volume. Drag-and-drop UF2 updates work.

11Bricking analysis — why this is harder to brick than it looks

There is exactly one way to brick the device with bl_updater: pull the USB cable between the first page erase and the final word write. That window is about 1.4 seconds.

⚠ Risk window
Power loss between erase and full write

If power drops mid-update, the bootloader region is left in an indeterminate state. The MBR will jump to 0xF3000 regardless of what's there; partial garbage means no USB enumeration and no software recovery path. SWD is the only escape. Run hw update_bl on a laptop running on battery, not on a USB hub being unplugged during a phone call.

Other than that window, the design has three layers of protection:

The complete update sequence from a working state:

Update workflow · Linux host
# Build new bootloader + extract embedded header
$ cd firmware
$ ./build.sh
$ ./tools/gen_embedded_bl.py \
      objects/bootloader.hex \
      application/src/embedded_bootloader.h
Wrote application/src/embedded_bootloader.h: 39948 bytes, CRC32 = 0x4826BA0C
BL region usage: 39948 / 45056 bytes (88.7%)

# Rebuild application with new BL embedded
$ ./build.sh

# Push application via UF2 (current bootloader is on the device)
$ # cold-boot + hold B + plug to enter DFU mode
$ ./tools/uf2conv.py objects/application.hex -o /tmp/new_app.uf2
$ sudo dd if=/tmp/new_app.uf2 of=/dev/sda bs=512 conv=notrunc oflag=direct
$ sync

# Device auto-resets into new app. Connect CLI, issue:
> hw update_bl
WARNING: this overwrites the bootloader region.
Type 'yes' to proceed: yes
Issuing bootloader self-update...
 - Update issued. The device should reset within a second.
 - Replug and verify in DFU mode.

// Takeaways

// 01

Build artifacts are not ground truth. nm shows what's in your binary; only physical observation (SWD or unambiguous behaviour) shows what's on the chip. Linux dmesg beats Windows pnputil every time when the question is "what is this USB device actually presenting right now".

// 02

USB interface numbers must be contiguous starting at zero. Dropping a class from a composite device requires renumbering whatever's left. Most SDK descriptor builders compute bNumInterfaces automatically but trust the class instance for bInterfaceNumber.

// 03

Windows PnP cache is keyed on (VID, PID, USB serial). Two device personas sharing a FICR-derived serial collide. Use distinct PIDs for bootloader and app to keep them visibly separate to Windows.

// 04

Intel HEX uses both type 02 (Extended Segment Address, seg << 4) and type 04 (Extended Linear Address, linear << 16) records. A parser handling only one will silently miss data from anything built with the other. Bootloader hexes commonly use type 02.

// 05

Pushing a full DFU zip with both BL+SD and app images requires the user to hold the DFU-entry button across the reboot between images. The device resets between stages, and without the button held it boots back into the application instead of re-entering DFU mode for image 2. The script reports a generic timeout when this happens, not a button-related error.

// 06

Removing your update transport without first building a recovery path locks you out. Design the recovery before you need it — bl_updater is 80 lines plus a header generator, but retrofitting it under pressure is not a comfortable position.

// 07

Raw NVMC writes outside SoftDevice-assigned pages require SD to be offline. nrf_sdh_disable_request() is async — spin on nrf_sdh_is_enabled() until it's actually down before touching flash.

// 08

The MSC stack accepts writes even when reads are broken. During the descriptor-bug period, partition scans were failing with Hardware Error but dd writes still worked — that's how the modified application reached the device before bl_updater fixed the descriptor.

// 09

Bounds-check enforcement in the UF2 transport is what makes the system unbrickable from userspace — and what locks you out if the bootloader itself is what needs updating. bl_updater is the controlled break in that protection: it runs from the application, where the constraint doesn't apply.

← Back to Blog