Why the Raspberry Pi GPIO API is broken and how to fix it
How a quiet change in Linux and libgpiod boned developers… ioctl saves the day.
Back in one of my first Raspberry Pi irrigation articles, I wrote about building systems meant to last and how I walked away from Apple’s ecosystem because tools like Swift and iOS changed so often that working code would rot within months.
On Linux and the Raspberry Pi, I thought I’d found what Apple had lost: stability. I could write my projects in C++ using low-level system APIs with confidence because Linux had a long tradition of not breaking userspace. The kernel team’s rule was simple: if your program worked last year, it should still work this year.
But there was one exception. While writing the code, I was also designing and debugging the custom hardware, and I took a shortcut. I built my GPIO code around the official user-space library libgpiod, assuming it was the stable, official way forward. That choice came back to bite me in the ass.
So to borrow a line from Animal House:
“You can’t spend your whole life worrying about your mistakes! You fucked up... you trusted us! Hey, make the best of it!”
And sure enough, the Linux world, the one I thought had outgrown that kind of churn, boned us and went and broke GPIO anyway.
Do No Harm, Do Know Harm
The change that broke everything didn’t come from carelessness, but rather from good intentions. It went like this.
The Linux kernel developers improved the GPIO subsystem to what’s now called the GPIO Character Device Userspace API. A better, more capable design that stayed backward-compatible at the kernel level. A good call on their part.
However, the libgpiod maintainers, led by Bartosz Golaszewski, took a different route. Instead of evolving the old API, they completely rewrote the library to match the new interface, adopting an object-oriented design that broke every existing 1.x project in the process.
“It’ll be quite hard to create a wrapper for both as the API has been completely rebuilt and there are no 1:1 translations between the interfaces.” — Bartosz Golaszewski, GitHub
This broke a lot of stuff.
Every core data structure - struct gpiod_chip and struct gpiod_line were replaced with opaque object types and accessors.
Function names and signatures - the old gpiod_line_request_input() / gpiod_line_get_value() style calls were removed entirely and replaced with methods bound to “request” and “line” objects.
Initialization and teardown flow - instead of simple open/use/close patterns, 2.x introduced context-based objects with internal lifetime management.
Event and edge handling - moved to a new model using “requests” and “info” objects, breaking the old polling logic.
Header organization and constants - nearly all moved or renamed, making old includes incompatible.
I don’t fault them for updating the API; progress needs to happen sometimes. But the rewrite was effectively a new library wearing the old name. Code that had worked for years suddenly stopped compiling. Tutorials, examples, and embedded projects built around the old API all went stale overnight. Developers, like me, trying to modernize their systems found that the function names, structures, and usage model was different. There was no simple upgrade path.
But wait, there’s more fun
f you’ve been in this game long enough, you know better than to get butt-hurt when something breaks, it happens. Updating the API itself wasn’t the problem; I already had a C++ wrapper around my GPIO calls. What really boned me was how I did the upgrade. I was still running the previous Raspberry Pi OS (Bookworm), which shipped with the older libgpiod 1.6 runtime.
Since I hadn’t installed the development headers yet, I ran:
sudo apt install libgpiod-devWhat I didn’t realize was that apt quietly fetched the 2.x developer package from Debian’s testing branch. My compiler was now building against v2 headers while the system still linked against the v1 runtime. It’s not supposed to work that way.
On my first compile, I quickly realized the API had changed, not thrilling, but fine, I figured I’d update my wrapper code and move on. Except when I linked, half the functions had vanished. The linker complained about missing symbols, and the structures I passed no longer matched what the library expected. That’s when it clicked: I was compiling against libgpiod 2.x headers but still linking to the 1.6 runtime.
The two spoke completely different dialects. What should’ve been a simple rebuild turned into a one huge dumpster fire. The compiler and linker were both technically “right,” but the system itself was split in two.
The culprit turned out to be the Raspberry Pi OS repositories. Bookworm’s default repo still carried the old libgpiod 1.6 runtime, but when I installed the dev package, apt quietly fetched the libgpiod 2.x headers from Debian’s testing branch. The system ended up with libraries from two different worlds: one stable, one bleeding edge. My build landed squarely in the middle.
I should know better than that
My decision to rely on libgpiod instead of calling the ioctl() interface directly turned out to be the wrong call, not because the kernel changed, but because the library did. The abstraction that was supposed to protect my code from breakage became the weakest link. It left an entire range of developers boned, all of whom count on Linux for one thing above all else: stability. It’s not supposed to pull this kind of stunt.
The irony is that the most stable option was the one I’d tried to avoid: using the raw kernel interface. In the end, this episode reinforced what I’ve believed all along—If you want your code to survive, strip away the layers and own every dependency you can actually control.
The ioctl interface
I ended up rewriting my wrapper to use the ioctl interface. ioctl, short for input/output control, is a system call that lets user-space programs send commands directly to device drivers. It’s how you talk to hardware in Linux when simple reads and writes aren’t enough.
Each device type defines its own set of control codes, numeric constants that tell the kernel what action to perform. For example, in my I²C interface code, I use:
ioctl(fd, I2C_SLAVE, devAddr);which tells the kernel which device address I want to communicate with on the I²C bus. That single call configures the file descriptor to talk to a specific chip.
GPIO uses the same ioctl mechanism as I²C, just with its own control codes and data structures. Here’s the basic flow:
Open the GPIO chip device
Each GPIO controller is exposed to the system as /dev/gpiochipN. The main Broadcom GPIO controller, the one that handles the familiar 40-pin header that every standard Pi has a path of /dev/gpiochip0.
Thus you would open the connection to this hardware in the same way you would open a file descriptor.
int chip_fd = open(”/dev/gpiochip0”, O_RDONLY);Request a line
You fill out a struct gpiohandle_request describing which pin (line) you want and how you want to use it: input, output, pull-up, etc. Then you pass it to:
ioctl(chip_fd, GPIO_GET_LINEHANDLE_IOCTL, &req);
int line_fd = req.fd;The kernel fills in the req structure and returns a new file descriptor (line_fd)
Read or write the line
For input:
ioctl(line_fd, GPIOHANDLE_GET_LINE_VALUES_IOCTL, &data);For output, fill in data.values[0] and call:
ioctl(line_fd, GPIOHANDLE_SET_LINE_VALUES_IOCTL, &data);Clean up
close(line_fd);
close(chip_fd);That’s all there is to it. Open the chip, grab a handle, do your I/O, and clean up.
GPIO lines
Now that we’ve talked about how the kernel interface works, let’s look at how it maps to actual Raspberry Pi hardware.
Every Raspberry Pi, from the Zero 2 W to the Pi 5, exposes a 40-pin header that includes power, ground, and a set of general-purpose input/output (GPIO) pins. They’re tied straight into the System-on-Chip or SoC, a processor made by Broadcom that powers the entire board. Inside that chip lives the CPU, GPU, and all the I/O hardware, including the GPIO controller. The Linux kernel exposes that controller to user space as /dev/gpiochip0.
Each GPIO line is identified by a number inside that controller. Those numbers don’t match the physical pin numbers on the header. They refer to Broadcom GPIO indexes inside the chip. For example, physical pin 11 on the Raspberry Pi’s 40-pin header corresponds to GPIO 17 in the Broadcom numbering scheme. Command-line tools like raspi-gpio or gpioinfo can show the mapping for any given model.
In my case, when I use the gpioinfo command on the Raspberry Pi, I got something like:
gpiochip0 - 58 lines:
line 0: “ID_SDA” unused input active-high
line 1: “ID_SCL” unused input active-high
line 2: “SDA1” “i2c1” input active-high [used]
line 3: “SCL1” “i2c1” input active-high [used]
line 17: “GPIO17” “sensor” input active-high [used]
line 18: “GPIO18” “relay” output active-high [used]
line 22: “GPIO22” unused input active-high
line 27: “GPIO27” “valve” output active-low [used]Lines 0–1 (
ID_SDA, ID_SCL)
These are GPIO 0 and 1, reserved for the HAT EEPROM identification bus (I²C0).
Normally left unused, since the boot loader probes them during startup to detect official HAT boards.Lines 2–3 (
SDA1, SCL1)
These are GPIO 2 and 3, the main I²C bus exposed as /dev/i2c-1. They’re shown as “i2c1” because the kernel I²C driver claims those pins at boot to handle bus communication.Line 17 (GPIO17, labeled “sensor”)
Configured as an input, I was using it to read a digital signal for my rain sensor. The[used]tag means your user-space program has requested control of that line.Line 18 (
GPIO18, labeled “relay”)
My code set it as an output, driving a relay. It’s marked active-high, meaning a logic “1” energizes the relay.Line 22 (
GPIO22)
Unused general-purpose pin left configured as input by default.Line 27 (
GPIO27, labeled “valve”)
Configured as an output, active-low. I connected it to a valve that energizes when the pin is driven low.
The Broadcom SoC lets each GPIO line be configured as a digital input, output, or one of several alternate functions such as I²C, SPI, or UART. In normal GPIO mode, the kernel can also control internal bias resistors: pull-up, pull-down, or floating.
When a GPIO line is claimed by a hardware peripheral like I²C or SPI, that driver takes ownership and the kernel stops exposing those bias options through the GPIO subsystem. At that point, either the external circuitry (like the fixed 1.8 kΩ pull-ups on GPIO 2 and 3) or the peripheral hardware itself determines the line’s electrical state.
Show me the money
Enough theoretical talk. Let’s walk through a few real examples of controlling GPIO directly with ioctl talking straight to the hardware with zero library baggage.
Input Example
The following code snippet demonstrates how to configure a GPIO line as an input with an internal pull-up resistor. In this case, GPIO17 is connected to a simple switch that shorts to ground when pressed.
// clang++ -Wall -O2 -std=c++23 -o test1 test1.c
#include <stdio.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/ioctl.h>
#include <linux/gpio.h>
int main(void)
{
int chip_fd, line_fd;
struct gpiohandle_request req;
struct gpiohandle_data data;
// 1. Open the GPIO chip device
chip_fd = open(”/dev/gpiochip0”, O_RDONLY);
if (chip_fd < 0) {
perror(”open /dev/gpiochip0”);
return 1;
}
// 2. Prepare the line request
memset(&req, 0, sizeof(req));
req.lineoffsets[0] = 17; // GPIO17
req.lines = 1;
req.flags = GPIOHANDLE_REQUEST_INPUT |
GPIOHANDLE_REQUEST_BIAS_PULL_UP; // Enable pull-up
strcpy(req.consumer_label, “input_test”);
// 3. Request control of the line
if (ioctl(chip_fd, GPIO_GET_LINEHANDLE_IOCTL, &req) < 0) {
perror(”GPIO_GET_LINEHANDLE_IOCTL”);
close(chip_fd);
return 1;
}
line_fd = req.fd;
// 4. Read the value
if (ioctl(line_fd, GPIOHANDLE_GET_LINE_VALUES_IOCTL, &data) < 0) {
perror(”GPIOHANDLE_GET_LINE_VALUES_IOCTL”);
} else {
printf(”GPIO17 = %d\n”, data.values[0]);
}
// 5. Clean up
close(line_fd);
close(chip_fd);
return 0;
}How it works:
Opens the GPIO chip device
/dev/gpiochip0.Requests control of line 17 (Broadcom GPIO 17).
Configures it as an input and enables the internal pull-up resistor.
Reads its logic level using
GPIOHANDLE_GET_LINE_VALUES_IOCTL.When nothing is connected, it reads 1 (high) due to the pull-up. When the switch is pressed, it reads 0 (low) because the pin is pulled to ground.
Output Example
In the following example, we’ll configure GPIO 27 as an active-low output and toggle it a few times. By “Active-low” I mean that the output device turns on when the GPIO is driven low (0) and off when it’s driven high (1).
I often use this wiring style for relays drivers, valves, and even LEDs that expect a grounded control signal rather than a positive voltage. I consider active-low outputs a safer design for my projects because they fail safely. If the GPIO pin resets or the software crashes, the line floats high, which means the device stays off instead of energizing unexpectedly. It’s a simple convention that prevents accidents and surprises when things go wrong
//clang++ -Wall -O2 -std=c++23 -o test2 test2.c
#include <stdio.h>
#include <string.h>
#include <fcntl.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <linux/gpio.h>
int main(void)
{
int chip_fd, line_fd;
struct gpiohandle_request req;
struct gpiohandle_data data;
// 1. Open the GPIO chip device
chip_fd = open(”/dev/gpiochip0”, O_RDONLY);
if (chip_fd < 0) {
perror(”open /dev/gpiochip0”);
return 1;
}
// 2. Prepare the line request for output, active-low
memset(&req, 0, sizeof(req));
req.lineoffsets[0] = 27; // GPIO 27
req.lines = 1;
req.flags = GPIOHANDLE_REQUEST_OUTPUT |
GPIOHANDLE_REQUEST_ACTIVE_LOW; // Active-low output
strcpy(req.consumer_label, “valve_ctrl”);
// Initial value (off)
req.default_values[0] = 0;
// 3. Request control of the line
if (ioctl(chip_fd, GPIO_GET_LINEHANDLE_IOCTL, &req) < 0) {
perror(”GPIO_GET_LINEHANDLE_IOCTL”);
close(chip_fd);
return 1;
}
line_fd = req.fd;
// 4. Toggle the valve a few times
for (int i = 0; i < 3; i++) {
data.values[0] = 1; // Logic 1 = *energize* (active-low)
ioctl(line_fd, GPIOHANDLE_SET_LINE_VALUES_IOCTL, &data);
printf(”Valve ON\n”);
sleep(1);
data.values[0] = 0; // Logic 0 = *de-energize*
ioctl(line_fd, GPIOHANDLE_SET_LINE_VALUES_IOCTL, &data);
printf(”Valve OFF\n”);
sleep(1);
}
// 5. Clean up
close(line_fd);
close(chip_fd);
return 0;
}How it works:
GPIOHANDLE_REQUEST_OUTPUTconfigures GPIO 27 as an output.GPIOHANDLE_REQUEST_ACTIVE_LOWinverts the logic, so writing a “1” drives the line low, energizing the valve.GPIOHANDLE_SET_LINE_VALUES_IOCTLupdates the output state.The code toggles the line three times. Turning the valve on (logic 1 = low) and off (logic 0 = high).
Obviously you don’t have to use the active low flag. But I thought it was a good way to demonstrate the utility of the ioctl.
Flags are up
In both examples above, I included flags in the ioctl call used to request control of a GPIO line. These flags tell the kernel how you intend to use that pin. They are actually bit-masks combined in the request.flags field of your struct gpiohandle_request.
The most common ones are:
GPIOHANDLE_REQUEST_INPUT- Configures the line as an input.GPIOHANDLE_REQUEST_OUTPUT- Configures the line as an output.GPIOHANDLE_REQUEST_ACTIVE_LOW- Inverts logic levels. Writing a 1 drives the pin low, reading a 1 means the line is low.GPIOHANDLE_REQUEST_OPEN_DRAIN- The line can only pull low; a high level is achieved by releasing it. (I use it for wired-OR signaling).GPIOHANDLE_REQUEST_OPEN_SOURCE- The opposite of open-drain; can only drive high and otherwise floats.
You can combine these using a bitwise OR (|) operator as needed. For example, an output line with active-low logic would set:
req.flags = GPIOHANDLE_REQUEST_OUTPUT | GPIOHANDLE_REQUEST_ACTIVE_LOW;Let’s Get a Bit Edgy
In the first example, the code checked the current state of a GPIO line. If that line were tied to a switch, I’d have to poll it periodically to catch when it changed. That kind of constant checking wastes processor time, and on battery-powered systems, that means shorter runtime and more heat for no good reason.
A better way is to let the kernel do the watching. By enabling edge detection, your code can sleep until an actual event occurs: a button press, release, or both. Then wake up just long enough to do its thing.
The kernel’s event interface makes this simple. Instead of requesting the line with GPIO_GET_LINEHANDLE_IOCTL, you use GPIO_GET_LINEEVENT_IOCTL. You tell it which edge or edges you care about, rising, falling, or both when asking the kernel for the file descriptor.
When the event happens, a read on that descriptor returns a small structure with the event type and timestamp. This avoids polling loops and wasted cycles. Your code gets hardware notification straight from the GPIO controller.
//clang++ -Wall -O2 -std=c++23 -o test3 test3.c
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <sys/ioctl.h>
#include <linux/gpio.h>
int main(void)
{
int fd, ret;
struct gpioevent_request req;
struct gpioevent_data event;
// Open the GPIO chip
fd = open(”/dev/gpiochip0”, O_RDONLY);
if (fd < 0) {
perror(”open”);
return 1;
}
memset(&req, 0, sizeof(req));
req.lineoffset = 17; // GPIO17 (BCM numbering)
req.handleflags = GPIOHANDLE_REQUEST_INPUT;
req.eventflags = GPIOEVENT_REQUEST_FALLING_EDGE;
strcpy(req.consumer_label, “button”);
ret = ioctl(fd, GPIO_GET_LINEEVENT_IOCTL, &req);
if (ret < 0) {
perror(”ioctl”);
close(fd);
return 1;
}
printf(”Waiting for button press on GPIO17...\n”);
while (1) {
ret = read(req.fd, &event, sizeof(event));
if (ret == sizeof(event)) {
if (event.id == GPIOEVENT_EVENT_FALLING_EDGE)
printf(”Button pressed!\n”);
else if (event.id == GPIOEVENT_EVENT_RISING_EDGE)
printf(”Button released!\n”);
} else {
perror(”read”);
break;
}
}
close(req.fd);
close(fd);
return 0;
}
How it works:
Opens the GPIO chip device
/dev/gpiochip0.Requests control of line 17 (Broadcom GPIO 17).
GPIOEVENT_REQUEST_FALLING_EDGEtells the kernel to trigger when the pin goes from high to low.The read() call blocks until the event occurs, then returns a timestamped structure.
When the line goes low, the process wakes up and the read exits and the code checks the
event.idto determine which transition occurs.
In this sample, I only triggered on the falling edge. I could just as easily have asked for rising, or both rising and falling. In that case, checking the event ID would make more sense.
Since this is just Unix-style I/O (fine, let’s call it POSIX I/O), you can use the standard tools to get fancier. For example, if you want to include a timeout, you can wrap it with a select() call on the file descriptor. That’s a bit beyond what I want to get into today, but it’s worth knowing you can.
The low-level ioctl interface to GPIO is handy to know about. It not only survives system updates, but also gives you access to functionality you can use to make your code do some genuinely cool stuff
Now you know something!
What should’ve been a simple update turned into a reminder of why I build the way I do. The Linux kernel team did their job right, they improved the interface without breaking it. The layer above, on the other hand, could’ve learned a lesson or two from them. The abstractions and version mismatches quietly wrecked years of stable code.
Truth is, the API change wasn’t hard to adapt to for anyone with experience. But I build systems to outlive my willingness to fix them. I’ve got other things to do on the farm. Not using the ioctl interface and talking to the kernel directly from day one was was on me. Especially since I already had my own GPIO wrapper, it’s clear now that was the right place to draw the line.
This post is part of the Off-Grid Farm Automation with Raspberry Pi series — a full DIY walkthrough of building a self-hosted system for sensors, valves, and automation, no cloud required.




