Sitemap
Ruvinda Dhambarage

Ruvinda Dhambarage

Embedded Systems & Linux Techie

So you think you can blink an LED?

A deep dive into GPIO control in embedded Linux

24 min readAug 27, 2026

--

How hard can it be?

0. Intro

One of my go-to questions to ask at interviews is “How do you blink an LED wired to a GPIO pin on an embedded Linux device?”. It may seem like a softball question to the uninitiated, but there is a lot of hidden depth to it.

In this blog post I’m going to attempt to dive deep in to subject. Starting from the bottom bare metal assembly level and then working ourself up the various software abstractions to finally end up at the Linux user space.

I will be using a trusty BeagleBone Black (BBB for short) with a green LED wired up to the P9_12 pin on it’s expansion header (as seen in the title image). The BBB many be almost 15 years old at this point, but I chose it because it is cheap, open and has mainline Linux support. More importantly, I also happen to have one laying around. The P9_12 pin I chose has no significance. It just happened to be the closest GPIO to the breadboard when I placed it on my desk.

The full source code for all the examples I use will be available in my Github at: https://github.com/ruvi-d/bbb-blinky

1. The Fundamentals

Let’s start at the lowest electrical level by figuring out how our green LED is connected to the main microprocessor. For this we will look at the BBB schematic to see how that P9_12 pin is wired up. It will be pretty apparent that that pin is directly wired up to the main AM335X micro’s U18 pad.
Cool! Now we just need to figure out how to control that pad from the main micro. For this we will need to refer to the AM335X technical reference manual (TRM) and the AM335X datasheet.

1.1 Pin muxing vs GPIO control

The TRM will describe two systems that control pad U18. The key points to take away would be:

  1. The U18 pad is named GPMC_BEn1 and can be multiplexed to different “modes” via the “Control module” registers. One such supported mode is GPIO (mode 7), which wires up the U18 pad to a GPIO line called GPIO1_28.
  2. GPIO1_28 is the 28 line coming out of the “GPIO1 module”, which is one of the 4 GPIO modules on the AM335X chip.

Think of the “Control module” as kind of an electrical switchboard that allows you to wire that pad up to different internal modules like GPIO, I2C, SPI or another supported function. Based on how the silicon was designed, not every pads will be able to be muxed to be a GPIO. As in, only certain pads can be configured as GPIOs. In addition to controlling the pad muxing, the “Control module” also lets you configure electrical properties of the pad such as enabling internal pull up or pull down resistors. For the pad we are interested in GPMC_BEn1, the aptly named register conf_gpmc_be1n will control the muxing and enabling pull/down resistors.

Once the pin is multiplexed as a GPIO, we can control it via the “GPIO module” registers. The TRM doc will describe two registers in the GPIO module for this. GPIO_OE to configure the GPIO line as an output or input and then theGPIO_SETDATAOUT/GPIO_CLEARDATAOUT registers to drive the GPIO line high or low.

There won’t be any “errors” per se if you don’t set the multiplexing before adjusting the GPIO module registers; it’s just that the desired physical effect on the pin won’t happen. This “two” layered approach of controlling a pin is pretty universal across modern micro processors.

1.2 Memory mapped I/O (MMIO)

Now we know that we need to modify the contents of a few registers. But how do we access these registers? The answer is usually via a scheme called “memory mapped I/O”, where the main micro maps registers to it’s memory address space. As in, you can access these registers the same way you’d access system RAM; simply read/write to the registers the same way you’d allocate, read/write to RAM.

The AM335X TRM will list the MMIO addresses of the four registers we are are interested in. I’ve illustrated them out below for easy reference.

Press enter or click to view image in full size
The register addresses that control the GPIO1_28 pin in silicon

1.3 Alternatives to MMIO

The less common counterpart of MMIO is “Port-Mapped I/O” (PMIO). Here a micro would use a completely separate address space and instructions specifically for peripheral devices, leaving the main address space entirely for system RAM. This is not a thing anymore; you’d have to go back in time to the pre-32bit CPU area to find examples.

Another alternative is to not connect GPIOs to the main micro at all. Instead, GPIOs could be connected to a secondary “co-processor”. Kinda of like how x86–64 CPUs have a “chipset” on the motherboard for USB, Ethernet, etc. Depending on how the main micro and the co-processor are connected together, the way you control the GPIO would change. For example if the connection is SPI, you’d have to send SPI commands conforming to a mutually agreed upon protocol to control a GPIO.

Fun fact, Raspberry PI 5 (RPi5) uses a co-processor called a RP1 chip to provide the 40-pin header’s pin functionality. But this RP1 chip connects via PCIe to the main micro and thus is ultimately memory mapped. So, the RP1 chip is transparent to the application logic running on the RPi5. But behind the scenes when you think you are reading/writing to the main micro’s registers, you are actually accessing functionality on a totally different chip.

2. Baremetal

Now to the fun stuff! Let’s start blinking that LED!

Press enter or click to view image in full size
Did someone say metal?? 🤘

Let put the fundamentals we learnt to practice and write a baremetal code (No Linux yet.. sorry metal Tux). In theory we will just need a handful of assembly lines to make an LED blink. See the except below:

// PRCM clock module (CM_PER)
#define CM_PER_BASE 0x44E00000
#define CM_PER_L4LS_CLKCTRL 0x000 // L4LS interconnect clock
#define CM_PER_GPIO1_CLKCTRL 0x0AC // GPIO1 module functional clock
#define MODULEMODE_ENABLE 0x2 // MODULEMODE = ENABLE
#define IDLEST_MASK (3 << 16) // 0b00 in these bits == Func

// Control module (pin mux)
#define CTRL_BASE 0x44E10000
#define CONF_GPMC_BE1N 0x0878 // ball V18/U18 -> gpio1_28
#define PINMUX_GPIO1_28 0x27 // mmode=7 (gpio), rxactive=1

