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 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_PinsESP32S3.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

OperationMechanismConcurrency
Set (Pin)Hardware W1TS bankAtomic in silicon — safe to call concurrently as-is
Clear (Pin)Hardware W1TC bankAtomic in silicon
Write (Pin, On)W1TS or W1TCAtomic in silicon
Read (Pin)A plain loadPure read — always safe
Configure (…)Read-modify-writeSerialised through a protected object
Toggle (Pin)Read-modify-writeSerialised 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:

ProfileWithout the restrictionWith 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.

GPIO 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 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_PinsESP32S3.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

OperationMechanismConcurrency
Set (Pin)Hardware W1TS bankAtomic in silicon — safe to call concurrently as-is
Clear (Pin)Hardware W1TC bankAtomic in silicon
Write (Pin, On)W1TS or W1TCAtomic in silicon
Read (Pin)A plain loadPure read — always safe
Configure (…)Read-modify-writeSerialised through a protected object
Toggle (Pin)Read-modify-writeSerialised 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:

ProfileWithout the restrictionWith 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.

GPIO 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 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_PinsESP32S3.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

OperationMechanismConcurrency
Set (Pin)Hardware W1TS bankAtomic in silicon — safe to call concurrently as-is
Clear (Pin)Hardware W1TC bankAtomic in silicon
Write (Pin, On)W1TS or W1TCAtomic in silicon
Read (Pin)A plain loadPure read — always safe
Configure (…)Read-modify-writeSerialised through a protected object
Toggle (Pin)Read-modify-writeSerialised 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:

ProfileWithout the restrictionWith 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.

GPIO 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 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_PinsESP32S3.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

OperationMechanismConcurrency
Set (Pin)Hardware W1TS bankAtomic in silicon — safe to call concurrently as-is
Clear (Pin)Hardware W1TC bankAtomic in silicon
Write (Pin, On)W1TS or W1TCAtomic in silicon
Read (Pin)A plain loadPure read — always safe
Configure (…)Read-modify-writeSerialised through a protected object
Toggle (Pin)Read-modify-writeSerialised 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:

ProfileWithout the restrictionWith 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.

GPIO 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 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_PinsESP32S3.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

OperationMechanismConcurrency
Set (Pin)Hardware W1TS bankAtomic in silicon — safe to call concurrently as-is
Clear (Pin)Hardware W1TC bankAtomic in silicon
Write (Pin, On)W1TS or W1TCAtomic in silicon
Read (Pin)A plain loadPure read — always safe
Configure (…)Read-modify-writeSerialised through a protected object
Toggle (Pin)Read-modify-writeSerialised 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:

ProfileWithout the restrictionWith 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.