Character Device Drivers

A character device driver is the most general and most common shape a Linux device driver takes: it exposes the device as a byte stream behind a node in /dev, and userspace talks to it with the ordinary file syscalls — open, read, write, lseek, ioctl, close. The kernel does almost nothing of substance on a character device’s behalf; the driver supplies a table of callbacks (struct file_operations) and the kernel routes each syscall straight to the matching callback. Writing one is a four-step ritual: reserve a device number (a dev_t major/minor pair), wire up a struct cdev that binds that number to your file_operations, register it with cdev_add(), and create the /dev node so userspace can find it. This note is the archetype overview — what a character device is, the full lifecycle of writing one, the open→read/write→release flow from the userspace syscall down to your callback, the role of private_data, and a complete worked minimal driver. The exact file_operations table is dissected in file_operations and the Character Driver Interface; the cdev registration plumbing is detailed in struct cdev and Character Device Registration.

Mental Model: a byte pipe with a name in /dev

The three classic Unix driver archetypes are distinguished by what abstraction the kernel imposes between userspace and the hardware. A character device imposes essentially none: it is an unstructured stream of bytes, accessed sequentially (or with lseek if the driver chooses to support seeking), one byte or one buffer at a time, with no kernel-side buffering, scheduling, or caching forced upon it. A block device is the opposite — the kernel interposes a whole request layer (the block I/O stack, request queues, the page cache, the I/O scheduler) so that fixed-size blocks can be cached, merged, and reordered for the benefit of rotating or flash storage; its mechanics live in Block Device Drivers. A network device is not file-like at all: it has no /dev node, and packets enter and leave through the socket/protocol stack rather than read/write on a descriptor, as covered in Network Device Drivers and net_device.

Because the character abstraction is so thin, it is the catch-all: serial ports, /dev/null, /dev/zero, /dev/random, terminals (/dev/tty*), input event devices (/dev/input/event*), GPU render nodes, framebuffers, sound devices, and the vast majority of “control” interfaces for custom hardware are all character devices. If a device does not naturally fit the block-storage or network-packet mold, it is almost certainly a character device. The kernel docs confirm that the read/write members of the operations table are “called by read(2)/write(2) and related system calls” — the driver is the implementation of those syscalls for that file (VFS docs, v6.12).

flowchart TB
  subgraph US["Userspace"]
    APP["application:<br/>fd = open(&quot;/dev/foo&quot;)<br/>read(fd, buf, n)"]
  end
  subgraph VFS["VFS layer (generic)"]
    SC["read(2) syscall<br/>→ ksys_read → vfs_read"]
    INODE["char-special inode<br/>i_fop = def_chr_fops<br/>i_rdev = (major,minor)"]
  end
  subgraph CHR["Character device core (fs/char_dev.c)"]
    COPEN["chrdev_open():<br/>kobj_lookup(cdev_map, i_rdev)<br/>→ recover struct cdev<br/>→ replace_fops(filp, cdev->ops)"]
  end
  subgraph DRV["Your driver (a kernel module)"]
    FOPS["struct file_operations:<br/>.open .read .write<br/>.unlocked_ioctl .release"]
  end
  APP -->|"open(2)"| SC
  SC -->|"f_op->open = chrdev_open"| INODE
  INODE --> COPEN
  COPEN -->|"now filp->f_op == your table"| FOPS
  APP -->|"read(2)"| SC
  SC -->|"f_op->read"| FOPS

From /dev node to your callback. What it shows: the first open on a character-special file hits a generic stub (def_chr_fops.open == chrdev_open); that stub uses the inode’s device number i_rdev to look up the cdev you registered, then swaps the file’s operation table to your driver’s file_operations. Every subsequent read/write/ioctl on that descriptor goes directly to your callbacks with no further indirection. The insight: the device number is the only thing connecting the filesystem node to your code — everything else is a table-of-function-pointers dispatch the VFS performs on your behalf.

Mechanical Walk-through: the four-step lifecycle

Step 1 — reserve a device number

Every character device is identified by a dev_t: a 32-bit value packing a major number (which driver) and a minor number (which instance/sub-device the driver manages). In Linux 6.12 the split is fixed in include/linux/kdev_t.h: MINORBITS is 20, so the low 20 bits are the minor (MINOR(dev) = dev & MINORMASK, up to 1,048,575) and the upper 12 bits are the major (MAJOR(dev) = dev >> 20, up to 4095); MKDEV(ma,mi) composes the two. The mechanics and history of this split are detailed in Major and Minor Numbers.

