Skip to content

RP2350 Support - #113

Open
andyp1per wants to merge 20 commits into
ArduPilot:masterfrom
andyp1per:rp2350-clean-v7
Open

andyp1per wants to merge 20 commits into
ArduPilot:masterfrom
andyp1per:rp2350-clean-v7

Conversation

@andyp1per

@andyp1per andyp1per commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Summary

RP2350 support for ArduPilot's ChibiOS, including SMP across both cores. This is what ArduPilot/ardupilot#32995 (the RP2350 flight controller port) sits on, and that PR cannot merge until something like this does.

Shape of the diff

One large commit plus nineteen small ones.

RP: restore upstream RP2350 support on ArduPilot stable is 92 files and the bulk of the +22k. It brings the RP port from upstream ChibiOS onto the older stable base ArduPilot's fork tracks. It is a port-forward, not new work, and is best reviewed as "does this match upstream" rather than line by line.

The other nineteen are each a single fix found while bringing a real flight controller up on the chip, and are worth reading individually:

  • VREG and QMI configured before the PLL switch, so the core is not asked to run at the target clock on the reset voltage with the flash timing still at reset values
  • QFN-80 ADC channel mapping (the RP2350B package exposes ADC inputs the RP2350A map does not describe)
  • per-core SRAM idle loops, so an idle core is not fetching from XIP flash and evicting the other core's cache lines
  • cross-core DMA channel release, and not continuing past a failed DMA allocation
  • spi_lld_abort implemented, with receive overrun detection
  • shared DMA vector runs at the most urgent priority any sharer asked for
  • PWM divider overflow at high clocks
  • SIO UART FIFO and DMA control initialisation
  • USB endpoint buffer state handling
  • scheduler and DMA metadata kept out of XIP
  • early ramtext copied without memcpy (memcpy itself may not be resident yet)
  • single-core ARMv8-M-ML-ALT builds, so the port works without SMP
  • optional kernel spinlock contention stats, default off

What this changes for existing boards