// GPIO1
#define GPIO1_BASE 0x4804C000
#define GPIO_OE 0x134 // output-enable (0 = output)
#define GPIO_CLEARDATAOUT 0x190 // self-masking: write 1 -> pin low
#define GPIO_SETDATAOUT 0x194 // self-masking: write 1 -> pin high

#define PIN28 (1 << 28)

// busy-wait iterations between toggles (visible blink, no timer needed)
#define BLINK_DELAY 0x00300000


.fun _start
// --- 1. enable GPIO1 module clock ------------------------------------
ldr r0, = CM_PER_BASE
movs r1, MODULEMODE_ENABLE
str r1, [ r0, CM_PER_L4LS_CLKCTRL ]
str r1, [ r0, CM_PER_GPIO1_CLKCTRL ]
1: ldr r1, [ r0, CM_PER_GPIO1_CLKCTRL ] // wait until IDLEST == Func
tst r1, IDLEST_MASK
bne 1b

// --- 2. pin mux: conf_gpmc_be1n = 0x27 -------------------------------
ldr r0, = CTRL_BASE
movs r1, PINMUX_GPIO1_28
str r1, [ r0, CONF_GPMC_BE1N ]

// --- 3. GPIO1_28 direction = output (read-modify-write clears bit) ---
ldr r0, = GPIO1_BASE
ldr r1, [ r0, GPIO_OE ]
bic r1, r1, PIN28
str r1, [ r0, GPIO_OE ]

// --- 4. blink forever (r0 = GPIO1_BASE) ------------------------------
mov r2, PIN28
blink:
str r2, [ r0, GPIO_SETDATAOUT ] // drive HIGH
bl delay
str r2, [ r0, GPIO_CLEARDATAOUT ] // drive LOW
bl delay
b blink
.done


// crude busy-wait; clobbers r3 only
.fun delay
ldr r3, = BLINK_DELAY
1: subs r3, r3, 1
bne 1b
ret
.done

Full source https://github.com/ruvi-d/bbb-blinky/tree/main/bare-metal-blinky/bare-metal-asm

Okay, I lied. It’s not just 4 registers that we need to touch, we also need to enable the GPIO1 module clock per the TRM.

To actually get this to run on a BBB, we need to use a special linker script to layout the instructions at the correct address and then we also need to add a header/preamble so that the AM335X Boot ROM recognizes our binary as a bootable binary. I got these missing pieces from https://github.com/mvduin/bbb-asm-demo.

Here is the C except of the assembly above

#define MMIO32(addr)  (*(volatile uint32_t *)(addr))

// PRCM clock module (CM_PER)
#define CM_PER_BASE 0x44E00000u
#define CM_PER_L4LS_CLKCTRL MMIO32(CM_PER_BASE + 0x000)
#define CM_PER_GPIO1_CLKCTRL MMIO32(CM_PER_BASE + 0x0AC)
#define MODULEMODE_ENABLE 0x2u
#define IDLEST_MASK (3u << 16)

// Control module (pin mux)
#define CTRL_BASE 0x44E10000u
#define CONF_GPMC_BE1N MMIO32(CTRL_BASE + 0x0878)
#define PINMUX_GPIO1_28 0x27u

// GPIO1
#define GPIO1_BASE 0x4804C000u
#define GPIO_OE MMIO32(GPIO1_BASE + 0x134)
#define GPIO_CLEARDATAOUT MMIO32(GPIO1_BASE + 0x190)
#define GPIO_SETDATAOUT MMIO32(GPIO1_BASE + 0x194)

#define PIN28 (1u << 28)
#define BLINK_DELAY 0x00300000u

static void delay(void)
{
for (volatile uint32_t i = BLINK_DELAY; i != 0; i--)
;
}

int main(void)
{
// 1. enable GPIO1 module clock
CM_PER_L4LS_CLKCTRL = MODULEMODE_ENABLE;
CM_PER_GPIO1_CLKCTRL = MODULEMODE_ENABLE;
while (CM_PER_GPIO1_CLKCTRL & IDLEST_MASK)
;

// 2. pin mux: conf_gpmc_be1n = 0x27
CONF_GPMC_BE1N = PINMUX_GPIO1_28;

// 3. GPIO1_28 direction = output (read-modify-write clears bit)
GPIO_OE &= ~PIN28;

// 4. blink forever
for (;;) {
GPIO_SETDATAOUT = PIN28;
delay();
GPIO_CLEARDATAOUT = PIN28;
delay();
}
}

This C implementation is going to add a bunch of stack-pointer overhead to the assembly implementation, but otherwise will produce the same assembly code.
Full source code: https://github.com/ruvi-d/bbb-blinky/tree/main/bare-metal-blinky/bare-metal-c

3. Linux MMIO memory access

The baremetal metal implementation in the previous section is a good learning exercise but is of little practical use. This is an embedded Linux blog after all, so lets now look at how we can manipulate those registers from Linux!

3.1 Linux kernel space

Typically when a Linux kernel driver needs to control memory mapped registers, it would send a request to “own” that memory region first. Usually with the request_mem_region() function. The only thing it does is to check if another driver has already called dibs on that memory region. It doesn’t actually prevent your driver from accessing that memory region. As in, you can ignore/skip the request and directly access the memory section you want via the ioremap() function.

At runtime you can cat /proc/iomem to see a list of drivers that have already requested memory regions. On my BBB, the output is:

