Device Drivers

Jul 21, 2024
#computer systems


A driver is a piece of software that allows the operating system and other programs to interact with hardware devices, such as mice, keyboards, and graphics cards. Typically, drivers need higher privileges than ordinary programs, and need to call kernel-specific APIs to communicate with hardware. So, drivers are implemented differently on each operating system.

Linux

Module System

The Linux kernel allows custom code to run in kernel-space via the module system. A module is a piece of code that can be dynamically loaded into and unloaded from the kernel. Device drivers, excluding those that are built-in to the operating system, are written as kernel modules. Some other types of programs that can be written as kernel modules are filesystems and network protocols.

Kernel modules are written in C. In order to develop kernel modules on Linux, you have to install the package linux-headers-$(uname -r) (uname -r prints the version of the kernel you are running). This will create a directory /lib/modules/$(uname -r)/, which contains the tools for module development, such as C header files at /lib/modules/4.15.0-42-generic/build/include/linux and a Makefile at /lib/modules/4.15.0-42-generic/build/Makefile.

Drivers can be categorized based on their implementation and the type of interface they expose to users.

Character Device Drivers

One of the simplest types of drivers to implement is a character device driver. A character device driver exposes a byte-stream interface for reading from and writing to the device. Below is an example of how to implement one. This driver does not actually communicate with a real hardware device. Instead, the driver stores the input data in memory and increments each character, allowing this modified data to be read back.

// chrdev_driver.c
#include <linux/init.h>
#include <linux/device.h>
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/fs.h>
#include <linux/uaccess.h>

#define DEVICE_NAME "chrdev_driver"
#define CLASS_NAME "example"
#define BUFFER_SIZE 1024

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Anton Cao");
MODULE_DESCRIPTION("A linux character device driver that adds 1 to each character");
MODULE_VERSION("0.1");

static int major_number;
static struct class* my_class = NULL;
static struct device* my_device = NULL;

static char message[BUFFER_SIZE] = {0};
static size_t message_size = 0;

static int dev_open(struct inode *, struct file *);
static int dev_release(struct inode *, struct file *);
static ssize_t dev_read(struct file *, char *, size_t, loff_t *);
static ssize_t dev_write(struct file *, const char *, size_t, loff_t *);

static struct file_operations fops = {
    .open = dev_open,
    .read = dev_read,
    .write = dev_write,
    .release = dev_release,
};

static int __init my_init(void) {
    printk(KERN_INFO "chrdev driver: Initializing\n");

    major_number = register_chrdev(0, DEVICE_NAME, &fops);
    if (major_number < 0) {
        printk(KERN_ALERT "chrdev driver: Failed to register a major number\n");
        return major_number;
    }

    my_class = class_create(THIS_MODULE, CLASS_NAME);
    if (IS_ERR(my_class)) {
        unregister_chrdev(major_number, DEVICE_NAME);
        printk(KERN_ALERT "chrdev driver: Failed to register device class\n");
        return PTR_ERR(my_class);
    }

    my_device = device_create(my_class, NULL, MKDEV(major_number, 0), NULL, DEVICE_NAME);
    if (IS_ERR(my_device)) {
        class_destroy(my_class);
        unregister_chrdev(major_number, DEVICE_NAME);
        printk(KERN_ALERT "chrdev driver: Failed to create the device\n");
        return PTR_ERR(my_device);
    }

    printk(KERN_INFO "chrdev driver: Device created successfully\n");
    return 0;
}

static void __exit my_exit(void) {
    device_destroy(my_class, MKDEV(major_number, 0));
    class_unregister(my_class);
    class_destroy(my_class);
    unregister_chrdev(major_number, DEVICE_NAME);
    printk(KERN_INFO "chrdev driver: Exiting\n");
}

static int dev_open(struct inode *inodep, struct file *filep) {
    printk(KERN_INFO "chrdev driver: Open\n");
    return 0;
}

static int dev_release(struct inode *inodep, struct file *filep) {
    printk(KERN_INFO "chrdev driver: Close\n");
    return 0;
}

