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 17 of 56

GDMA: the DMA engine everything else borrows

Five channel pairs, assigned at run time rather than wired per peripheral — and a buffer type whose rules exist because PSRAM is reached through a cache.

Channels are a resource, not a fixture

The S3 has one AHB GDMA block with five channel pairs (0 .. 4). Each pair has an independent transmit (OUT) and receive (IN) path, and either can be wired to any peripheral through the GDMA crossbar. A channel is therefore something you claim, not something a peripheral owns:

type Channel_Id is mod 5;
type Peripheral is (Mem2Mem, SPI2, SPI3, UHCI0, I2S0, I2S1, LCD_CAM, AES, SHA, ADC_DAC, RMT);

procedure Claim (C : in out Channel; Peri : Peripheral);
function  Is_Valid (C : Channel) return Boolean;
procedure Release (C : in out Channel);

Claim goes through a protected allocator, so two tasks can never be handed the same channel. The Channel handle is limited (non-copyable, so it cannot be aliased into another task) and controlled (it releases on scope exit, including on an exception, so a channel cannot leak or be reused through a stale copy). If all five are busy, Claim leaves the handle invalid rather than raising — check Is_Valid.

Once you hold a channel, only you touch its registers and descriptors, so the transfer operations themselves need no further locking. This is the same ownership pattern as SPI and I2C, and for the same reason.

Why a buffer type exists

DMA needs DMA-capable memory, and on this chip that is not a single rule:

DMA_Alignment : constant := 32;

type DMA_Buffer is array (Natural range <>) of Interfaces.Unsigned_8
  with Alignment => DMA_Alignment;

function Is_DMA_Capable (A : System.Address) return Boolean;

Alignment is only half of it. The type carries the aligned start; the operations' preconditions additionally demand a whole-cache-line size. That is not implied by alignment and it matters: the PSRAM write-back/invalidate rounds the region up to a whole line, so a buffer ending mid-line would have the maintenance touch its neighbour — discarding an adjacent object's dirty cached write. Size the payload up to a multiple of 32 (128 bytes for 100 useful ones).

Hence the calling convention: pass the whole buffer plus a transfer length, never a slice. Slicing to the length would fail the size precondition, while the whole line-multiple buffer keeps every rounded maintenance op inside itself.

procedure Copy (C : Channel; Dst, Src : DMA_Buffer; Length : Natural)
with Pre => Length <= Src'Length and then Length <= Dst'Length
            and then Src'Length mod DMA_Alignment = 0
            and then Dst'Length mod DMA_Alignment = 0;

These preconditions are pinned on with pragma Assertion_Policy (Pre => Check) in the spec, so they hold even when the build has assertions off — a buffer in flash or unaligned PSRAM corrupts the transfer silently, which is exactly the failure worth paying a check for.

The three shapes of transfer

CallBehaviour
Copy Blocking memory-to-memory, completed by looping one channel's OUT path into its own IN path. Buffers and the driver's descriptors must be in internal SRAM, because the descriptor link address is a 20-bit field.
Start Arms a single-buffer peripheral transfer in one Direction and returns immediately. You configure and start the peripheral separately; the GDMA moves data as the peripheral raises its DMA request.
Start_Loop A single descriptor whose link points back at itself, so the engine replays the buffer forever with no gap between passes and no CPU involvement after the kick. Never completes on its own.

Direction is Mem_To_Periph (the OUT path reads RAM and feeds the peripheral) or Periph_To_Mem. A single descriptor caps at Max_Transfer = 4095 bytes, the hardware's 12-bit buffer-size field — which is where SPI's 1 .. 4095 precondition comes from.

Beyond one descriptor: the framebuffer path

For a buffer larger than 4095 bytes there is a chained ring: up to Max_Chain = 256 descriptors that between them cover the buffer, the last linking back to the first. This is the display path — stream an LCD framebuffer to LCD_CAM continuously. The buffer may live in PSRAM (32-byte aligned) and is written back before the loop starts. After the CPU draws into a live framebuffer, Flush pushes the changes out so the running DMA re-reads them.

The descriptor ring is one shared internal-SRAM array, so exactly one chained loop runs at a time.

Completion, and an interrupt you must not take

Completion is interrupt-driven rather than polled: Wait suspends the calling task and the channel's end-of-transfer interrupt wakes it.

