R0KernelPath
module 088 / 262

Character Device Drivers

Build a real character device driver from scratch — mastering the major/minor system, the file_operations vtable, and the kernel/userspace memory boundary that makes Linux drivers both powerful and safe.

~12h focused study10 concepts

Overview

Character device drivers are the gateway to understanding how Linux abstracts hardware and exposes it to userspace. When you type into a terminal, read from /dev/random, or talk to a custom sensor — a character device driver is what makes that possible. Before you write your first kernel module, this machinery seems almost magical. After understanding it, you will see that it is an elegantly layered system of function pointer tables, integer identifiers, and carefully managed memory boundaries.

In Linux, "everything is a file" is not just a Unix philosophy bumper sticker — it is a design contract. A character device driver is the kernel-side implementation of that contract for devices that transfer data as a stream of bytes (characters), one at a time, as opposed to block devices that deal in fixed-size sectors. Your serial port, your keyboard event interface, your custom FPGA — all of these speak the character device protocol.

This module builds a complete mental model from the bottom up: how the kernel identifies devices (major/minor numbers), how it routes file operations to driver code (file_operations), how kernel and userspace memory are kept safely separated (copy_to/from_user), and how to expose a device node in /dev. By the end you will have written, loaded, and used a real, working character driver — not a toy "hello world" module, but a device that userspace programs can open, read, write, and close.

The concepts here — registration, dispatch tables, safe memory transfer — recur throughout the entire kernel driver ecosystem. Master them here and block drivers, network drivers, and platform drivers will feel like familiar territory.

why this matters for the kernel

Understanding character device drivers is not optional for kernel contributors — it is foundational. The file_operations struct and the major/minor number system appear everywhere: in USB gadget drivers, in GPU driver interfaces, in the miscdevice subsystem used by everything from hardware random number generators to Android's Binder IPC. The patterns you learn here — registering with a subsystem, implementing a vtable of callbacks, safely crossing the kernel/user boundary — are the same patterns you will use when you eventually write a real driver for real hardware.

More importantly, character drivers are where the rubber meets the road for the kernel's security model. The copy_to_user / copy_from_user family of functions is not bureaucratic boilerplate — it is the enforcement point that prevents a buggy or malicious userspace program from corrupting kernel memory or reading secrets from other processes. Understanding why these functions exist, and what happens when a driver skips them (spoiler: it is a critical vulnerability), is essential preparation for writing code that maintainers will actually accept and trust.

Character Driver: From open() Call to Driver Callback

rendering diagram…

When userspace calls open() on a /dev node, VFS looks up the inode's major:minor, finds the registered cdev, installs its file_operations into the struct file, and then every subsequent syscall is dispatched directly through that fops table into your driver.

The concepts

01The Device File Abstraction

In Unix and Linux, hardware devices are represented as files in the filesystem, specifically under /dev. This is not just a naming convention — it means you can use standard POSIX file calls (open, read, write, close, ioctl) to interact with hardware, with no hardware-specific API exposed to userspace.

When you run ls -l /dev/ttyS0, you see something like:

crw-rw---- 1 root dialout 4, 64 Jun  8 10:00 /dev/ttyS0

The c at the start means it is a character device. The numbers 4, 64 are the major and minor numbers — the true identity of the device from the kernel's perspective. The filename is just a human-readable label. You can even create a second name for the same device with mknod /dev/myserial c 4 64 and it will work identically, because the kernel only looks at those numbers.

When userspace calls open("/dev/ttyS0", ...), the Virtual Filesystem (VFS) layer looks up the inode of that file, sees that it is a character device with major=4 minor=64, and routes all subsequent calls (read, write, etc.) through the driver registered for that major number. The filename never reaches the driver — only the major/minor pair does.

# See the major:minor numbers for all character devices on your system
ls -l /dev | grep '^c'

# The kernel's own table of major numbers
cat /proc/devices

02Major and Minor Numbers

