RFC: GPIO api as a driver node and ioctl

RFC: a generic GPIO API as a device node and ioctl

RTEMS has GPIO support in two BSPs and no general interface. I would like to
propose one, with four prototype drivers written against it, and get the shape
reviewed before any of it is proposed for merge.

Where this came from

I have been bringing up the ESP32-C3 on RTEMS, with ESPHome running on top of
it. That is almost entirely a GPIO application – the switches, relays,
buttons, one-wire sensors and bit-banged buses that make up a configuration
all come down to configuring a pin and then reading or writing it. Without a
general interface that layer gets rewritten against whatever each BSP happens
to offer, which for most BSPs is nothing. With one, a new board or the next
ESP part is a controller driver, and the application above it does not change.

The design brief is not mine. After surveying every GPIO implementation in the
tree, Chris Johns set out what the interface should be:

BSP hardware support is an RTEMS driver implemented in the BSP and the BSP
exclusively controls the pin allocation and modes at the SOC level. The API
controls the GPIO settings only for configured pins. A BSP has a standard
RTEMS driver with a node in the IMFS and an ioctl handler to implement the
API functionality. If the BSP does not support a feature not supported is
returned. The API uses logical pin numbering that are continuous and the
BSP maps that to the hardware. BSP pins can be in banks, and sparse. … Add
physical and virtual pin characteristic. A BSP driver can implement virtual
IO. Provide a call to return the configuration details of a pin in terms of
the published API set of configuration options.

What follows implements that brief, virtual IO included.

Something to say up front

This was written with AI assistance, and it is the first API of that origin
the project will have to review.
I would rather that be stated than
discovered. I have tried to make it reviewable on evidence rather than on
trust: the API is at 100% of coverable lines under tcgcov on a real QEMU run;
four independent drivers exist against it, which is the real test of whether
an abstraction fits; the documentation is complete in both manuals; and the
things that are not verified are listed below rather than left for a
reviewer to discover. Review it as hard as any other contribution.

The shape

A controller is a BSP driver registered as a device node, publishing
pin_count logical pins numbered 0…pin_count-1 with no holes; banks and
sparse hardware are the driver’s problem, not the caller’s. Thirteen
directives, each one ioctl():

fd = open("/dev/gpio0", O_RDWR);

rtems_gpio_pin_by_name(fd, "LED0", &pin);      /* name the pad, not the number */

memset(&config, 0, sizeof(config));
config.direction = RTEMS_GPIO_DIRECTION_OUTPUT;
config.flags = RTEMS_GPIO_FLAG_ACTIVE_LOW;     /* no hardware inverter needed */
config.drive_strength = 12000;                 /* microamps; reads back 20000 */
rtems_gpio_pin_configure(fd, pin, &config);

rtems_gpio_pin_set(fd, pin, 1);                /* logical 1; the pad goes low */

The generic layer does the locking, the pin range checks and the capability
checks, so no driver writes them. A handler left NULL answers ENOTSUP,
so a controller that can only read and write pins is a legal driver.

This ground has been covered before

Worth saying plainly, because none of the choices below are new:

  • Linux, December 2012. Alexandre Courbot’s RFC “gpiolib: introduce
    descriptor-based GPIO interface”

    replaced integer pin IDs with opaque descriptors, because the integer
    namespace was fixed at system build time, needed a statically allocated
    descriptor array, and required the ARCH_NR_GPIOS maximum. The modern
    gpiod_* interface is what came of it.
  • RTEMS, 2014-2015. This is not the project’s first generic GPIO API.
    bsps/include/bsp/gpio.h and bsps/shared/dev/gpio/gpio-support.c are
    Andre Marques’s, from the Raspberry Pi GSoC work, and they already answer
    “pins or ports?” with both: individual pins, plus an explicit
    rtems_gpio_group with rtems_gpio_write_group() /
    rtems_gpio_read_group() over a rtems_gpio_bsp_multi_read(bank, bitmask)
    backend op. It is still in the tree and still used by two BSPs — see the
    open questions.
  • Zephyr, April 2026. RFC: Fast GPIO API
    Extension
    —
    one descriptor is one port plus a pin mask, all masked pins change in a
    single register write, and cross-port atomicity is explicitly not promised.
    It is a latency extension for bit-bang drivers rather than a redesign;
    Zephyr’s standard API already has the port-and-mask shape.

