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 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:

ExceptionRaised 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);

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.

I2C 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 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:

ExceptionRaised 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);

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.

I2C 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 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:

ExceptionRaised 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);

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.

I2C 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 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:

ExceptionRaised 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);

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.

I2C 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 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:

ExceptionRaised 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);

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.

I2C 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 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:

ExceptionRaised 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);

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.