This driver owns Device_L3_1 (CPU_INT 27). An application must not attach its own handler there. It is a level-3 slot rather than a level-2 one because the LCD RGB bounce refill runs from this completion ISR and has to preempt the level-2 devices to make its deadline.

Proving the coherency path

type Self_Test_Result is (Passed_PSRAM, Passed_SRAM, Failed, No_Channel);
function Self_Test (Buf_A, Buf_B : System.Address) return Self_Test_Result;

A memory-to-memory round trip between two buffers of your choosing, which reports which memory it actually exercised. Hand it PSRAM buffers (from a task whose stack is in PSRAM) and a Passed_PSRAM result means the cache write-back/invalidate path really works on your board — not merely that DMA works in SRAM. Buffers must be 32-byte aligned and at least 64 bytes.

GDMA: the DMA engine everything else borrows · 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 17 of 56

GDMA: the DMA engine everything else borrows

Five channel pairs, assigned at run time rather than wired per peripheral — and a buffer type whose rules exist because PSRAM is reached through a cache.

Channels are a resource, not a fixture

The S3 has one AHB GDMA block with five channel pairs (0 .. 4). Each pair has an independent transmit (OUT) and receive (IN) path, and either can be wired to any peripheral through the GDMA crossbar. A channel is therefore something you claim, not something a peripheral owns:

type Channel_Id is mod 5;
type Peripheral is (Mem2Mem, SPI2, SPI3, UHCI0, I2S0, I2S1, LCD_CAM, AES, SHA, ADC_DAC, RMT);

procedure Claim (C : in out Channel; Peri : Peripheral);
function  Is_Valid (C : Channel) return Boolean;
procedure Release (C : in out Channel);

Claim goes through a protected allocator, so two tasks can never be handed the same channel. The Channel handle is limited (non-copyable, so it cannot be aliased into another task) and controlled (it releases on scope exit, including on an exception, so a channel cannot leak or be reused through a stale copy). If all five are busy, Claim leaves the handle invalid rather than raising — check Is_Valid.

Once you hold a channel, only you touch its registers and descriptors, so the transfer operations themselves need no further locking. This is the same ownership pattern as SPI and I2C, and for the same reason.

Why a buffer type exists

DMA needs DMA-capable memory, and on this chip that is not a single rule:

DMA_Alignment : constant := 32;

type DMA_Buffer is array (Natural range <>) of Interfaces.Unsigned_8
  with Alignment => DMA_Alignment;

function Is_DMA_Capable (A : System.Address) return Boolean;

Alignment is only half of it. The type carries the aligned start; the operations' preconditions additionally demand a whole-cache-line size. That is not implied by alignment and it matters: the PSRAM write-back/invalidate rounds the region up to a whole line, so a buffer ending mid-line would have the maintenance touch its neighbour — discarding an adjacent object's dirty cached write. Size the payload up to a multiple of 32 (128 bytes for 100 useful ones).

Hence the calling convention: pass the whole buffer plus a transfer length, never a slice. Slicing to the length would fail the size precondition, while the whole line-multiple buffer keeps every rounded maintenance op inside itself.

procedure Copy (C : Channel; Dst, Src : DMA_Buffer; Length : Natural)
with Pre => Length <= Src'Length and then Length <= Dst'Length
            and then Src'Length mod DMA_Alignment = 0
            and then Dst'Length mod DMA_Alignment = 0;

These preconditions are pinned on with pragma Assertion_Policy (Pre => Check) in the spec, so they hold even when the build has assertions off — a buffer in flash or unaligned PSRAM corrupts the transfer silently, which is exactly the failure worth paying a check for.

The three shapes of transfer

CallBehaviour
Copy Blocking memory-to-memory, completed by looping one channel's OUT path into its own IN path. Buffers and the driver's descriptors must be in internal SRAM, because the descriptor link address is a 20-bit field.
Start Arms a single-buffer peripheral transfer in one Direction and returns immediately. You configure and start the peripheral separately; the GDMA moves data as the peripheral raises its DMA request.
Start_Loop A single descriptor whose link points back at itself, so the engine replays the buffer forever with no gap between passes and no CPU involvement after the kick. Never completes on its own.