The one thing the 2014 RTEMS API does that this one does not is fix its size
at compile time: it #errors unless the BSP defines
BSP_GPIO_PIN_COUNT and BSP_GPIO_PINS_PER_BANK, and rejects
BSP_GPIO_PINS_PER_BANK > 32. That is the same build-time-maximum problem
Courbot’s RFC set out to remove, and it is why pin_count here is a runtime
property of a controller instance.

Trade study: Linux gpiolib, Zephyr, ESP-IDF, and this proposal

Linux gpiolib Zephyr ESP-IDF this proposal
unit gpio_chip, ngpio lines a port device, ≤32 pins flat gpio_num_t per SoC controller node, pin_count logical pins
access /dev/gpiochipN + chardev v2 direct C calls direct C calls device node + ioctl()
pin numbering 0…ngpio-1 per chip 0…31 within a port fixed per SoC 0…pin_count-1 per controller
bulk get_multiple/set_multiple, unsigned long* gpio_port_set_masked(), uint32_t pin_bit_mask in gpio_config_t, uint64_t caller-sized uint32_t bitmaps
active low in gpiolib, above the driver GPIO_ACTIVE_LOW, above the driver none in the generic layer, above the driver
may block gpio_chip.can_sleep no equivalent n/a rtems_gpio_drv_ctrl::can_block
ownership line-request fd, per process none none application’s, see below
units debounce in µs flags; none documented enum indexes µA and µs
names gpio-line-names devicetree none per-pin name, and owner

ESP-IDF is the simplest of the four: one flat pin space per SoC, no
controller object, no device node, a 64-bit pin_bit_mask in gpio_config_t.
A good fit for one chip, and it does not carry over to the next one — which
is the problem here.

Zephyr is the closest match, and differs in one choice worth noting: its
gpio_port_pins_t is a uint32_t, so a port is at most 32 pins and a wider
controller is shown as several port devices. That is a fair answer — it makes
the bank the unit — but it hands the bank sums back to the caller, which the
brief above rules out for us. So: one controller with pin_count pins, and
caller-sized bitmaps across the whole of it.

Six things changed because of what Linux does.

  1. The fixed bulk maximum is gone. RTEMS_GPIO_PIN_LIST_MAX was 32 and
    tied together three unrelated things — transaction size, pin choice, and
    value word width. Now controller-relative bitmaps whose length the caller
    supplies, which answers Chris’s “what if the BSP has 96 pins?”.
  2. uint32_t, not unsigned long. Linux uses unsigned long internally
    but its userspace ABI is fixed width, deliberately. This header is both
    at once, and unsigned long is 32 bits on rv32 and 64 on aarch64.
  3. Active low moved above the driver and CAP_INVERT was removed.
    Requiring a hardware inversion register to support an active-low pin is
    backwards — it is a property of how the board is wired. No driver now
    contains any inversion at all.
  4. can_block is a property, not a guess. It was being worked out from
    physical-vs-virtual, which is a different question: a pad behind a slow bus
    may block and a software pin may not.
  5. BIDIRECTIONAL renamed to CAP_OUTPUT_READBACK. Bidirectional means a
    line that switches direction; this was an output whose read returns the pad
    rather than the latch.
  6. The atomicity claim was weakened to “done as one transaction”.
    Linux’s set_multiple does not promise the pins change at the same
    instant either, Zephyr promises nothing at all about its port
    operations, and a controller wider than one register cannot give it —
    the Zynq driver is exactly that case.

