Step 14 of 56
I2C in depth
A master you cannot use wrongly: the raw registers are unreachable, the host is owned by an RAII session that releases itself even through an exception, and payload length is not a thing you have to think about.
Why you cannot reach the registers
The unsynchronised register driver lives in a private child,
ESP32S3.I2C.Engine, which cannot be withed from outside
that subtree. ESP32S3.I2C is the only interface an application sees,
so the raw primitives cannot be called by accident. Access to the hardware is
always mediated.
One-time setup
Call these once per host at startup, single-threaded, before any task contends for the bus:
procedure Setup (Host : I2C_Host; Clock_Hz : Positive := 100_000);
procedure Configure_Pins
(Host : I2C_Host; Scl : ESP32S3.GPIO.Pin_Id; Sda : ESP32S3.GPIO.Pin_Id);
I2C_Host is I2C0 or I2C1.
Configure_Pins routes SCL and SDA to physical pads as open-drain
lines with the internal pull-ups enabled, so a quick bring-up needs no
external resistors. The pin arguments are Pin_Id, so a reserved pad
is rejected at compile or run time exactly as on the
GPIO page.
Internal pull-ups are weak. They are fine on a short bench wire at 100 kHz; put real resistors on a production bus, especially at 400 kHz or with any cable length.
Ownership: the Session
Each host is guarded by a protected object, and Acquire hands out
a Session that owns it exclusively. Other tasks suspend in
Acquire until it is released.
type Session is limited private; -- limited: cannot be copied
-- controlled: releases itself on scope exit
Because Session is a controlled type, it releases the host
automatically on scope exit — including during exception
unwinding. A fault between Acquire and Release
cannot leak the lock and wedge every other task on the bus. Release
remains available to hand the host back early, and is idempotent.
Two exceptions enforce the ordering, rather than letting misuse fail quietly:
| Exception | Raised when |
|---|---|
Not_Initialized |
Acquire on a host that was never Setup. |
Not_Owned |
A transaction attempted with a session that holds no host. Both
Write and Read reach the hardware through
one ownership-checked gateway, so this fails loudly. |
The API also carries contracts: Acquire has
Post => Is_Held (S), the transactions have
Pre => Is_Held (S), and Release has
Post => not Is_Held (S).
The protected object arbitrates ownership only. The blocking transaction itself runs outside the lock — the lock is never held across the bus busy-wait, so a slow device cannot stall the protected object.
The three transactions
procedure Write
(S : Session; Addr : Slave_Address; Data : Byte_Array;
Success : out Boolean; Check_Ack : Boolean := True);
procedure Read
(S : Session; Addr : Slave_Address; Data : out Byte_Array;
Success : out Boolean);
procedure Write_Read
(S : Session; Addr : Slave_Address; Tx : Byte_Array;
Rx : out Byte_Array; Success : out Boolean);
Write— START,Addr<<1 | W, the data bytes, STOP.Successis True only if the slave ACKed the address and every byte.Read— START,Addr<<1 | R, thenData'Lengthbytes, ACKing all but the last and NACKing the last, STOP. A zero-length read returnsSuccess => False.Write_Read— one transaction with a REPEATED START between the phases and no STOP separating them, so the slave sees a single command. This is what a "write the register index, then read it back" device requires, and it is precisely what back-to-backWritethenReadcalls cannot express.
Length is not your problem
The package exports a constant that is easy to misread:
Max_Transfer : constant := 32; -- the FIFO depth -- NOT a transfer limit
All three transactions take payloads of any length. The
driver refills the transmit FIFO (or drains the receive FIFO) mid-transaction
using the command FSM's END opcode, which pauses the sequence with
the bus still held. The length is therefore invisible on the wire: a 200-byte
write is still one START…STOP transaction.
That matters more than it sounds. For a device where a STOP means end-of-command — an EEPROM page write, say — a driver that silently split at 32 bytes would produce a subtly corrupt device, not an error.
Scanning the bus
Passing a Data array of length zero does not skip the
transaction — it sends a complete one with no payload: START, the
address byte, then STOP. Nothing is written to the device, so the only thing
the exchange can tell you is whether something out there pulled SDA low to
acknowledge its address. That answer arrives in Success, which is
exactly what a bus scan needs, so scanning is the same call in a loop over the
address range:
-- Guide step 13 -- "Scanning the bus".
--
-- A zero-length Write is a complete transaction with no payload: START, the
-- address byte, STOP. Success is then simply "did anything ACK this address",
-- which is exactly what a bus scan needs.
--
-- Two things the compiler will hold you to, and the reason this file is
-- compiled rather than hand-copied into the page:
-- * (1 .. 0 => 0) is the null array aggregate -- upper bound below lower.
-- * Slave_Address is a Natural subtype but Put_Hex takes an Unsigned_32, so
-- the conversion is required.
with Interfaces;
with ESP32S3.I2C;
with ESP32S3.Log; use ESP32S3.Log;
procedure I2C_Scan is
S : ESP32S3.I2C.Session; -- releases itself when this scope exits
Ok : Boolean;
begin
ESP32S3.I2C.Acquire (S, ESP32S3.I2C.I2C0);
for A in ESP32S3.I2C.Slave_Address'Range loop -- 0 .. 16#7F#
ESP32S3.I2C.Write (S, A, Data => (1 .. 0 => 0), Success => Ok);
if Ok then
Put ("[i2c] device at 0x");
Put_Hex (Interfaces.Unsigned_32 (A), Width => 2);
New_Line;
end if;
end loop;
end I2C_Scan;
Two details in there are easy to get wrong. (1 .. 0 => 0) is
how you write a null array aggregate — a range whose upper bound is below
its lower one. And Slave_Address is a Natural subtype
while Put_Hex takes an Interfaces.Unsigned_32, so the
conversion is required; without it the compiler says
expected type "Interfaces.Unsigned_32", found type
"Standard.Integer".
Check_Ack => False on Write does the opposite:
it clocks the whole transaction out regardless of whether anything answers,
which is how the self-test exercises the bus with no device attached.
The self-test, and what it cannot prove
./x run esp32s3_i2c_loopback needs no wiring and no device. It
checks three things: that an ACK-checked write to an absent address correctly
reports NACK; that the same write with Check_Ack => False runs to
completion; and that a session which goes out of scope through an
exception still releases its host, so a following Acquire
does not deadlock.
There is no internal loopback for I2C, and there cannot be. SDA is a bidirectional open-drain (wired-AND) node: both ends must drive and read the same wire. The ESP32-S3's GPIO matrix gives each pad exactly one output source, so two on-chip controllers cannot be wired-AND onto one pad. Cross-coupling two pads breaks the master's mandatory write-readback; a single shared pad breaks the slave's mandatory ACK. Verifying the read direction and the ACK handshake needs a real shared bus — an external device, or a jumper tying two pads together.
The controlled Session needs finalization, so I2C is
part of the HAL's embedded subset and
is excluded under light-tasking. The loopback example's
build.sh sets ESP32S3_RTS_PROFILE=embedded
accordingly.