Character Devices, Major and Minor Numbers
A free Linux device drivers course lecture: how the kernel identifies character devices, and how to talk to one from user space and from a driver.
What You Will Learn
- How major and minor numbers identify a character device
- The three ways device nodes get created
- How to allocate a device number dynamically on the latest kernel
- Writing a minimal original character driver with the cdev API
- Writing a user-space program that reads from it
Prerequisites
This continues directly from the previous lecture on device driver types in this free Linux kernel development course. You should be comfortable cross-compiling a kernel module for your target board and have a working embedded Linux setup from the earlier toolchain and kernel-porting chapters.
Identifying a Character Device: Major and Minor Numbers
Every character device node in /dev is identified by a pair of numbers, not by its filename. The major number tells the kernel which driver is responsible for the device. The minor number is passed to that driver so it can tell which specific hardware instance is being accessed, when a single driver manages more than one device of the same kind.
You can see both numbers directly with ls -l — they appear where a regular file’s size would normally be shown:
$ ls -l /dev/ep_demo0 /dev/ep_demo1
crw-rw---- 1 root root 240, 0 Jan 1 1970 /dev/ep_demo0
crw-rw---- 1 root root 240, 1 Jan 1 1970 /dev/ep_demo1
Here, 240 is the major number shared by both nodes — meaning the same driver handles both — and 0 and 1 are the minor numbers that let the driver distinguish which specific device instance a given open() call refers to.
On the latest stable kernel, device numbers are represented internally as a single 32-bit dev_t value: 12 bits for the major number (giving a valid range of 1 to 4,095) and 20 bits for the minor number (0 to 1,048,575). The MAJOR() and MINOR() macros extract each half, and MKDEV() combines them back into a single value.
How Device Nodes Get Created
A device number by itself is meaningless to a shell or an application — you need an actual node in the filesystem to open(). There are three ways that node comes into existence on a modern system:
| Method | How it works | When it’s used |
|---|---|---|
| devtmpfs | The kernel itself creates the node automatically the moment a driver registers a device, using the name the driver supplies | Default on virtually every modern embedded distribution |
| udev / mdev | A user-space daemon watches uevents from the kernel, reads device metadata from sysfs, and creates the node itself | Systems that need custom naming rules, symlinks, or permissions per device |
| mknod | The node is created manually, once, with a hardcoded major and minor number | Static, minimal systems with no dynamic device management, or manual debugging |
Manual creation with mknod looks like this:
$ mknod /dev/ep_demo0 c 240 0
$ ls -l /dev/ep_demo0
crw-r--r-- 1 root root 240, 0 Jan 1 1970 /dev/ep_demo0
The c tells mknod to create a character node (use b for a block node), followed by the major and minor numbers.
Static vs. Dynamic Major Numbers
Early Unix drivers hardcoded a fixed major number, and a global registry kept those numbers from colliding across every driver ever written. That approach doesn’t scale, and on a modern kernel it’s considered bad practice for anything except a small set of long-standing legacy drivers. The recommended approach today is to let the kernel pick an unused major number for you at registration time, using alloc_chrdev_region(), and then read back whatever number was assigned.
A Minimal Original Character Driver
The example below, ep_chardemo, is a self-contained skeleton driver written against the current cdev API. It dynamically allocates a device number, registers a single character device, and implements just enough of the file operations to demonstrate the mechanism — it returns a fixed greeting string on read(). It does not reuse any vendor names, code, or structure from a textbook example; treat it as a clean starting point for your own driver.
// ep_chardemo.c — minimal original character driver
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/uaccess.h>
#include <linux/device.h>
#define EP_DEVICE_NAME "ep_demo"
static const char ep_msg[] = "hello from ep_chardemo\n";
static dev_t ep_devno;
static struct cdev ep_cdev;
static struct class *ep_class;
static ssize_t ep_read(struct file *filp, char __user *buf,
size_t count, loff_t *offp)
{
size_t len = sizeof(ep_msg) - 1;
if (*offp >= len)
return 0;
if (count > len - *offp)
count = len - *offp;
if (copy_to_user(buf, ep_msg + *offp, count))
return -EFAULT;
*offp += count;
return count;
}
static const struct file_operations ep_fops = {
.owner = THIS_MODULE,
.read = ep_read,
};
static int __init ep_chardemo_init(void)
{
int ret;
ret = alloc_chrdev_region(&ep_devno, 0, 1, EP_DEVICE_NAME);
if (ret < 0)
return ret;
cdev_init(&ep_cdev, &ep_fops);
ret = cdev_add(&ep_cdev, ep_devno, 1);
if (ret < 0)
goto err_region;
ep_class = class_create(EP_DEVICE_NAME);
if (IS_ERR(ep_class)) {
ret = PTR_ERR(ep_class);
goto err_cdev;
}
device_create(ep_class, NULL, ep_devno, NULL, "%s0", EP_DEVICE_NAME);
pr_info("ep_chardemo: registered major %d minor %d\n",
MAJOR(ep_devno), MINOR(ep_devno));
return 0;
err_cdev:
cdev_del(&ep_cdev);
err_region:
unregister_chrdev_region(ep_devno, 1);
return ret;
}
static void __exit ep_chardemo_exit(void)
{
device_destroy(ep_class, ep_devno);
class_destroy(ep_class);
cdev_del(&ep_cdev);
unregister_chrdev_region(ep_devno, 1);
pr_info("ep_chardemo: unregistered\n");
}
module_init(ep_chardemo_init);
module_exit(ep_chardemo_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("EmbeddedPathashala minimal character device demo");
Building, Loading, and Testing
$ make -C /lib/modules/$(uname -r)/build M=$PWD modules
$ sudo insmod ep_chardemo.ko
$ dmesg | tail -n 1
[ 312.884511] ep_chardemo: registered major 240 minor 0
$ ls -l /dev/ep_demo0
crw------- 1 root root 240, 0 Jan 1 1970 /dev/ep_demo0
$ cat /dev/ep_demo0
hello from ep_chardemo
$ sudo rmmod ep_chardemo
$ dmesg | tail -n 1
[ 340.221007] ep_chardemo: unregistered
Because alloc_chrdev_region() picked the major number automatically, devtmpfs created /dev/ep_demo0 the instant the module called device_create() — no manual mknod step was needed.
Talking to the Driver from a C Program
Reading from a device node in an application follows the same raw system-call pattern regardless of what the device actually is. Here is an original example that opens ep_demo0 directly rather than relying on the shell’s cat:
// ep_reader.c — original user-space example
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
int main(void)
{
char buf[64] = {0};
int fd = open("/dev/ep_demo0", O_RDONLY);
if (fd < 0) {
fprintf(stderr, "open failed: %s\n", strerror(errno));
return 1;
}
ssize_t n = read(fd, buf, sizeof(buf) - 1);
if (n < 0) {
fprintf(stderr, "read failed: %s\n", strerror(errno));
close(fd);
return 1;
}
printf("driver returned %zd bytes: %s", n, buf);
close(fd);
return 0;
}
$ gcc -o ep_reader ep_reader.c
$ ./ep_reader
driver returned 24 bytes: hello from ep_chardemo
Notice this uses the raw open()/read()/close() system calls, not the buffered stream functions fopen()/fread()/fclose(). Stream I/O buffers data in user space before it ever reaches the kernel, which can silently delay writes until you call fflush() — for device access you almost always want the unbuffered, direct system calls instead.
Real-World Use Cases
- A custom FPGA control interface exposed as a single character device with a handful of
ioctl()commands. - A debug UART accessed directly at
/dev/ttyS0, identified by its own major/minor pair separate from other UARTs on the board. - A vendor security chip driver that dynamically allocates its major number so it never collides with other drivers on a shared platform image.
Common Mistakes and Troubleshooting
- Forgetting to unregister on every error path. If
cdev_add()fails, you still must callunregister_chrdev_region()before returning, or the major number leaks until reboot. - Hardcoding a major number that’s already taken. Check
/proc/devicesfirst, or better, just use dynamic allocation as shown above. - No device node after loading the module. If devtmpfs isn’t mounted, or you skipped
class_create()/device_create(), the node simply won’t appear — checkdmesgand/sys/classfor your device class. - Using stream I/O functions against a device node. As covered above,
fread()/fwrite()buffering can produce confusing, inconsistent behavior against hardware-backed devices.
Best Practices
- Always prefer
alloc_chrdev_region()over a hardcoded major number. - Unwind every registration step in the reverse order on both the error path and module exit.
- Bound every
copy_to_user()/copy_from_user()call against the caller-supplied buffer size to avoid overruns. - Set device node permissions deliberately through
udevrules rather than leaving default root-only access on production images.
Summary and Key Takeaways
Character devices are identified by a major/minor pair rather than a filename: the major number selects the driver, and the minor number selects the specific hardware instance within that driver. Modern kernels favor dynamically allocated major numbers over hardcoded ones, and devtmpfs creates the actual /dev node automatically once a driver calls device_create(). The ep_chardemo example above shows the full round trip on the latest stable kernel: allocate a device number, register a cdev, create the class and device, and serve a read() from user space through the raw system-call interface. With this foundation in place, the next lecture in this free embedded systems course moves on to block devices.
FAQ
What’s the difference between a major number and a minor number?
The major number tells the kernel which driver handles a device node; the minor number is passed to that driver so it can identify which specific hardware instance is being accessed.
Why should I use alloc_chrdev_region() instead of a fixed major number?
A fixed major number can collide with another driver on the same system. Dynamic allocation lets the kernel hand out a number that’s guaranteed free at registration time.
Do I need to call mknod manually for my driver’s device node?
Not if devtmpfs is active, which it is on virtually all modern embedded Linux systems. Calling device_create() in your driver is enough for the node to appear automatically.
What is dev_t?
dev_t is the kernel’s 32-bit type that packs both the major and minor number into a single value, split as 12 bits for the major number and 20 bits for the minor number on modern kernels.
Why does the example use raw read()/write() instead of fread()/fwrite()?
The C library’s stream functions add a user-space buffering layer that can delay or reorder what actually reaches the driver. Device access should go through the unbuffered system calls directly.
What happens if I forget to unregister my device number on exit?
The major number stays allocated until the system reboots, which can eventually exhaust available numbers or block a future insmod of the same driver.
Can one driver manage more than one device node?
Yes — that’s exactly what the minor number is for. One major number, one driver, many minor numbers, one per hardware instance.
Continue This Free Linux Device Drivers Course
Next up: block devices, the page cache, and how storage drivers differ from character drivers.
Next Lecture Back to Course Index
3 Comments