Step 15 of 56
SPI in depth
One host, several devices, each with its own clock, mode and chip select — applied per hold rather than per host. Plus a DMA transfer whose alignment rules are preconditions, not comments.
Two hosts, and two that are not offered
type SPI_Host is (SPI2, SPI3);
SPI0 and SPI1 are the flash and PSRAM controllers. They are deliberately
absent from the type — you cannot name them, so you cannot take out the
memory your program is executing from. As with I2C, the raw register driver is a
private child (ESP32S3.SPI.Engine) that cannot be
withed from outside the subtree.
What is per-host and what is per-device
This is the design decision that shapes the whole API. Bring-up splits in two:
-- Per HOST, once at startup, single-threaded:
procedure Setup (Host : SPI_Host); -- master mode + claim a GDMA channel
procedure Configure_Pins -- the SHARED wires
(Host : SPI_Host;
Sclk : ESP32S3.GPIO.Optional_Pin;
Mosi : ESP32S3.GPIO.Optional_Pin;
Miso : ESP32S3.GPIO.Optional_Pin;
Cs : ESP32S3.GPIO.Optional_Pin := No_Pin);
Notice what is not there: mode and clock. Those are
properties of a device, not of the bus, so they are applied at
Acquire under the exclusive hold. A flash at mode 0 and 8 MHz
and a display at mode 3 and 40 MHz can therefore share one host without
either one reprogramming the controller underneath the other:
procedure Acquire
(S : in out Session;
Host : SPI_Host;
Mode : SPI_Mode := 0; -- 0 .. 3, this device's
Clock_Hz : Positive := 1_000_000; -- this device's
Sclk, Mosi, Miso : ESP32S3.GPIO.Optional_Pin := No_Pin;
CS_Pin : ESP32S3.GPIO.Optional_Pin := No_Pin;
Select_CB : CS_Select := null;
Ctx : System.Address := System.Null_Address)
with Post => Is_Held (S);
The Sclk/Mosi/Miso arguments are
normally left as No_Pin, meaning "keep the host's routing". Set them
only for the rare device wired to a different set of pads on the same
controller; the GPIO matrix is then re-routed for the duration of that hold.
The session is the same limited, controlled RAII handle as
I2C's: it releases the host on scope exit including
during exception unwinding, Release is available (and idempotent)
to hand it back early, and Not_Initialized /
Not_Owned enforce the ordering. One addition worth knowing:
pragma Assertion_Policy (Pre => Check); -- in the spec of ESP32S3.SPI itself
The SPI (and GDMA) specs pin their own assertion policy, so their preconditions are checked whether or not the build enables assertions generally.
Chip select, three ways
"Chip select" is not always one pin, so the driver takes it three ways, in order of preference:
| You pass | What happens | Use it when |
|---|---|---|
CS_Pin => 21 |
The driver drives that GPIO itself as an active-low software select: configures the pad as an output, parks it deselected, and holds it low across the whole transaction. | The common case. One device, one plain GPIO. |
Select_CB => …, Ctx => … |
The driver calls your procedure with Active => True
before the bytes move and False when the transaction
ends. |
The select is not one GPIO — several pins into a 3:8 decoder, an I/O-expander line. |
| Neither | The host's single hardware CS0, routed by
Configure_Pins, toggles per Transfer. |
A single device that can live with per-transfer CS. |
With CS_Pin or Select_CB, hardware CS0 is
suppressed for that hold, so it cannot disturb another device sharing
the bus.
The callback rules, and why
type CS_Select is access procedure (Ctx : System.Address; Active : Boolean);
It must be library-level with no captured state.
Same reason as the GPIO interrupt callback: the HAL
builds under No_Implicit_Dynamic_Code, so a closure would emit a
GNAT trampoline that faults on the S3. Per-device state travels in
Ctx instead — that is exactly what the parameter is for.
It must be fast, non-blocking, and must not raise.
It runs while the bus lock is held, and again at scope exit during
finalization. Drive the line and return: no delay, no
Acquire, no I2C round-trip to an expander that might block.
Holding CS across a multi-phase command
Most SPI devices expect one command to arrive as opcode, then address, then data, with CS held low throughout. If CS dropped between those phases the device would see three separate commands. So bracket them:
Select_Device (S, True);
Transfer (S, Opcode'Address, Rx'Address, 1);
Transfer (S, Address'Address, Rx'Address, 3);
Transfer (S, Data'Address, Rx'Address, N);
Select_Device (S, False);
Select_Device is a no-op for a hardware-CS session, where the
peripheral toggles CS0 per transfer anyway. And if an exception escapes between
the two calls, Finalize deselects before releasing the
host — a fault can never leave a device asserted on a bus another task is
about to take.
Transfers are DMA, and the rules are preconditions
procedure Transfer (S : Session; Tx, Rx : System.Address; Length : Natural)
with Pre => Is_Held (S) and then Length in 1 .. 4095;
procedure Transfer (S : Session; Tx, Rx : ESP32S3.GDMA.DMA_Buffer; Length : Natural)
with Pre => Is_Held (S) and then Length in 1 .. 4095
and then Length <= Tx'Length and then Length <= Rx'Length
and then Tx'Length mod ESP32S3.GDMA.DMA_Alignment = 0
and then Rx'Length mod ESP32S3.GDMA.DMA_Alignment = 0;
Every transfer is full-duplex and blocking: Tx shifts out on
MOSI while MISO is captured into Rx. The 4095-byte ceiling is one
DMA descriptor; the precondition catches an out-of-range length that the engine
would otherwise drop silently.
Prefer the second overload. DMA_Buffer carries
Alignment => 32, so declaring one gets you an aligned start for
free, and the precondition additionally requires the buffer footprint to
be a whole number of 32-byte cache lines:
Tx_Buf : ESP32S3.GDMA.DMA_Buffer (0 .. 63); -- 64 = 2 whole cache lines
Rx_Buf : ESP32S3.GDMA.DMA_Buffer (0 .. 63);
Why the size rule, not just alignment. Cache maintenance for these buffers operates on whole 32-byte lines. A buffer whose length is not a multiple of 32 shares its last line with whatever is next in memory, so invalidating it would reach into a neighbouring object. The precondition makes that unrepresentable rather than a rare corruption. Transfer buffers live in internal SRAM.
Changing speed mid-hold
procedure Set_Clock (Host : SPI_Host; Hz : Positive); -- ~80 kHz .. 80 MHz
For a device that changes speed within one hold — the classic case being an SD card, which must complete its initialisation handshake slowly and then run fast. It re-programs only the bit clock, with no GDMA re-claim.
Proving the bus with no wiring
procedure Enable_Loopback (Host : SPI_Host; Pad : ESP32S3.GPIO.Pin_Id);
Routes MOSI back to MISO through a single pad, so
./x run esp32s3_spi_loopback exercises the real data path —
clock divider, mode, DMA in both directions — with nothing attached. Two
in-tree examples then show the shared-bus pattern against real silicon:
esp32s3_w25q (a 32 MB NOR flash) and
esp32s3_tlv2556 (a 12-bit ADC), each with its CS on an ordinary
GPIO the driver drives.