Introduction to Linux Device Driver Development in C

Developing low-level device drivers for Linux is a critical skill for system programmers and hardware engineers. Using C as the primary language gives developers precise control over hardware interactions, memory management, and kernel resources—control that higher-level languages cannot provide. This article covers the essentials of writing production-ready device drivers in C for Linux, from kernel module fundamentals to advanced topics like interrupt handling and synchronization. By the end, you will have a solid foundation to start writing your own drivers, along with practical code examples and references to authoritative resources.

Understanding Linux Device Drivers

Linux device drivers are kernel modules that enable the operating system to communicate with hardware devices such as storage controllers, network interfaces, GPUs, and embedded peripherals. They act as a bridge between user-space applications and the hardware, handling data transfers, hardware initialization, interrupt handling, and power management. The Linux kernel categorizes drivers into several types:

  • Character drivers – for devices that transfer data as a stream of bytes (e.g., serial ports, GPIO, keyboards).
  • Block drivers – for devices that manage data in fixed-size blocks (e.g., hard drives, SSDs).
  • Network drivers – for handling network packet transmission and reception.
  • USB/PCI/Platform drivers – for hot-pluggable or memory-mapped hardware.

Every driver must be registered with the kernel through well-defined interfaces. The kernel provides a standardized set of APIs, which allows drivers to be compiled either as built-in modules or as loadable kernel modules (LKMs).

Why C for Device Drivers

C remains the language of choice for Linux kernel development for several reasons:

  • Low-level control – Direct access to memory-mapped registers, pointers, and inline assembly when needed.
  • Predictable performance – No hidden runtime overhead or garbage collection, critical for interrupt service routines.
  • Kernel compatibility – The entire Linux kernel is written in C, and driver APIs are designed around C data structures and function pointers.
  • Portability – C compilers exist for virtually every architecture Linux supports (x86, ARM, RISC-V, etc.).

While Rust has recently been introduced as an alternative, C remains the standard for the vast majority of existing drivers and documentation. For new drivers, C provides the most mature tooling and debugging infrastructure.

Prerequisites for Developing Drivers in C

Before attempting to write a device driver, you must have:

  • A solid understanding of the C programming language, including pointers, structures, and memory allocation.
  • Knowledge of Linux kernel architecture (user space vs kernel space, system calls, process management).
  • Familiarity with hardware specifications (datasheets, bus protocols, register maps).
  • A development environment with the Linux kernel headers, build tools (GCC, make), and a kernel source tree (or at least the headers for your running kernel).
  • Access to a test machine or virtual machine capable of loading and testing kernel modules.

Kernel Module Lifecycle

Every loadable kernel module must implement two functions: an initialization function and a cleanup function. These are declared using the module_init() and module_exit() macros, respectively.

static int __init my_driver_init(void)
{
    // Allocate resources, register device, set up interrupt handlers
    return 0;
}

static void __exit my_driver_exit(void)
{
    // Release resources, unregister device, free memory
}

module_init(my_driver_init);
module_exit(my_driver_exit);

The __init attribute causes the initialization code to be discarded after the module loads, saving memory. The __exit attribute is ignored if the module is built into the kernel (since it cannot be unloaded).

Additionally, every module must include its license to avoid tainting the kernel: MODULE_LICENSE("GPL");. Other metadata include MODULE_AUTHOR, MODULE_DESCRIPTION, and MODULE_VERSION.

Core Data Structures and Device Registration

Character Device Registration

The most common entry-point for learning is a character driver. The key structure is struct file_operations, which defines the callback functions for system calls like open, read, write, release, and ioctl.

static struct file_operations my_fops = {
    .owner   = THIS_MODULE,
    .open    = my_open,
    .release = my_release,
    .read    = my_read,
    .write   = my_write,
};

To register a character device, allocate a major number (dynamic allocation using register_chrdev(0, name, &fops) or the more modern alloc_chrdev_region + cdev_init + cdev_add). The modern approach gives you finer control and is recommended for new drivers.

dev_t dev_num;
alloc_chrdev_region(&dev_num, 0, 1, "my_driver");
major = MAJOR(dev_num);

struct cdev my_cdev;
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
cdev_add(&my_cdev, dev_num, 1);

Unregister in the cleanup function using cdev_del and unregister_chrdev_region.

Platform and Device Tree Binding

For memory-mapped hardware (e.g., GPIO controllers, SPI), use the platform driver framework. You match the driver with a compatible string in the device tree or ACPI table. The struct platform_driver provides probe and remove callbacks.

static struct platform_driver my_platform_driver = {
    .probe  = my_probe,
    .remove = my_remove,
    .driver = {
        .name = "my_device",
        .of_match_table = my_of_match,
    },
};
module_platform_driver(my_platform_driver);

Memory Management in Drivers

Kernel memory allocation differs from user space. You cannot use malloc; instead, use:

  • kmalloc(size, flags) – for physically contiguous memory (flags like GFP_KERNEL for process context, GFP_ATOMIC for interrupts).
  • kzalloc – same as kmalloc but zero-initialized.
  • kfree(ptr) – free the memory.
  • dma_alloc_coherent – allocate DMA-safe memory that is visible to both CPU and hardware.
  • ioremap(phys_addr, size) – map physical I/O memory into kernel virtual address space.