Considered and not taken: line-request file descriptors. Linux gives you a
request fd from /dev/gpiochipN and operations go against that, so closing
it releases the lines. It exists because of process isolation. RTEMS is one
address space, so a descriptor is not a trust boundary, and the same calls are
made from BSP code holding no descriptor at all; any fd-keyed rule needs a
“no owner” escape that a caller can simply use. Per the brief, the BSP
alone controls allocation, and coordination between users is the
application’s. What the API does check is the board’s decision:
RTEMS_GPIO_PIN_RESERVED is refused with EACCES before the driver is asked
anything, and rtems_gpio_pin_info::owner reports what holds a pin. That is
now written down in the header rather than left looking like something I
missed.

Also not taken: gpio-line-names. The FDT drivers use child nodes
instead, per the requested binding shape; the array would be a second way to
say the same thing.

Where this goes further than either:

  • Capability, board reservation and current owner are three separate
    questions
    , not one. CAP_OUTPUT says the silicon can; PIN_RESERVED says
    the board says no, and is refused before the driver is asked anything;
    owner says who has it now. Neither Linux nor ESP-IDF separates the middle
    one, and on a part where GPIO12…17 are the SPI flash it executes from,
    driving one does not produce an error — it produces a board that stops
    running.
  • Physical units — µA and µs — rather than hardware enum indexes, with the
    driver rounding up and writing back what it actually selected.
  • A virtual controller as a first-class driver, so a clock gate or a
    subsystem enable is driven through the same directives.

The drivers

Four, deliberately different from each other, because one driver proves
nothing about an abstraction:

driver what it exercises
ESP32-C3 22 pads in one register; flash and strapping pins; drive strength in µA mapped to four hardware settings
Zynq / ZynqMP one driver for an ARM and an AArch64 BSP, pin layout read from the device tree; banks that are not word-aligned (bank 1 is 22 pins wide, so bank 2 starts mid-word); MIO vs EMIO differing in what a read means
TI OMAP4 / BeagleBone Black four banks as four controllers; FDT ranges walked three levels; #interrupt-cells resolved from the interrupt parent
virtual pins that are callbacks — a proxy, a clock gate, a subsystem enable, a software signal with interrupt delivery

The two FDT drivers share one binding: stock vendor properties on the
controller node, rtems,-prefixed child nodes describing individual pins.

Documentation

Both manuals, as separate MRs against rtems-docs: c-user/gpio/, a
five-file manager chapter with thirteen directives in the rubric template,
modelled on c-user/regulator/; and bsp-howto/gpio.md, the driver-writer
half, in can.md’s shape. Both build with no new Sphinx warnings against
the manual’s baseline.

A question for the docs maintainers: c-user is largely generated from
rtems-central spec items. regulator and iodev are hand-written, so it has
been done by hand before, but if spec items are wanted instead I would rather
know now.

What is verified

  • cpukit/dev/gpio/gpio.c — 306/306 coverable lines, tcgcov over an
    unmodified image on qemu-system-arm -M xilinx-zynq-a9
  • the virtual driver — 183/183
  • gpio01 and gpio02 both pass; gpio02’s seven negative controls are
    recorded in its .doc, each mutation compiled and run

What is not verified, and this matters

No hardware. Not one of the four drivers has run on silicon. QEMU does not
model the Zynq PS GPIO registers at all — a write to DIRM_0 reads back zero
— and there is no am335x machine in QEMU. So the FDT parsing, the working out of
the pin layout and the API plumbing are tested; every register-level behaviour
in the three hardware drivers comes from a TRM and has never been executed.

There is also no AArch64 toolchain here, so the ZynqMP half of that driver is
checked by reading it, not by a build.

If anyone has a Zynq, a BeagleBone Black or a C3 on a desk, that is the most
useful thing anyone could contribute to this.

Open questions

  1. The existing API. Andre Marques’s bsps/include/bsp/gpio.h and
    bsps/shared/dev/gpio/gpio-support.c are used by beagle and raspberrypi.
    There is no symbol collision — that one is rtems_gpio_bsp_* and this is
    rtems_gpio_pin_* / rtems_gpio_drv_* — so they can coexist. But two GPIO
    APIs is not a good end state. Deprecate, port the two BSPs, or leave it?
    Note the TI driver and bbb-gpio.c touch the same registers and both ISRs
    read-and-clear IRQSTATUS for the whole bank, so enabling both on one bank
    is a real risk of losing interrupts.
  2. Level-triggered re-entrancy. The block holds the status up while the
    level lasts, so a handler that does not remove its cause is re-entered.
    Linux masks the line and unmasks on ack. Both FDT drivers behave the same
    way; it should be settled once in the API rather than per driver.
  3. gpio-line-names — worth supporting alongside the child-node form?