You almost always ask the kernel to allocate a free major dynamically rather than hard-coding one, because static major assignments are a scarce, registry-managed resource. alloc_chrdev_region(&dev, baseminor, count, name) does this: it returns a fresh major (searching downward from 234, then through an extended range 511→384, per the constants CHRDEV_MAJOR_DYN_END/CHRDEV_MAJOR_DYN_EXT_START/CHRDEV_MAJOR_DYN_EXT_END in fs.h and find_dynamic_major() in char_dev.c), reserves count consecutive minors starting at baseminor, and writes the resulting dev_t back through the pointer. If you genuinely need a known major (rare), register_chrdev_region(from, count, name) reserves a specific range. Both are covered with their kernel-doc and failure semantics in struct cdev and Character Device Registration.

Step 2 — initialize and add a cdev

struct cdev is the kernel’s representation of a live character device. In v6.12 it is exactly (include/linux/cdev.h):

struct cdev {
	struct kobject kobj;                  /* refcounted node in the device model */
	struct module *owner;                 /* the module that owns the ops table  */
	const struct file_operations *ops;    /* YOUR callbacks                       */
	struct list_head list;                /* links inodes currently using it      */
	dev_t dev;                            /* first device number this cdev covers */
	unsigned int count;                   /* how many minors it covers            */
} __randomize_layout;

You associate your file_operations with a cdev and then publish it to the kernel’s device map:

cdev_init(&my_cdev, &my_fops);   /* zero it, set ops, init the embedded kobject */
my_cdev.owner = THIS_MODULE;     /* tie the ops' lifetime to your module         */
cdev_add(&my_cdev, dev, count);  /* go live: now openable                        */

cdev_add() records dev/count and calls kobj_map(cdev_map, dev, count, …), inserting your cdev into the global radix-tree-like map keyed by device number. After this returns 0, the device is “live immediately” (the kernel’s own kernel-doc wording in char_dev.c). The internals of cdev_init/cdev_add/cdev_del and the alternative cdev_device_add() (which couples the cdev to a struct device) belong to struct cdev and Character Device Registration.

Step 3 — create the /dev node

Reserving a number and adding a cdev makes the device openable in principle, but userspace still needs a filesystem node whose i_rdev carries that device number. There are three eras of how that node appears, and on a modern system the first dominates:

  • devtmpfs + a struct class. The modern idiom is to create a class and then call device_create(class, parent, dev, NULL, "foo%d", i). This registers a struct device with the device model, which emits a uevent; the kernel-maintained devtmpfs (mounted at /dev) auto-creates /dev/foo0 immediately, and udev in userspace then fixes up ownership/permissions per its rules. See Device Classes, devtmpfs and the dev Directory, and udev and Device Management.
  • mknod by hand. mknod /dev/foo c 511 0 creates a character-special node with major 511, minor 0. This is what you do for quick experiments before wiring up a class; the node persists on disk and you must keep its number in sync with what the driver reserved.
  • init_special_inode. Underneath both, when the VFS instantiates the inode for a character-special file, init_special_inode() (in fs/inode.c) sets inode->i_mode, points inode->i_fop = &def_chr_fops, and stamps inode->i_rdev = rdev. That def_chr_fops is the generic stub whose .open is chrdev_open — the hinge of the dispatch described next.

Step 4 — implement file_operations, and let the VFS dispatch

The driver’s substance is its file_operations table. When userspace runs open("/dev/foo", …), the syscall walks to the inode, and do_dentry_open() (in fs/open.c) does f->f_op = fops_get(inode->i_fop) — which for a char-special inode is def_chr_fops — then calls f->f_op->open(inode, f), i.e. chrdev_open().

chrdev_open() (in fs/char_dev.c) is the whole trick. Verbatim, its core is:

kobj = kobj_lookup(cdev_map, inode->i_rdev, &idx);   /* find the cdev by dev number */
new = container_of(kobj, struct cdev, kobj);          /* recover the struct cdev     */
inode->i_cdev = p = new;                              /* cache it on the inode       */
...
fops = fops_get(p->ops);                               /* pin the driver module       */
replace_fops(filp, fops);                              /* SWAP in your table          */
if (filp->f_op->open)
	ret = filp->f_op->open(inode, filp);           /* call YOUR ->open            */

So the inode’s i_rdev looks up your cdev; container_of recovers the full structure from its embedded kobj; replace_fops overwrites filp->f_op with your ops; and from this open onward, every operation on the descriptor goes to your callbacks. fops_get performs try_module_get(ops->owner), which is why owner = THIS_MODULE matters: it pins your module in memory for as long as any file is open against it, so it cannot be unloaded out from under an active read. The full dispatch and the owner refcount tie are dissected in file_operations and the Character Driver Interface.