Direction is Mem_To_Periph (the OUT path reads RAM and feeds the peripheral) or Periph_To_Mem. A single descriptor caps at Max_Transfer = 4095 bytes, the hardware's 12-bit buffer-size field — which is where SPI's 1 .. 4095 precondition comes from.

Beyond one descriptor: the framebuffer path

For a buffer larger than 4095 bytes there is a chained ring: up to Max_Chain = 256 descriptors that between them cover the buffer, the last linking back to the first. This is the display path — stream an LCD framebuffer to LCD_CAM continuously. The buffer may live in PSRAM (32-byte aligned) and is written back before the loop starts. After the CPU draws into a live framebuffer, Flush pushes the changes out so the running DMA re-reads them.

The descriptor ring is one shared internal-SRAM array, so exactly one chained loop runs at a time.

Completion, and an interrupt you must not take

Completion is interrupt-driven rather than polled: Wait suspends the calling task and the channel's end-of-transfer interrupt wakes it.

This driver owns Device_L3_1 (CPU_INT 27). An application must not attach its own handler there. It is a level-3 slot rather than a level-2 one because the LCD RGB bounce refill runs from this completion ISR and has to preempt the level-2 devices to make its deadline.

Proving the coherency path

type Self_Test_Result is (Passed_PSRAM, Passed_SRAM, Failed, No_Channel);
function Self_Test (Buf_A, Buf_B : System.Address) return Self_Test_Result;

A memory-to-memory round trip between two buffers of your choosing, which reports which memory it actually exercised. Hand it PSRAM buffers (from a task whose stack is in PSRAM) and a Passed_PSRAM result means the cache write-back/invalidate path really works on your board — not merely that DMA works in SRAM. Buffers must be 32-byte aligned and at least 64 bytes.

GDMA: the DMA engine everything else borrows · 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 17 of 56

GDMA: the DMA engine everything else borrows

Five channel pairs, assigned at run time rather than wired per peripheral — and a buffer type whose rules exist because PSRAM is reached through a cache.

Channels are a resource, not a fixture

The S3 has one AHB GDMA block with five channel pairs (0 .. 4). Each pair has an independent transmit (OUT) and receive (IN) path, and either can be wired to any peripheral through the GDMA crossbar. A channel is therefore something you claim, not something a peripheral owns:

type Channel_Id is mod 5;
type Peripheral is (Mem2Mem, SPI2, SPI3, UHCI0, I2S0, I2S1, LCD_CAM, AES, SHA, ADC_DAC, RMT);

procedure Claim (C : in out Channel; Peri : Peripheral);
function  Is_Valid (C : Channel) return Boolean;
procedure Release (C : in out Channel);

Claim goes through a protected allocator, so two tasks can never be handed the same channel. The Channel handle is limited (non-copyable, so it cannot be aliased into another task) and controlled (it releases on scope exit, including on an exception, so a channel cannot leak or be reused through a stale copy). If all five are busy, Claim leaves the handle invalid rather than raising — check Is_Valid.

Once you hold a channel, only you touch its registers and descriptors, so the transfer operations themselves need no further locking. This is the same ownership pattern as SPI and I2C, and for the same reason.

Why a buffer type exists

DMA needs DMA-capable memory, and on this chip that is not a single rule:

DMA_Alignment : constant := 32;

type DMA_Buffer is array (Natural range <>) of Interfaces.Unsigned_8
  with Alignment => DMA_Alignment;

function Is_DMA_Capable (A : System.Address) return Boolean;

Alignment is only half of it. The type carries the aligned start; the operations' preconditions additionally demand a whole-cache-line size. That is not implied by alignment and it matters: the PSRAM write-back/invalidate rounds the region up to a whole line, so a buffer ending mid-line would have the maintenance touch its neighbour — discarding an adjacent object's dirty cached write. Size the payload up to a multiple of 32 (128 bytes for 100 useful ones).

Hence the calling convention: pass the whole buffer plus a transfer length, never a slice. Slicing to the length would fail the size precondition, while the whole line-multiple buffer keeps every rounded maintenance op inside itself.

procedure Copy (C : Channel; Dst, Src : DMA_Buffer; Length : Natural)
with Pre => Length <= Src'Length and then Length <= Dst'Length
            and then Src'Length mod DMA_Alignment = 0
            and then Dst'Length mod DMA_Alignment = 0;