root@dogbonedark:~# cat /proc/iomem
40300000-4030ffff : 40300000.sram sram@0
44e00c00-44e00cff : 44e00c00.prm prm@c00
44e00d00-44e00dff : 44e00d00.prm prm@d00
44e00e00-44e00eff : 44e00e00.prm prm@e00
44e00f00-44e00fff : 44e00f00.prm prm@f00
44e01000-44e010ff : 44e01000.prm prm@1000
44e01100-44e011ff : 44e01100.prm prm@1100
44e01200-44e012ff : 44e01200.prm prm@1200
44e07000-44e07fff : 44e07000.gpio gpio@0
44e09000-44e0901f : serial
44e0b000-44e0bfff : 44e0b000.i2c i2c@0
44e10620-44e1062f : 44e10620.control phy_ctrl
44e10648-44e1064b : 44e10620.control wakeup
44e10800-44e10a37 : pinctrl-single
44e10f90-44e10fcf : 44e10f90.dma-router dma-router@f90
47401000-474011ff : musb-hdrc.0 control
47401400-474017ff : mc
47401400-474017ff : musb-hdrc.0 mc
47401800-474019ff : musb-hdrc.1 control
47401c00-47401fff : mc
47401c00-47401fff : musb-hdrc.1 mc
47402000-47402fff : 47400000.dma-controller controller
47403000-47403fff : 47400000.dma-controller scheduler
47404000-47407fff : 47400000.dma-controller queuemgr
48042000-480423ff : 48042000.timer timer@0
48044000-480443ff : 48044000.timer timer@0
48046000-480463ff : 48046000.timer timer@0
48048000-480483ff : 48048000.timer timer@0
4804a000-4804a3ff : 4804a000.timer timer@0
4804c000-4804cfff : 4804c000.gpio gpio@0
48060000-48060fff : 48060000.mmc mmc@0
480c8000-480c81ff : 480c8000.mailbox mailbox@0
480ca000-480cafff : 480ca000.spinlock spinlock@0
4819c000-4819cfff : 4819c000.i2c i2c@0
481ac000-481acfff : 481ac000.gpio gpio@0
481ae000-481aefff : 481ae000.gpio gpio@0
481d8000-481d8fff : 481d8000.mmc mmc@0
48310000-48311fff : 48310000.rng rng@0
49000000-4900ffff : 49000000.dma edma3_cc
4a100000-4a103fff : 4a100000.switch switch@0
80000000-9fefffff : System RAM
80008000-810fffff : Kernel code
81200000-813b2a8f : Kernel data

Here we can see that the GPIO1 module registers are reserved by 4804c000.gpio, the OMAP GPIO driver (gpio-omap). Likewise the pinmux register is reserved by the pinctrl-single driver. Curiously the CM_PER_L4LS_CLKCTRL/CM_PER_GPIO1_CLKCTRL registers are not owned by anyone. This region is for sure being used by a TI clock related driver, as we know that GPIOs are functioning. It’s just that nobody has reserved it.

If we were doing this correctly, we’d have to go through whatever interface that the 4804c000.gpio and pinctrl-single drivers provide in order to safely take control of our GPIO. But to keep things simple in this part of our journey, let’s see how we would directly access these memory regions from a custom driver.

#define pr_fmt(fmt) KBUILD_MODNAME ": " fmt

/* Control module: pin mux/pad config, one register per pin. */
#define CTRL_BASE 0x44E10000u
#define CTRL_SIZE 0x1000u
#define CONF_GPMC_BE1N 0x0878u /* ball V18/U18 -> gpio1_28 */

#define CONF_MMODE(x) ((x) & 0x7) /* mux mode; 7 = gpio */
#define CONF_RXACTIVE BIT(5) /* input buffer enabled */
#define PINMUX_GPIO1_28 (CONF_MMODE(7) | CONF_RXACTIVE)

/* GPIO1. Clock already enabled by gpio-omap -- unlike bare metal. */
#define GPIO1_BASE 0x4804C000u
#define GPIO1_SIZE 0x1000u
#define GPIO_OE 0x134u /* output enable, 0 = output */
#define GPIO_CLEARDATAOUT 0x190u /* self-masking: 1 -> pin low */
#define GPIO_SETDATAOUT 0x194u /* self-masking: 1 -> pin high */

#define PIN28 BIT(28)
#define BLINK_HALF_PERIOD_MS 500u

static void __iomem *ctrl_base;
static void __iomem *gpio1_base;

/* Saved on entry, restored on unload. */
static u32 saved_pinmux;
static u32 saved_oe;

static bool led_on;

static void kblinky_work_fn(struct work_struct *work);
static DECLARE_DELAYED_WORK(kblinky_work, kblinky_work_fn);

/* SET/CLEARDATAOUT are self-masking: only PIN28 is touched. */
static void kblinky_set(bool on)
{
writel(PIN28, gpio1_base + (on ? GPIO_SETDATAOUT : GPIO_CLEARDATAOUT));
led_on = on;
}

/* Runs on a workqueue; re-arms itself to keep blinking. */
static void kblinky_work_fn(struct work_struct *work)
{
kblinky_set(!led_on);
schedule_delayed_work(&kblinky_work,
msecs_to_jiffies(BLINK_HALF_PERIOD_MS));
}

static int __init kblinky_init(void)
{
/* No request_mem_region(): gpio-omap and pinctrl-single already own
* these ranges, so a properly arbitrated mapping would fail. */
ctrl_base = ioremap(CTRL_BASE, CTRL_SIZE);
if (!ctrl_base) {
pr_err("failed to map control module\n");
return -ENOMEM;
}

gpio1_base = ioremap(GPIO1_BASE, GPIO1_SIZE);
if (!gpio1_base) {
pr_err("failed to map GPIO1\n");
iounmap(ctrl_base);
return -ENOMEM;
}

/* 1. Pin mux: point ball V18/U18 at its gpio1_28 function. */
saved_pinmux = readl(ctrl_base + CONF_GPMC_BE1N);
writel(PINMUX_GPIO1_28, ctrl_base + CONF_GPMC_BE1N);

/* 2. Direction: GPIO1_28 = output. Not self-masking, so read-modify-write. */
saved_oe = readl(gpio1_base + GPIO_OE);
writel(saved_oe & ~PIN28, gpio1_base + GPIO_OE);

/* 3. Start blinking; the work item carries on from here. */
kblinky_set(true);
schedule_delayed_work(&kblinky_work,
msecs_to_jiffies(BLINK_HALF_PERIOD_MS));

pr_info("blinking GPIO1_28 at 1 Hz\n");
return 0;
}