Subsequent read(2) follows ksys_read → vfs_read → file->f_op->read(file, buf, count, pos) (verified in fs/read_write.c, v6.12). vfs_read first checks FMODE_READ/FMODE_CAN_READ and runs access_ok(buf, count) on the userspace pointer, then dispatches: if (file->f_op->read) … else if (file->f_op->read_iter) new_sync_read(...). Your callback then moves bytes across the user/kernel boundary with copy_to_user/copy_from_user and updates *pos.

private_data: per-open state

A single cdev (one driver, one major) frequently backs many opens — many descriptors, possibly many minors. The driver needs somewhere to hang per-open state: which minor was opened, a position cursor, a per-session buffer, a pointer back to the driver’s own device structure. The struct file provides exactly one slot for this: void *private_data. The convention is that ->open allocates or looks up the per-device structure and stashes it: filp->private_data = my_dev;. Every later read/write/ioctl/release recovers it with struct my_dev *d = filp->private_data;. Because private_data lives on the struct file (the open file description), it is per-open, not per-inode and not per-process — two open()s of the same node get two struct files and two independent private_data pointers. The lifetime and sharing semantics of struct file itself are in The struct file and Open File Description.

A complete minimal character driver

The following is a self-contained module implementing /dev/scullsingle-style behavior: a fixed in-kernel buffer you can write to and read back. Every line is commented; APIs are verified against v6.12 headers.

#include <linux/module.h>
#include <linux/fs.h>          /* file_operations, alloc_chrdev_region   */
#include <linux/cdev.h>        /* struct cdev, cdev_init, cdev_add       */
#include <linux/uaccess.h>     /* copy_to_user / copy_from_user          */
#include <linux/slab.h>        /* kzalloc / kfree                        */
#include <linux/device.h>      /* class_create / device_create           */
 
#define BUFSZ 4096
 
struct hello_dev {             /* our per-device state                   */
	char buf[BUFSZ];
	size_t len;                /* bytes currently stored                 */
};
 
static dev_t           hello_devt;       /* allocated (major,minor)      */
static struct cdev     hello_cdev;       /* the live character device    */
static struct class   *hello_class;      /* drives /dev node creation    */
static struct hello_dev *the_dev;        /* single shared instance       */
 
/* open: stash the per-device struct on the open file description */
static int hello_open(struct inode *inode, struct file *filp)
{
	filp->private_data = the_dev;        /* recoverable in read/write    */
	return 0;
}
 
/* read: copy stored bytes out to userspace, honoring the cursor *ppos  */
static ssize_t hello_read(struct file *filp, char __user *ubuf,
			  size_t count, loff_t *ppos)
{
	struct hello_dev *d = filp->private_data;
	if (*ppos >= d->len)                 /* past EOF → return 0          */
		return 0;
	if (count > d->len - *ppos)          /* clamp to what we have       */
		count = d->len - *ppos;
	if (copy_to_user(ubuf, d->buf + *ppos, count))  /* returns #unwritten */
		return -EFAULT;                /* faulting user pointer        */
	*ppos += count;                      /* advance the cursor           */
	return count;                        /* bytes actually delivered     */
}
 
/* write: overwrite the buffer from userspace */
static ssize_t hello_write(struct file *filp, const char __user *ubuf,
			   size_t count, loff_t *ppos)
{
	struct hello_dev *d = filp->private_data;
	if (count > BUFSZ)                   /* don't overrun the buffer     */
		count = BUFSZ;
	if (copy_from_user(d->buf, ubuf, count))
		return -EFAULT;
	d->len = count;                      /* remember new length          */
	*ppos = count;
	return count;
}
 
static int hello_release(struct inode *inode, struct file *filp)
{
	return 0;                            /* nothing per-open to free     */
}
 
static const struct file_operations hello_fops = {
	.owner   = THIS_MODULE,              /* pins module while open       */
	.open    = hello_open,
	.read    = hello_read,
	.write   = hello_write,
	.release = hello_release,
	.llseek  = default_llseek,           /* enables lseek on the buffer  */
};
 
static int __init hello_init(void)
{
	int err;
	the_dev = kzalloc(sizeof(*the_dev), GFP_KERNEL);
	if (!the_dev)
		return -ENOMEM;
 
	/* Step 1: reserve one (major,minor) dynamically */
	err = alloc_chrdev_region(&hello_devt, 0, 1, "hello");
	if (err)
		goto err_free;
 
	/* Step 2: bind ops to a cdev and go live */
	cdev_init(&hello_cdev, &hello_fops);
	hello_cdev.owner = THIS_MODULE;
	err = cdev_add(&hello_cdev, hello_devt, 1);
	if (err)
		goto err_region;
 
	/* Step 3: create /dev/hello via a class + devtmpfs */
	hello_class = class_create("hello");
	if (IS_ERR(hello_class)) { err = PTR_ERR(hello_class); goto err_cdev; }
	device_create(hello_class, NULL, hello_devt, NULL, "hello");
	return 0;
 
err_cdev:    cdev_del(&hello_cdev);
err_region:  unregister_chrdev_region(hello_devt, 1);
err_free:    kfree(the_dev);
	return err;
}
 