These preconditions are pinned on with pragma Assertion_Policy (Pre => Check) in the spec, so they hold even when the build has assertions off — a buffer in flash or unaligned PSRAM corrupts the transfer silently, which is exactly the failure worth paying a check for.

The three shapes of transfer

CallBehaviour
Copy Blocking memory-to-memory, completed by looping one channel's OUT path into its own IN path. Buffers and the driver's descriptors must be in internal SRAM, because the descriptor link address is a 20-bit field.
Start Arms a single-buffer peripheral transfer in one Direction and returns immediately. You configure and start the peripheral separately; the GDMA moves data as the peripheral raises its DMA request.
Start_Loop A single descriptor whose link points back at itself, so the engine replays the buffer forever with no gap between passes and no CPU involvement after the kick. Never completes on its own.

Direction is Mem_To_Periph (the OUT path reads RAM and feeds the peripheral) or Periph_To_Mem. A single descriptor caps at Max_Transfer = 4095 bytes, the hardware's 12-bit buffer-size field — which is where SPI's 1 .. 4095 precondition comes from.

Beyond one descriptor: the framebuffer path

For a buffer larger than 4095 bytes there is a chained ring: up to Max_Chain = 256 descriptors that between them cover the buffer, the last linking back to the first. This is the display path — stream an LCD framebuffer to LCD_CAM continuously. The buffer may live in PSRAM (32-byte aligned) and is written back before the loop starts. After the CPU draws into a live framebuffer, Flush pushes the changes out so the running DMA re-reads them.

The descriptor ring is one shared internal-SRAM array, so exactly one chained loop runs at a time.

Completion, and an interrupt you must not take

Completion is interrupt-driven rather than polled: Wait suspends the calling task and the channel's end-of-transfer interrupt wakes it.

This driver owns Device_L3_1 (CPU_INT 27). An application must not attach its own handler there. It is a level-3 slot rather than a level-2 one because the LCD RGB bounce refill runs from this completion ISR and has to preempt the level-2 devices to make its deadline.

Proving the coherency path

type Self_Test_Result is (Passed_PSRAM, Passed_SRAM, Failed, No_Channel);
function Self_Test (Buf_A, Buf_B : System.Address) return Self_Test_Result;

A memory-to-memory round trip between two buffers of your choosing, which reports which memory it actually exercised. Hand it PSRAM buffers (from a task whose stack is in PSRAM) and a Passed_PSRAM result means the cache write-back/invalidate path really works on your board — not merely that DMA works in SRAM. Buffers must be 32-byte aligned and at least 64 bytes.

GDMA: the DMA engine everything else borrows · 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 17 of 56

GDMA: the DMA engine everything else borrows

Five channel pairs, assigned at run time rather than wired per peripheral — and a buffer type whose rules exist because PSRAM is reached through a cache.

Channels are a resource, not a fixture

The S3 has one AHB GDMA block with five channel pairs (0 .. 4). Each pair has an independent transmit (OUT) and receive (IN) path, and either can be wired to any peripheral through the GDMA crossbar. A channel is therefore something you claim, not something a peripheral owns:

type Channel_Id is mod 5;
type Peripheral is (Mem2Mem, SPI2, SPI3, UHCI0, I2S0, I2S1, LCD_CAM, AES, SHA, ADC_DAC, RMT);

procedure Claim (C : in out Channel; Peri : Peripheral);
function  Is_Valid (C : Channel) return Boolean;
procedure Release (C : in out Channel);

Claim goes through a protected allocator, so two tasks can never be handed the same channel. The Channel handle is limited (non-copyable, so it cannot be aliased into another task) and controlled (it releases on scope exit, including on an exception, so a channel cannot leak or be reused through a stale copy). If all five are busy, Claim leaves the handle invalid rather than raising — check Is_Valid.

Once you hold a channel, only you touch its registers and descriptors, so the transfer operations themselves need no further locking. This is the same ownership pattern as SPI and I2C, and for the same reason.

Why a buffer type exists

DMA needs DMA-capable memory, and on this chip that is not a single rule:

DMA_Alignment : constant := 32;

type DMA_Buffer is array (Natural range <>) of Interfaces.Unsigned_8
  with Alignment => DMA_Alignment;

function Is_DMA_Capable (A : System.Address) return Boolean;