Branches

Happy to reshape any of it. The API is the part worth arguing about; the
drivers are evidence that it fits, and are replaceable.

What the BSPs actually do with GPIO, and what the new API cannot say

Scope and method

Every bsps/ directory was scanned: 16 architectures, 128 BSP directories.
Thirteen BSP families expose a GPIO or pad-configuration interface, plus one
I2C expander driver and the four drivers written against the proposed API.

The scan keyed on register names and public function names, then every
interesting result was read. Two deliberate exclusions: vendor SDK imports
(imxrt/mcux-sdk/, altera-cyclone-v/socal/) are in the tree but are not
RTEMS interfaces, and spi-gpio.c / imx-spi-gpio.c are consumers of GPIO
rather than providers.

A scan that keys on names misses a BSP that does something without using the
usual words for it. Treat absence in the tables below as “not found”, not as
“not there”.

Inventory

BSP family interface lines
arm/beagle old generic API (bsp/gpio.h) 525
arm/raspberrypi old generic API 372 + 138
aarch64/raspberrypi its own, 4 functions 110
arm/efm32gg11 its own 242
arm/lpc176x its own 396
arm/lm3s69xx pin config at start-up 146
arm/stm32f4 pin config at start-up 262
arm/lpc24xx pin config at start-up 608
arm/imx (shared) its own, FDT-driven 409
arm/tms570 pinmux only 280
microblaze/microblaze_fpga its own, AXI GPIO 361
sparc GRLIB gpiolib + grgpio 308 + 488
riscv/esp32 new API 578
shared zynq-gpio new API 1073
shared ti-gpio new API 1089
shared gpio-virtual new API 512
dev/i2c/gpio-nxp-pca9535 its own ioctls 188
shared gpio-support.c the old generic API 2037

Nothing is shared. Eleven of the thirteen families invented their own
vocabulary, and the two that did not — beagle and raspberrypi — are the two
the old generic API was written for.

Feature matrix

Y = present in the interface. - = absent. ~ = reachable but unnamed.

dir pull open drain drive str slew debounce edge irq level irq shared irq mask/unmask ack pending bulk analog/off mux
beagle Y Y - - Y Y¹ Y Y Y - - - Y ~² Y
raspberrypi (arm) Y Y - - - Y¹ Y Y Y - - - Y - Y
raspberrypi (a64) Y Y - - - - - - - - - - - - Y
efm32gg11 ~ - - - - - Y - - Y - - Y ~³ ~
lpc176x Y - - - - - Y - - - - - - - Y
lm3s69xx Y Y Y Y Y - - - - - - - - Y Y
stm32f4 Y Y Y - Y - - - - - - - - Y Y
lpc24xx Y Y Y - Y - - - - - - - - ~ Y
imx Y - - - - - Y Y - Y Y Y - - Y
tms570 - - - - - - - - - - - - - - Y
microblaze_fpga Y - - - - - Y⁴ - - Y Y Y Y - -
GRLIB gpiolib Y - - - - - Y Y - Y Y - - - -
pca9535 Y - - - - - - - - - - - Y - -
old generic API Y Y - - - Y¹ Y Y Y - - - Y - Y
proposed API Y Y Y Y - Y Y Y - - - - Y - n/a

¹ software debounce, in clock ticks — not a hardware debouncer.
² RXACTIVE in the AM335x pad config; bbb-gpio.c never touches it.
³ efm32gg11_gpio_mode() takes a raw nibble and EFM32 mode 0 is DISABLED.
⁴ per channel, not per pin.

Where the new API is ahead

Nothing else in the tree has drive strength as a first-class setting except
lm3s69xx, and nothing has open source at all. Capability reporting is
unique to the new API — every other interface makes you ask and find out.
Hardware debounce in real units (µs) exists nowhere else; the old API’s
debounce_clock_tick_interval is a software delay measured in ticks.