static ssize_t dev_read(struct file *filep, char *buffer, size_t len, loff_t *offset) {
    if (*offset >= message_size) {
        printk(KERN_INFO "chrdev driver: EOF\n");
        return 0;
    }
    size_t bytes_to_copy = min(len, message_size - *offset);
    int error_count = copy_to_user(buffer, message + *offset, bytes_to_copy);
    if (error_count == 0) {
        printk(KERN_INFO "chrdev driver: Read %ld characters\n", bytes_to_copy);
        *offset += bytes_to_copy;
        return bytes_to_copy;
    } else {
        printk(KERN_INFO "chrdev driver: Failed to read characters\n");
        return -EFAULT;
    }
}

static ssize_t dev_write(struct file *filep, const char *buffer, size_t len, loff_t *offset) {
    size_t bytes_to_copy = min(len, (size_t)(BUFFER_SIZE - 1) - *offset);
    printk(KERN_INFO "chrdev driver: Writing %ld characters\n", bytes_to_copy);
    copy_from_user(message + *offset, buffer, bytes_to_copy);
    message_size = bytes_to_copy + *offset;
    message[message_size] = '\0';
    // increment each character
    while (*offset < message_size && message[*offset] != '\0') {
        ++message[*offset];
        ++(*offset);
    }
    return bytes_to_copy;
}

module_init(my_init);
module_exit(my_exit);

Most of this code is boilerplate. The most interesting pieces are the functions dev_read and dev_write, which contain the core logic of this driver. To compile the module, run make -C /lib/modules/$(uname -r)/build M=$(pwd) in the directory containing the source file. This will output a kernel object file chrdev_driver.ko in the same directory. To load the module, run sudo insmod simple_driver.ko. The printk statements in the driver log to the kernel ring buffer, which can be viewed with dmesg command. In addition, we can run lsmod to list the loaded modules, in order to confirm that our module was loaded successfully.

Once the driver is loaded, the path /dev/chrdev_driver will appear. Just like a regular file, you can read from and write to this path. However, instead of reading and writing data to storage, these requests will get forwarded by the kernel to the driver's dev_read and dev_write functions. So, we can interact with the driver as if it's a regular file. Note that it has root-only access by default

$ echo "abcd" | sudo tee /dev/chrdev_driver
abcd
$ sudo cat /dev/chrdev_driver
bcde

Finally, to unload the module, run sudo rmmod simple_driver.

ioctl Drivers

Another type of interface that drivers can expose is based on ioctl, which is a system call used specifically to communicate with drivers. The ioctl interface is more flexible than the character device one, because you can define multiple subcommands instead of just read and write. Here's the implementation of a simple driver that logs which subcommand was called.

 // ioctl_driver.c
#include <linux/init.h>
#include <linux/device.h>
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/fs.h>
#include <linux/uaccess.h>
    
#define DEVICE_NAME "ioctl_driver"
#define CLASS_NAME "example2"

#define IOCTL_CMD1 _IO('a', 1)
#define IOCTL_CMD2 _IO('a', 2)
#define IOCTL_CMD3 _IO('a', 3)

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Anton Cao");
MODULE_DESCRIPTION("A linux ioctl driver");
MODULE_VERSION("0.1");

static int major_number;
static struct class* my_class = NULL;
static struct device* my_device = NULL;

static int dev_open(struct inode *, struct file *);
static int dev_release(struct inode *, struct file *);
static long dev_ioctl(struct file *, unsigned int, unsigned long);

static struct file_operations fops = {
    .open = dev_open,
    .release = dev_release,
    .unlocked_ioctl = dev_ioctl,
};

static int __init my_init(void) {
    printk(KERN_INFO "ioctl driver: Initializing\n");

    major_number = register_chrdev(0, DEVICE_NAME, &fops);
    if (major_number < 0) {
        printk(KERN_ALERT "ioctl driver: Failed to register a major number\n");
        return major_number;
    }

    my_class = class_create(THIS_MODULE, CLASS_NAME);
    if (IS_ERR(my_class)) {
        unregister_chrdev(major_number, DEVICE_NAME);
        printk(KERN_ALERT "ioctl driver: Failed to register device class\n");
        return PTR_ERR(my_class);
    }

    my_device = device_create(my_class, NULL, MKDEV(major_number, 0), NULL, DEVICE_NAME);
    if (IS_ERR(my_device)) {
        class_destroy(my_class);
        unregister_chrdev(major_number, DEVICE_NAME);
        printk(KERN_ALERT "ioctl driver: Failed to create the device\n");
        return PTR_ERR(my_device);
    }

    printk(KERN_INFO "ioctl driver: Device created successfully\n");
    return 0;
}

