Bare-Metal Ada on the ESP32-S3 A step-by-step guide to running Ada on the ESP32-S3 with no ESP-IDF, no FreeRTOS, and no Python.

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 passWhat happensUse 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.

SPI in depth · Bare-Metal Ada on the ESP32-S3
Bare-Metal Ada on the ESP32-S3 A step-by-step guide to running Ada on the ESP32-S3 with no ESP-IDF, no FreeRTOS, and no Python.

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 passWhat happensUse 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.

SPI in depth · Bare-Metal Ada on the ESP32-S3
Bare-Metal Ada on the ESP32-S3 A step-by-step guide to running Ada on the ESP32-S3 with no ESP-IDF, no FreeRTOS, and no Python.

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 passWhat happensUse 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.

SPI in depth · Bare-Metal Ada on the ESP32-S3
Bare-Metal Ada on the ESP32-S3 A step-by-step guide to running Ada on the ESP32-S3 with no ESP-IDF, no FreeRTOS, and no Python.

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 passWhat happensUse 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.

SPI in depth · Bare-Metal Ada on the ESP32-S3
Bare-Metal Ada on the ESP32-S3 A step-by-step guide to running Ada on the ESP32-S3 with no ESP-IDF, no FreeRTOS, and no Python.

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 passWhat happensUse 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.