Most programmers treat the Linux kernel like a black box — they write userspace code, poke /proc, maybe tune sysctl, and call it a day. That’s fine until it isn’t. Network packet filtering, custom hardware, performance-critical data paths, security hooks — at some point you need to go deeper. And going deeper means writing kernel code.
A character device driver is the canonical entry point. It’s the simplest kernel-userspace interface: your module registers a file under /dev, and from that moment on, any userspace program can open, read, write, and ioctl it like a regular file. The kernel handles the plumbing; you write the logic.
This guide builds a working character device from a blank file. No hand-waving, no "see the kernel docs for details." Every line of code is shown, explained, and tested.
What you need before starting
A Linux machine (physical or VM) with:
- Kernel headers matching your running kernel:
sudo apt install linux-headers-$(uname -r)on Debian/Ubuntu - Build tools:
sudo apt install build-essential - A text editor and a terminal
Do not develop on a production machine. Kernel bugs cause hard lockups. Use a VM you can snapshot. KVM, VirtualBox, anything works.
Check that headers are present:
ls /lib/modules/$(uname -r)/build
If that directory doesn’t exist, install the headers first.
Hello World first — understand the module skeleton
Before touching devices, internalize the module skeleton. Every kernel module is a shared object loaded at runtime into kernel address space. There’s no main(). You provide two functions:
// hello.c
#include <linux/init.h>
#include <linux/module.h>
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("Minimal kernel module");
static int __init hello_init(void)
{
pr_info("hello: module loaded\n");
return 0; /* non-zero aborts loading */
}
static void __exit hello_exit(void)
{
pr_info("hello: module unloaded\n");
}
module_init(hello_init);
module_exit(hello_exit);
And the Makefile that drives the kernel build system:
# Makefile
obj-m += hello.o
KDIR := /lib/modules/$(shell uname -r)/build
all:
$(MAKE) -C $(KDIR) M=$(PWD) modules
clean:
$(MAKE) -C $(KDIR) M=$(PWD) clean
Build and load:
make
sudo insmod hello.ko
dmesg | tail -3
sudo rmmod hello
dmesg | tail -3
You should see hello: module loaded and hello: module unloaded. That’s your feedback loop. dmesg is your debugger here.
Gotcha #1: The Makefile uses a hard tab before $(MAKE), not spaces. Make is famously tab-sensitive. Copy-paste from a browser often replaces tabs with spaces and breaks the build immediately.
How character devices work
The Linux kernel identifies every device by two numbers: major and minor. The major number maps to a driver; the minor number is handed to the driver to distinguish between multiple instances (think /dev/sda vs /dev/sdb).
There are two ways to get a major number:
- Static allocation: you pick a free number from the kernel docs. Fragile, prone to conflicts.
- Dynamic allocation via
alloc_chrdev_region(): the kernel gives you an unused major at load time. Always use this in new drivers.
The kernel represents a character device as a struct cdev. You fill it with a struct file_operations — a table of function pointers that map syscalls to your code. Then you register it, create a class for udev, and let udev materialize the /dev node automatically.
The full driver — step by step
This driver implements a shared in-kernel buffer. Any process can write data to it and any process can read it back. Trivial, but it exercises every fundamental concept.
// simple_char.c
#include <linux/init.h>
#include <linux/module.h>
#include <linux/fs.h> /* file_operations, alloc_chrdev_region */
#include <linux/cdev.h> /* cdev_init, cdev_add */
#include <linux/device.h> /* class_create, device_create */
#include <linux/uaccess.h> /* copy_to_user, copy_from_user */
#include <linux/mutex.h> /* DEFINE_MUTEX */
#include <linux/slab.h> /* kmalloc, kfree */
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("Simple character device — shared kernel buffer");
#define DEVICE_NAME "simple_char"
#define CLASS_NAME "simple_class"
#define BUF_SIZE 4096
static dev_t dev_num; /* packed major:minor */
static struct cdev simple_cdev;
static struct class *simple_class;
static struct device *simple_device;
static char *kbuf; /* kernel-side buffer */
static size_t data_len = 0; /* bytes currently stored */
/* One mutex protects both kbuf and data_len */
static DEFINE_MUTEX(buf_lock);
open and release
These map to open(2) and close(2). Our driver has nothing to allocate per-file, so they’re stubs — but you still need them declared in file_operations.
static int dev_open(struct inode *inode, struct file *file)
{
pr_info("simple_char: opened (pid %d)\n", current->pid);
return 0;
}
static int dev_release(struct inode *inode, struct file *file)
{
pr_info("simple_char: closed\n");
return 0;
}
read
static ssize_t dev_read(struct file *file, char __user *buf,
size_t count, loff_t *ppos)
{
ssize_t ret;
if (mutex_lock_interruptible(&buf_lock))
return -ERESTARTSYS; /* signal delivered while waiting */
if (*ppos >= data_len) {
ret = 0; /* EOF */
goto out;
}
count = min(count, (size_t)(data_len - *ppos));
/* copy_to_user returns the number of bytes NOT copied */
if (copy_to_user(buf, kbuf + *ppos, count)) {
ret = -EFAULT;
goto out;
}
*ppos += count;
ret = count;
out:
mutex_unlock(&buf_lock);
return ret;
}
Gotcha #2: Never dereference a userspace pointer directly in kernel code. buf lives in user virtual address space. In kernel context, that mapping may not be valid, and even if it is, bypassing the page-fault machinery is wrong. copy_to_user / copy_from_user are the only correct way to exchange data. Skip them and you get random crashes or security holes.
Gotcha #3: mutex_lock_interruptible instead of mutex_lock. If a signal arrives while a process is blocked waiting for the lock, interruptible returns -ERESTARTSYS so the kernel can deliver the signal. mutex_lock would block forever and make your process unkillable with Ctrl-C.
write
static ssize_t dev_write(struct file *file, const char __user *buf,
size_t count, loff_t *ppos)
{
ssize_t ret;
if (count > BUF_SIZE)
return -EINVAL;
if (mutex_lock_interruptible(&buf_lock))
return -ERESTARTSYS;
if (copy_from_user(kbuf, buf, count)) {
ret = -EFAULT;
goto out;
}
data_len = count; /* overwrite semantics: write replaces buffer */
*ppos = count;
ret = count;
out:
mutex_unlock(&buf_lock);
return ret;
}
file_operations table
static const struct file_operations simple_fops = {
.owner = THIS_MODULE, /* prevents unload while device is open */
.open = dev_open,
.release = dev_release,
.read = dev_read,
.write = dev_write,
};
init — the careful version
Error paths in kernel init functions must undo everything done so far. Use the goto-label pattern — it’s idiomatic kernel style and not a code smell here.
static int __init simple_char_init(void)
{
int ret;
kbuf = kmalloc(BUF_SIZE, GFP_KERNEL);
if (!kbuf)
return -ENOMEM;
ret = alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME);
if (ret < 0) {
pr_err("simple_char: alloc_chrdev_region failed: %d\n", ret);
goto fail_region;
}
pr_info("simple_char: major=%d minor=%d\n", MAJOR(dev_num), MINOR(dev_num));
cdev_init(&simple_cdev, &simple_fops);
simple_cdev.owner = THIS_MODULE;
ret = cdev_add(&simple_cdev, dev_num, 1);
if (ret < 0) {
pr_err("simple_char: cdev_add failed: %d\n", ret);
goto fail_cdev;
}
/*
* class_create signature changed in kernel 6.4:
* before 6.4: class_create(THIS_MODULE, CLASS_NAME)
* 6.4+: class_create(CLASS_NAME)
* The code below targets 6.4+.
*/
simple_class = class_create(CLASS_NAME);
if (IS_ERR(simple_class)) {
ret = PTR_ERR(simple_class);
pr_err("simple_char: class_create failed: %d\n", ret);
goto fail_class;
}
simple_device = device_create(simple_class, NULL, dev_num, NULL, DEVICE_NAME);
if (IS_ERR(simple_device)) {
ret = PTR_ERR(simple_device);
pr_err("simple_char: device_create failed: %d\n", ret);
goto fail_device;
}
pr_info("simple_char: /dev/%s ready\n", DEVICE_NAME);
return 0;
fail_device:
class_destroy(simple_class);
fail_class:
cdev_del(&simple_cdev);
fail_cdev:
unregister_chrdev_region(dev_num, 1);
fail_region:
kfree(kbuf);
return ret;
}
exit — reverse order, no exceptions
static void __exit simple_char_exit(void)
{
device_destroy(simple_class, dev_num);
class_destroy(simple_class);
cdev_del(&simple_cdev);
unregister_chrdev_region(dev_num, 1);
kfree(kbuf);
pr_info("simple_char: unloaded\n");
}
module_init(simple_char_init);
module_exit(simple_char_exit);
Update your Makefile:
obj-m += simple_char.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
make
sudo insmod simple_char.ko
dmesg | tail -5
ls -la /dev/simple_char
udev picks up the device_create call and creates /dev/simple_char automatically. No manual mknod needed.
Test with basic shell tools:
# write to the device
echo "hello kernel" | sudo tee /dev/simple_char
# read it back
sudo cat /dev/simple_char
You should get hello kernel back. Check dmesg to see the open/close messages.
Verify major number registration:
cat /proc/devices | grep simple_char
Remove cleanly:
sudo rmmod simple_char
dmesg | tail -3
ls /dev/simple_char # should be gone
Gotchas and things that will bite you
Gotcha #4 — Kernel version drift. The kernel API is not stable across major versions. The class_create signature example above is real: it changed in 6.4. The __devinit / __devexit macros were removed years ago. If you’re copying driver code from Stack Overflow and it’s more than a few years old, audit every kernel API call against the current headers or elixir.bootlin.com.
Gotcha #5 — No floating point. Kernel code cannot use floating-point arithmetic. The FPU state is not saved on kernel entry (it belongs to the user process). If you call any function that internally uses float, you’ll corrupt the FPU state of whatever userspace process was running. The kernel will not warn you. Use fixed-point arithmetic if you need decimals.
Gotcha #6 — Stack size is tiny. Kernel stack is typically 8 KB on x86_64 (16 KB with some configs). A large local array declaration is a stack overflow waiting to happen. Allocate anything bigger than a few hundred bytes with kmalloc or vmalloc.
Gotcha #7 — Sleeping in atomic context. Holding a spinlock, being in interrupt context, or having preemption disabled means you cannot call any function that might sleep — including mutex_lock, kmalloc(GFP_KERNEL), copy_to_user. If you do, the kernel will scream (BUG: scheduling while atomic) or silently corrupt state. Know your context. The rule: spinlocks for short atomic sections, mutexes for sections that can block.
Gotcha #8 — Reference counting on THIS_MODULE. The .owner = THIS_MODULE field in file_operations increments the module’s reference count when the device is open. This prevents rmmod from unloading a module while a process has the device open. Omit it and you get a use-after-free when userspace still holds an fd after you unload. The kernel may or may not crash immediately.
Gotcha #9 — pr_err vs printk. Use pr_info, pr_err, pr_debug, etc. rather than raw printk with KERN_INFO. The pr_* family prepends the module name automatically and is cleaner to grep in dmesg. pr_debug compiles out to nothing unless DEBUG is defined or you enable dynamic debug.
Production-ready considerations
Permissions on /dev: device_create defaults to root-owned 0600. Most real drivers need group-readable or world-readable nodes. Override via a udev rule:
# /etc/udev/rules.d/99-simple_char.rules
KERNEL=="simple_char", MODE="0666"
Or programmatically set a devnode callback on your class.
Error codes matter: Return standard POSIX error codes from your file_operations. Userspace errno is set from the negated return value. Return -ENOMEM for allocation failures, -EFAULT for bad user pointers, -EINVAL for invalid arguments, -EBUSY if the device can’t handle another open. Don’t invent return codes.
LLVM/Clang builds: The mainline kernel builds cleanly with Clang. If you’re developing for an embedded target that uses Clang-based toolchains, test with make CC=clang. GCC and Clang sometimes accept different code silently; a warning in one is often an error in the other.
Kernel debug features: Develop with CONFIG_KASAN (Address Sanitizer), CONFIG_LOCKDEP, and CONFIG_DEBUG_KERNEL enabled. These catch memory errors and locking violations at runtime instead of manifesting as silent corruption in production. On a Debian VM, you can install a debug kernel: sudo apt install linux-image-$(uname -r)-dbg.
Avoid global state when supporting multiple instances: This driver uses a single global buffer. A real driver that supports multiple devices should store per-instance state in a struct, allocate one per device, and stash a pointer in file->private_data during open. Read it back in subsequent calls. That’s the standard pattern for multi-instance drivers.
Module parameters: Expose tunable values with module_param() so they can be set at load time without recompiling:
static int buf_size = 4096;
module_param(buf_size, int, 0644);
MODULE_PARM_DESC(buf_size, "Kernel buffer size in bytes");
Then: sudo insmod simple_char.ko buf_size=8192
Where to go from here
Once you’re comfortable with character devices, the natural next steps are:
ioctl: add a custom control channel beyond read/write. Study_IO,_IOR,_IOW,_IOWRmacros for type-safe command definitions.poll/selectsupport: implement thepollfile operation so userspace can useepollon your device. Requires understandingwait_queue_head_t.- mmap: map kernel memory directly into userspace. Critical for high-throughput drivers (network, video). Requires careful VMA manipulation.
- Platform and PCI drivers: plug into the device model for real hardware, with probe/remove callbacks, resource management, and interrupt handlers.
The authoritative reference is Linux Device Drivers, 3rd edition — free online, and while some code examples are dated, the conceptual material holds up. For current API details, elixir.bootlin.com lets you browse kernel source cross-referenced by version.
The complete source for this driver is clean enough to use as a template. Keep a copy. The character device skeleton you built here is the same one used in production drivers — the difference is what happens inside read, write, and ioctl.