Skip to content

Native USB on the nRF54LM20 (TinyUSB on USBHS) - #2

Open
cvaldess wants to merge 11 commits into
andyshinn:ashinn/nrf54lm20afrom
cvaldess:pr/nrf54lm20-usb
Open

cvaldess wants to merge 11 commits into
andyshinn:ashinn/nrf54lm20afrom
cvaldess:pr/nrf54lm20-usb

Conversation

@cvaldess

Copy link
Copy Markdown

Native USB on the nRF54LM20: TinyUSB on the USBHS block, with Serial as a USB CDC port when USE_TINYUSB is set, and the pieces a USB DFU bootloader needs.

Commits

  • Import Adafruit TinyUSB Arduino 3.7.7 unchanged (separate commit, so the nRF54-specific changes below are reviewable on their own).
  • USBHS port (ports/nrf54, dwc2_nrf.h, Bluefruit hooks). The USBHS block is a Synopsys DWC2 with a high-speed PHY, driven by TinyUSB's dwc2 port (OPT_MCU_NRF54). The USB task waits for VBUS (VREGUSB) and starts PCLK24M before the stack, as the DWC2 registers are not accessible earlier. dwc2_nrf.h gains the production NRF54LM20A_XXAA define (upstream only checks NRF54LM20A_ENGA_XXAA, so the PHY bring-up compiled to nothing).
    • HFXO and the SoftDevice: PCLK24M keeps the HFXO running, and s145 (in sd_softdevice_enable() too) restarts the HFXO and waits for XOTUNED, which a running, tuned crystal never raises: the CPU spun in the SoftDevice's clock code until the watchdog reset it. So Bluefruit's SoftDevice enable moves into bluefruit_softdevice_enable() (acts once, begin() calls it), and the port calls it before setup() and requests the HFXO through the SoftDevice before starting PCLK24M. USB attaches once and never has to leave the bus. Weak usb_softdevice_pre_enable() / usb_softdevice_post_enable() hooks cover a SoftDevice enabled after USB started.
  • Core: a weak USBHS_IRQHandler for IRQ 90 in startup_nrf54lm20a.S (it sat in the Default_Handler block, so the port's handler was never reached), TinyUSB_Device_Init(0) before setup() as on nRF52, and with USE_TINYUSB Serial is the CDC port instead of an alias of the console UART.
  • nrf54lm20a_s145_v10_bl64k.ld: the same layout with the application at 0x10000, for boards whose bootloader carries the USB stack and takes 64 KB (see Add the nRF54LM20-DK with DFU over USB (CDC and UF2) nRF54_Bootloader#2). Selected per board with build.arduino.ldscript.
  • enterUf2Dfu() was declared in wiring.h but never defined; it now resets with GPREGRET 0x57, which that bootloader answers with its UF2 drive.

Base

The branch starts from a merge of meshtastic/nRF54_Arduino master into ashinn/nrf54lm20a (the 5 commits #1–meshtastic#5, already merged there). The early SoftDevice enable relies on #1: until begin() enables SD_EVT_IRQn, the flash driver drains its own completions, and Meshtastic writes to flash before it starts BLE. Without #1 this is untested.

Tested

On an nRF54LM20-DK (nRF54LM20B) with Meshtastic and andyshinn/nRF54_Bootloader#2:

  • SoftDevice up at 426 ms and PCLK24M 1 ms later; the CDC port enumerates at 0.7 s, attaches once and shows the whole boot log;
  • 3 boots to 60 s with no watchdog reset; 18 boot-time flash writes through the SoftDevice, none timed out;
  • Meshtastic CLI and web client over USB, BLE (iPhone) at the same time;
  • 1200 bps touch into the bootloader and enterUf2Dfu() into its UF2 drive.

Not tested: hot-plugging the cable while the application runs, other boards with USBHS.

caveman99 and others added 11 commits October 4, 2026 17:51
…stic#2)

* Seed the SoftDevice RNG from the CRACEN TRNG on every request

NRF_EVT_RAND_SEED_REQUEST was answered with FICR DEVICEID XOR the
domain 0 GRTC SYSCOUNTER, which is predictable. The SoftDevice RNG
feeds LESC key generation.

Both the initial seed in begin() and later seed requests now go
through seed_softdevice_rng(), which draws from the CRACEN TRNG. The
RNG is no longer stopped after seeding, so a reseed cannot cut off
key generation running on another task.

* Retry the SoftDevice RNG seed until it succeeds

A failed seed attempt on NRF_EVT_RAND_SEED_REQUEST left the SoftDevice
RNG unseeded. Retry, restarting the CRACEN RNG between attempts so a
generator stuck in its error state is reset.

* Retry a failed RNG seed without blocking SoC event dispatch

f94ff6e retried inline in the seed request handler, so a TRNG that
kept failing would stop adafruit_soc_task from dispatching any further
SoC events, flash completions included. Mark the seed as pending and
retry it between event batches, waking every 10 ms while it is
outstanding.

* Recover a halted CRACEN RNG in place instead of stopping it

0acc127 recovered a failed seed by calling nRF54Crypto.end(), which
disables the RNG while LESC key generation may be reading it on another
task. cracen_rng_fill() now soft-resets a generator in its error state
while it waits for the FIFO, the same reset cracen_rng_start() already
applied, so no caller has to stop the RNG to recover it. The SoC task
only retries the seed.

* Serialize CRACEN RNG FIFO reads across tasks

SoftDevice seed requests and LESC key generation read the RNG from
different tasks. A read by one between the other's FIFO level check and
its read, or a soft reset clearing the FIFO there, returned a word that
is not random. Suspend the scheduler for the duration of each fill.

* Limit the CRACEN RNG lock to the FIFO check and read

bf63374 suspended the scheduler for a whole fill, including the FIFO
polling wait, which can run for RNG_TIMEOUT iterations per word while
the generator is failing. Take one word at a time in a critical section
that covers only the level check and read, or the reset of a halted
generator, and poll outside it.
…eshtastic#1)

* Fix tickless idle on the 52-bit GRTC, GPIOTE per port, and the SYSCOUNTER read

port_cmsis_systick.c:
- grtc_cc_set() wrote CCH = 0. The SYSCOUNTER is 52 bits at 1 MHz and keeps running across
  soft resets, so it passes 2^32 about 71 minutes after power-on; from then on every compare
  lands in the past and never fires. The tick only survived while some other interrupt woke
  the CPU, and the first idle sleep after that never ended. Rebuild the high word from the
  live counter, carrying when the 32-bit target wrapped.
- grtc_counter_get() read SYSCOUNTERL without checking validity. SYSCOUNTERH is latched by
  the read of SYSCOUNTERL and its reset value already has BUSY set, so re-read the pair until
  BUSY clears (as nrfx does). A junk read here fed the tick catch-up below.
- The tick ISR catch-up had no bound: a bogus counter read stepped the tick by hours. Anything
  beyond a few seconds is treated as one tick and the tick base is re-anchored; genuine long
  sleeps are accounted in vPortSuppressTicksAndSleep.

WInterrupts.c:
- attachInterrupt() only ever used GPIOTE20, but nRF54L routes P0 to GPIOTE30 (4 channels)
  and P1 to GPIOTE20 (8 channels); P2 has no GPIOTE at all. Pick the instance by port, keep
  per-instance channel maps, serve both IRQ lines, and return 0 for P2 pins.

Verified on an nRF54L15-DK with an SX1262 (Meshtastic): boots cold and warm, tick tracks
wall clock, DIO1 interrupts on P0.00.

* Bound the SoftDevice flash-completion wait and cap the tickless idle product

flash_nrf5x.c: wait_for_async_flash_op_completion() blocked on the completion semaphore
with portMAX_DELAY. A lost NRF_EVT_FLASH_OPERATION_* event (seen on nRF54L15 during a BLE
connection) parked the writing task forever and, with every task blocked, the tickless idle
put the CPU to sleep until an arbitrary wake-up: the firmware froze, BLE included. Wait in
bounded slices, drain sd_evt_get() from the waiting task so the completion cannot get stuck
behind a task that is not running, hand other SoC events to a weak application hook, and
report NRF_ERROR_TIMEOUT when nothing arrives.

port_cmsis_systick.c: clamp xExpectedIdleTime before multiplying by the GRTC ticks per
systick; with every task blocked the product wrapped and the wake-up compare landed anywhere.

* Write the InternalFS pages in place on RRAM and report flash failures to LittleFS

The page cache flushed every 256-byte LittleFS block as erase-then-program of
its 4 KB page: 32 sd_flash_write() calls filling the page with 0xFF (the nRF54L
RRAM has no erase, s145 has no sd_flash_page_erase) followed by the whole page
again. A hang or reset between the two left the page at 0xFF; when the page was
page 0 both LittleFS superblocks vanished and the next boot reformatted the
filesystem (seen on an nRF54L15-DK: the node came back unconfigured with a new
identity after an overnight freeze).

Flush only the 128-byte chunks that differ from the flash, in place: the other
blocks sharing the page are never rewritten, so an interrupted flush can only
damage the chunk in flight, which is the power-loss model LittleFS is built for.
A boot now costs 11 operations instead of 34 per page.

Propagate failures instead of ignoring them: flash_cache_flush() returns false,
flash_cache_write() -1 when the flush it forced failed, and the LittleFS prog/
sync callbacks return LFS_ERR_IO so the caller learns the write did not happen.
Create the completion semaphore on first use (fal_erase() was the only place
that did, and it no longer runs before every program). Re-issue BUSY 20 times
5 ms apart and a lost completion 3 times; any other SoftDevice error is final.

Keep a flight recorder (flash_nrf5x_stats) of the SoftDevice flash operations:
counts of issued, completed, self-drained, timed-out and failed operations,
the last destination and the longest wait, and an in_flight flag, so a stuck
firmware can be read from the debugger by symbol.

* Sleep the tickless idle with WFI instead of a WFE/ISPR loop

WFI completes on any enabled pending interrupt regardless of PRIMASK, so
the idle sleep no longer depends on SEVONPEND and on the event latch being
in the right state when the loop re-checks ISPR. Same pattern Zephyr uses
on this part.

* Run the FreeRTOS tick on the application core's GRTC domain

The tick used SYSCOUNTER[0], INTEN0 and GRTC_0_IRQn, which belong to
domain 0, the FLPR's. The secure application core is domain 2
(GRTC_IRQ_GROUP in nrf54l15_interim.h), and the SoftDevice already uses
group 3 for its own compares.

Use the SYSCOUNTER index, INTEN group and IRQ of domain 2, and fail the
build if the MDK's GRTC_IRQ_GROUP ever disagrees. This is a correctness
fix on its own: it did not cure the idle hang seen on the nRF54L15-DK,
whose cause was the GRTC sleep timing (next commit).

* Set the GRTC sleep timing that nrfx_grtc_init() would

Nothing on this core calls nrfx_grtc_init(), so TIMEOUT and WAKETIME kept
their reset values, 0 and 1: the SYSCOUNTER stopped the moment the CPU
slept and had a single 32 kHz cycle to wake up before a compare. On the
nRF54L15-DK the core hung in the tickless idle WFI with the SYSCOUNTER
stopped, every 40 min to 3.5 h. In all five hangs caught live, a
SoftDevice compare sat 1.5-2 of those cycles past the stop and never
fired, and neither did the tick compare 19.5 ms later.

Write the values of NRFX_GRTC_SLEEP_DEFAULT_CONFIG (TIMEOUT 5, WAKETIME 4),
which is what Zephyr applies on this part. Do it outside the start path,
like AUTOEN, since the GRTC survives soft resets.

* Cap the tickless idle at half the GRTC compare range

grtc_cc_set() places a 32-bit target in the right 2^32 epoch of the
52-bit compare by carrying into the high word when the target wrapped,
and it decides that from (int32_t)(val - lo) > 0. That only holds for
targets less than 2^31 GRTC ticks ahead. The tickless idle allowed up to
2^32 ticks, about 71 min at 1 MHz, so with every task blocked forever a
wrapped target further than ~35 min out kept the current high word, sat
in the past, and the CPU slept until some other interrupt woke it.

Cap the idle at 0x7FFFFFFF GRTC ticks instead of 0xFFFFFFFF.

* Keep a page that failed to flush in the InternalFS cache

When the flush forced by a page switch failed, flash_cache_flush()
dropped the cached page and flash_cache_write() loaded the next one over
it. That page held writes LittleFS had already been told were done, so
they were lost, while the write that triggered the flush was cached
anyway and could reach flash later despite having returned LFS_ERR_IO.

Leave the page cached when its flush fails and return -1 before taking
the new write, so the next flush (or sync) writes it again; on RRAM only
the chunks that still differ are sent. With that, a failed write in the
erase callback no longer stores its 0xFF, so the erase now reports
LFS_ERR_IO like prog instead of claiming the block was erased.

* Answer RNG seed requests the flash driver drains

While it waits for a flash completion, the InternalFS driver drains the
SoC event queue itself and passes every non-flash event to
flash_nrf5x_soc_event_hook(), which nothing defined. A seed request
pulled out there was dropped and never reached adafruit_soc_task(), so
sd_rand_seed_set() was not called.

Give Bluefruit54Lib a weak default for the hook that seeds the RNG
through the same function as the SOC task. It is weak so that an
application that reads the SoC events itself (Meshtastic does) can still
define its own without a duplicate symbol.

* Settle a timed-out flash write before issuing the next one

When the wait for a SoftDevice flash completion timed out, the write
could still be running. Its completion then arrived later, as a give on
the semaphore or as an event in the SoC queue, and the next write (the
retry, or any later one) took it for its own and reported success while
it was still being written.

Count accepted operations and completions instead of taking the
semaphore once per operation. The SoftDevice runs one operation at a
time and reports them in order, so all accepted operations are finished
once the counts match, and the last completion is the current write's.
Before issuing, wait (with the same 2 s bound) for any operation left
outstanding. If its completion still does not come, issue anyway: BUSY
means it is still running and goes through the usual BUSY retries,
which no longer wait again; acceptance means it finished and another
reader of the SoC queue took its completion without passing it on, so
it is written off and counted in flash_nrf5x_stats.written_off.

The one case left is a completion counted in the instant between that
last check and sd_flash_write(), after it had been missing for more
than 2 s. It can end one wait early; the surplus count is then dropped.

* Count a flash write as done only once its data has landed

After a completion was written off as lost, it could still arrive: the
SoftDevice accepting the next write proves the earlier one finished,
not that another reader consumed its completion. Counted late, it made
the counts match while the new write was still running, so the driver
returned success early. The cache could then load another page into
the buffer the SoftDevice was still reading from, and write that data
at the wrong address.

Also require the destination to read back as the source before the
wait for a write succeeds. The cache only programs chunks that differ
from the flash, so a match means the SoftDevice has written them and is
done reading the buffer. A stray completion can now only make the wait
look again, never end it early. If every completion is in, the last one
reported a failure and the data is not there, the write fails and is
retried as before.

The only write a match cannot vouch for is an erase chunk over flash
that is already 0xFF; its source is a constant, so finishing it later
cannot write anything else.

* Run the GRTC tick on 64-bit counter and compare values

grtc_cc_set() rebuilt the high word of a 32-bit target from the live
counter. It only ever carried into the next epoch, never borrowed: a
target slightly behind the counter when the low word had just wrapped
(the tick ISR held off for more than a period by a SoftDevice interrupt)
landed about 2^32 us, 71.6 min, ahead, and the tick stopped. Its
SYSCOUNTERH read also ignored BUSY and OVERFLOW.

Read the counter with nrfy_grtc_sys_counter_get(), which retries on
both and reads this core's domain, and arm compares with the full
value through nrfy_grtc_sys_counter_cc_set(). A target already in the
past now fires at once; the spurious event that writing CCL before CCH
can raise is dropped while the target is still ahead, as nrfx does.
That removes the epoch reconstruction, the hand-rolled counter read
and the 2^31 cap on the tickless idle.

Ticks now stay on a grid (grtc_next_tick) instead of being re-armed one
period after the ISR ran. A backlog beyond 4 s is no longer dropped by
re-anchoring the tick base: it is caught up 4 s per interrupt and
counted in grtc_tick_backlogs. The tickless idle wakes on the grid and
never steps past the unblock tick; ticks beyond it are caught up by
the tick interrupt.

Write the sleep configuration the way nrfx_grtc_sleep_configure() does,
with the SYSCOUNTER stopped, using NRFX_GRTC_SLEEP_DEFAULT_CONFIG
instead of literal 5/4. The nrfx GRTC driver itself is not enabled in
this core.

* Mask SoftwareSerial's RX interrupt on the GPIOTE that serves its pin

attachInterrupt() returns a channel mask of GPIOTE30 for P0 pins and of
GPIOTE20 for P1 pins, but SoftwareSerial applied it to NRF_GPIOTE, which
nrf54l_compat.h aliases to GPIOTE20. With the RX pin on P0, write()
masked GPIOTE20 channel N instead of the RX channel: the RX edge
interrupt kept firing during TX, and whatever P1 pin owned that channel
was masked during every byte.

Add digitalPinToGpiote() to WInterrupts, using the same pin mapping as
attachInterrupt(), and have SoftwareSerial keep the instance its RX pin
uses.

* Tighten the InternalFS flash retry policy and bound a failing page

Write only the words of a chunk that differ from the flash, first to
last. A chunk ending in words that already matched could otherwise read
back equal while the SoftDevice still had those words to write and was
still reading the buffer, so a late completion could end the wait
before it was done and the cache could reload the buffer under it. A
range already in place is now not written at all, which also covers
erasing flash that is already 0xFF.

Give each chunk a total budget of 2 s, every attempt and wait included,
instead of 2 s per wait: with retries one failing chunk could hold the
filesystem lock for about 10 s. Settling an operation left outstanding
by a timeout may use up to 0.5 s of it.

A failure reported by the SoftDevice (NRF_EVT_FLASH_OPERATION_ERROR,
which it raises when it cannot fit the write around radio activity) is
now retried like BUSY, up to MAX_RETRY times with a pause, instead of
three times back to back, and it counts in flash_nrf5x_stats.errors.

A page whose flush keeps failing is dropped after three failures in a
row. Kept for ever, it refused every write to any other page, including
the erases of InternalFS.format(), and its data does not survive a
reboot anyway.

Drop the NOR erase-then-program flush: the only flash_cache_t writes in
place.

---------

Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>
The GRTC tick grid advanced by portNRF_GRTC_TICKS_PER_SYSTICK, 10^6 /
1024 truncated to 976 us, so 1024 ticks took 999424 us. The tick count,
and millis() derived from it, ran 576 ppm fast, about 50 s a day.

Count grid ticks from the base set at scheduler start and place tick n
at base + ceil(n * 10^6 / configTICK_RATE_HZ) us. Rounding up makes a
compare armed at tick n see exactly n ticks due when it fires.
…meshtastic#4)

stopListening() detached the RX interrupt but kept _gpiote and _intMask,
so write() on an instance that no longer listens still disabled and
re-enabled that channel around every byte. Once another pin is attached
to the channel, its interrupt was masked during each transmitted byte.
Clear _gpiote, which every mask and unmask is guarded on.
…eshtastic#5)

A cached page whose flush fails three times in a row is dropped, and
with it writes LittleFS was already told had succeeded. Count each drop
in flash_nrf5x_stats.pages_dropped, so the loss shows in the flash
flight recorder.
Brings ashinn/nrf54lm20a (6c99190: MDK 8.75.1, s145 10.0.1 for every
board, LM20A startup/linker, SPI/UARTE pin routing, SAADC per chip) on
top of master 79a7ad6 (meshtastic#1-meshtastic#5).

Conflicts resolved in favour of master, whose tick and RNG seeding have
the long soak on the nRF54L15-DK behind them:
- port_cmsis_systick.c, portmacro_cmsis.h, FreeRTOSConfig.h: master's
  GRTC domain-2 tick (TIMEOUT/WAKETIME 5/4, 1/1024 s grid). The LM20A
  MDK has the same GRTC_IRQ_GROUP (2) and GRTC_2_IRQn (228), so the
  domain #error still holds there. Kept 650f534's compile-time check
  that the tick's CC channel and IRQ group stay clear of the
  SoftDevice's (SD_GRTC_CC_CHANNELS_USED / SD_GRTC_IRQn_USED).
- bluefruit.cpp: master's deferred CRACEN seeding (meshtastic#2); the branch's
  only change there was its own inline seeding.

Also route P3 pin interrupts to GPIOTE20: the LM20 adds P3, which
shares GPIOTE20 with P1, and instanceForPin() returned NULL for it.
Vendored as released (src, library.properties, LICENSE, README; no
examples). It bundles TinyUSB with the DWC2 port for the nRF54LM20 USBHS;
the nRF54 port and the fixes it needs follow in separate commits.
The nRF54LM20's USBHS block is a Synopsys DWC2 core with a high-speed
PHY, which TinyUSB drives through its dwc2 port (OPT_MCU_NRF54).

- ports/nrf54: tusb_config (device-only CDC at high speed, 512-byte bulk
  endpoints) and the Arduino port. The USB task waits for VBUS (VREGUSB;
  bit 2 at +0x400 for the state at start-up, as in Zephyr's quirk for the
  nRF54L, VBUSDETECTED for a cable plugged in later) and starts PCLK24M
  (CLOCK XO24MSTART) before the stack, as the DWC2 registers are not
  accessible earlier. tinyusb_nrf54_stage and two timestamps record the
  bring-up for reading over SWD.
- tusb_config.h / Adafruit_TinyUSB_API.h: select the port for
  ARDUINO_ARCH_NRF54.
- dwc2_nrf.h: also match NRF54LM20A_XXAA (upstream only checks the
  engineering-sample NRF54LM20A_ENGA_XXAA, so the PHY bring-up compiled to
  nothing), and take nrfx_coredep.h from either soc/ or lib/ (nrfx 4).

PCLK24M keeps the HFXO running, and the SoftDevice, which starts the HFXO
itself (sd_softdevice_enable() included) and waits for XOTUNED, never
gets one from a crystal that is already running and tuned: it spun in its
clock code and the watchdog reset the chip. So the SoftDevice has to hold
the HFXO before PCLK24M starts:

- Bluefruit: the LF clock configuration and sd_softdevice_enable() move
  into bluefruit_softdevice_enable(), which only acts the first time;
  begin() calls it. The TinyUSB port calls it from
  TinyUSB_Port_InitDevice(), before setup(), when Bluefruit is linked
  (weak reference), and requests the HFXO through the SoftDevice
  (sd_clock_hfclk_request) before starting PCLK24M. USB then attaches
  once and never has to leave the bus to hand the crystal over.
- Weak usb_softdevice_pre_enable() / usb_softdevice_post_enable() hooks
  around sd_softdevice_enable() cover a SoftDevice enabled after USB
  started: the port detaches, releases PCLK24M across the enable and
  attaches again.

SD_EVT_IRQn is still only enabled by begin(); until then the flash driver
drains its own completions (meshtastic meshtastic#1), so this relies on that.

Tested on the nRF54LM20-DK with Meshtastic: the SoftDevice up at 426 ms
and PCLK24M 1 ms later, the CDC port at 0.7 s with no drop and the whole
boot log; 3 boots to 60 s with no watchdog reset; 18 boot-time flash
writes through the SoftDevice, 0 timeouts; the Meshtastic CLI and web
client over USB, BLE alongside.
- startup_nrf54lm20a.S: give IRQ 90 (USBHS) a weak USBHS_IRQHandler;
  it sat in the Default_Handler block, so the port's handler was never
  reached (and was dropped by the linker).
- main.cpp: TinyUSB_Device_Init(0) before setup(), as on nRF52.
- Arduino.h / Uart.h: with USE_TINYUSB, Serial is the TinyUSB CDC port
  instead of an alias of the console UART.
nRF54_Bootloader's USB DFU (nRF54LM20-DK) does not fit in 32 KB, so that
bootloader takes 64 KB (linker/nrf54lm20a_bl64k.ld) and starts the
application at 0x10000. This script is nrf54lm20a_s145_v10.ld with FLASH
moved to match; InternalFS, the settings page and the SoftDevice stay
where they were. A board selects it with build.arduino.ldscript.
wiring.h declared it but nothing defined it. 0x57 is DFU_MAGIC_UF2_RESET,
as in Adafruit's nRF52 bootloader: nRF54_Bootloader with USB (nRF54LM20)
then shows its UF2 drive; a bootloader without USB does serial DFU over
its UART.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants