Arc II · The Camera Driver Lab
Chapter 08

RPi3 Platform Driver + GPIO Interrupt — Yocto

First driver on real silicon with a real build system. Platform driver probed via Device Tree overlay — GPIO23 interrupt with workqueue bottom half, atomic counter exposed via /dev node, built and deployed through a custom Yocto meta layer.

platform_driver DT overlay request_irq workqueue atomic_t devm_ Yocto/Kirkstone RPi3

Why this driver?

Lab 07 touched real hardware but still used a simple GPIO API with no Device Tree, no bus model, no build system. Real production drivers don't work that way. This lab introduces the platform driver model — where the kernel matches a driver to hardware via the Device Tree — and wraps the entire thing in a Yocto meta layer. This is the workflow used by every embedded Linux camera driver in the field.

The hardware

ParameterValue
Target boardRaspberry Pi 3 (BCM2837)
GPIOGPIO23 (Pin 16)
Interrupt triggerFalling edge (IRQF_TRIGGER_FALLING)
Pull configurationPull-up via DT overlay (brcm,pull = 2)
Build systemYocto Kirkstone — custom meta-sanath layer
HostDell Latitude 5320, Ubuntu 22.04

Platform driver model

A platform driver manages devices connected directly to the SoC — not through a bus like I2C or SPI. The kernel matches a platform driver to a device using the compatible string in the Device Tree. When the compatible string in a DT node matches a registered driver's of_match_table, the kernel calls that driver's probe() function.

This is fundamentally different from earlier labs which had no hardware representation. The platform driver represents real hardware — a GPIO pin on the BCM2837.

static const struct of_device_id sanath_irq_of_match[] = {
    { .compatible = "sanath-irq-lab", },
    { }
};
MODULE_DEVICE_TABLE(of, sanath_irq_of_match);

static struct platform_driver sanath_irq_driver = {
    .probe  = sanath_irq_probe,
    .remove = sanath_irq_remove,
    .driver = {
        .name           = "sanath-irq-lab",
        .of_match_table = sanath_irq_of_match,
    },
};

module_platform_driver(sanath_irq_driver);

Device Tree overlay

The overlay does two things: configures GPIO23 as input with pull-up, and creates the platform device node with the matching compatible string and interrupt binding.

/dts-v1/;
/plugin/;

/ {
    compatible = "brcm,bcm2837";

    fragment@0 {
        target = <&gpio>;
        __overlay__ {
            sanath_irq_pins: sanath-irq-pins {
                brcm,pins = <23>;
                brcm,function = <0>;   /* input */
                brcm,pull = <2>;       /* pull-up */
            };
        };
    };

    fragment@1 {
        target-path = "/";
        __overlay__ {
            sanath_irq_lab: sanath-irq-lab {
                compatible = "sanath-irq-lab";
                pinctrl-names = "default";
                pinctrl-0 = <&sanath_irq_pins>;
                interrupt-parent = <&gpio>;
                interrupts = <23 2>;   /* GPIO23, falling edge */
                status = "okay";
            };
        };
    };
};

The compatible string "sanath-irq-lab" in the overlay matches the driver's of_match_table — this is the link that causes probe() to be called when the overlay is loaded.

probe() and devm_ resource management

devm_ prefixed APIs tie resource lifetime to the device. When remove() is called, all devm_ allocated resources are freed automatically by the kernel — no manual cleanup needed.

static int sanath_irq_probe(struct platform_device *pdev)
{
    struct sanath_irq_data *data;
    int ret;

    data = devm_kzalloc(&pdev->dev, sizeof(*data), GFP_KERNEL);
    if (!data)
        return -ENOMEM;

    init_waitqueue_head(&data->wq);

    data->irq = platform_get_irq(pdev, 0);  /* reads IRQ from DT */
    if (data->irq < 0)
        return data->irq;

    ret = devm_request_irq(&pdev->dev, data->irq, sanath_irq_handler,
                           IRQF_TRIGGER_FALLING, "sanath-irq-lab", data);
    if (ret)
        return ret;

    platform_set_drvdata(pdev, data);
    dev_info(&pdev->dev, "sanath-irq-lab probed successfully\n");
    return 0;
}
APIWhat it does
platform_get_irq(pdev, 0)Reads IRQ number from DT node — no hardcoding
devm_kzalloc()Allocates zeroed memory — freed automatically on remove()
devm_request_irq()Requests IRQ — released automatically on remove()
platform_set_drvdata()Attaches per-device struct to the device

How edge detection works in hardware

Edge detection is done entirely in hardware by the BCM2835 GPIO controller — the CPU never sees the voltage. The controller has dedicated registers:

GPIO23 voltage drops 3.3V → 0V
→ BCM2835 writes GPFEN register (Falling Edge Detect Enable) for GPIO23
→ Sets status bit in GPEDS register
→ Raises interrupt line to GIC (Generic Interrupt Controller)
→ GIC interrupts the CPU
→ CPU saves state, enters your handler

When IRQF_TRIGGER_FALLING is passed to request_irq(), the kernel's pinctrl-bcm2835 driver writes to GPFEN for GPIO23. The CPU only sees the interrupt signal — all detection is done before the handler runs.

IRQ handler — debounce

static irqreturn_t sanath_irq_handler(int irq, void *dev_id)
{
    struct sanath_irq_data *data = dev_id;
    ktime_t now = ktime_get();

    /* Reject edges within 50ms of last valid interrupt */
    if (ktime_to_ms(ktime_sub(now, data->last_irq)) < 50)
        return IRQ_HANDLED;

    data->last_irq = now;
    schedule_work(&data->work);  /* all real work in workqueue */
    return IRQ_HANDLED;
}

ktime_get() is used instead of jiffies because on RPi3 (HZ=250) one jiffy is 4ms — too coarse for bounce windows of 1–5ms. ktime_get() gives nanosecond resolution and is safe in hardIRQ context. Sleeping calls like msleep() are forbidden in the handler — the CPU cannot be scheduled away during interrupt handling.

Spurious interrupts — what actually happened

During testing, interrupts fired even before the jumper wire touched GND. Investigation revealed three distinct causes of spurious GPIO interrupts:

CausePull-up fixes it?SW debounce fixes it?HW filter fixes it?
Floating pin (no pull)✓ YesPartiallyPartially
Antenna / external EMIPartiallyPartially✓ Yes
Contact bounce✗ No✓ Yes✓ Yes

In this lab the pull-up was already configured via DT overlay (brcm,pull = <2>), ruling out a floating pin. The actual cause was the jumper wire acting as an antenna — picking up EMI from the RPi3's switching regulators and USB power lines.

Hardware problems and software problems look identical from the driver — both show up as spurious interrupts in dmesg. Always reason about the physical setup before touching code.

Verifying interrupt activity

# More reliable than dmesg — pr_info can be rate-limited under high IRQ load
cat /proc/interrupts | grep sanath

184:   420   466   458   435   pinctrl-bcm2835   23 Edge   sanath-irq-lab

Each column is the interrupt count on one CPU core. The BCM2837 is quad-core, so four columns appear.

Yocto meta layer

The driver, DT overlay, and Makefile are packaged as a Yocto recipe in a custom meta-sanath layer on top of Kirkstone. The recipe handles cross-compilation and deployment to the RPi3 image automatically.

# Prevent Yocto from deleting work directory during active development
RM_WORK_EXCLUDE += "sanath-dtbo"
# Remove once the recipe is stable

INHERIT += "rm_work" in Yocto's default config deletes tmp/work/ after every successful build to save ~30GB of disk space. Adding a recipe to RM_WORK_EXCLUDE preserves its work directory for debugging.

Workqueue bottom half

The IRQ handler must never sleep and must complete as fast as possible — it interrupts the CPU mid-execution. All real work is deferred to a workqueue which runs in process context where sleeping, memory allocation, and longer operations are safe.

INIT_WORK is used instead of DECLARE_WORK because the work_struct is embedded inside the per-device struct. Each device instance gets its own work item. container_of recovers the parent struct inside the work function.

struct sanath_irq_data {
    int irq;
    atomic_t counter;
    unsigned long last_irq;
    struct work_struct work;   /* embedded — one per device instance */
};

/* in probe() — initialise once */
INIT_WORK(&data->work, sanath_irq_work);

/* IRQ handler — top half */
static irqreturn_t sanath_irq_handler(int irq, void *dev_id)
{
    struct sanath_irq_data *data = dev_id;
    unsigned long now = jiffies;

    if (now - data->last_irq < msecs_to_jiffies(50))
        return IRQ_HANDLED;  /* debounce */

    data->last_irq = now;
    schedule_work(&data->work);  /* defer everything else */
    return IRQ_HANDLED;
}

/* work function — bottom half, process context */
static void sanath_irq_work(struct work_struct *work)
{
    struct sanath_irq_data *data =
        container_of(work, struct sanath_irq_data, work);
    atomic_inc(&data->counter);
    pr_info("sanath-irq-lab: IRQ fired, counter = %d\n",
            atomic_read(&data->counter));
}
MechanismContextCan sleep?Use when
IRQ handler (top half)hardIRQ atomicNeverMinimal — save timestamp, schedule work
Workqueue (bottom half)ProcessYesAll real work — counter, logging, wake queue
TaskletsoftIRQ atomicNeverLow latency, no sleeping needed (deprecated)

atomic_t — race-free counter without locking

The counter is incremented in the work function and read in etx_read(). A mutex would technically work since both run in process context — but atomic_t is the correct choice for a single integer because it is race-free by design, has no locking overhead, and works in any context including atomic.

/* wrong — unsigned long with no protection */
data->counter++;

/* correct — atomic, race-free, works in any context */
atomic_inc(&data->counter);
unsigned long val = atomic_read(&data->counter);
atomic_tmutexspinlock
Use forSingle integerMultiple variables, process context onlyData touched from IRQ context
Can sleep?N/AYesNo
Works in IRQ?YesNoYes

/dev node — exposing counter to userspace

A character device exposes the IRQ counter via read(). The module registers both the platform driver and the char device from module_init()module_platform_driver() macro cannot be used when the module also manages a char device.

static ssize_t etx_read(struct file *filp, char __user *buf,
                        size_t len, loff_t *off)
{
    char kbuf[32];
    int kbuf_len;

    if (*off > 0)
        return 0;  /* EOF — prevent cat from looping forever */

    if (!g_data)
        return -ENODEV;

    kbuf_len = snprintf(kbuf, sizeof(kbuf), "%d\n",
                        atomic_read(&g_data->counter));

    if (copy_to_user(buf, kbuf, kbuf_len))
        return -EFAULT;

    *off += kbuf_len;
    return kbuf_len;
}

The *off > 0 check is critical — without it, cat calls read() in a loop forever because it never sees EOF (return 0). After returning data, *off is advanced by bytes returned. On the next call *off > 0 triggers and returns 0, signalling EOF.

# Verify
cat /dev/sanath_queue
5

cancel_work_sync in remove()

Without cancel_work_sync, a use-after-free is possible: rmmod triggers remove(), devm_ frees sanath_irq_data, then the work function runs and accesses freed memory — kernel oops.

cancel_work_sync(&data->work) blocks until any pending or in-flight work completes before remove() continues. The data struct is guaranteed valid for the entire duration of the work function.

static int sanath_irq_remove(struct platform_device *pdev)
{
    struct sanath_irq_data *data = platform_get_drvdata(pdev);
    cancel_work_sync(&data->work);  /* wait for in-flight work */
    dev_info(&pdev->dev, "sanath-irq-lab removed\n");
    return 0;  /* devm_ handles free_irq and kfree automatically */
}

devm_request_irq() means free_irq() is called automatically — no manual cleanup needed in remove() beyond cancel_work_sync.