Linux Kernel Labs: Character device driver (Part 1)
Linux Kernel Labs/Operating System 2 - (Week 3- 5): Character device, interrupts, I/O access, deferred work, Kprobe based tracer
Software Developer | Love Low Level Eng. | Python, Javascript, C, Linux I'm learning backend development and system programming.
Character device drivers
Another week with the Linux kernel source! This week was tough with the topics and the assignments.
Lectures
The lectures of these weeks were mostly about:
Interrupts: Events that can alter program execution and can be generated by hardware devices or the CPU.
I/O access: Operations that allow read/write functionality from/to device ports (=registers).
Deferred work: Postponing or scheduling for a later time the execution of a routine.
But let's focus on the labs, because yes, they were more interesting and I learned more from them (by practicing).
Labs
Character device drivers
A character device is a hardware or virtual computer component that sends and receives data sequentially, one byte or character at a time. The device driver is the kernel module that interacts with this device. Examples of character devices include the keyboard and mouse.
The hardware devices are accessed by the user through special device files. These files are grouped into the /dev directory, and system calls open, read, write, close, lseek, mmap etc. are redirected by the operating system to the device driver associated with the device. Usually we'll write user code (client code) that will allow interaction with them.
NB: In contrast to character devices, we have block devices where data volume is large and organized into blocks, and they also have their own API to interact with them. I've already read that there is a future lab dedicated to them.
As the character device driver supports many operations, let's see how to create a character device driver (=kernel module) and handle these operations.
Following are the common steps (the long way) to set up a character device driver named my_char_dev:
Set the Major and Minor numbers
Major number identifies the device type (we can see it like the group/category of the device), while the minor number identifies the device itself. The major number can identify the DISK or Serial Port and the minor identifies the first disk or the second serial port on which the driver will work.
In the initialization function passed to module_init (refer to my article on modules):
#include <linux/cdev.h>
int register_chrdev_region(dev_t first, unsigned int count, char *name);
In our case:
#define MY_MAJOR 42
#define MY_MINOR 0
#define MY_MINOR_COUNT 0
#define MY_DRIVER_NAME "my_char_dev"
// in the initialization function
register_chrdev_region(MKDEV(MY_MAJOR, MY_MINOR), MY_MINOR_COUNT, MY_DRIVER_NAME);
The registration can fail and return an error, we need to check that and do the appropriate operations.
I recommend at the same time to unregister the device allocated in the exit so we will not forget to do it. In the exit function passed to module_exit (refer to my article on modules):
#include <linux/cdev.h>
void unregister_chrdev_region(dev_t first, unsigned int count);
In our case:
// in the exit function
unregister_chrdev_region(MKDEV(MY_MAJOR,MY_MINOR),MY_MINOR_COUNT);
NB: If the number of minor devices exceeds MY_MINOR_COUNT, the registration will move to the next major.
Initialize and add cdev structure to kernel
Here are the methods we will use to add and initialize our device to the kernel:
void cdev_init(struct cdev *cdev, struct file_operations *fops);
int cdev_add(struct cdev *dev, dev_t num, unsigned int count);
We need the struct cdev. In reality, our driver can manage some data related to each device, like holding some information per device. So we'll define a struct that will have cdev as an attribute with additional data:
struct my_cdev_data {
struct cdev cdev;
int nb_used;
pid_t related_pid;
};
struct my_cdev_data my_dev;
NB: We can have more than one minor and we can do struct my_cdev_data my_devs[MY_MINOR_COUNT]; to index all the minor devices.
Inside the initialization as always we'll initialize:
// inside the initialization function
cdev_init(&my_dev.cdev, &my_fops);
cdev_add(&my_dev.cdev, MKDEV(MY_MAJOR, MY_MINOR), 1);
In the next section, we'll talk about my_fops.
In the exit function (when the module is unloaded), we will not forget to delete the device before unregistration:
void cdev_del(struct cdev *dev);
So:
// in the exit function
cdev_del(&my_dev.cdev);
unregister_chrdev_region(MKDEV(MY_MAJOR,MY_MINOR),MY_MINOR_COUNT);
Define file operations with struct file_operations
Above, we said that we can do some actions (syscalls) like open, read, write, close and many more on the devices through our driver, right? Good.
struct file_operations will allow us to do that, because it defines some functions that we need to provide for each operation.
Refer to this link struct file_operations to have an overview of these operations.
Here I'll just show briefly some of them:
struct file_operations {
struct module *owner;
...
loff_t (*llseek) (struct file *, loff_t, int);
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
...
long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
...
int (*open) (struct inode *, struct file *);
int (*release) (struct inode *, struct file *);
...
...
};
release is the equivalent of close.
When we do a syscall from the user space to interact with our driver like:
int fd = open("/dev/my_char_dev", O_RDONLY);
...
read(fd, buffer, size)
...
Under the hood, the equivalent operation handlers defined in file_operations are called with the appropriate arguments.
In our case we can do something like:
static int my_open (struct inode *inode, struct file *file)
{
// pr_info("Device opened");
return 0;
}
static int my_release (struct inode *inode, struct file *file)
{
// pr_info("Device released");
return 0;
}
static ssize_t my_read (struct file *file, char __user *buffer, size_t size, loff_t *offset)
{
// pr_info("Data read");
return size;
}
static ssize_t my_write (struct file *, const char __user *, size_t, loff_t *)
{
// pr_info("Data write");
return size;
}
struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.read = my_read,
.write = my_write,
.release = my_release,
.unlocked_ioctl = my_ioctl
};
In these functions, you define the operations and how things will be handled. But keep in mind:
Use
copy_from_userandcopy_to_userwhen you want to transfer data between the kernel and user space. For example onchar __user *buffer(it's marked__user). So the kernel under the hood can do some security checks like availability and rights to access these memories.unlocked_ioctl: We'll talk later in (the assignment) about this handler and ioctl (Input Output Control), which is really super fun and powerful to use. It allows the device to do custom operations, not only read or write.In these functions, usually we'll need to manage synchronization to avoid concurrency pitfalls when the same resource (maybe
my_dev) is accessed at the same time by two CPUs.
mknod to create /dev/my_char_dev
After loading the driver in the kernel with insmod, we need to create the device. There are many ways to do it: programmatically or manually.
For the programmatic way, check device_create and class_create.
Manually, to create an entry for our driver in /dev, we do:
//sudo insmod my_char_dev.ko
sudo mknod /dev/my_char_dev c 42 0
where:
c: is for character device (bfor block device)42and0: are respectively the major and the minor
Optional: Ports registration
As a character device can interact with hardware devices, there are some functions that allow reading/writing from/to the ports or registers of the hardware device. We can look for inb and outb in #include <asm/io.h>.
Too much? 😭😂, don't worry, in the next post, I'll present my second assignment: Kprobe-based tracer. In this assignment we did tracing on kmalloc and some kernel functions, and we used a new way to define device drivers in a shorter way with operations like open, read, mknod, handled under the hood (not by us) by using struct miscdevice.
Here is the complete code of our character device driver with some checks:
#include <linux/init.h>
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/uaccess.h>
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Kernel Dev");
MODULE_DESCRIPTION("A minimal character device driver with a single minor device");
#define MY_MAJOR 42
#define MY_MINOR 0
#define MY_DRIVER_NAME "my_char_dev"
/* Single-instance device wrapper */
struct my_cdev_data {
struct cdev cdev;
int nb_used;
pid_t related_pid;
};
/* Single device instance */
static struct my_cdev_data my_dev;
/* File operations */
static int my_open(struct inode *inode, struct file *file)
{
pr_info("%s: Device opened\n", MY_DRIVER_NAME);
return 0;
}
static int my_release(struct inode *inode, struct file *file)
{
pr_info("%s: Device released\n", MY_DRIVER_NAME);
return 0;
}
static ssize_t my_read(struct file *file, char __user *buffer, size_t size, loff_t *offset)
{
pr_info("%s: Read operation triggered\n", MY_DRIVER_NAME);
return 0; /* EOF */
}
static ssize_t my_write(struct file *file, const char __user *buffer, size_t size, loff_t *offset)
{
pr_info("%s: Write operation triggered (%zu bytes)\n", MY_DRIVER_NAME, size);
return size;
}
static const struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.read = my_read,
.write = my_write,
.release = my_release,
};
static int __init my_char_init(void)
{
int ret;
dev_t dev_num = MKDEV(MY_MAJOR, MY_MINOR);
/* 1. Reserve 1 device number */
ret = register_chrdev_region(dev_num, 1, MY_DRIVER_NAME);
if (ret < 0) {
pr_err("%s: Failed to register device number (err: %d)\n", MY_DRIVER_NAME, ret);
return ret;
}
/* 2. Initialize and add the single cdev */
cdev_init(&my_dev.cdev, &my_fops);
my_dev.cdev.owner = THIS_MODULE;
ret = cdev_add(&my_dev.cdev, dev_num, 1);
if (ret < 0) {
pr_err("%s: Failed to add cdev\n", MY_DRIVER_NAME);
unregister_chrdev_region(dev_num, 1);
return ret;
}
pr_info("%s: Registered successfully (Major %d, Minor %d)\n",
MY_DRIVER_NAME, MY_MAJOR, MY_MINOR);
return 0;
}
static void __exit my_char_exit(void)
{
/* Clean up cdev and unregister region */
cdev_del(&my_dev.cdev);
unregister_chrdev_region(MKDEV(MY_MAJOR, MY_MINOR), 1);
pr_info("%s: Driver unloaded successfully\n", MY_DRIVER_NAME);
}
module_init(my_char_init);
module_exit(my_char_exit);
Next
The next post will be about assignment 2 Kprobe based tracer: the task is to do an instrumentation (count the calls) of functions like kmalloc, kfree, up, down_interruptible in the Linux kernel by using kretprobe. I learnt more about device driver by using miscdevice, ioctl and hashtable
By the way, here's my GitHub repo for the assignments: kernel-labs-OS-2