Always check return values for NULL and free resources in the cleanup path. Use devm_ (managed device resource) APIs to simplify error handling—they automatically release on driver detach.

Handling Interrupts

Interrupts allow hardware to signal the CPU when an event occurs (e.g., data received, transfer complete). To register an interrupt handler, use request_irq(irq_number, handler, flags, name, dev_id). The handler must be declared with the irqreturn_t return type:

static irqreturn_t my_interrupt_handler(int irq, void *dev_id)
{
    // Acknowledge the interrupt
    // Schedule bottom half work if needed
    return IRQ_HANDLED;
}

Because interrupt handlers run in atomic context (no sleeping allowed), heavy processing should be deferred to the bottom half. Linux provides several mechanisms:

  • Tasklets – lightweight, run in softirq context, cannot sleep.
  • Workqueues – run in process context, can sleep, useful for long operations.
  • Threaded IRQsrequest_threaded_irq() turns the handler into a kernel thread.

Modern best practice is to use threaded IRQs for most drivers because they simplify locking and allow sleeping.

Synchronization and Locking

Concurrency is inherent in the kernel—multiple processes, interrupt handlers, and SMP cores may access your driver simultaneously. The kernel provides several locking primitives:

  • Spinlocks (spin_lock/spin_unlock) – for very short critical sections where sleeping is not allowed (e.g., interrupt handlers).
  • Mutexes (mutex_lock/mutex_unlock) – for longer sections that may sleep (process context only).
  • Atomic operations (atomic_t, set_bit) – for simple counters and flags.
  • Read-copy-update (RCU) – for read-mostly data structures with infrequent updates.

Always choose the simplest lock that works. Missing locking leads to data races and crashes; overly coarse locking reduces performance. Use lockdep (enabled via CONFIG_PROVE_LOCKING) to detect deadlocks during development.

Debugging Techniques for Driver Developers

Debugging kernel code is harder than user-space. Essential tools include:

  • printk – the kernel's equivalent of printf. Use KERN_DEBUG, KERN_INFO, KERN_ERR etc., and check output via dmesg.
  • Dynamic debug – enabled with CONFIG_DYNAMIC_DEBUG. Allows runtime control of pr_debug and dev_dbg messages via debugfs.
  • Kernel probe (kprobe) / ftrace – instrument specific functions without recompiling.
  • kgdb / kdb – kernel debugger over serial or keyboard.
  • QEMU + GDB – run the kernel in a virtual machine and debug it with GDB.
  • Static analysis – sparse, smatch, and Coccinelle can catch common errors.

Also enable CONFIG_DEBUG_KERNEL and related options in your kernel configuration during development.

Example: Simple GPIO Driver Skeleton

Below is a minimal platform driver that controls a GPIO output. It does not include interrupt handling for brevity, but illustrates the core structure.

#include <linux/module.h>
#include <linux/platform_device.h>
#include <linux/gpio.h>
#include <linux/err.h>

static int my_gpio_probe(struct platform_device *pdev)
{
    int ret;
    struct device *dev = &pdev->dev;

    ret = gpio_request(17, "my_gpio");
    if (ret) {
        dev_err(dev, "Cannot request GPIO 17\n");
        return ret;
    }
    gpio_direction_output(17, 0);
    dev_info(dev, "GPIO 17 initialized\n");
    return 0;
}

static int my_gpio_remove(struct platform_device *pdev)
{
    gpio_set_value(17, 0);
    gpio_free(17);
    return 0;
}

static const struct of_device_id my_gpio_dt_ids[] = {
    { .compatible = "vendor,my-gpio" },
    { }
};
MODULE_DEVICE_TABLE(of, my_gpio_dt_ids);

static struct platform_driver my_gpio_driver = {
    .probe  = my_gpio_probe,
    .remove = my_gpio_remove,
    .driver = {
        .name = "my_gpio",
        .of_match_table = my_gpio_dt_ids,
    },
};
module_platform_driver(my_gpio_driver);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("Simple GPIO driver example");

This driver matches the device tree compatible string "vendor,my-gpio", claims GPIO 17 on probe, and sets it as an output low. On remove, it releases the GPIO.

Next Steps and Further Reading

Developing device drivers is a deep topic. To advance your skills, consult the following authoritative resources:

Additionally, reading existing drivers in the kernel tree (under drivers/) is invaluable. Start with simple ones like drivers/gpio/gpio-sysfs.c or drivers/char/mem.c.

Conclusion

Writing device drivers in C for Linux is a demanding but rewarding discipline. This article covered the core building blocks: kernel module lifecycle, character and platform device registration, memory management, interrupt handling, synchronization, and debugging. By mastering these fundamentals, you can create efficient, reliable drivers that integrate seamlessly with the Linux kernel. Always test your driver on multiple kernel versions and use kernel debugging tools extensively. With practice and careful reading of kernel documentation, you can contribute drivers that enhance hardware functionality and system performance.