Userspace_to_Device_IO_Journey

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 the struct cdev and links it to your file_operations. It sets the internal kobject state 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 as cdev_add returns successfully, your device is “live”, and the kernel may immediately call your open or read methods.
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 on open and passed to every function that operates on the file until the final close. It contains the f_op (operations) and private_data (often used to store driver state).
  • struct inode: Represents the file on disk. While a file can be opened multiple times (multiple struct file instances), there is only one inode for a given file. The i_cdev field points to the cdev structure 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 during open to identify the device via its major/minor numbers (i_rdev) and to access the registered cdev structure (i_cdev).

  • struct file * (Read/Write): Represents an active session (file pointer). It tracks dynamic state like the current position (f_pos) and private_data. Since struct file contains 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.

  1. Find the Major Number: Inspect the kernel log after loading.
insmod char-driver.ko
dmesg | tail
  1. Create the Node: Use mknod with the major number obtained above.
mknod /dev/mychardev c <MAJOR_NUMBER> 0
chmod 666 /dev/mychardev
  1. 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)

Pasted image 20251228222045

You can also use cat and echo to read and write respectively.


Interesting Notes

  • Decoupling Drivers from /dev: Creating a device file in /dev is optional for the kernel. Character devices can be accessed programmatically by major/minor numbers without a filesystem node, but /dev entries provide the standard, user-friendly interface most applications expect.
  • Dynamic Major Numbers: LDD3 strongly recommends using alloc_chrdev_region over 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_add returns, the device is active. If your initialization logic (like setting up hardware registers) isn’t finished before calling cdev_add, a process could open the device and trigger a crash by accessing uninitialized resources.
  • Private Data Usage: LDD3 emphasizes using filp->private_data in open to point to a driver’s state structure. This allows the read and write methods 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