Alignment is only half of it. The type carries the aligned start; the operations' preconditions additionally demand a whole-cache-line size. That is not implied by alignment and it matters: the PSRAM write-back/invalidate rounds the region up to a whole line, so a buffer ending mid-line would have the maintenance touch its neighbour — discarding an adjacent object's dirty cached write. Size the payload up to a multiple of 32 (128 bytes for 100 useful ones).

Hence the calling convention: pass the whole buffer plus a transfer length, never a slice. Slicing to the length would fail the size precondition, while the whole line-multiple buffer keeps every rounded maintenance op inside itself.

procedure Copy (C : Channel; Dst, Src : DMA_Buffer; Length : Natural)
with Pre => Length <= Src'Length and then Length <= Dst'Length
            and then Src'Length mod DMA_Alignment = 0
            and then Dst'Length mod DMA_Alignment = 0;

These preconditions are pinned on with pragma Assertion_Policy (Pre => Check) in the spec, so they hold even when the build has assertions off — a buffer in flash or unaligned PSRAM corrupts the transfer silently, which is exactly the failure worth paying a check for.

The three shapes of transfer

CallBehaviour
Copy Blocking memory-to-memory, completed by looping one channel's OUT path into its own IN path. Buffers and the driver's descriptors must be in internal SRAM, because the descriptor link address is a 20-bit field.
Start Arms a single-buffer peripheral transfer in one Direction and returns immediately. You configure and start the peripheral separately; the GDMA moves data as the peripheral raises its DMA request.
Start_Loop A single descriptor whose link points back at itself, so the engine replays the buffer forever with no gap between passes and no CPU involvement after the kick. Never completes on its own.

Direction is Mem_To_Periph (the OUT path reads RAM and feeds the peripheral) or Periph_To_Mem. A single descriptor caps at Max_Transfer = 4095 bytes, the hardware's 12-bit buffer-size field — which is where SPI's 1 .. 4095 precondition comes from.

Beyond one descriptor: the framebuffer path

For a buffer larger than 4095 bytes there is a chained ring: up to Max_Chain = 256 descriptors that between them cover the buffer, the last linking back to the first. This is the display path — stream an LCD framebuffer to LCD_CAM continuously. The buffer may live in PSRAM (32-byte aligned) and is written back before the loop starts. After the CPU draws into a live framebuffer, Flush pushes the changes out so the running DMA re-reads them.

The descriptor ring is one shared internal-SRAM array, so exactly one chained loop runs at a time.

Completion, and an interrupt you must not take

Completion is interrupt-driven rather than polled: Wait suspends the calling task and the channel's end-of-transfer interrupt wakes it.

This driver owns Device_L3_1 (CPU_INT 27). An application must not attach its own handler there. It is a level-3 slot rather than a level-2 one because the LCD RGB bounce refill runs from this completion ISR and has to preempt the level-2 devices to make its deadline.

Proving the coherency path

type Self_Test_Result is (Passed_PSRAM, Passed_SRAM, Failed, No_Channel);
function Self_Test (Buf_A, Buf_B : System.Address) return Self_Test_Result;

A memory-to-memory round trip between two buffers of your choosing, which reports which memory it actually exercised. Hand it PSRAM buffers (from a task whose stack is in PSRAM) and a Passed_PSRAM result means the cache write-back/invalidate path really works on your board — not merely that DMA works in SRAM. Buffers must be 32-byte aligned and at least 64 bytes.

GDMA: the DMA engine everything else borrows · 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 17 of 56

GDMA: the DMA engine everything else borrows

Five channel pairs, assigned at run time rather than wired per peripheral — and a buffer type whose rules exist because PSRAM is reached through a cache.

Channels are a resource, not a fixture

The S3 has one AHB GDMA block with five channel pairs (0 .. 4). Each pair has an independent transmit (OUT) and receive (IN) path, and either can be wired to any peripheral through the GDMA crossbar. A channel is therefore something you claim, not something a peripheral owns:

type Channel_Id is mod 5;
type Peripheral is (Mem2Mem, SPI2, SPI3, UHCI0, I2S0, I2S1, LCD_CAM, AES, SHA, ADC_DAC, RMT);

procedure Claim (C : in out Channel; Peri : Peripheral);
function  Is_Valid (C : Channel) return Boolean;
procedure Release (C : in out Channel);

