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.
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.
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:
- $(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.
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.
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.
/* 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.
A FAT12 disk needs surprisingly little structure to convince a host. The bootloader's GhostFAT lays out a virtual 4 MB volume:
| Sector | Contents | Notes |
|---|---|---|
| 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.
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:
/* 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.
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.
> 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.
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:
[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.
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.
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.
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:
- #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.
Trivial fix. Rebuild. Push. Except — the push failed:
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.
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.
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.
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.
#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:
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.
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.
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:
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.
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.
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.
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:
[0x27000, 0xF3000) are rejected. A malicious or buggy UF2 dragged onto the drive cannot overwrite the bootloader. Future bootloader updates must go through bl_updater again, intentionally.The complete update sequence from a working state:
# 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.
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".
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.
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.
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.
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.
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.
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.
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.
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.