
Overview
Character devices are distinguished by the fact that they are accessed as a stream of bytes, much like a file. A character driver is responsible for implementing this behavior by mapping standard system calls to device-specific operations. Unlike block devices, which require an intermediate layer for buffering and management, character devices communicate directly with the Virtual File System (VFS).
In the Linux kernel, character devices are identified by a major number (identifying the driver) and a minor number (distinguishing between specific device instances).
Part 1: The Kernel Module
The implementation begins with the definition of the device structure and its registration within the kernel’s internal tables.
1. Headers and Data Structures
To interface with the character device subsystem, the driver must include headers for device registration and file system abstractions.
#include <linux/module.h>
#include <linux/init.h>
#include <linux/kernel.h>
#include <linux/cdev.h>
#include <linux/fs.h>
#include <linux/uaccess.h> // Required for copy_to_user/copy_from_user
static struct cdev my_cdev;
static dev_t dev_num;
2. Device Registration
The kernel uses the cdev structure to represent character devices internally. Before the kernel can invoke driver operations, the driver must allocate device numbers and connect its file_operations to the cdev.
The major number acts as an index into the kernel’s internal array of character devices, effectively identifying the specific driver code responsible for the hardware. The minor number is strictly for the driver’s internal use; the kernel passes it to the driver, which then uses it to determine which physical port or sub-channel is being accessed.
To complete the registration, two primary functions are used:
cdev_init: This function initializes thestruct cdevand links it to yourfile_operations. It sets the internalkobjectstate and ensures the kernel knows which methods to invoke when the device is accessed.cdev_add: This tells the kernel that the device is ready to handle requests. It adds the device to the kernel’s internal character device database. Crucially, as soon ascdev_addreturns successfully, your device is “live”, and the kernel may immediately call youropenorreadmethods.
static int __init my_init(void) {
int ret;
/* Dynamic allocation is preferred to avoid conflicts */
ret = alloc_chrdev_region(&dev_num, 0, 1, "my_device");
if (ret < 0) {
pr_err("Char-driver: Failed to allocate major number\n");
return ret;
}
/* Initialize cdev and associate it with file_operations */
cdev_init(&my_cdev, &my_fops);
my_cdev.owner = THIS_MODULE;
/* Add the device to the system; at this point, the device is "live" */
ret = cdev_add(&my_cdev, dev_num, 1);
if (ret < 0) {
unregister_chrdev_region(dev_num, 1);
return ret;
}
return 0;
}
Part 2: Important Data Structures
Two data structures are fundamental to the implementation of driver methods: struct file and struct inode.
struct file: Represents an open file. It is created by the kernel onopenand passed to every function that operates on the file until the finalclose. It contains thef_op(operations) andprivate_data(often used to store driver state).struct inode: Represents the file on disk. While a file can be opened multiple times (multiplestruct fileinstances), there is only one inode for a given file. Thei_cdevfield points to thecdevstructure initialized during registration.
Part 3: Driver Methods (File Operations)
The file_operations structure is a collection of function pointers that define the driver’s behavior.
1. The Read and Write Methods
Because user-space addresses cannot be safely dereferenced in kernel space, the kernel provides specialized functions to handle the transfer.
static ssize_t my_read(struct file *filp, char __user *buf, size_t count, loff_t *f_pos) {
char data[] = "Hello from Kernel!\n";
ssize_t datalen = strlen(data);
if (*f_pos >= datalen)
return 0;
if (count > datalen - *f_pos)
count = datalen - *f_pos;
/* copy_to_user returns 0 on success */
if (copy_to_user(buf, data + *f_pos, count)) {
return -EFAULT;
}
*f_pos += count;
return count;
}
static ssize_t my_write(struct file *filp, const char __user *buf, size_t count, loff_t *f_pos) {
char kbuf[128];
size_t to_copy = min(count, sizeof(kbuf) - 1);
/* Transfer data safely from user-space to kernel-space */
if (copy_from_user(kbuf, buf, to_copy)) {
return -EFAULT;
}
kbuf[to_copy] = '\0';
pr_info("Char-driver: Received %zu bytes: %s\n", to_copy, kbuf);
return to_copy;
}
2. Open and Release Methods
The open method initializes the device or identifies specific minor numbers, while the release method (corresponding to close) deallocates data or shuts down the hardware.
static int my_open(struct inode *inode, struct file *filp) {
return 0;
}
static int my_release(struct inode *inode, struct file *filp) {
return 0;
}
static struct file_operations my_fops = {
.owner = THIS_MODULE,
.read = my_read,
.write = my_write,
.open = my_open,
.release = my_release,
};
3. Understanding Method Arguments
LDD3 explains that method arguments differ based on the kernel’s distinction between file identification (inode) and session management (file pointer):
struct inode *(Open/Release): Represents the static file on disk. It is required duringopento identify the device via its major/minor numbers (i_rdev) and to access the registeredcdevstructure (i_cdev).struct file *(Read/Write): Represents an active session (file pointer). It tracks dynamic state like the current position (f_pos) andprivate_data. Sincestruct filecontains a pointer to the inode, passing a separate inode argument to high-frequency I/O methods is unnecessary.
Part 4: Module Cleanup and Unloading
To properly unload the module, the character device must be removed from the kernel’s internal tables before unregistering the allocated device numbers. This ensures that no further access to the device is possible once the driver is removed.
static void __exit my_cleanup(void) {
/* Step 1: Remove the character device from the system */
cdev_del(&my_cdev);
/* Step 2: Unregister the device numbers */
unregister_chrdev_region(dev_num, 1);
pr_info("Char-driver: Module unloaded successfully\n");
}
module_init(my_init);
module_exit(my_cleanup);
MODULE_LICENSE("GPL");
Part 5: Compilation and Setup
The following Makefile facilitates cross-compilation for an ARM64 target using Buildroot.
DRIVER_NAME:=char-driver
obj-m += $(DRIVER_NAME).o
BUILDROOT_SRC_DIR=~/project/Platform/buildroot
KDIR := $(BUILDROOT_SRC_DIR)/output/build/linux-6.12.27
CROSS_COMPILE := $(BUILDROOT_SRC_DIR)/output/host/bin/aarch64-linux-
TEST_APPLICATION:=$(DRIVER_NAME)-app
all:
$(MAKE) ARCH=arm64 CROSS_COMPILE=$(CROSS_COMPILE) -C $(KDIR) M=$(PWD) modules
app:
$(CROSS_COMPILE)gcc -Wall -Wextra -o $(TEST_APPLICATION) $(TEST_APPLICATION).c
clean:
$(MAKE) ARCH=arm64 CROSS_COMPILE=$(CROSS_COMPILE) -C $(KDIR) M=$(PWD) clean
rm -f $(TEST_APPLICATION)
Part 6: Interaction and Testing
Since this driver uses dynamic allocation without automatic node creation, the device node must be created manually on the target.
- Find the Major Number: Inspect the kernel log after loading.
insmod char-driver.ko
dmesg | tail
- Create the Node: Use
mknodwith the major number obtained above.
mknod /dev/mychardev c <MAJOR_NUMBER> 0
chmod 666 /dev/mychardev
- Execute the Test: Run the user-space app to verify bidirectional data transfer.
./char-driver-app
Following is my output, yours should look kind of same as well. (good luck)
You can also use
catandechoto read and write respectively.
Interesting Notes
- Decoupling Drivers from
/dev: Creating a device file in/devis optional for the kernel. Character devices can be accessed programmatically by major/minor numbers without a filesystem node, but/deventries provide the standard, user-friendly interface most applications expect. - Dynamic Major Numbers: LDD3 strongly recommends using
alloc_chrdev_regionover static assignment. This prevents “Major Number Exhaustion” and conflicts in modern systems where many drivers may be loaded simultaneously. - The “Live” Device Risk: As soon as
cdev_addreturns, the device is active. If your initialization logic (like setting up hardware registers) isn’t finished before callingcdev_add, a process could open the device and trigger a crash by accessing uninitialized resources. - Private Data Usage: LDD3 emphasizes using
filp->private_datainopento point to a driver’s state structure. This allows thereadandwritemethods to access device state without using global variables, making the driver thread-safe and re-entrant.
All this code can be found at my github: https://github.com/rishav-singh-0/kernel-study
References
- Must read Chapter 3 of LDD3: https://static.lwn.net/images/pdf/LDD3/ch03.pdf