Atomic clear-and-set is covered. pca9535 has
clear_and_set_output(fd, clear, set) and efm32gg11 has
gpio_clear_set(port, clear, set); rtems_gpio_pin_set_multiple() with a mask
and a values bitmap is the same operation. No gap.

Gaps, ranked

1. Shared interrupt handlers — a regression, not a gap

The old API has rtems_gpio_handler_flag { SHARED_HANDLER, UNIQUE_HANDLER },
and gpio-support.c implements it properly: handler_chain is an
rtems_chain_control walked in the ISR, and a handler returns
rtems_gpio_irq_state { IRQ_HANDLED, IRQ_NONE } so it can say “not mine”.

That is exactly the RTEMS_INTERRUPT_SHARED behaviour asked for in review, and
RTEMS has had it since 2014. The proposed API has one handler per pin, and the
ESP32-C3 driver silently overwrites it on a second irq_enable
(esp32c3-gpio.c:509) — the second caller steals the line and the first is
never told.

Whatever is decided about sharing, the silent overwrite is a defect.

2. Mask and unmask, distinct from register and unregister

Three BSPs separate “stop delivering this interrupt” from “forget the handler”:

  • GRLIB: gpiolib_irq_mask() / gpiolib_irq_unmask() and separately
    gpiolib_irq_enable() / gpiolib_irq_disable() and separately
    gpiolib_irq_register(). Five operations where the new API has two.
  • efm32gg11_gpio_int_enable(which, bool) — keyed on the interrupt number, so
    the handler registration survives.
  • imx_gpio_int_enable() / imx_gpio_int_disable(), independent of the
    handler.

In the new API rtems_gpio_pin_irq_disable() drops the handler, so re-arming
means passing it again. More seriously, it takes the controller mutex, so it
cannot be called from the handler at all — which is what MR !12 addresses with
rtems_gpio_pin_irq_disable_isr().

3. Explicit acknowledge

gpiolib_irq_clear(), imx_gpio_clear_isr(pin, clr),
microblaze_gpio_interrupt_clear(). Three independent BSPs expose it.

The new API has nothing: drivers clear the status before calling the handler
and the caller never sees it. That is fine for edge triggers and wrong for
level triggers, which is open question 2 of the RFC — a level-triggered handler
that does not remove its cause is re-entered, and Linux masks the line and
unmasks on ack precisely to avoid that.

4. Pending status

imx_gpio_get_isr(), microblaze_gpio_interrupt_get_status(),
microblaze_gpio_interrupt_get_enabled(). No equivalent in the new API, so a
caller cannot poll instead of taking an interrupt, and a shared handler could
not work out which pin fired even if sharing existed.

5. Threaded handlers — also a regression

The old API’s rtems_gpio_interrupt_configuration carries
bool threaded_interrupts, and gpio-support.c routes handlers either to
interrupt context or to a server task accordingly (per bank, not per pin).

The new API has no choice: the handler runs wherever the driver raises it, and
rtems_gpio_get_info() only reports which that is. The blocking-ioctl
model discussed in review would cover the same ground more cleanly, but until
it exists this is capability that was in the tree and is not in the proposal.

6. Finite interrupt-line pools

efm32gg11_gpio_int_register() searches a pool of 16 external interrupt lines,
and a pin may only use the four lines in its group (pin / 4). It returns -1
when they are all taken.

So on that part a pin’s ability to interrupt is dynamic and contended. The
new API’s RTEMS_GPIO_CAP_EDGE_* are static bits from pin_get_info: a pin can
truthfully advertise edge capability and still fail to arm. irq_enable can
return an errno, so it is expressible — but no errno is documented for it and
there is no way to ask in advance. EBUSY would be the honest answer and it
should be written down.

7. Bank-level interrupts

AXI GPIO interrupts per channel, not per pin
(microblaze_gpio_interrupt_enable(ctx, channel)). The new API is per-pin only,
so such a driver must either demultiplex by reading the data register and
diffing, or report no interrupt capability at all.

