Device Drivers
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:
- The device file (
/dev/ioctl_driver
) corresponds to the URL (https://antoncao.me
) - The macro (
_IO, _IOR, _IOW
) corresponds to the HTTP request type (GET, POST
) - The macro arguments (
type, nr
) correspond to the endpoint (/blog/drivers
) - The argument
arg
corresponds to the HTTP request body
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
.