Over the years, Linux has given us multiple ways to talk to those pins, the most notable being the shiny new libgpiod and the venerable, battle-scarred sysfs interface.
The libgpiod Way: Modern, Safe, and Sane
Starting from kernel 4.8, Linux introduced a new character device interface for GPIOs. Instead of poking files under /sys/class/gpio/, you now get neat device nodes like /dev/gpiochip0, /dev/gpiochip1, and so on. Each of these “gpiochips” represents a set of GPIO lines handled by a specific controller, and with libgpiod, you get a clean C API to interact with them.
The libgpiod library acts as your friendly middleman between user space and the kernel’s gpiod subsystem. It lets you read, write, and listen for edge-triggered events on GPIO lines without manually juggling file descriptors or shell scripts. Even better, it handles concurrency and race conditions behind the scenes, so you don’t have to play mutex whack-a-mole at 2 a.m.
In short: libgpiod does GPIO right, atomic operations, consistent API, and future-proof design.
No deprecated hacks, no unsafe writes, no tears.
The sysfs Era: Simpler Times (and Simpler Bugs)
Before libgpiod came along, we had sysfs, which exposed GPIOs through the /sys/class/gpio/ tree. You could “export” a line by echoing its number into a file, set its direction by writing "in" or "out", and then toggle its value like a digital light switch. It was easy, scriptable… and full of hidden dragons.
Concurrency? Weak. Security? Minimal. Error handling? Basically “hope for the best.” While sysfs was a great educational tool and a quick fix for embedded projects, it was never meant to scale and the kernel developers have made it clear that its days are numbered. If you’re working on a modern Linux system, it’s time to leave sysfs behind and embrace libgpiod.
Translating GPIO Numbers: From sysfs to libgpiod
So you’ve decided to ditch sysfs and move to libgpiod. But then the big question hits:
“Wait… what happened to my GPIO numbers?”
In the sysfs world , each GPIO line had a global number, and you could just export it directly, like:
echo 24 > /sys/class/gpio/export
But libgpiod doesn’t play that game anymore. Instead of one giant pool of numbers, GPIOs are now grouped into chips (like /dev/gpiochip0, /dev/gpiochip1, etc.), each managed by its own hardware controller. Inside each chip, lines are numbered locally, starting from 0.
So, a line that used to be GPIO24 in sysfs might now live as line 8 in /dev/gpiochip1.
It’s the same pin, just a different address system.
Finding Your Way Around: Using gpiodetect and gpioinfo
Luckily, you don’t have to guess. The libgpiod tools make it easy to map sysfs-style numbers to the new chip-based scheme.
Run:
gpiodetect
This lists all available GPIO chips:
gpiochip0gpiochip1
Then, inspect a specific chip:
gpioinfo gpiochip0
You’ll get a detailed list like:
line 0: "GPIO0" unused input active-highline 1: "GPIO1" unused input active-high...line 24: "GPIO24" "led0" output active-high
Here, you can see line names, directions, consumers, and other useful metadata.
By matching the label or function name, you can find the line that corresponds to your old sysfs GPIO number.
Names Beat Numbers
In the new world of libgpiod, line names are your best friends.
Instead of referring to lines by their (potentially shifting) numeric offsets, you can request them by name, for example:
gpioget gpiochip0 GPIO24
This is safer, more readable, and much less error-prone than chasing down hardware-specific numbering schemes.
Remember: on modern kernels, line names are often defined by the board or SoC device tree, so using them helps keep your code portable across hardware revisions.
A Real Mapping Example (Raspberry Pi 4, kernel 6.x)
| Physical Pin (Header) | Sysfs GPIO (legacy) | libgpiod Chip | libgpiod Line | Line Name (if available) |
|---|---|---|---|---|
| Pin 11 | GPIO17 | gpiochip0 | 17 | GPIO17 |
| Pin 12 | GPIO18 | gpiochip0 | 18 | GPIO18 |
| Pin 13 | GPIO27 | gpiochip0 | 27 | GPIO27 |
| Pin 15 | GPIO22 | gpiochip0 | 22 | GPIO22 |
| Pin 16 | GPIO23 | gpiochip0 | 23 | GPIO23 |
| Pin 18 | GPIO24 | gpiochip0 | 24 | GPIO24 |
| Pin 22 | GPIO25 | gpiochip0 | 25 | GPIO25 |
This table shows how the old sysfs numbers still align with the same logical GPIO lines under libgpiod, but now you access them through a proper device interface.
On other platforms (like BeagleBone or STM-based SBCs), these offsets can differ dramatically, so it’s always best to query the chip before hardcoding any numbers.
I repeat it: gpiochip offsets usually differ from sysfs GPIOs. Don’t say I didn’t warn you !
Yet Another Mapping Example GPIO Numbers on a BeagleBone
The BeagleBone Black is a classic example of why migrating from sysfs to libgpiod isn’t just a matter of “same number, new command.”
On this board, GPIOs are spread across several controllers, each one mapped to a different gpiochip device.
That means your old GPIO48 might live on /dev/gpiochip1 as line 16, while GPIO60 could be line 28 on another chip.
Let’s demystify that !
BeagleBone: The Sysfs World …
In the old sysfs interface, each GPIO had a global number, calculated as:
GPIO number = (bank number × 32) + line number
So for example:
- GPIO1_16 → (1 × 32) + 16 = 48
- GPIO1_28 → (1 × 32) + 28 = 60
That’s why you might have seen these commands all over BeagleBone tutorials:
echo 48 > /sys/class/gpio/exportecho out > /sys/class/gpio/gpio48/directionecho 1 > /sys/class/gpio/gpio48/value
… vs the libgpiod World
With libgpiod, there are no “global” GPIO numbers anymore.
Let’s see what that looks like on a BeagleBone:
gpiodetect
Example output:
gpiochip0 [44e07000.gpio] (32 lines)gpiochip1 [4804c000.gpio] (32 lines)gpiochip2 [481ac000.gpio] (32 lines)gpiochip3 [481ae000.gpio] (32 lines)
Each of these corresponds to a GPIO controller (“bank”) in the AM335x SoC.
Let’s take our earlier example — GPIO1_16 (sysfs = 48).
To find it with libgpiod, check gpiochip1 (because the 1 in GPIO1_16 means “controller 1”):
gpioinfo gpiochip1 | grep "16"
Example output:
line 16: "P9_15" "sysfs" output active-high
There it is!
The line you once exported as GPIO48 in sysfs is now line 16 on gpiochip1.
You can now toggle it using:
gpioset gpiochip1 16=1
or read it with:
gpioget gpiochip1 16
And just for reference:
BeagleBone Black with Linux kernel 6.x and the default device tree
| Header Pin | Sysfs GPIO (legacy) | SoC Signal | GPIO Bank | libgpiod Chip | libgpiod Line | Line Name |
|---|---|---|---|---|---|---|
| P8_07 | 66 | GPIO2_2 | 2 | gpiochip2 | 2 | P8_07 |
| P8_08 | 67 | GPIO2_3 | 2 | gpiochip2 | 3 | P8_08 |
| P8_09 | 69 | GPIO2_5 | 2 | gpiochip2 | 5 | P8_09 |
| P8_10 | 68 | GPIO2_4 | 2 | gpiochip2 | 4 | P8_10 |
| P9_12 | 60 | GPIO1_28 | 1 | gpiochip1 | 28 | P9_12 |
| P9_14 | 50 | GPIO1_18 | 1 | gpiochip1 | 18 | P9_14 |
| P9_15 | 48 | GPIO1_16 | 1 | gpiochip1 | 16 | P9_15 |
| P9_23 | 49 | GPIO1_17 | 1 | gpiochip1 | 17 | P9_23 |
On a Custom Board
When you’re working with consumer dev boards, life is good. You Google an error message and get 40 StackOverflow threads, three neat blog posts, a YouTube tutorial featuring a guy with a British accent, and at least one GitHub repo that solves the entire problem with a Bash script named fixit.sh.
But when you’re dealing with a custom-made embedded Linux machine, the kind of board someone designed on a Friday afternoon after “just one more espresso”, the entire internet suddenly becomes a barren wasteland.
Welcome to The Dark Forest of Embedded Linux, where nothing is documented, everything is custom, and the only reference manual is your brain.
If you ever need to translate manually, remember:
sysfs_gpio = (bank × 32) + offset
So the reverse is:
bank = sysfs_gpio / 32offset = sysfs_gpio % 32
That gives you:
- The gpiochipN =
bank - The line =
offset
If you’re migrating a large codebase, you can automate the mapping process using a simple script:
gpioinfo | grep "GPIO" | awk '{print $2, $4}'
This will quickly dump all available GPIOs with their names and line offsets, helping you rewrite your old sysfs-based logic in minutes.
Using libgpiod on a Custom Board
If your distribution has been tailored with Yocto (highly recommended, unless you enjoy suffering), one of the build outputs is the Toolchain for the exact image you just created. This is not just any toolchain , this is a custom-made, perfectly aligned, bit-for-bit soulmate of your root filesystem and kernel.
With Yocto, the toolchain is generated alongside your image, so everything matches:
- The GCC version
- The libraries you wanted to include
- The kernel headers
- The sysroots (both target and native)
So, if you added a recipe for libgpiod, you should already have, inside your Yocto-generated distribution:
- the compiled library (the thing that does the actual GPIO wizardry), and
- the header files (the sacred scrolls that tell the compiler what sorcery the library can perform).
Nice, right?
Well, prepare yourself because now you actually want to use libgpiod.
At this point you have two options:
Option 1: Build Directly on the Embedded System
(a.k.a. The “I regret nothing” → “I regret everything” pipeline)
Sure, you can compile your software directly on the target device.
But unless your board has the processing power of a mid-range laptop (spoiler: it doesn’t), this experience will be:
- slow
- painful
- mysteriously slow
- occasionally on fire
- and slow
Your friendly embedded CPU running at the speed of an enthusiastic snail , will graciously take 14 minutes to compile a C file that your desktop could chew through before you finish blinking.
Not to mention:
- missing build tools
- limited storage
- no swap
- and you fighting the onboard package manager like it owes you money
So yes, this method works… technically.
But so does digging a tunnel with a spoon.
Option 2: Write on Your Workstation and Cross-Compile
(a.k.a. The Method That Keeps You Sane)
This is the professional way, where you write your code on a real, respectable, grown-up machine with:
- multiple CPU cores,
- ample RAM,
- an editor that doesn’t lag,
- and a proper keyboard.
Thanks to Yocto, you already have the cross-toolchain and sysroot that match your target image exactly.
This is crucial, because if your compiler and your target system don’t agree on libc versions, ABIs, or endianess, your binary will crash hard.
To do this, you typically:
Source your Yocto environment script
(always forget the first time, always remember the second time)
source <toolchain_path>/<toolchain>
Cross-compile your code with something like this
$CC -I <toolchain_installation_path>/sysroots/cortexa9t2hf-neon-linux-nueabi/usr/lib -L <toolchain_installation_path>/sysroots/cortexa9t2hf-neon-linux-gnueabi/usr/lib source.c -o binary -lgpiod
Okay, okay, I know what you’re thinking: “But there’s no source.c file yet!”
Fair point. So where’s the example?
Relax, I’ve got you covered. Below you’ll find some clean and battle-tested code snippets that show how to read from and write to a GPIO. Grab your coffee, open your editor, and let’s make those pins do something useful.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <gpiod.h>
#ifndef GPIOD_API_IS_V2
#define CHIP_NAME "gpiochip3"
#define LINE_OFFSET 28
#define CONSUMER "GPIOReader"
#endif
int main(int argc, char **argv)
{
const char *chipname = CHIP_NAME;
unsigned int line_num = LINE_OFFSET;
struct gpiod_chip *chip;
struct gpiod_line *line;
int value;
int ret;
printf("Reading GPIO %u on chip %s\n", line_num, chipname);
// Open the GPIO chip
chip = gpiod_chip_open_by_name(chipname);
if (!chip) {
perror("gpiod_chip_open_by_name failed");
return EXIT_FAILURE;
}
// Get the GPIO line
line = gpiod_chip_get_line(chip, line_num);
if (!line) {
perror("gpiod_chip_get_line failed");
gpiod_chip_close(chip);
return EXIT_FAILURE;
}
// Request the line as INPUT
ret = gpiod_line_request_input(line, CONSUMER);
if (ret < 0) {
perror("gpiod_line_request_input failed");
gpiod_chip_close(chip);
return EXIT_FAILURE;
}
// Read the line value
value = gpiod_line_get_value(line);
if (value < 0) {
perror("gpiod_line_get_value failed");
gpiod_line_release(line);
gpiod_chip_close(chip);
return EXIT_FAILURE;
}
// Show the result
printf("The state of GPIO %u is: **%d**\n", line_num, value);
printf("(0 = Low / Inactive, 1 = High / Active)\n");
// Release the line and close the chip
gpiod_line_release(line);
gpiod_chip_close(chip);
return EXIT_SUCCESS;
}
… and writing …
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <gpiod.h>
// Definition of the GPIO chip and line
#define GPIO_CHIP_NAME "gpiochip3"
#define GPIO_LINE_OFFSET 30
#define CONSUMER "blink_example"
#define DELAY_SEC 1
#define NUM_OPERATIONS 20
int main(int argc, char *argv[]) {
struct gpiod_chip *chip;
struct gpiod_line *line;
int value;
int ret;
printf("Starting GPIO test: %s, Line %d\n", GPIO_CHIP_NAME, GPIO_LINE_OFFSET);
printf("Running %d operations (10 full blinks).\n", NUM_OPERATIONS);
// Opening the GPIO chip
chip = gpiod_chip_open_by_name(GPIO_CHIP_NAME);
if (!chip) {
perror("Error opening the GPIO chip");
return EXIT_FAILURE;
}
// Retrieving the specific line
line = gpiod_chip_get_line(chip, GPIO_LINE_OFFSET);
if (!line) {
perror("Error retrieving the GPIO line");
gpiod_chip_close(chip);
return EXIT_FAILURE;
}
// Requesting the line as output (initialized to LOW)
ret = gpiod_line_request_output(line, CONSUMER, 0);
if (ret < 0) {
perror("Error requesting the line as output");
gpiod_chip_close(chip);
return EXIT_FAILURE;
}
// FOR loop of 20 repetitions
for (int i = 0; i < NUM_OPERATIONS; i++) {
// Alternating the value: even = HIGH, odd = LOW
value = (i % 2 == 0) ? 1 : 0;
ret = gpiod_line_set_value(line, value);
if (ret < 0) {
perror("Error setting the value");
break;
}
printf("Step %2d/%d: Line %d set to %s\n",
i + 1, NUM_OPERATIONS, GPIO_LINE_OFFSET,
(value == 1) ? "HIGH" : "LOW");
sleep(DELAY_SEC);
}
printf("Loop completed. Releasing the line.\n");
// Release and close
gpiod_line_release(line);
gpiod_chip_close(chip);
return EXIT_SUCCESS;
}
Before wrapping up this (admittedly way-too-long) article, let me leave you with two quick side notes that might save you some future head-scratching.
First, gpiod_chip_open_by_name(GPIO_CHIP_NAME) allows you to reference a GPIO chip simply by its name, without having to specify the full device path every time. It’s a small detail, but it helps keep your code clean, readable, and free of those mystical /dev incantations that nobody wants to type more than once.
Second, the CONSUMER string acts as a friendly label that identifies who is using that particular GPIO resource. Think of it as putting your name on your lunch in the office fridge: it won’t magically stop conflicts, but at least you’ll know who else might be trying to toggle the same line at the same time. A tiny feature, but extremely handy when debugging or tracing resource contention.