8. Slew rate

lm3s69xx (slr field, LM3S69XX_GPIO_SLEW_RATE_CONTROL), beagle
(BBB_SLEWCTRL), lpc24xx. Three BSPs. The new rtems_gpio_config has
direction, bias, drive, trigger, flags, drive_strength, debounce
and initial_value — no slew, and no capability bit for one. Bits 15 to 31 of
the capability word are free.

9. Analog / disconnected pad state

lm3s69xx keeps two bits — digital (the den register) and analog
(amsel) — plus a runtime setter. stm32f4 has STM32F4_GPIO_MODE_ANALOG and
STM32F4_GPIO_CNF_IN_ANALOG. Versal/ZynqMP has PM_PINCTRL_CONFIG_TRI_STATE.
mpc55xx has APC and IBE. The input-buffer lever is wider still: AM335x
RXACTIVE, ESP32-C3 FUN_IE — which our own new driver already writes.

RTEMS_GPIO_DIRECTION_NONE cannot absorb this: it is documented as the state
before configure and after release, a lifecycle state. Note the old API has the
identical conflation, reached independently — NOT_USED in
rtems_gpio_function is bookkeeping, and asking for it returns
RTEMS_NOT_DEFINED (gpio-support.c:1407).

Because the four BSPs decompose it four different ways, one enumerator and one
capability bit is the right granularity. Modelling which kind of disconnected
would be wrong on at least one part.

10. Software debounce

The old API’s debounce is a software delay in clock ticks and works on any
part. The new API’s is hardware and in µs, so raspberrypi and beagle — the two
BSPs that use the old API today — get ENOTSUP where they used to get
debounce. Better layering, real capability loss, and a migration plan should
say so rather than discover it.

11. Hardware polarity inversion

pca9535 has a polarity inversion register
(GPIO_NXP_PCA9535_SET_POL_INV). The proposal deliberately removed
CAP_INVERT and applies RTEMS_GPIO_FLAG_ACTIVE_LOW above the driver, so a
pca9535 driver written against the new API cannot use the hardware and must be
careful not to invert twice. By design, worth stating.

12. Alternate function selection

aarch64/raspberrypi’s only configuration call is
raspberrypi_gpio_set_function(), whose enum mixes GPIO_INPUT, GPIO_OUTPUT
and GPIO_AF0 through GPIO_AF5. The old API had BSP_SPECIFIC plus an
io_function and a void *bsp_specific escape.

The new API excludes muxing on purpose — it is the BSP’s. That is the right
call, and it means porting raspberrypi is not a translation: the mux half of
its public interface has to go somewhere else first. Which is the pad
arbitration question.

Our own four drivers

zynq-gpio and ti-gpio report all fifteen capability bits. esp32c3-gpio
reports thirteen, omitting DEBOUNCE and OPEN_SOURCE, which the part does
not have. gpio-virtual reports three, which is right for a software pin.

No under-reporting found.

Recommendations

  1. Define what a second irq_enable on one pin does, before merge. Today it
    is undefined by omission and silently wrong in one driver.
  2. Carry an options field in rtems_gpio_pin_irq now. It is already the
    ioctl payload struct, so adding uint32_t options costs a line before merge
    and an ABI break after. RTEMS_GPIO_IRQ_SHARED can return ENOTSUP until a
    driver implements it.
  3. Add RTEMS_GPIO_DIRECTION_DISCONNECTED and a capability bit. Four BSPs
    already model it; Zephyr’s GPIO_DISCONNECTED is the precedent.
  4. Add slew rate — a field and a bit, three BSPs, free capability bits.
  5. Document an errno for “no interrupt resource” (EBUSY), for parts with
    a line pool.
  6. Settle acknowledge and level re-entrancy once, in the API rather than
    per driver. Three BSPs expose an explicit clear.
  7. Say what happens to threaded_interrupts and software debounce in the
    deprecation plan for the old API. Both are capability the tree has today.

Items 1 and 2 are pre-merge. The rest can follow, and items 3 to 6 are each
small enough to be one commit with a gpio01 test.