static void __exit hello_exit(void)
{
	device_destroy(hello_class, hello_devt);
	class_destroy(hello_class);
	cdev_del(&hello_cdev);
	unregister_chrdev_region(hello_devt, 1);
	kfree(the_dev);
}
 
module_init(hello_init);
module_exit(hello_exit);
MODULE_LICENSE("GPL");

The teardown order is the mirror image of setup — destroy the /dev node and class, cdev_del, free the device number, free memory — and matters because reversing it can leave a node pointing at a freed cdev. The single-argument class_create(const char *name) used above is correct for v6.12 (verified against include/linux/device/class.h); the old owner/key parameters were dropped in the 6.4 era.

Failure Modes and Common Misunderstandings

Forgetting owner = THIS_MODULE. If ops->owner is NULL, fops_get()’s try_module_get(NULL) returns success without pinning anything, so the module can be unloaded while a file is open — and the next read jumps into freed code, an immediate oops. Always set it (via cdev_init it is not set automatically; register_chrdev’s legacy path copies fops->owner, but the modern cdev_init path requires you to set cdev.owner yourself).

Ignoring copy_*_user return values. Both functions return the number of bytes not copied; a nonzero return means the user pointer faulted partway and you must return -EFAULT. Treating the return as a success/fail boolean is correct only because nonzero is truthy — but dereferencing user memory directly (without copy_*_user) is a classic exploitable bug and will fault or leak kernel memory.

Not advancing *ppos. read/write receive loff_t *ppos, the open file’s position. If you do not advance it, cat /dev/foo loops forever re-reading the same bytes (it never sees EOF). Returning count < requested is not EOF; only returning 0 signals end-of-file to the reader.

Assuming one open == one read. A read(fd, buf, 8192) may legitimately be satisfied by your returning fewer bytes; userspace must loop. Conversely the kernel caps any single transfer at MAX_RW_COUNT (vfs_read clamps count), so your callback never sees an arbitrarily huge count.

Device-number collision. Hard-coding a major that another driver already registered makes register_chrdev_region fail with -EBUSY. Dynamic allocation (alloc_chrdev_region) sidesteps this — at the cost that the major is not known until runtime, which is exactly why udev/devtmpfs create the node from the kernel-reported number rather than a hard-coded mknod.

Alternatives and When to Choose Them

Use the misc framework for a single-minor device. If your driver needs exactly one device node and does not care about the major, misc_register(&my_miscdev) is dramatically less boilerplate: all misc devices share major 10, you get a dynamically allocated minor (MISC_DYNAMIC_MINOR == 255 asks for one), and misc_register internally does the class/device_create/cdev dance for you. You supply only a name and a file_operations pointer. The full struct miscdevice and trade-offs are in The miscdevice Framework.

Use a block driver if the device is random-access fixed-block storage that benefits from the page cache and I/O scheduler — see Block Device Drivers. Use a network driver for a packet interfaceNetwork Device Drivers and net_device. Use the legacy register_chrdev(major, name, fops) helper only for the simplest cases: it grabs 256 minors at once and allocates+adds a hidden cdev for you, but gives you no per-minor control and is discouraged for new code in favor of the explicit alloc_chrdev_region + cdev_add pair.

Production Notes

Real character drivers rarely use a global single buffer; they embed a struct cdev inside their per-device structure (container_of(inode->i_cdev, struct my_dev, cdev) in ->open recovers the device from the inode) so that one driver can manage many minors, each with independent state. The canonical teaching example is scull from Linux Device Drivers, 3rd ed. (LDD3); its APIs are dated (it predates devtmpfs, class_create, and the current unlocked_ioctl) but its structurecontainer_of, per-device cdev, private_data — is exactly how in-tree drivers like drivers/char/mem.c (/dev/mem, /dev/null, /dev/zero) are still organized in 6.12.

Two correctness disciplines dominate production char drivers: (1) concurrency — multiple processes can hold the same node open simultaneously, so any shared state (the buffer, the length) needs a mutex or other lock; the worked example above is racy on purpose for brevity. (2) blocking semantics — a device with no data to read should block the caller (sleep on a wait queue) unless O_NONBLOCK is set, in which case it returns -EAGAIN; getting this right is the difference between a usable device and one that busy-spins userspace. Both topics are mechanism owned by file_operations and the Character Driver Interface (blocking vs non-blocking, poll) and kernel synchronization.

See Also