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

Port expanders: TCA9555, CH422G and HC595

Three ways to buy more pins, and three quite different bargains — per-pin control, an all-or-nothing direction bit, and a shift register with no readback at all.

Two levels of locking, again

The two I2C expanders share the pattern the ST7789 uses: a Session owns the device, while the I2C host is locked only for each transaction. The per-device guards live in a fixed library-level array keyed by (host, strap value), not inside the Device record — a protected object there would be a local PO, which this runtime forbids.

TCA9555 — the conventional one

subtype Hardware_Address is Natural range 0 .. 7;    --  three strap pins
type Pin_Number is range 0 .. 15;                    --  P0 = 0..7, P1 = 8..15
type Port_Value is mod 2**16;
type Direction  is (Output, Input);
type Pin_State  is (Low, High);

Sixteen pins in two 8-bit ports, per-pin direction, three address straps so up to eight can share a bus, and an interrupt output with a .Interrupts child. If you want an expander that behaves the way you expect, this is it.

CH422G — the one that will surprise you

Eight bidirectional pins plus four output-only ones. Same locking shape, very different chip:

It is not a register-pointer device. Each operation is a single-byte transaction to a fixed, function-specific I2C address: 0x24 system config, 0x23 write OC outputs, 0x38 write IO outputs, 0x26 read IO inputs. One consequence follows immediately — there are no address straps and only one CH422G per bus, so Setup takes no address.

Direction is global. A single IO_OE bit makes all of IO0..IO7 inputs or all of them outputs. There is no per-pin direction. If you need three inputs and five outputs from one CH422G, you cannot have it.

type IO_Direction is (Inputs, Outputs);    --  all of IO0..IO7, together
type OC_Drive     is (Push_Pull, Open_Drain);   --  all of OC0..OC3, together

The config, OC and IO-output registers cannot be read back — only the IO pins can, via RD-IO. So the driver keeps a shadow initialised to the datasheet's power-on defaults (IO inputs, OC high, push-pull). That shadow is only correct if the chip really is at its defaults when you start, so a warm restart without a power cycle can leave the driver's idea of the outputs out of step with reality. The chip also has no interrupt output, hence no .Interrupts child.

This is the expander that holds the GT911's reset line on the Waveshare ESP32-S3-Touch-LCD-7 — see step 31 for why that ordering matters.

HC595 — a shift register on the SPI bus

Not I2C at all: any number of 74HC595s daisy-chained (each chip's QH' into the next chip's SER), giving N * 8 outputs from three wires.

type Controller (Chips : Positive) is limited private;

procedure Set_Output   (C : in out Controller; Index : Natural; On : Boolean);
procedure Set_Byte     (C : in out Controller; Chip : Natural; Value : Byte);
procedure Update       (C : in out Controller);        --  shift out + latch
procedure Write_Output (C : in out Controller; Index : Natural; On : Boolean);
procedure Clear_All / Set_All (C : in out Controller);

The driver keeps a shadow of the intended state; Update shifts the whole string out in chain order and pulses RCLK to latch it. So Set_Output is buffered and Write_Output is set-then-update — batch your changes and call Update once rather than paying a full string shift per bit.

It borrows the SPI host per Update with no chip select asserted, so other devices on the bus are undisturbed. The application must Setup and Configure_Pins the shared SPI host first — the expander does not own it.