This post is part of the series about my personal BeagleBone Black project. The Go REST API and the Rust HAL both access GPIO/I2C from user space (/sys/class/gpio, /dev/i2c-*). That’s plenty for most use cases. This post shows the deeper level: a kernel module of your own, for when user-space access no longer suffices.

When is user space no longer enough?

RequirementUser space (sysfs//dev/i2c-*)

Kernel module

Occasional read/write

Sufficient

Unnecessary overhead

Event-driven reaction to a signal (e.g. a button interrupt)

Only possible via polling — costs CPU time and latency

request_irq reacts without polling, with low latency

Hard timing requirements

Cannot be guaranteed (user space can be preempted at any time)

Considerably better, though still not hard, real-time guarantees

A kernel module is no silver bullet — it comes with its own risks (a crash brings down the whole system, not just one process). Rule of thumb: try user space first, reach for a kernel module only once polling or latency actually become a problem.

Basic structure of an out-of-tree module

// gpio_irq_demo.c
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>

static int __init gpio_irq_demo_init(void)
{
    pr_info("gpio_irq_demo: loaded\n");
    return 0;
}

static void __exit gpio_irq_demo_exit(void)
{
    pr_info("gpio_irq_demo: unloaded\n");
}

module_init(gpio_irq_demo_init);
module_exit(gpio_irq_demo_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Paul Fleischmann");
MODULE_DESCRIPTION("GPIO interrupt demo character device");

The matching Makefile for an out-of-tree module:

obj-m += gpio_irq_demo.o

KDIR ?= /lib/modules/$(shell uname -r)/build

default:
	$(MAKE) -C $(KDIR) M=$(PWD) modules

clean:
	$(MAKE) -C $(KDIR) M=$(PWD) clean

Locally, against a kernel with matching headers, plain make is enough. For the BeagleBone Black, it’s cross-compiled against the Yocto kernel instead (see below).

Registering a character-device interface

A kernel module alone doesn’t do anything yet — it needs an interface that user space can interact with. For a simple device, a character device fits well:

// gpio_irq_demo.c (continued)
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/uaccess.h>

#define DEVICE_NAME "gpio_irq_demo"

static dev_t dev_num;
static struct cdev my_cdev;
static int irq_count = 0;

static ssize_t demo_read(struct file *file, char __user *buf,
                          size_t len, loff_t *offset)
{
    char msg[16];
    int msg_len = snprintf(msg, sizeof(msg), "%d\n", irq_count);

    if (*offset > 0)
        return 0;
    if (copy_to_user(buf, msg, msg_len))
        return -EFAULT;
    *offset += msg_len;
    return msg_len;
}

static const struct file_operations fops = {
    .owner = THIS_MODULE,
    .read  = demo_read,
};

demo_read returns the number of interrupts seen so far to user space — a simple cat /dev/gpio_irq_demo shows the current count.

GPIO interrupt handling

The real payoff over user-space polling: the kernel only becomes active when an event actually occurs.

// gpio_irq_demo.c (continued)
#include <linux/gpio.h>
#include <linux/interrupt.h>

#define GPIO_PIN 60   /* e.g. P9_12 on the BeagleBone Black */

static int irq_number;

static irqreturn_t gpio_irq_handler(int irq, void *dev_id)
{
    irq_count++;
    pr_info("gpio_irq_demo: interrupt #%d on GPIO %d\n", irq_count, GPIO_PIN);
    return IRQ_HANDLED;
}

static int setup_gpio_irq(void)
{
    int ret;

    ret = gpio_request(GPIO_PIN, "gpio_irq_demo");
    if (ret)
        return ret;

    gpio_direction_input(GPIO_PIN);
    irq_number = gpio_to_irq(GPIO_PIN);

    ret = request_irq(irq_number, gpio_irq_handler,
                       IRQF_TRIGGER_RISING, "gpio_irq_demo", NULL);
    if (ret) {
        gpio_free(GPIO_PIN);
        return ret;
    }
    return 0;
}

gpio_irq_handler runs in interrupt context — no blocking calls, no sleeping, no long-running processing. Anything that takes longer than a few instructions belongs in a bottom half (tasklet, workqueue, or threaded IRQ), not directly in the handler.

For comparison, the equivalent user-space solution via sysfs looks like this — with the crucial difference that it has to actively poll:

echo 60 > /sys/class/gpio/export
echo in > /sys/class/gpio/gpio60/direction
echo rising > /sys/class/gpio/gpio60/edge

# Polling loop instead of a real interrupt:
while true; do
  cat /sys/class/gpio/gpio60/value
  sleep 0.1
done

Cross-compiling against the Yocto kernel

Unlike the local make against the host kernel headers, the BeagleBone Black needs the kernel headers and toolchain from the Yocto build:

source /opt/poky/*/environment-setup-cortexa8hf-neon-poky-linux-gnueabi

make -C $KERNEL_SRC M=$(pwd) modules

$KERNEL_SRC points to the configured kernel source tree of the Yocto build (tmp/work-shared/beaglebone-yocto/kernel-source), not a generic ARM kernel — the module must be built against exactly the kernel version and configuration running on the target, otherwise loading fails with a version mismatch.

Loading, unloading, diagnostics

sudo insmod gpio_irq_demo.ko
dmesg | tail -5

cat /dev/gpio_irq_demo   # current interrupt count

sudo rmmod gpio_irq_demo
dmesg | tail -5

dmesg is the first place to look when insmod fails — typical failure patterns are a version mismatch (see above), a GPIO pin already claimed by another module (gpio_request fails), or a missing prior gpio_free from a previously crashed module.

Summary

AspectKey takeaway

When a kernel module is needed

When user-space (sysfs) polling fails on CPU load or latency

Interrupt handler

Keep it short and non-blocking — move long work into a bottom half

Cross-compilation

Must happen against the exact kernel version/configuration of the Yocto build

Diagnostics

dmesg first — version mismatches and claimed GPIOs are the most common failure patterns