Every device in Linux is identified by a pair of integers: a major number and a minor number, packed into a single dev_t value (a 32-bit unsigned integer on modern kernels, split 12 bits major / 20 bits minor).

Think of it like this: the major number identifies the driver — it says "which piece of kernel code handles this device". The minor number identifies the specific instance — it says "which of the possibly-many devices this driver manages is being accessed". For example, the SCSI disk driver has major 8. /dev/sda is major 8 minor 0, /dev/sdb is major 8 minor 16, /dev/sda1 is major 8 minor 1.

You can work with dev_t values using provided macros:

  • MAJOR(dev) — extract major number from a dev_t
  • MINOR(dev) — extract minor number from a dev_t
  • MKDEV(major, minor) — pack a pair into a dev_t

You have two ways to get a major number for your driver:

  1. Static allocation: pick a number from the unofficially-available range and hardcode it. Risky — someone else might use the same number on a different system.
  2. Dynamic allocation (preferred): call alloc_chrdev_region() and the kernel assigns you a free major number. Your driver then creates /dev nodes via udev or manually with mknod, using the assigned major.

Documented assigned numbers live in Documentation/admin-guide/devices.txt in the kernel source tree.

#include <linux/fs.h>

dev_t dev;  /* will hold our major:minor */
int major;

/* Dynamically allocate 1 minor number, starting at minor 0 */
int ret = alloc_chrdev_region(&dev, 0, 1, "mydevice");
if (ret < 0) {
    pr_err("alloc_chrdev_region failed: %d\n", ret);
    return ret;
}

major = MAJOR(dev);
pr_info("Got major number: %d\n", major);

/* ... later, on cleanup ... */
unregister_chrdev_region(dev, 1);

03The file_operations Struct: Your Driver's VTable

The file_operations struct (defined in <linux/fs.h>) is the core interface between the VFS layer and your driver. It is a table of function pointers — one for each file operation the kernel might invoke. When userspace calls read() on your device file, the kernel calls your_driver_read() through the .read pointer in this struct. This is the classic vtable (virtual dispatch table) pattern, here implemented in pure C.

You do not need to implement every operation. C's designated initializer syntax lets you set only the ones you care about; the rest default to NULL, and the VFS handles NULL entries gracefully (usually returning -EINVAL or similar to userspace).

Key fields you will use in almost every driver:

  • .owner — set to THIS_MODULE to prevent the module from being unloaded while a file is open
  • .open — called when userspace calls open(). Receive and validate the file/inode, allocate per-file state if needed.
  • .release — called when the last reference to the open file is closed. Counterpart to .open.
  • .read — copy data from the device/kernel buffer to userspace
  • .write — receive data from userspace and send it to the device
  • .llseek — handle lseek() calls (use no_llseek if your device does not support seeking)
  • .unlocked_ioctl — handle ioctl() calls for device-specific control commands

The function signatures are fixed by the kernel ABI. You must match them exactly — the .read callback signature is ssize_t (*read)(struct file *, char __user *, size_t, loff_t *), not whatever you feel like.

#include <linux/fs.h>
#include <linux/module.h>

static int my_open(struct inode *inode, struct file *filp)
{
    pr_info("mydev: opened\n");
    return 0;
}

static int my_release(struct inode *inode, struct file *filp)
{
    pr_info("mydev: closed\n");
    return 0;
}

static ssize_t my_read(struct file *filp, char __user *buf,
                       size_t count, loff_t *f_pos)
{
    /* implementation shown in copy_to_user concept */
    return 0;
}

static ssize_t my_write(struct file *filp, const char __user *buf,
                        size_t count, loff_t *f_pos)
{
    /* implementation shown in copy_from_user concept */
    return count;
}

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

04The cdev Structure and Driver Registration

Having a dev_t number and a file_operations struct is not enough — you need to register the connection between them with the kernel's character device subsystem. This is what struct cdev and the cdev_* family of functions are for.