static void __exit my_exit(void) {
    device_destroy(my_class, MKDEV(major_number, 0));
    class_unregister(my_class);
    class_destroy(my_class);
    unregister_chrdev(major_number, DEVICE_NAME);
    printk(KERN_INFO "ioctl driver: Exiting\n");
}

static int dev_open(struct inode *inodep, struct file *filep) {
    printk(KERN_INFO "ioctl driver: Open\n");
    return 0;
}

static int dev_release(struct inode *inodep, struct file *filep) {
    printk(KERN_INFO "ioctl driver: Close\n");
    return 0;
}

static long dev_ioctl(struct file *filep, unsigned int cmd, unsigned long arg) {
    switch(cmd) {
        case IOCTL_CMD1:
            printk(KERN_INFO "ioctl driver: IOCTL_CMD1 called\n");
            break;
        case IOCTL_CMD2:
            printk(KERN_INFO "ioctl driver: IOCTL_CMD2 called\n");
            break;
        case IOCTL_CMD3:
            printk(KERN_INFO "ioctl driver: IOCTL_CMD3 called\n");
            break;
        default:
            printk(KERN_WARNING "ioctl driver: Invalid IOCTL command\n");
            return -EINVAL;
    }
    return 0;
}

module_init(my_init);
module_exit(my_exit);

Most of the boilerplate is the same as before. The important differences are the constants IOCTL_CMD{1,2,3}, and the function dev_ioctl. Each constant represents a subcommand that the driver supports. The definitions of the constants involve a macro _IO, which accepts two 8-bit integers type and nr (number) and returns a 32-bit integer. The type and number are used to group subcommands together. By convention, the type is an ascii letter. Note that kernel uses the device file, not the type of the command, to route ioctl requests to the correct driver, so it's ok if different drivers use the same magic number. dev_ioctl is the function that the kernel forwards ioctl requests to. The cmd parameter is the value of the subcommand that was called by the user, e.g. IOCTL_CMD1. The arg parameter is a 64-bit integer that the user provides. If the user needs to pass more than 64-bits of data to the driver, then arg can be a pointer to that data.

The instructions for building and loading the driver are the same as before. A device file /dev/ioctl_driver will be created when the driver is loaded. We can write a simple program to interact with the driver:

 // ioctl_client.c
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/ioctl.h>

#define IOCTL_CMD1 _IO('a', 1)
#define IOCTL_CMD2 _IO('a', 2)
#define IOCTL_CMD3 _IO('a', 3)

int main() {
    int fd = open("/dev/ioctl_driver", O_RDWR);
    if (fd < 0) {
        perror("Failed to open the device...");
        return -1;
    }

    ioctl(fd, IOCTL_CMD1);
    ioctl(fd, IOCTL_CMD2);
    ioctl(fd, IOCTL_CMD3);

    close(fd);
    return 0;
}

Typically, constants like IOCTL_CMD1 will be provided by the driver in a header file, so they don't need to be redefined by the user. There are other macros for defining subcommands, like _IOR (read) and _IOW (write). Technically, it does not really matter which macro you use; the purpose is to document the behavior of the command. ioctl drivers are kind of like HTTP web servers, where:

Other Drivers

In addition to character device drivers and ioctl drivers, some other types of drivers include block device drivers, which operate on large blocks of data instead of a stream of bytes, and network drivers. The distinction between drivers is not clear cut. For example, drivers can have both a byte-stream interface and an ioctl interface.

Communication between Drivers and Hardware

Drivers communicate with hardware devices through several methods, depending on the specific hardware and system architecture. One way is memory-mapped IO, in which the registers on the hardware device are mapped to memory addresses. Another way is via interrupts, where the driver will register an interrupt handler that gets called when the device signals the CPU.

Windows

On Windows, drivers are typically written in C or C++. The Windows Driver Kit is like the Windows equivalent of linux-headers. It provides the header files and other tools for driver development on Windows.

MacOS

On MacOS, kernel-space drivers are typically written in C++. User-space drivers can be written in Swift, using Apple's DriverKit framework. Kernel-space drivers are also called Kernel Extensions (KEXTs), and can typically be found at /Library/Extensions or /System/Library/Extensions with a .kext file extension. To enable and disable a Kernel Extension, you can use the commands kextload and kextunload. For DriverKit extensions, you can use systemextensionsctl.





Comment