Most of the diff is under os/hal/ports/RP/** or the ARMv8-M-ML-ALT port and cannot affect an STM32 target. Three things are shared and worth attention:

os/hal/src/hal_mmc_spi.c has two changes. The first writes a block as one full duplex exchange instead of four transfers, and is reached only when the driver is given a staging buffer - a board that does not supply one keeps the existing path, so this is opt-in at runtime. The second is not opt-in: mmc_wait_idle now reads MMC_BUFFER_SIZE bytes per poll rather than one. Overshooting past the card going idle is harmless because the extra bytes clock against an idle bus, but it is a behaviour change for every board using SD over SPI, and it has only been tested on RP2350.

os/hal/include/hal_usb.h adds USB_USE_EP0_THREAD, defaulting FALSE. The RP USB LLD tests the switch, so it needs a defined default; nothing else reads it.

os/rt/src/chinstances.c is guarded by defined(RP2350) && CH_CFG_SMP_MODE == TRUE.

os/hal/ports/common/ARMCMx/mpu_v8m.h is a new file, so it cannot regress anything.

Write-path statistics in the MMC-SPI driver are behind MMC_USE_WRITE_STATS, default FALSE.

Testing

Exercised on RP2350 hardware, on two boards: a Raspberry Pi UAV flight controller that has flown with this, and a Laurel RP2350B board on the bench. SMP, PIO peripherals, SPI, I2C, ADC, USB CDC, PWM and SD over SPI are all in use on that hardware.

Not tested: any STM32 target. The mmc_wait_idle change above is the one place an STM32 board could notice this PR, and someone with an SD-over-SPI STM32 board should confirm it before this merges. RP2040 files move in the port-forward commit but have not been run.

andyp1per and others added 12 commits September 10, 2026 15:53
Upstream's RP2350 support is written against RT 8.0.0. Taking that tree wholesale
would bump the kernel under every ArduPilot board, so the RP HAL and the
ARMv8-M-ML-ALT core port are brought in on top of the existing RT 7.0.6 kernel
instead. Nothing in either needs RT 8; what they do need is added to files the
port owns - PORT_WORKING_AREA in the ALT port's chcore.h, and
PORT_CORE0_BSS_SECTION / PORT_CORE1_BSS_SECTION in the ALT SMP header aliased to
the existing PORT_MEM_LOCAL_COHERENT_BSSn names.

ArduPilot's local patches to the replaced files are retained: chMtxForceReleaseS,
PORT_IRQ_ATTRIBUTES, the CH_CFG_STACK_OVERFLOW_HOOK call sites and the 1MHz
CH_CFG_ST_FREQUENCY conversions.
RP2350: order QMI timing update before XIP access
mmcSequentialWrite() framed every block as four separate transfers - prologue,
data, dummy CRC, response - and then polled mmc_wait_idle() a byte at a time.
On a port where an SPI transfer completes by interrupt each of those is a
thread suspend and resume, which costs far more than the bytes are worth: a one
byte poll is under a microsecond on the wire wrapped in a reschedule.

Stage the whole frame instead and clock it in one exchange, with a busy window
after the response slot so the common case of the card finishing promptly needs
no polling transfer at all. The exchange is in place, so the staging buffer
afterwards holds the card's replies and the response token can be read out of
it.

Opt-in and backwards compatible: wbuffer is NULL out of mmcObjectInit() and
NULL keeps the original path, so drivers that do not supply a buffer are
unaffected.

hal_mmc: always poll for card idle after a block write

The single exchange block write inferred completion from the last byte of the
busy window: all ones meant the card had finished programming, so the poll was
skipped. That reads the same whether the card has finished or has not yet
pulled MISO low, and on the second case the next data token goes into a busy
card. The card discards that block and reports nothing, so the write returns
success with the data gone.

Above this, f_write() reports success and AP_Logger advances its file offset,
which is how whole 4 KB writes went missing from the middle of a log while the
file kept its full length.

Keep the busy window - the card usually does finish inside it, so the poll
returns on its first byte and the saving is retained - but confirm it rather
than assume it.
@andyp1per andyp1per mentioned this pull request Sep 11, 2026
10 tasks
spi_lld_start() guarded both dmaChannelAllocI() calls with osalDbgAssert only.
With debug assertions disabled, which is how flight builds are compiled, a
failed allocation returns NULL and execution falls straight through to
dmaChannelSetSourceX() on a null channel, leaving the peripheral in reset while
the caller has no way to know it did not start.

Release any partial allocation and return instead, so the driver is left with
null channels for the caller to test.
RP2350 pads come out of a power-on reset isolated and stay that way until
something writes the pad register. The PAL clears ISO as a side effect of
writing a whole mode word, but an ADC pad never goes through palSetLineMode,
and adcRPGpioInit() read-modify-writes the pad so it preserved the bit.

Clear it alongside the pulls and the input buffer, matching the Pico SDK -
adc_gpio_init() goes through gpio_set_function(), which ends by clearing ISO.
mmc_wait_idle() read one byte per SPI transfer. Either size is one DMA setup
and one thread suspend, so a byte at a time paid that overhead sixteen times
over for the same sixteen byte-times on the wire - and it did so on the path
the staged block write exists to keep clear.

Read MMC_BUFFER_SIZE and scan instead. Overshooting past the moment the card
goes idle costs nothing: the extra bytes clock against an idle bus, and a gap
before the next token is allowed.

Measured over 5932 block writes on RPI_UAVFC at 22.5 MHz: the card finishes
inside the in-frame busy window 91.4% of the time. Of the waits that outlast
it, 87% complete within 256 us and so are resolved by the two reads that land
before the first sleep. The remainder run from 512 us to 10 ms - internal
erase, not overhead - so they are slept on rather than polled.

The same measurement rules out folding a 4 KB write into one exchange: at an
8.6% per-block overrun rate, half of all eight-block frames would contain a
block that ran long, and every one of those would have to be rewritten.
Two silent failure modes on the RP SPIv1 port, both of which corrupt a
transfer without reporting anything.

spi_lld_abort() was an empty stub gated on SPI_SUPPORTS_CIRCULAR, which
this port declares FALSE, so a transfer abandoned on timeout had nothing
to tear it down. Its DMA stayed armed and whatever the device had already
clocked in stayed in the receive FIFO, where the next transfer's DMA takes
those bytes ahead of its own and shifts everything it reads. Implement it
for real - stop both channels, drain the receive FIFO, cycle SSE to clear
the transmit side - and drop the gate, since aborting a timed out transfer
is not specific to circular mode. Upstream ChibiOS master and
stable_21.11.x both still carry the stub.

Nothing ever read SSPRIS or wrote SSPICR either, so an SSP receive overrun
was invisible: the transfer completes, the DMA moves its full count, and
the caller gets a buffer shifted down by one from the discarded byte.
Check and clear it in the rx service routine and count it per driver.

Counters are per SPIDriver so a non-zero value is attributable to a bus.
A core's DMA channels all share one interrupt, so the priority argument
to dmaChannelAllocI() cannot be per channel. It was applied only to the
first allocation for a core and silently discarded for every later one,
which made the result depend on driver init order.

On an RP2350 with SPI0 pinned to core1 that put the IMU's transfers at
whatever priority the first unrelated driver to take a channel happened
to want - 6 rather than the 2 the SPI driver asked for, because an
overlay claimed a channel earlier in boot. Core 0 had the same problem,
with the ADC's 3 sitting where SPI wanted 2.

Track the most urgent request per core and lower the vector to match, so
the outcome is the same whoever allocates first.
Counters splitting a block into wire time, card busy time and the rest,
plus the distribution of blocks per disk_write call. Off by default;
MMC_USE_WRITE_STATS TRUE turns them on.

They are what found the real cost of the write path on RP2350: the
logger was syncing every 4 KB, so four fifths of everything reaching the
card was single sector filesystem metadata, and the card's apparent
program time was mostly the consequence of being asked for eight times
too many program cycles rather than the card being slow.

Two traps worth knowing before reading them. A poll is a 16 byte
exchange, so mmc_wait_idle can never take zero microseconds at 1 us
resolution - an earlier "no wait" counter measured nothing. And the
outcome counters catch every caller of mmc_wait_idle, not only the per
block one, so their proportions are usable and their absolute counts are
not per block.
chSysWaitSystemState() polls ch_system.state, which another core writes,
but the field is not volatile. The compiler loads it once before the loop
and then compares the cached value forever, so a core that arrives before
chSysInit() has finished on the other core never leaves.

On RP2350 core1 calls it from c1_main(). It only worked because core1's
CRT0 filled a 16 KB process stack first; with a 1 KB stack core1 won the
race and hung with the kernel state already running.
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