Posted in

GPIO control in Linux

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:

gpiochip0 
gpiochip1 

Then, inspect a specific chip:

gpioinfo gpiochip0

You’ll get a detailed list like:

line 0:  "GPIO0"       unused   input  active-high
line 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 Chiplibgpiod LineLine Name (if available)
Pin 11GPIO17gpiochip017GPIO17
Pin 12GPIO18gpiochip018GPIO18
Pin 13GPIO27gpiochip027GPIO27
Pin 15GPIO22gpiochip022GPIO22
Pin 16GPIO23gpiochip023GPIO23
Pin 18GPIO24gpiochip024GPIO24
Pin 22GPIO25gpiochip025GPIO25

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/export
echo out > /sys/class/gpio/gpio48/direction
echo 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 PinSysfs GPIO (legacy)SoC SignalGPIO Banklibgpiod Chiplibgpiod LineLine Name
P8_0766GPIO2_22gpiochip22P8_07
P8_0867GPIO2_32gpiochip23P8_08
P8_0969GPIO2_52gpiochip25P8_09
P8_1068GPIO2_42gpiochip24P8_10
P9_1260GPIO1_281gpiochip128P9_12
P9_1450GPIO1_181gpiochip118P9_14
P9_1548GPIO1_161gpiochip116P9_15
P9_2349GPIO1_171gpiochip117P9_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 / 32
offset = 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.

C
#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 …

C
#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.

Leave a Reply

Your email address will not be published. Required fields are marked *