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

UART in depth

The one driver here with no setup call at all: you cannot touch a port you do not hold. Plus interrupt-driven RX, an Ada declaration that must be written a particular way, and a pin-routing trap.

Three ports, all of them yours

type UART_Port is (UART0, UART1, UART2);

On most ESP32 boards UART0 is the ROM console and effectively spoken for. Not here: this runtime puts the console on the USB-Serial-JTAG peripheral, so UART0's pads are free to repurpose like any other port.

No setup call — ownership comes first

I2C and SPI both have a port-level Setup you call before anyone contends. UART deliberately has none. Acquire takes the port and shapes it in one call, and every later configuration call requires the held session:

Acquire (S, UART1);                           --  bare: 115200 8-N-1
Acquire (S, UART1, Tx => 17, Rx => 16);       --  full-duplex link
Acquire (S, UART1, Rx => 18);                 --  RX only (e.g. a GPS)
Acquire (S, UART1, Tx => 17, Rx => 16,
                   Rts => 19, Cts => 20);     --  + RTS/CTS flow control

The parameters are typed rather than numeric, so a nonsense frame format does not reach the hardware: Baud : Baud_Rate (300 .. 5_000_000), Bits : Data_Bits (5 .. 8), Parity : Parity_Mode (None, Even, Odd) and Stop : Stop_Bits (One, Two).

So changing a setting requires owning the port and can never race another task. There is no way to reconfigure a UART somebody else is mid-transfer on, because there is no API that takes a port instead of a session.

Acquire sets the full state. The first Acquire of a port creates the controller; every Acquire then re-applies baud, frame format and pin routing. A session does not inherit the previous holder's settings — you get exactly what you asked for, defaulting to 115200 8-N-1 with nothing routed.

Flow control

Passing Rts enables RX flow control: the controller drives RTS to pause the peer once our RX FIFO reaches Rx_Flow_Threshold bytes of its 128. Passing Cts enables TX flow control: the transmitter only sends while the peer asserts CTS. Inputs (RX, CTS) get an internal pull-up, so an idle line reads high rather than floating.

The pin-routing trap

No_Pin does two different things at once. In Configure_Pins (and in Reconfigure), No_Pin means "leave this line's routing alone" — which is what Acquire's defaults rely on. But the flow-control enable bits are written in full every time, so that same No_Pin still turns that line's flow control off. If you re-route TX and RX on a link that had RTS/CTS, and do not name RTS and CTS again, you keep the wires and lose the flow control.

A second, subtler one: a named output line moves. The pad it used to drive is released back to a pulled-up input so it stops transmitting. That is necessary because the GPIO matrix selects an output per pad, not per signal — without the release, the old pad would go on driving TXD alongside the new one.

Full state versus one attribute

CallEffect
Reconfigure (S, …) Re-applies the whole baud + frame + routing state on the held port, without releasing it. An omitted attribute returns to its default; an omitted pin is unrouted.
Set_Baud, Set_Data_Bits, Set_Parity, Set_Stop_Bits Read-modify-write of just that attribute, effective immediately, leaving everything else (including routing) untouched.
Set_Inversion Inverts (or un-inverts) each line's polarity independently. Sets the full state of all four lines, so an omitted one is cleared.

Reach for the finer setters when you mean "change one thing" — Reconfigure with a single argument silently resets the rest.

Interrupt-driven RX

Polled Read is fine for a device that answers when spoken to. It is not fine for one that streams asynchronously — a modem, a GPS — because a burst can overflow the 128-byte hardware FIFO between your calls. Enable_Buffered_Rx switches the port to an RX interrupt (FIFO-full plus byte-timeout) that drains the FIFO into a ring buffer the instant bytes arrive; Read and Available then serve from that buffer.

The buffer is caller-owned — you pass an Rx_Buffer_Access — and its size is the ring depth. It must outlive the port and must be library-level, because the RX ISR writes it — never a stack object.

Declare it without bounds. This is a real Ada constraint, not a style preference:

It goes in a package, not in your procedure. Declaring it inside the subprogram that calls Enable_Buffered_Rx fails with non-local pointer cannot point to local object — the language enforcing the same lifetime rule the ISR needs:

--  Guide step 15 -- the caller-owned ring buffer for interrupt-driven RX.
--
--  It lives in a PACKAGE, not in the procedure that calls Enable_Buffered_Rx:
--  the RX ISR writes it, so it has to outlive every scope.  Declare it inside a
--  subprogram and the compiler says
--     error: non-local pointer cannot point to local object
--
--  And it must be declared WITHOUT bounds, taking them from the initial value,
--  so its nominal subtype stays the unconstrained Byte_Array the access type
--  designates.  With explicit bounds it is a constrained subtype, 'Access is
--  illegal, and the only way through would be 'Unrestricted_Access:
--     Ring : aliased ESP32S3.UART.Byte_Array (0 .. 255) := (others => 0);  -- WRONG
with ESP32S3.UART;

package UART_Buf is
   Ring : aliased ESP32S3.UART.Byte_Array := (0 .. 255 => 0);
end UART_Buf;
--  then, anywhere:
ESP32S3.UART.Enable_Buffered_Rx (ESP32S3.UART.UART1, Uart_Buf.Ring'Access);

Get it wrong and the compiler tells you precisely, which is worth recognising because the error lands on the 'Access line while the actual mistake is up at the declaration:

ring.ads:8:04: warning: aliased object has explicit bounds
ring.ads:8:04: warning: declare without bounds (and with explicit initialization)
ring.ads:8:04: warning: for use with unconstrained access
ring.ads:10:28: error: object subtype must statically match designated subtype

Call it once at startup, single-threaded, before any task acquires the port; it brings the port up itself if nothing has acquired it yet.

Repairing a skewed RX FIFO

procedure Repair_Rx (S : Session);

An RX FIFO overflow can leave the hardware's read and write pointers skewed, after which the port serves correct bytes in the wrong order — a rotation, not a corruption, which is far more confusing to debug than dropped data. Repair_Rx re-aligns them. It is cheap and safe on a quiet line and a no-op when the pointers are already sane, so it costs nothing to call after a suspected overrun.

Transfers

A loopback that actually works on-chip

procedure Enable_Loopback (S : Session; On : Boolean := True);

Unlike I2C, where an internal loopback is impossible because SDA is a wired-AND node, UART is push-pull and unidirectional — so the controller's internal TX→RX loopback proves the whole real data path: baud divider, frame format, TX FIFO, RX FIFO, with no pins and no wiring.

./x run esp32s3_uart_loopback runs three tests on that basis: a known buffer at 115200 8-N-1 written and read back byte-exact; hardware RTS/CTS flow control, with RTS matrix-looped to CTS so the CTS-gated transmitter visibly stalls at a low threshold and then drains intact; and per-line inversion, where inverting only TX breaks the link and inverting RX as well makes both ends agree again.