Step 13 of 56
GPIO in depth
A pin type that refuses to name a pad which would hang the chip, three operations that are atomic in hardware, two that are not, and pin interrupts with one rule you cannot break.
The type that stops you first
Most GPIO APIs take an integer. This one takes a subtype whose predicate encodes which pads exist and which are already spoken for:
type Pad_Number is range -1 .. 48;
No_Pin : constant Pad_Number := -1; -- an optional line, left unrouted
subtype Pin_Id is Pad_Number range 0 .. 48
with Static_Predicate => Pin_Id in 0 .. 21 | 38 .. 48;
subtype Optional_Pin is Pad_Number
with Static_Predicate => Optional_Pin in -1 | 0 .. 21 | 38 .. 48;
Pads 22…25 do not exist on the ESP32-S3. Pads 26…37 are bonded to the in-package SPI flash and octal PSRAM. Driving any of them hangs the chip — not an exception you can catch, a dead board. So they are not in the subtype.
Why it is a Static_Predicate and not a
dynamic one.
Because it is static, naming a reserved pad as a compile-time value is a
compile error (static expression fails static predicate
check), not a runtime surprise. That only bites if the value is static
— so declare your pin constants as Pin_Id, not as untyped
numerals, and you get the check:
Led : constant ESP32S3.GPIO.Pin_Id := 30; -- rejected at COMPILE time (PSRAM pad)
Led : constant := 30; -- just an integer; no check here
A value computed at run time is checked by the subtype predicate instead,
wherever assertions are enabled (-gnata, which the HAL project turns
on). There is deliberately no Predicate_Failure message: supplying
one would drag in Ada.Exceptions, which the
light-tasking runtime does not provide.
Configuring a pad
procedure Configure
(Pin : Pin_Id;
Mode : Pin_Mode; -- Input | Output
Pull : Pull_Mode := Floating; -- Floating | Pull_Up | Pull_Down
Drive : Drive_Strength := Drive_Medium);
Drive_Strength is the IO_MUX FUN_DRV field —
Drive_Weak, Drive_Medium, Drive_Strong,
Drive_Strongest, roughly 5 / 10 / 20 / 40 mA. Output pads get
their driver enabled; input pads get the input buffer enabled.
Configure is for plain software GPIO
only. It always routes the pad through the GPIO matrix as a
software-controlled pin (IO_MUX MCU_SEL = 1, GPIO output index 256).
Routing a pad to a peripheral signal is the job of that peripheral's own
Configure_Pins — ESP32S3.I2C.Configure_Pins,
ESP32S3.SPI's, and so on — which programs the matrix directly.
Calling GPIO.Configure on a pad you have handed to a peripheral
takes it back.
What is atomic, and what is not
| Operation | Mechanism | Concurrency |
|---|---|---|
Set (Pin) | Hardware W1TS bank | Atomic in silicon — safe to call concurrently as-is |
Clear (Pin) | Hardware W1TC bank | Atomic in silicon |
Write (Pin, On) | W1TS or W1TC | Atomic in silicon |
Read (Pin) | A plain load | Pure read — always safe |
Configure (…) | Read-modify-write | Serialised through a protected object |
Toggle (Pin) | Read-modify-write | Serialised through a protected object |
The write-1-to-set and write-1-to-clear banks are why the first three need no
lock at all: the CPU never reads the output register, so two tasks driving two
different pins cannot lose each other's update. Configure and
Toggle must read before they write, so they take the lock.
The lock keeps the registers consistent, not your intent. Two tasks driving the same pin still race over what the pin should be; that is the application's problem, and no driver can solve it for you. Note also that the protected object means this package needs a tasking runtime — every profile here has one.
Pin interrupts
The GPIO peripheral has exactly one interrupt source: the OR
of every pin's latched status. ESP32S3.GPIO.Interrupts owns that
source, routes it to the runtime's level-2 device slot
(Ada.Interrupts.Names.Device_L2_1, CPU_INT 20), and demuxes by
status to call your per-pin action. It takes a level-2 rather than a level-3
slot because on an RGB-LCD board both L3 slots are already spoken for — the
LCD engine's relock and the GDMA end-of-frame.
type Trigger is (Rising_Edge, Falling_Edge, Any_Edge, Low_Level, High_Level);
type Callback is access procedure;
procedure Enable (Pin : Pin_Id; On : Trigger; Action : Callback)
with Pre => Action /= null;
procedure Disable (Pin : Pin_Id);
Jorvik attaches handlers statically, so you never pass an ISR — you
register a callback that the module's own ISR invokes. The pin's input buffer
must be on, which GPIO.Configure already arranges.
The one rule you cannot break
The callback must be closure-free and
library-level. On this target, stacks live in the data-bus SRAM window,
whose instruction-bus alias is a different address. A GNAT
trampoline — the small stack stub emitted when you take
'Access of a nested subprogram that references up-level
variables — is therefore not executable, and calling it faults with
InstrFetchProhibited: a silent hang, not a clean error.
The cure is a restriction that makes the compiler reject the
trampoline at the 'Access line instead of emitting one:
pragma Restrictions (No_Implicit_Dynamic_Code);
Whether you already have it depends on your profile. Compiling a nested callback that captures a local, against each runtime in turn:
| Profile | Without the restriction | With it |
|---|---|---|
light-tasking |
Rejected — the restriction is implicit in this runtime | Rejected |
embedded |
Compiles clean — and faults on hardware | Rejected |
full |
Compiles clean — and faults on hardware | Rejected |
So on embedded and full — the profiles this guide
recommends — nothing stops you by default. The HAL opts itself in, which is why
driver code is safe; your own project is not until you do the same. It is two
steps.
1. Create no_dynamic_code.adc next to your
app.gpr, containing exactly one line:
pragma Restrictions (No_Implicit_Dynamic_Code);
2. Name it in app.gpr's Compiler
package — one attribute, alongside the switches the scaffold already
wrote. The path is relative to the project file:
package Compiler is
for Switches ("Ada") use ("-O2", "-g");
for Local_Configuration_Pragmas use "no_dynamic_code.adc"; -- <-- add this
end Compiler;
From then on the mistake is a build error naming the file that forbade it,
pointing at the exact column of the 'Access:
u.adb:9:24: error: violation of restriction "No_Implicit_Dynamic_Code"
at no_dynamic_code.adc:1
Under light-tasking, where the restriction is already implicit,
the same mistake reads violation of implicit restriction and names
no file.
The idiom that works
A library-level package holding an atomic flag, with the real work done by a task. This is the shape used by the in-tree interrupt clients:
-- imu_irq.ads -- library level, no enclosing subprogram, no closure
package IMU_IRQ is
Fired : Boolean := False
with Atomic, Volatile;
procedure Handler;
end IMU_IRQ;
-- imu_irq.adb
package body IMU_IRQ is
procedure Handler is
begin
Fired := True; -- latch only; the task does the slow work
end Handler;
end IMU_IRQ;
The callback runs in interrupt context, inside a protected
action at the level-2 ceiling. Keep it short; do not call a lower-ceiling
protected object; do not block; and do not touch a slow bus like I2C from
inside it. Set an Atomic flag or a
Suspension_Object, and let a normal task at task level do the
rest.