Skip to content

displayio: send one area buffer while composing the next - #11468

Merged
tannewt merged 4 commits into
adafruit:mainfrom
MakerClassCZ:display-bus-async-core
Oct 1, 2026
Merged

tannewt merged 4 commits into
adafruit:mainfrom
MakerClassCZ:display-bus-async-core

Conversation

@lynt-smitka

Copy link
Copy Markdown

BusDisplay on a FourWire bus now composes the next area buffer while the previous one is sent over SPI with DMA.

  • busio: internal common_hal_busio_spi_write_async() and common_hal_busio_spi_wait(), enabled by CIRCUITPY_BUSIO_SPI_ASYNC. Implemented on raspberrypi, samd51/same5x and espressif. No new Python API.
  • displayio: the display bus gets an optional send_async. FourWire provides both when the SPI port has the async write. Without a DC pin, with a chip select toggled per byte, and on the other buses nothing changes.
  • Background tasks run between area buffers only while the bus is released, as before, so another device on the same SPI bus is not locked out during a refresh.

Full-screen repaint of a TileGrid with an 8-bit Bitmap, main -> this PR:

board rotation 0 rotation 90
PicoPad, RP2040 (320x240) 76.7 -> 57.2 ms 198.4 -> 178.4 ms
PyBadge, SAMD51 (160x128) 26.5 -> 15.5 ms 58.4 -> 47.4 ms
Feather ESP32-S3 TFT (240x135) 29.9 -> 19.6 ms 49.3 -> 38.3 ms

Flash: PicoPad +624 B, Fruit Jam +576 B, Feather ESP32-S3 +528 B, PyBadge +320 B.

With an async bus the second area buffer is on the stack during a refresh. A full-screen refresh at the Python recursion limit ran without a crash on both boards. On RP2 the display's SPI bus keeps one DMA channel claimed until it is deinitialized.

picogame will use this in a follow-up PR and drop its own port DMA backends.

@lynt-smitka
lynt-smitka force-pushed the display-bus-async-core branch from e1ad929 to 63e7ee7 Compare September 28, 2026 15:26

@tannewt tannewt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

common_hal_busio_spi_write_start() starts a write and returns;
common_hal_busio_spi_write_end() waits for it without running background
tasks, since the caller still holds the bus. The done flag uses the
circuitpy_async_flag_t macros from adafruit#11040, so a port can later set it
from an interrupt. Ports that have the write set CIRCUITPY_BUSIO_SPI_ASYNC.
There is no Python API.

raspberrypi: the blocking transfer and the async write share one DMA
path. A blocking transfer claims its two channels per call as before;
an async write keeps them until deinit. Writes shorter than 32 bytes or
from flash/PSRAM stay synchronous.

atmel-samd: samd51/same5x only, through shared_dma_transfer_start().

espressif: up to two queued ESP-IDF transactions (8 KB). Writes of four
bytes or less, longer writes and word sizes other than 8 bits stay
synchronous.
A display bus can now have send_async next to send, and flush waits
for it. FourWire provides both when the SPI port has an async write.
BusDisplay then uses two area buffers: it composes the next one while
the previous one is sent. Background tasks still run only while the bus
is released, so another device on the same SPI bus is not locked out.

Full-screen repaint: PicoPad (RP2040) 76.7 -> 57.7 ms, PyBadge (SAMD51)
26.5 -> 15.6 ms, Feather ESP32-S3 TFT 29.9 -> 19.5 ms. Ports without the
async write build the same code as before.
@lynt-smitka

Copy link
Copy Markdown
Author

I reworked the busio part to match the abusio interface: common_hal_busio_spi_write_start(self, data, len, done) takes a circuitpy_async_flag_t (same macros as #11040), and common_hal_busio_spi_write_end() finishes the write. displayio uses these, and abusio could use them too instead of its own DMA code. On RP2 the blocking transfer and this write now share one DMA path.

For now the flag is set in write_end(). displayio always calls it, so it doesn't need the interrupt. The interrupt is needed for abusio, which isn't finished yet. On RP2 I tried setting the flag from the DMA interrupt, with write_end() waiting on it, and it works, but displayio gets no faster and it adds about 300 B. If you want, I can add it here, or I can try to take over abusio and finish it on top of this.

common_hal_busio_spi_write_end() becomes common_hal_busio_spi_end(). It
finishes whatever a common_hal_busio_spi_*_start() call started, so an
async read or full-duplex transfer added later can use it too.
@lynt-smitka

Copy link
Copy Markdown
Author

I renamed common_hal_busio_spi_write_end() to common_hal_busio_spi_end() in a new commit. The async SPI module will use it to finish reads too, not only writes.

Comment thread supervisor/shared/async_flag.h
py/ is kept for the core; circuitpy_async_flag.h becomes
supervisor/shared/async_flag.h. No code changes.

@tannewt tannewt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Please follow up with a new module for async spi and move the implementation there.

@tannewt
tannewt merged commit b60b0fa into adafruit:main Oct 1, 2026
679 checks passed
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