The struct cdev (defined in <linux/cdev.h>) is the kernel's internal representation of a character device. It holds a pointer to your file_operations struct, and after you call cdev_add(), the kernel knows: "when someone opens a file with this major:minor, use these file_operations."

The initialization sequence is:

  1. Allocate your dev_t range with alloc_chrdev_region()
  2. Initialize the cdev with cdev_init(&my_cdev, &my_fops)
  3. Register it with the kernel with cdev_add(&my_cdev, dev, count). After this line returns successfully, your device is live — userspace can open it. Make sure everything your callbacks need is initialized before cdev_add().

The cleanup sequence (in your module's exit function) reverses this:

  1. cdev_del(&my_cdev) — unregisters the device, no new opens will succeed
  2. unregister_chrdev_region(dev, count) — releases the major/minor numbers

Note the older API register_chrdev() / unregister_chrdev() — you will see this in older drivers and tutorials. It is simpler (one call handles both number allocation and cdev registration) but less flexible: it always allocates 256 minor numbers and cannot be used with the device model (struct device, udev). For new code, use alloc_chrdev_region + cdev_init + cdev_add.

#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>

static dev_t dev_num;
static struct cdev my_cdev;
extern const struct file_operations my_fops; /* defined elsewhere */

static int __init mydev_init(void)
{
    int ret;

    /* Step 1: get a dynamic major, 1 minor starting at 0 */
    ret = alloc_chrdev_region(&dev_num, 0, 1, "mydevice");
    if (ret < 0)
        return ret;

    /* Step 2: bind fops to the cdev struct */
    cdev_init(&my_cdev, &my_fops);
    my_cdev.owner = THIS_MODULE;

    /* Step 3: make it live — after this, opens can arrive */
    ret = cdev_add(&my_cdev, dev_num, 1);
    if (ret < 0) {
        unregister_chrdev_region(dev_num, 1);
        return ret;
    }

    pr_info("mydevice: registered with major %d\n", MAJOR(dev_num));
    return 0;
}

static void __exit mydev_exit(void)
{
    cdev_del(&my_cdev);
    unregister_chrdev_region(dev_num, 1);
    pr_info("mydevice: unregistered\n");
}

module_init(mydev_init);
module_exit(mydev_exit);
MODULE_LICENSE("GPL");

05copy_to_user and copy_from_user: The Security Boundary

This is one of the most important concepts in all of kernel programming. The kernel and userspace live in different virtual address spaces. A pointer that is valid in userspace (like char *buf passed to your .read callback) points into the user process's address space — not the kernel's. If you try to dereference it directly in kernel code, at best it will contain garbage data; at worst it will cause a kernel panic or, critically, a security vulnerability.

The functions copy_to_user(to, from, n) and copy_from_user(to, from, n) safely bridge this gap:

  • copy_to_user(user_ptr, kernel_ptr, n) — copies n bytes from a kernel buffer to a userspace pointer
  • copy_from_user(kernel_ptr, user_ptr, n) — copies n bytes from a userspace pointer into a kernel buffer

They do several things beyond a plain memcpy:

  1. Address validation: verify that the user pointer actually points into the user's address space (not into kernel space, which would be a privilege escalation attack)
  2. Page fault handling: the userspace pages might not be physically present (paged out). These functions handle the page fault gracefully rather than crashing the kernel
  3. Return value: they return the number of bytes that could NOT be copied. Zero means full success. Your driver must check this and return -EFAULT on failure.

The __user annotation you see in function signatures (e.g., char __user *buf) is a hint for the Sparse static analysis tool — it marks pointers that point into userspace, so Sparse can warn you if you accidentally dereference them without going through copy_to_user/copy_from_user. The double-underscore prefix is a convention for "this is for tooling, not the compiler."

Never, ever use memcpy to transfer data between kernel and userspace. The bugs this causes are exploitable.

#include <linux/uaccess.h>  /* copy_to_user, copy_from_user */

static char kernel_buf[256] = "Hello from kernelspace!\n";

static ssize_t my_read(struct file *filp, char __user *ubuf,
                       size_t count, loff_t *f_pos)
{
    size_t len = strlen(kernel_buf);
    size_t to_copy;
    unsigned long not_copied;

    if (*f_pos >= len)
        return 0;  /* EOF */

    to_copy = min(count, len - (size_t)*f_pos);

    not_copied = copy_to_user(ubuf, kernel_buf + *f_pos, to_copy);
    if (not_copied)
        return -EFAULT;

    *f_pos += to_copy;
    return to_copy;  /* bytes successfully delivered */
}

static ssize_t my_write(struct file *filp, const char __user *ubuf,
                        size_t count, loff_t *f_pos)
{
    size_t to_copy = min(count, sizeof(kernel_buf) - 1);
    unsigned long not_copied;

    not_copied = copy_from_user(kernel_buf, ubuf, to_copy);
    if (not_copied)
        return -EFAULT;

    kernel_buf[to_copy] = '\0';
    pr_info("mydev: received: %s\n", kernel_buf);
    return to_copy;
}

06Creating /dev Nodes: mknod and udev

After cdev_add() succeeds, the kernel knows about your device — but there is no /dev/mydevice file yet. The /dev/ directory is not automatically populated by the kernel; it is managed by a userspace daemon called udev (or its successor systemd-udevd).

udev listens for uevent netlink messages from the kernel. When the kernel wants to create a /dev node automatically, you call device_create() which emits a uevent, udev receives it, and creates the file with the correct major/minor, permissions, and symlinks as configured in udev rules.

The chain is:

  1. Create a struct class with class_create() — this creates an entry in /sys/class/ that udev monitors
  2. Call device_create(class, NULL, dev_num, NULL, "mydevice") — this creates /sys/class/myclass/mydevice/ and emits the uevent
  3. udev processes the uevent and creates /dev/mydevice automatically

For development and testing, you can skip udev entirely and just run mknod /dev/mydevice c <major> <minor> manually as root, using the major number printed by your driver. This is perfectly fine for driver development — udev integration is a polish step.

When your module unloads, call device_destroy(class, dev_num) and class_destroy(class) to clean up the sysfs entries. udev will automatically remove the /dev node.

#include <linux/device.h>
#include <linux/class.h>

static struct class *my_class;

/* In init, after cdev_add: */
my_class = class_create(THIS_MODULE, "mydevclass");
if (IS_ERR(my_class)) {
    cdev_del(&my_cdev);
    unregister_chrdev_region(dev_num, 1);
    return PTR_ERR(my_class);
}

if (!device_create(my_class, NULL, dev_num, NULL, "mydevice")) {
    class_destroy(my_class);
    cdev_del(&my_cdev);
    unregister_chrdev_region(dev_num, 1);
    return -ENOMEM;
}
/* /dev/mydevice now exists automatically via udev */

/* In exit: */
device_destroy(my_class, dev_num);
class_destroy(my_class);
cdev_del(&my_cdev);
unregister_chrdev_region(dev_num, 1);

07The miscdevice Subsystem: The Easy Path

The cdev + alloc_chrdev_region + class_create + device_create sequence is thorough, but verbose. For simple drivers that only need a single device node, the kernel provides the miscdevice (miscellaneous device) subsystem as a convenience wrapper.

All miscdevices share major number 10 (the "misc" major), and each gets its own unique minor number. The kernel maintains a list of registered misc devices and dispatches file operations through misc_register() / misc_deregister(). udev integration is automatic.

You just need to:

  1. Fill in a struct miscdevice with .minor = MISC_DYNAMIC_MINOR, .name = "mydevice", and .fops = &my_fops
  2. Call misc_register(&my_miscdev) in your init function
  3. Call misc_deregister(&my_miscdev) in your exit function

That is the entire registration sequence. The /dev/mydevice node appears automatically.

When is miscdevice appropriate? When you have a single instance of a device and you do not need the full flexibility of the cdev API (multiple minors, integration with the platform bus model, etc.). Many real kernel drivers use miscdevice: /dev/mem, /dev/rtc0, /dev/watchdog, Android's /dev/binder — all registered via this subsystem.

When you need multiple instances, or need to integrate with platform_device / of_device (device tree), use the full cdev path.

#include <linux/miscdevice.h>
#include <linux/fs.h>
#include <linux/module.h>

extern const struct file_operations my_fops;

static struct miscdevice my_misc = {
    .minor = MISC_DYNAMIC_MINOR,
    .name  = "mydevice",
    .fops  = &my_fops,
};

static int __init mydev_init(void)
{
    int ret = misc_register(&my_misc);
    if (ret)
        pr_err("misc_register failed: %d\n", ret);
    else
        pr_info("mydevice: registered as /dev/mydevice\n");
    return ret;
}

static void __exit mydev_exit(void)
{
    misc_deregister(&my_misc);
}

module_init(mydev_init);
module_exit(mydev_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("Minimal miscdevice example");

08A Complete Working Character Driver

Putting it all together: here is a complete, compilable character driver that implements a simple in-kernel "message box" — userspace can write a string to it, and read it back. It uses the full cdev path with device_create for automatic /dev node creation.

This driver demonstrates all the pieces in context: module init/exit lifecycle, alloc_chrdev_region, cdev_init/cdev_add, class_create/device_create, copy_from_user/copy_to_user, proper error handling with cleanup on each failure path, and the file_operations vtable.

To build it, you need a Makefile that invokes the kernel build system (kbuild). The Makefile is a one-liner that tells kbuild which source file to compile as a module. You then run make with the path to your kernel headers (on Ubuntu: linux-headers-$(uname -r)).

After insmod msgbox.ko, the driver prints its major number to the kernel log (dmesg). If you have udev, /dev/msgbox appears automatically. Otherwise, mknod /dev/msgbox c <major> 0. Then echo hello > /dev/msgbox followed by cat /dev/msgbox should print hello.

/* msgbox.c — complete char driver: write a message, read it back */
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>
#include <linux/uaccess.h>
#include <linux/string.h>

#define DEVICE_NAME "msgbox"
#define BUF_SIZE    256

static dev_t          dev_num;
static struct cdev    msgbox_cdev;
static struct class  *msgbox_class;
static char           msg_buf[BUF_SIZE];
static size_t         msg_len;

static int msgbox_open(struct inode *inode, struct file *filp)
{
    return 0;
}

static int msgbox_release(struct inode *inode, struct file *filp)
{
    return 0;
}

static ssize_t msgbox_read(struct file *filp, char __user *ubuf,
                           size_t count, loff_t *pos)
{
    size_t available;
    unsigned long not_copied;

    if (*pos >= msg_len)
        return 0;  /* EOF */

    available = msg_len - *pos;
    if (count > available)
        count = available;

    not_copied = copy_to_user(ubuf, msg_buf + *pos, count);
    if (not_copied)
        return -EFAULT;

    *pos += count;
    return count;
}

static ssize_t msgbox_write(struct file *filp, const char __user *ubuf,
                            size_t count, loff_t *pos)
{
    unsigned long not_copied;

    if (count >= BUF_SIZE)
        count = BUF_SIZE - 1;

    not_copied = copy_from_user(msg_buf, ubuf, count);
    if (not_copied)
        return -EFAULT;

    msg_buf[count] = '\0';
    msg_len = count;
    *pos = count;
    return count;
}

static const struct file_operations msgbox_fops = {
    .owner   = THIS_MODULE,
    .open    = msgbox_open,
    .release = msgbox_release,
    .read    = msgbox_read,
    .write   = msgbox_write,
};

static int __init msgbox_init(void)
{
    int ret;

    ret = alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME);
    if (ret < 0) {
        pr_err("msgbox: alloc_chrdev_region failed\n");
        return ret;
    }

    cdev_init(&msgbox_cdev, &msgbox_fops);
    msgbox_cdev.owner = THIS_MODULE;

    ret = cdev_add(&msgbox_cdev, dev_num, 1);
    if (ret < 0)
        goto err_unreg;

    msgbox_class = class_create(THIS_MODULE, DEVICE_NAME);
    if (IS_ERR(msgbox_class)) {
        ret = PTR_ERR(msgbox_class);
        goto err_cdev;
    }

    if (!device_create(msgbox_class, NULL, dev_num, NULL, DEVICE_NAME)) {
        ret = -ENOMEM;
        goto err_class;
    }

    pr_info("msgbox: ready at /dev/%s (major=%d)\n",
            DEVICE_NAME, MAJOR(dev_num));
    return 0;

err_class:
    class_destroy(msgbox_class);
err_cdev:
    cdev_del(&msgbox_cdev);
err_unreg:
    unregister_chrdev_region(dev_num, 1);
    return ret;
}

static void __exit msgbox_exit(void)
{
    device_destroy(msgbox_class, dev_num);
    class_destroy(msgbox_class);
    cdev_del(&msgbox_cdev);
    unregister_chrdev_region(dev_num, 1);
    pr_info("msgbox: removed\n");
}

module_init(msgbox_init);
module_exit(msgbox_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("You");
MODULE_DESCRIPTION("In-kernel message box char driver");

09The Kbuild Makefile and Module Loading

A kernel module is a special kind of shared object (.ko file) that the kernel's module loader understands. You do not compile it with a plain gcc invocation — you use the kbuild system, which is the kernel's own build infrastructure. This ensures the module is compiled with the exact same compiler flags, symbol versions, and configuration as the running kernel. A module compiled against a different kernel version will fail to load.

The Makefile for a simple out-of-tree module is almost ceremonially short. The key variable obj-m tells kbuild which source files to compile as modules. The make -C invocation changes into the kernel source/headers directory and runs the kernel's Makefile, which in turn builds your module.

After building, you get a msgbox.ko file. Key tools:

  • insmod msgbox.ko — load the module (requires root). Runs your module_init function.
  • rmmod msgbox — unload the module. Runs your module_exit function. Will refuse if the module is in use (open file descriptors).
  • lsmod — list currently loaded modules
  • modinfo msgbox.ko — show module metadata (license, description, author, parameters)
  • dmesg | tail — see your pr_info / pr_err output

If insmod fails with "Invalid module format", the module was compiled against a different kernel version. Rebuild against the correct headers. If it fails with "Unknown symbol", your module is using a kernel function that is not exported (see EXPORT_SYMBOL).

# Makefile (place in same directory as msgbox.c)
obj-m += msgbox.o

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

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

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

# --- Build and test commands ---
# make
# sudo insmod msgbox.ko
# dmesg | tail -3
# echo 'kernel says hi' | sudo tee /dev/msgbox
# cat /dev/msgbox
# sudo rmmod msgbox

10Per-File Private Data and the file->private_data Pointer

A real device may be opened by multiple processes simultaneously, each with its own state (position, mode, partial buffer). The struct file passed to your .open callback has a .private_data field (type void *) specifically for you to hang per-open-instance state on.

The pattern:

  1. In .open: allocate a per-instance struct, initialize it, store it in filp->private_data
  2. In .read/.write/.ioctl: cast filp->private_data back to your struct type and use it
  3. In .release: free the struct you allocated in .open

This is how drivers support multiple concurrent opens without global state. For the msgbox example this was not needed (we used global state for simplicity), but a production driver for anything from a sensor to a pipe absolutely requires this pattern.

Note the difference between struct file (an open file description, one per open() call) and struct inode (the file's identity, one per device node regardless of how many times it is open). The inode carries the major/minor numbers and is how you can retrieve your cdev from within the callback using container_of(inode->i_cdev, struct my_dev, cdev) — an invaluable pattern when embedding struct cdev inside your own device struct.

struct my_instance {
    int   mode;       /* O_RDONLY or O_RDWR */
    char  local_buf[64];
};

static int my_open(struct inode *inode, struct file *filp)
{
    struct my_instance *inst;

    inst = kzalloc(sizeof(*inst), GFP_KERNEL);
    if (!inst)
        return -ENOMEM;

    inst->mode = filp->f_flags & O_ACCMODE;
    filp->private_data = inst;  /* stash for later callbacks */
    return 0;
}

static int my_release(struct inode *inode, struct file *filp)
{
    kfree(filp->private_data);
    filp->private_data = NULL;
    return 0;
}

static ssize_t my_read(struct file *filp, char __user *ubuf,
                       size_t count, loff_t *pos)
{
    struct my_instance *inst = filp->private_data;
    /* use inst->local_buf, inst->mode, etc. */
    (void)inst;
    return 0;
}

Key insights

  • ✦The kernel never touches a userspace pointer directly — copy_to_user and copy_from_user are not optional convenience wrappers; they are the security boundary. Bypassing them is a critical vulnerability class.
  • ✦Major numbers identify drivers; minor numbers identify instances. The filename in /dev is just a human-readable alias — the kernel routes operations entirely on the major:minor pair.
  • ✦The file_operations struct is a C vtable. The entire Unix 'everything is a file' abstraction is implemented through this one dispatch table. Understanding it unlocks block drivers, network socket files, procfs, sysfs, and more.
  • ✦cdev_add() is the point of no return in driver initialization — after it returns, opens can arrive immediately. Initialize all data structures your callbacks need BEFORE calling cdev_add.
  • ✦miscdevice is the right tool for single-instance drivers. It eliminates the alloc_chrdev_region + cdev + class + device boilerplate. Prefer it unless you need multiple minors or bus integration.
  • ✦The cleanup path on init failure must undo exactly the steps that succeeded, in reverse order. A goto-based error unwind ladder is the standard kernel idiom — it is not sloppy, it is idiomatic.
  • ✦Modules must be compiled against the exact running kernel's headers. A version mismatch gives 'Invalid module format' — not a bug in your code.
  • ✦The __user annotation is a Sparse hint, not a compiler directive. Run sparse (via make C=1) on your driver code before submitting upstream — it catches missing copy_to/from_user calls.

Common pitfalls

  • ⚠Calling copy_to_user or copy_from_user and ignoring the return value. These functions can return a non-zero value (bytes NOT copied) even without a total failure — you must check and return -EFAULT if non-zero.
  • ⚠Initializing device data structures AFTER cdev_add(). The moment cdev_add returns, a racing open() from userspace can invoke your callbacks. If your buffers or mutexes are not ready, you have a race condition.
  • ⚠Forgetting to check IS_ERR() on the return value of class_create() and device_create(). These return error-encoded pointers on failure, not NULL. Treating a PTR_ERR value as a valid pointer will crash the kernel.
  • ⚠Using memcpy instead of copy_from_user when receiving a userspace buffer. On some architectures (e.g., ARM with strict alignment) this causes a bus error. On all architectures it is a security bug.
  • ⚠Not setting .owner = THIS_MODULE in file_operations. Without this, a user can call open(), then rmmod the driver, and then the kernel will call a function pointer into unmapped memory when they call read(). THIS_MODULE makes rmmod refuse if files are open.
  • ⚠Not handling the case where read() is called multiple times (multiple reads to drain a buffer). The f_pos offset must be advanced correctly, and the EOF case (returning 0) must be handled when the offset reaches the end of available data.
  • ⚠Allocating memory in module_init with kmalloc but not freeing it on every error path in the init function. If class_create fails after cdev_add succeeds, you must call cdev_del before returning — or you have a leak of a live cdev with no way to unregister it.

Practice

  1. 01
    easy

    Build and load the `msgbox.c` driver from this module. Verify it works by writing a string with `echo` and reading it back with `cat`. Then modify it to prepend the string 'ECHO: ' to whatever is written before storing it in the kernel buffer.

  2. 02
    medium

    Add an `ioctl` command to the msgbox driver. Define a command `MSGBOX_CLEAR` (use _IO macro to define it) that zeros out the message buffer and resets msg_len to 0. Test it from a small C userspace program that opens /dev/msgbox and calls ioctl(fd, MSGBOX_CLEAR, 0).

  3. 03
    easy

    Rewrite the msgbox driver using miscdevice instead of the full cdev path. The driver should behave identically from userspace. Verify that /dev/msgbox still appears after insmod and that its major number is 10 (the misc major).

  4. 04
    medium

    Write a driver that supports multiple simultaneous opens, each with independent message storage. Use filp->private_data to allocate a per-open struct (containing its own 256-byte buffer and length). Writes to one open file descriptor must NOT affect reads from another file descriptor.

  5. 05
    hard

    Add a read limit to the msgbox driver: after the message has been read once (the full buffer drained, returning 0/EOF), subsequent reads should block until a new message is written. Use a wait_queue_head_t and wake_up() to implement this. This turns msgbox into a minimal pipe.

Resources

📕Linux Device Drivers, 3rd Edition (Corbet, Rubini, Kroah-Hartman)
bookfree

The canonical free book on Linux driver development. Chapters 3 and 4 cover char drivers exhaustively. Slightly dated (2.6 era) but the concepts and APIs are still correct.

📄The Linux Kernel Module Programming Guide
docsfree

Actively maintained, covers modern kernels (5.x/6.x), has working char driver examples with Makefiles you can run immediately.

📄Linux kernel source: drivers/char/
docsfree

Browse real production char drivers (mem.c, random.c, misc.c) on Bootlin's cross-referenced kernel source viewer. Reading real kernel code is irreplaceable.

📄Linux kernel source: include/linux/fs.h (file_operations)
docsfree

The authoritative definition of struct file_operations with all fields and their semantics documented inline.

✎LWN: Driver porting series
articlefree

LWN articles on driver porting track API changes in the kernel over time — useful for understanding why the cdev API exists and what replaced register_chrdev.

🎓Bootlin Linux Kernel and Driver Development Training Materials
coursefree

Free PDF slides from Bootlin's professional kernel training. The driver development slides are among the best structured overviews available.

📄kernelnewbies.org: KernelBuild
docsfree

Step-by-step guide to setting up a kernel build environment, essential for compiling and loading your first module.

📕Linux Programming Interface (Kerrisk)
bookpaidNo Starch Press / man7.org by Michael Kerrisk

Covers the userspace side of device files, ioctl, and the POSIX file model in exhaustive detail. Essential complement to LDD3 for understanding both sides of the driver interface.

Checkpoints — you can now…

  • ✓You can explain why major and minor numbers exist separately, and use MAJOR(), MINOR(), and MKDEV() correctly in kernel code.
  • ✓You can write a compilable file_operations struct with correct function signatures for open, release, read, and write.
  • ✓You can implement read and write handlers that correctly use copy_to_user and copy_from_user, check their return values, and return -EFAULT on failure.
  • ✓You can write a complete module init function that calls alloc_chrdev_region, cdev_init, cdev_add, class_create, and device_create in the correct order, with a goto-based error unwind ladder.
  • ✓You can build a kernel module with a kbuild Makefile, load it with insmod, verify it registered correctly with dmesg and /proc/devices, and interact with it from userspace.
  • ✓You can rewrite the same driver using miscdevice and explain when miscdevice is the better choice.
  • ✓You can explain why filp->private_data exists and implement per-open-instance state using kzalloc in .open and kfree in .release.
  • ✓You can explain the security implications of bypassing copy_to_user/copy_from_user and identify the vulnerability class it prevents.