static void __exit kblinky_exit(void)
{
cancel_delayed_work_sync(&kblinky_work);

/* Undo in reverse: LED off, then direction, then mux. */
kblinky_set(false);
writel(saved_oe, gpio1_base + GPIO_OE);
writel(saved_pinmux, ctrl_base + CONF_GPMC_BE1N);

iounmap(gpio1_base);
iounmap(ctrl_base);

pr_info("stopped\n");
}

module_init(kblinky_init);
module_exit(kblinky_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Ruvinda Dhambarage <contact@ruvi.tech>");
MODULE_DESCRIPTION("Blink an AM335x LED via direct GPIO/pinmux MMIO access");
MODULE_VERSION("1.0");

Full source code at: https://github.com/ruvi-d/bbb-blinky/tree/main/linux-mmio-blinky/linux-mmio-kernel

Couple of things to note here compared to our baremetal implementation.

  1. No clock setup — As mentioned, we expect gpio-omap driver to have already enabled the GPIO1 module clock.
  2. No busy loop — In this implementation, I want to start blinking with module_init(). But module_init() must return or the watchdog will kick in and reboot the system. So, the toggling cannot live in an endless loop. As a workaround, I have used a self-rescheduling delayed work item to do the toggling via a timer.
  3. The use of ioremap() — The kernel initializes the memory management unit (MMU) during the early boot up. So, all memory accesses need to be done via the MMU. The ioremap() call maps the MMIO of our registers to the virtual address space used by the kernel, allowing us to access the registers via the MMU.

3.2 Linux userspace — C lang

To access MMIO registers from userspace (as opposed to the kernel space access we did in the previous section) we will need to rely on the /dev/mem char driver enabled via the CONFIG_DEVMEM kernel config. Though CONFIG_IO_STRICT_DEVMEM will need to be disabled, as otherwise access to memory regions requested by drivers such as gpio-omap will be blocked.

Again, I must implore that the following is for education purposes only!

#define MAP_SIZE                0x1000u
#define REG32(base, off) (*(volatile uint32_t *)((uint8_t *)(base) + (off)))

/* Control module: pin mux/pad config, one register per pin. */
#define CTRL_BASE 0x44E10000u
#define CONF_GPMC_BE1N 0x0878u /* ball V18/U18 -> gpio1_28 */

#define CONF_MMODE(x) ((x) & 0x7u) /* mux mode; 7 = gpio */
#define CONF_RXACTIVE (1u << 5) /* input buffer enabled */
#define PINMUX_GPIO1_28 (CONF_MMODE(7) | CONF_RXACTIVE)

/* GPIO1. Clock already enabled by gpio-omap -- unlike bare metal. */
#define GPIO1_BASE 0x4804C000u
#define GPIO_OE 0x134u /* output enable, 0 = output */
#define GPIO_CLEARDATAOUT 0x190u /* self-masking: 1 -> pin low */
#define GPIO_SETDATAOUT 0x194u /* self-masking: 1 -> pin high */

#define PIN28 (1u << 28)
#define HALF_PERIOD_S 1u

static void *map_page(int fd, off_t phys, const char *what)
{
void *p = mmap(NULL, MAP_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd,
phys);

if (p == MAP_FAILED) {
fprintf(stderr, "mmap %s @ 0x%08lx: %s\n", what,
(unsigned long)phys, strerror(errno));
return NULL;
}
return p;
}

int main(void)
{
void *ctrl_base, *gpio1_base;
int fd;

/* O_SYNC for an uncached mapping. Needs root: /dev/mem is 0600 root:root. */
fd = open("/dev/mem", O_RDWR | O_SYNC);
if (fd < 0) {
fprintf(stderr, "open /dev/mem: %s\n", strerror(errno));
return EXIT_FAILURE;
}

ctrl_base = map_page(fd, CTRL_BASE, "control module");
gpio1_base = map_page(fd, GPIO1_BASE, "GPIO1");
close(fd); /* the mappings outlive the descriptor */
if (!ctrl_base || !gpio1_base)
return EXIT_FAILURE;

/* 1. Pin mux: point ball V18/U18 at its gpio1_28 function. */
REG32(ctrl_base, CONF_GPMC_BE1N) = PINMUX_GPIO1_28;

/* 2. Direction: GPIO1_28 = output. Not self-masking, so read-modify-write. */
REG32(gpio1_base, GPIO_OE) &= ~PIN28;

printf("blinking GPIO1_28 at 0.5 Hz\n");

/* 3. Blink forever. SET/CLEARDATAOUT are self-masking: only PIN28 is touched. */
for (;;) {
REG32(gpio1_base, GPIO_SETDATAOUT) = PIN28;
sleep(HALF_PERIOD_S);
REG32(gpio1_base, GPIO_CLEARDATAOUT) = PIN28;
sleep(HALF_PERIOD_S);
}
}

Full source code: https://github.com/ruvi-d/bbb-blinky/tree/main/linux-mmio-blinky/linux-mmio-c

This is very similar in spirit to the kernel space MMIO example. Only things worth mentioning are:

  1. Use mmap to “map” the MMIO address to the virtual address space of our process.
  2. Use sleep instead of a busy loop or timer.

If your language of choice is Python, there are modules available to easily access /dev/mem: https://pypi.org/project/devmem/

3.3 Linux userspace — shell script

Instead of writing a custom C app to access MMIO addresses, you can alternatively use the devmem2 binary via a shell script. devmem2 binary does the same /dev/mem + mmap calls behind the scenes.

#!/bin/sh
set -euo pipefail

CONF_GPMC_BE1N=0x44E10878 # control module: pin-mux conf reg for gpio1_28
PINMUX_GPIO1_28=0x27 # mmode=7 (gpio) | rxactive=1

GPIO1_OE=0x4804C134 # GPIO1 output enable, 0 = output
GPIO1_CLEARDATAOUT=0x4804C190 # self-masking: 1 -> pin low
GPIO1_SETDATAOUT=0x4804C194 # self-masking: 1 -> pin high

PIN28=0x10000000
HALF_PERIOD=0.5 # seconds; 1 Hz on/off cycle

# Pull the "0x"-prefixed value off devmem2's "Read at address ...: 0x..." line.
devmem_read() {
value=$(devmem2 "$1" w | awk '/^Read/{print $NF}')

if [ -z "$value" ]; then
echo "blinky.sh: could not read $1 via devmem2" >&2
exit 1
fi
echo "$value"
}

devmem_write() {
devmem2 "$1" w "$2" >/dev/null
}

# 1. Pin mux: point ball V18/U18 at its gpio1_28 function.
devmem_write "$CONF_GPMC_BE1N" "$PINMUX_GPIO1_28"

# 2. Direction: GPIO1_28 = output. OE is not self-masking, so devmem2's lack of
# read-modify-write means a separate read, then a write, of the whole register.
oe=$(devmem_read "$GPIO1_OE")
oe=$(( (oe & ~PIN28) & 0xFFFFFFFF ))
devmem_write "$GPIO1_OE" "$(printf '0x%08X' "$oe")"

echo "blinking GPIO1_28 at 1 Hz"

# 3. Blink forever. SET/CLEARDATAOUT are self-masking: one write per toggle.
while true; do
devmem_write "$GPIO1_SETDATAOUT" "$PIN28"
sleep "$HALF_PERIOD"
devmem_write "$GPIO1_CLEARDATAOUT" "$PIN28"
sleep "$HALF_PERIOD"
done

Full source code: https://github.com/ruvi-d/bbb-blinky/tree/main/linux-mmio-blinky/linux-mmio-bash

Parsing the output of devmem2 isn’t the easiest thing to do, but it is easier than writing a C app every time you want to access /dev/mem.

4. Linux pinctrl & gpiolib frameworks

In section 3.1 we we saw that there were other drivers in the system that had reserved the GPIO control and pin muxing registers that we were manipulating without any regard for. In this section we will work with those existing drivers to blink our LED in a more idiomatic way.

Press enter or click to view image in full size
Linux GPIO related subsystems

Per the classification in the diagram above, it was vendor drivers from Texas Instruments that were reserving our registers. Those vendor drivers act as a “HAL” (hardware abstraction layer) that abstracts away all the silicon level idiosyncrasies to exposes a consistent interface via the gpiolib and pinctrl frameworks to both the kernel space and user space. As the name implies, the pinctrl framework controls the pad muxing while the gpiolib framework controls the actual GPIO lines.

4.1 Linux kernel space

The idiomatic way to do this is to write what’s called a “platform driver” that leverages the gpiolib APIs. To understand what that means, we need to take a step back and dissect the “platform driver/bus” concept.

Get Ruvinda Dhambarage’s stories in your inbox

Join Medium for free to get updates from this writer.

Devices on buses like USB or PCIe can “enumerate” during bootup to announce their existence, allowing the kernel to dynamically “bind” the appropriate drivers for these devices using mechanisms such as vendor IDs and product IDs. On the flip side, LEDs wired to a GPIO can not be discovered at runtime. For these kind undiscoverable devices we have to write down a description of them and pass it to the kernel. There are a couple of options to pass this info to the kernel. e.g. device trees, ACPI tables and even hard-coded C structures called “platform data” that are compiled into the kernel. The preferred option for ARM SoC based embedded Linux devices is device trees, while x86–64 PCs and ARM64 servers prefer ACPI tables via firmware on a BIOS/UEFI chip.

Regardless of the mechanism, the pseudo-bus that holds all these undiscoverable devices is called the “platform bus”. i.e. a platform device means “a device that cannot announce itself, and therefore had to be declared.” It’s the catch-all bus for memory-mapped, board-level hardware.

To control our LED, let’s add following nodes to the BBB device tree.

#include "am335x-boneblack.dts"

/ {
model = "BBB Blinky";

led_on_p9_12 {
compatible = "ruvi,kblinky";
pinctrl-names = "default";
pinctrl-0 = <&kblinky_led_pins>;
blinky-gpios = <&gpio1 28 GPIO_ACTIVE_HIGH>;
};
};

&am33xx_pinmux {
kblinky_led_pins: kblinky-led-pins {
pinctrl-single,pins = <
AM33XX_PADCONF(AM335X_PIN_GPMC_BEN1, PIN_OUTPUT, MUX_MODE7) /* P9_12 */
>;
};
};

There is a lot to take in here. Let’s start with the pin muxing. At the bottom we have declared a new pin muxing group node called kblinky-led-pins that sets the pin muxing for our LED pad. But this declaration itself, doesn’t do anything. This group needs to be owned by a driver in order to trigger. That’s what the follow lines in the device tree are for

led_on_p9_12 {
..
pinctrl-names = "default";
pinctrl-0 = <&kblinky_led_pins>;
..
}

This means that the “led_on_p9_12” platform device node owns kblinky_led_pins (the label for the kblinky-led-pins node) and that there is one state for the pins called “default”. This is a standard name. “default” means that this state is selected before the driver is probed. Other valid states are “idle”, “sleep”, “init”, etc. For example, if both “init” and “default” states are defined, the “init” state is selected before the driver probe and the “default” state is selected after the driver probe. This is used by some drivers to workaround various hardware glitches.

With the pin muxing configuration out of the way, we are left with the following:

led_on_p9_12 {
..
compatible = "ruvi,kblinky";
blinky-gpios = <&gpio1 28 GPIO_ACTIVE_HIGH>;
..
}

This is the platform device description of a device called “led_on_p9_12”. The “compatible” string is what the kernel uses to find a matching platform driver. Typically this would be “vendor,driver-name”; allowing multiple vendors to provide a driver with the same name without conflicts.

Now let’s see what the corresponding driver code looks like:

#include <linux/container_of.h>
#include <linux/device.h>
#include <linux/err.h>
#include <linux/gpio/consumer.h>
#include <linux/jiffies.h>
#include <linux/mod_devicetable.h>
#include <linux/module.h>
#include <linux/platform_device.h>
#include <linux/slab.h>
#include <linux/workqueue.h>

#define KBLINKY_INTERVAL_MS 500

// per-device state: the LED line, the repeating work item, and its on/off state
struct kblinky {
struct gpio_desc *led;
struct delayed_work work;
bool on;
};

static void kblinky_set(struct kblinky *kb, bool on)
{
// set the logical level; gpiolib handles active-high/low inversion
gpiod_set_value_cansleep(kb->led, on);
kb->on = on;
}

// runs on a worker thread, toggles the LED, and re-arms itself
static void kblinky_work_fn(struct work_struct *work)
{
struct kblinky *kb = container_of(work, struct kblinky, work.work);

kblinky_set(kb, !kb->on);
schedule_delayed_work(&kb->work, msecs_to_jiffies(KBLINKY_INTERVAL_MS));
}

// called once per matching device tree node
static int kblinky_probe(struct platform_device *pdev)
{
struct device *dev = &pdev->dev;
struct kblinky *kb;

// devm_ ties this allocation's lifetime to the device
kb = devm_kzalloc(dev, sizeof(*kb), GFP_KERNEL);
if (!kb)
return -ENOMEM;

// claims the "blinky-gpios" line, muxed/clocked already, as an output driven low
kb->led = devm_gpiod_get(dev, "blinky", GPIOD_OUT_LOW);
if (IS_ERR(kb->led))
return dev_err_probe(dev, PTR_ERR(kb->led),
"failed to get LED GPIO\n");

INIT_DELAYED_WORK(&kb->work, kblinky_work_fn);
platform_set_drvdata(pdev, kb);

// set the LED so a working driver is obvious at a glance
kblinky_set(kb, true);
schedule_delayed_work(&kb->work, msecs_to_jiffies(KBLINKY_INTERVAL_MS));

dev_info(dev, "blinking every %u ms\n", KBLINKY_INTERVAL_MS);
return 0;
}

static void kblinky_remove(struct platform_device *pdev)
{
struct kblinky *kb = platform_get_drvdata(pdev);

// cancel the timer and wait for any running callback before tearing down
cancel_delayed_work_sync(&kb->work);
kblinky_set(kb, false);
}

// binds to a device tree node with compatible = "ruvi,kblinky"
static const struct of_device_id kblinky_of_match[] = {
{ .compatible = "ruvi,kblinky" },
{ }
};
MODULE_DEVICE_TABLE(of, kblinky_of_match);

static struct platform_driver kblinky_driver = {
.probe = kblinky_probe,
.remove = kblinky_remove,
.driver = {
.name = "kblinky",
.of_match_table = kblinky_of_match,
},
};
module_platform_driver(kblinky_driver);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Ruvinda Dhambarage <contact@ruvi.tech>");
MODULE_DESCRIPTION("Blink an LED described in the device tree via the gpiod API");
MODULE_VERSION("1.0");

Full source code: https://github.com/ruvi-d/bbb-blinky/tree/main/linux-gpiolib-blinky/linux-gpiolib-kernel

Couple of things worth highlighting here:

  • This driver uses the gpiod_get gpiolib/gpiod API to get access to the GPIO line via the blinky-gpios list entry in the device tree. The gpiolib framework does the required GPIO control module updates.
  • Everything that the blinky MMIO driver had to do explicitly are now handled by other lower level vendor drivers that actually own those responsibilities.
  • Electrical details like whether the GPIO pin is active high or low is completely abstracted away from this driver.
  • This driver has no register address, no pad references and no AM335X references. It is portable and could run on any hardware.
  • This driver can support multiple LEDs. We could define any arbitrary number of blinky compatible LED platform devices in the device tree and this driver would “probe” for each instance.
  • If you have udev on your BBB device, it can auto-load this blinky kernel module on boot up using the compile time modules alias info at /lib/modules/<kernel-version>/modules.alias.
[    4.282641] udevd[112]: starting version 3.2.14
[ 4.357435] udevd[113]: starting eudev-3.2.14
[ 4.599301] kblinky: loading out-of-tree module taints kernel.
[ 4.618693] kblinky led_on_p9_12: blinking every 500 ms
  • Information about our platform device and the drivers it has binded to can be seen under /sys/devices/platform/
root@dogbonedarker:~# cat /sys/devices/platform/led_on_p9_12/
driver/ of_node/ supplier:platform:44e10800.pinmux/
driver_override power/ supplier:platform:4804c000.gpio/
modalias subsystem/ uevent
root@dogbonedarker:~# cat /sys/devices/platform/led_on_p9_12/uevent
DRIVER=kblinky
OF_NAME=led_on_p9_12
OF_FULLNAME=/led_on_p9_12
OF_COMPATIBLE_0=ruvi,kblinky
OF_COMPATIBLE_N=1
MODALIAS=of:Nled_on_p9_12T(null)Cruvi,kblinky

There are lots of other features and systems at play here with this platform driver that I could write a separate blog post on. But for the sake of brevity let’s put a pin on this subject here.

4.2 Userspace access — shell script

The modern, standard way to access GPIO from Linux user space is via the GPIO character device interface (/dev/gpiochipX, enabled via CONFIG_GPIO_CDEV) using ioctls commands.
The legacy /sys/class/gpio sysfs interface (enabled via CONFIG_GPIO_SYSFS) has been deprecated since Kernel 4.8.

Using the legacy SYSFS interface was easier but it came with a lot drawbacks. To use it, you had to first find the SYSFS number via the formula:

SYSFS Number = (GPIO Bank × 32) + Line Number

For our GPIO1, line 28 the calculation is (1 * 32) + 28 = 60. Then you can use echo commands to blink the our LED.

debian@BeagleBone:~$ echo 60 > /sys/class/gpio/export
debian@BeagleBone:~$ echo out > /sys/class/gpio/gpio60/direction
debian@BeagleBone:~$ echo 1 > /sys/class/gpio/gpio60/value
debian@BeagleBone:~$ sleep 1
debian@BeagleBone:~$ echo 0 > /sys/class/gpio/gpio60/value
debian@BeagleBone:~$ echo 60 > /sys/class/gpio/unexport

To do the same using the char driver, we’d have to open the /dev/gpiochip1 device node and send ioctls to the file descriptor. Thankfully libgpiod provides a bunch of CLI tools to do the same. For e.g. use gpioset to drive our GPIO line high.

debian@BeagleBone:~$ gpioset --chip gpiochip1 28=1

The char driver has a bunch of advantages over the now defunct SYSFS interface. These include but are not limited to:

  1. It’s fast due to the the read/write/text parsing required with SYSFS vs the binary ioctl calls used with the char driver.
  2. Ownership: when a process takes control of a GPIO line via a file descriptor, no other process can access the same GPIO line. With SYSFS there is nothing stopping two processes writing to the same GPIO line. Also, when one process exports a GPIO, it is available for everyone else till someone unexports it.

This is all fine and dandy in theory, but there is a bigger caveat to consider. The silicon GPIO controller number to kernel gpiochip number mapping is NOT deterministic. You’d think that the silicon GPIO 0 would map to gpiochp0 in the kernel and likewise GPIO 1 would be gpiochip1; but that is not always the case. The order can change if the kernel probes drivers in a different order during boot.

For example let’s check how the BBB GPIOs enumerates in kernel 5.10 vs kernel 6.18

| Silicon GPIO | Base addr   | 5.10.168        | 6.18.38        |
|--------------|-------------|-----------------|----------------|
| GPIO0 | 0x44e07000 | gpiochip0 | gpiochip3 |
| GPIO1 | 0x4804c000 | gpiochip1 | gpiochip0 |
| GPIO2 | 0x481ac000 | gpiochip2 | gpiochip1 |
| GPIO3 | 0x481ae000 | gpiochip3 | gpiochip2 |

Yikes! GPIO 1 (with it’s starting address of 4804c000) is actually gpiochip0 in the newer 6.18 kernel! So it’s GPIO SYSFS number is 28 on kernel 6.18 vs 60 on kernel 5.10. Even our gpioset command call needs to change with the new kernel.

debian@BeagleBone:~$ gpioset --chip gpiochip0 28=1

This is where gpio-line-names come in to the picture. These are human friendly labels you can define for each GPIO line so that you can easily find it at runtime. Use the command gpioinfo to list these names

root@dogbonedarker:~# uname -r
6.16.0-dogbonedarker
root@dogbonedarker:~# gpioinfo -c 0
gpiochip0 - 32 lines:
line 0: "P8_25 [mmc1_dat0]" input
line 1: "[mmc1_dat1]" input
line 2: "P8_5 [mmc1_dat2]" input
line 3: "P8_6 [mmc1_dat3]" input
line 4: "P8_23 [mmc1_dat4]" input
line 5: "P8_22 [mmc1_dat5]" input
line 6: "P8_3 [mmc1_dat6]" input
line 7: "P8_4 [mmc1_dat7]" input
line 8: "NC" output active-low consumer="PHY reset"
line 9: "NC" input
line 10: "NC" input
line 11: "NC" input
line 12: "P8_12" input
line 13: "P8_11" input
line 14: "P8_16" input
line 15: "P8_15" input
line 16: "P9_15A" input
line 17: "P9_23" input
line 18: "P9_14 [ehrpwm1a]" input
line 19: "P9_16 [ehrpwm1b]" input
line 20: "[emmc rst]" input
line 21: "[usr0 led]" output consumer="beaglebone:green:heartbeat"
line 22: "[usr1 led]" output consumer="beaglebone:green:mmc0"
line 23: "[usr2 led]" output consumer="beaglebone:green:usr2"
line 24: "[usr3 led]" output consumer="beaglebone:green:usr3"
line 25: "[hdmi irq]" input
line 26: "[usb vbus oc]" input
line 27: "[hdmi audio]" output consumer="enable"
line 28: "P9_12" output
line 29: "P8_26" input
line 30: "P8_21 [emmc]" input
line 31: "P8_20 [emmc]" input

We can see that in this system there is a “P9_12” label that helps us easily identify that your LED is on gpiochip0 on this 6.16 kernel.

Note: if you still have our kblinky-gpiolib driver loaded, it will show up with a “consumer” in the output and you will not be able to control it via gpioset. The gpiolib framework enforces exclusive access.

root@dogbonedarker:~# gpioinfo | grep P9_12
line 28: "P9_12" output consumer="blinky"
root@dogbonedarker:~# gpioset --chip gpiochip0 28=1
gpioset: unable to request lines on chip '/dev/gpiochip0': Device or resource busy
Press enter or click to view image in full size
The gpio-line-names definitions in the device tree

In the image above we can see how these line-names are defined in the BBB device tree. Now to control the LED via the line-name name use:

root@dogbonedarker:~# gpioset P9_12=1

To blink,

root@dogbonedarker:~# gpioset --toggle 500ms P9_12=0

Easy peasy!

4.3 Userspace access — C lang

If you want to write you own app C app instead of using gpioset:

#include <dirent.h>
#include <fcntl.h>
#include <linux/gpio.h>
#include <stdio.h>
#include <string.h>
#include <sys/ioctl.h>
#include <unistd.h>

#define LINE_NAME "P9_12"
#define CONSUMER "blinky-c"

// Walk /dev/gpiochipN until a line named LINE_NAME turns up.
static int find_line(const char *name, unsigned int *offset)
{
DIR *dev = opendir("/dev");
struct dirent *ent;

while ((ent = readdir(dev))) {
if (strncmp(ent->d_name, "gpiochip", 8) != 0)
continue;

char path[64];
snprintf(path, sizeof(path), "/dev/%s", ent->d_name);
int fd = open(path, O_RDWR);

struct gpiochip_info info;
ioctl(fd, GPIO_GET_CHIPINFO_IOCTL, &info);

for (unsigned int i = 0; i < info.lines; i++) {
struct gpio_v2_line_info li = { .offset = i };
ioctl(fd, GPIO_V2_GET_LINEINFO_IOCTL, &li);
if (strcmp(li.name, name) == 0) {
*offset = i;
return fd;
}
}
close(fd);
}
return -1;
}

int main(void)
{
unsigned int offset;
int chip_fd = find_line(LINE_NAME, &offset);

// Request the line as an output.
struct gpio_v2_line_request req = { .num_lines = 1 };
strncpy(req.consumer, CONSUMER, sizeof(req.consumer) - 1);
req.offsets[0] = offset;
req.config.flags = GPIO_V2_LINE_FLAG_OUTPUT;
ioctl(chip_fd, GPIO_V2_GET_LINE_IOCTL, &req);
close(chip_fd);

// Toggle it forever, 1 Hz.
struct gpio_v2_line_values vals = { .mask = 1 };
for (;;) {
vals.bits = 1;
ioctl(req.fd, GPIO_V2_LINE_SET_VALUES_IOCTL, &vals);
usleep(500000);

vals.bits = 0;
ioctl(req.fd, GPIO_V2_LINE_SET_VALUES_IOCTL, &vals);
usleep(500000);
}
}

Full source code: https://github.com/ruvi-d/bbb-blinky/tree/main/linux-gpiolib-blinky/linux-gpiolib-c

One thing worth mentioning here is that if you run gpioinfo | grep P9_12 while this C app is running, you will see that the P9_12 line has a “consumer”, just like how the kblinky-gpiolib driver was able to consume the same line and have exclusive access. This of course means that we can’t have both the blinky driver and C app running at the same time.

root@dogbonedarker:~# ./blinky &
root@dogbonedarker:~# gpioinfo | grep P9_12
line 28: "P9_12" output consumer="blinky-c"
root@dogbonedarker:~# modprobe kblinky
[ 1235.445964] kblinky led_on_p9_12: error -EBUSY: failed to get LED GPIO
[ 1235.452801] kblinky led_on_p9_12: probe with driver kblinky failed with error -16

5. The idiomatic method: gpio-leds

We are finally at the end. The recommended way to blink an LED connected to a GPIO is via the in-built gpio-leds driver. Give yourself a pat on the back if you saw this coming.

#include "am335x-boneblack.dts"

/ {
model = "BBB Blinky";

p9_12_led {
compatible = "gpio-leds";
pinctrl-names = "default";
pinctrl-0 = <&p9_12_led_pins>;

led-p9-12 {
gpios = <&gpio1 28 GPIO_ACTIVE_HIGH>;
label = "ruvi:p9_12";
default-state = "off";
};
};
};

&am33xx_pinmux {
p9_12_led_pins: p9-12-led-pins {
pinctrl-single,pins = <
AM33XX_PADCONF(AM335X_PIN_GPMC_BEN1, PIN_OUTPUT, MUX_MODE7) /* gpmc_be1n.gpio1_28, P9_12 */
>;
};
};

This device tree update to use gpio-leds should look very familiar. It is basically the same as our kblinky-gpiolib device tree from section 4.1. We have just replaced the compatible string and defined additional properties (label and default-state). Effectively, gpio-leds is the “buttoned up” version of our crude kblinky-gpiolib driver.

Once you define a gpio-leds compatible node, the new device will show up under /sys/class/leds/. Controlling the LED via this interface is as simple as it gets:

#!/bin/sh -eu

LED=/sys/class/leds/ruvi:p9_12

echo none > "$LED/trigger" # disable any default trigger (heartbeat, etc.)
MAX=$(cat "$LED/max_brightness")

while true; do
echo "$MAX" > "$LED/brightness"
sleep 1
echo 0 > "$LED/brightness"
sleep 1
done

Just like before with our examples from section 4, the P9_12 line will now be “busy” from the gpiolib frameworks perspective. So, any attempts to access it via the gpiolib framework will fail as it rightly should.

root@dogbonedarker:~# gpioinfo | grep P9_12
line 28: "P9_12" output consumer="ruvi:p9_12"

root@dogbonedarker:~# gpioset P9_12=1
gpioset: unable to request lines on chip '/dev/gpiochip0': Device or resource busy

But like we did in the MMIO examples in section 3, you can still bypass the gpiolib framework and blink the LED if you so desire.

That’s all folks!