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
| Call | Effect |
|---|---|
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
Write (S, Data)pushes to the TX FIFO, waiting for room. It returns once every byte is queued — not necessarily shifted out of the pin. Do not power something down or drop the line immediately after it returns.Read (S, Data, Count)reads up toData'Lengthbytes, waiting briefly for each, and reports how many actually arrived. A short read is a timeout, not an error — always useCount, never assume the buffer filled.Available (S)is the number of bytes waiting now.Release (S)hands the port back early, for a session whose scope outlives its use of the link. It is idempotent, and scope exit does it for you — so it is a convenience, never an obligation.
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.