Claim goes through a protected allocator, so two tasks can never be handed the same channel. The Channel handle is limited (non-copyable, so it cannot be aliased into another task) and controlled (it releases on scope exit, including on an exception, so a channel cannot leak or be reused through a stale copy). If all five are busy, Claim leaves the handle invalid rather than raising — check Is_Valid.

Once you hold a channel, only you touch its registers and descriptors, so the transfer operations themselves need no further locking. This is the same ownership pattern as SPI and I2C, and for the same reason.

Why a buffer type exists

DMA needs DMA-capable memory, and on this chip that is not a single rule:

DMA_Alignment : constant := 32;

type DMA_Buffer is array (Natural range <>) of Interfaces.Unsigned_8
  with Alignment => DMA_Alignment;

function Is_DMA_Capable (A : System.Address) return Boolean;

Alignment is only half of it. The type carries the aligned start; the operations' preconditions additionally demand a whole-cache-line size. That is not implied by alignment and it matters: the PSRAM write-back/invalidate rounds the region up to a whole line, so a buffer ending mid-line would have the maintenance touch its neighbour — discarding an adjacent object's dirty cached write. Size the payload up to a multiple of 32 (128 bytes for 100 useful ones).

Hence the calling convention: pass the whole buffer plus a transfer length, never a slice. Slicing to the length would fail the size precondition, while the whole line-multiple buffer keeps every rounded maintenance op inside itself.

procedure Copy (C : Channel; Dst, Src : DMA_Buffer; Length : Natural)
with Pre => Length <= Src'Length and then Length <= Dst'Length
            and then Src'Length mod DMA_Alignment = 0
            and then Dst'Length mod DMA_Alignment = 0;

These preconditions are pinned on with pragma Assertion_Policy (Pre => Check) in the spec, so they hold even when the build has assertions off — a buffer in flash or unaligned PSRAM corrupts the transfer silently, which is exactly the failure worth paying a check for.

The three shapes of transfer

CallBehaviour
Copy Blocking memory-to-memory, completed by looping one channel's OUT path into its own IN path. Buffers and the driver's descriptors must be in internal SRAM, because the descriptor link address is a 20-bit field.
Start Arms a single-buffer peripheral transfer in one Direction and returns immediately. You configure and start the peripheral separately; the GDMA moves data as the peripheral raises its DMA request.
Start_Loop A single descriptor whose link points back at itself, so the engine replays the buffer forever with no gap between passes and no CPU involvement after the kick. Never completes on its own.

Direction is Mem_To_Periph (the OUT path reads RAM and feeds the peripheral) or Periph_To_Mem. A single descriptor caps at Max_Transfer = 4095 bytes, the hardware's 12-bit buffer-size field — which is where SPI's 1 .. 4095 precondition comes from.

Beyond one descriptor: the framebuffer path

For a buffer larger than 4095 bytes there is a chained ring: up to Max_Chain = 256 descriptors that between them cover the buffer, the last linking back to the first. This is the display path — stream an LCD framebuffer to LCD_CAM continuously. The buffer may live in PSRAM (32-byte aligned) and is written back before the loop starts. After the CPU draws into a live framebuffer, Flush pushes the changes out so the running DMA re-reads them.

The descriptor ring is one shared internal-SRAM array, so exactly one chained loop runs at a time.

Completion, and an interrupt you must not take

Completion is interrupt-driven rather than polled: Wait suspends the calling task and the channel's end-of-transfer interrupt wakes it.

This driver owns Device_L3_1 (CPU_INT 27). An application must not attach its own handler there. It is a level-3 slot rather than a level-2 one because the LCD RGB bounce refill runs from this completion ISR and has to preempt the level-2 devices to make its deadline.

Proving the coherency path

type Self_Test_Result is (Passed_PSRAM, Passed_SRAM, Failed, No_Channel);
function Self_Test (Buf_A, Buf_B : System.Address) return Self_Test_Result;

A memory-to-memory round trip between two buffers of your choosing, which reports which memory it actually exercised. Hand it PSRAM buffers (from a task whose stack is in PSRAM) and a Passed_PSRAM result means the cache write-back/invalidate path really works on your board — not merely that DMA works in SRAM. Buffers must be 32-byte aligned and at least 64 bytes.