In this module we will learn about device drivers, which is how Linux (as well as other kernels) handles peripheral devices. We will go through the different categories of devices, how applications interact with them and introduce the concept of device node as an interface to devices. Finally, we will focus on one specific type of device and write a simple driver for it.
Device nodes
From the point of view of Linux, there are three types of devices:
- Character devices, which are sequential streams. They can be thought of as files and as such can be used in conjunction with the
open,close,readandwritefunctions provided by POSIX. An example would be a serial port like/dev/ttyS0. - Block devices, which are randomly accessed only in block-size multiples, and for which I/O operations are usually cached and deal with mounted filesystems. An example would be a hard drive partition like
/dev/sda1. - Network devices, which transfer packets of data, and usually implement a socket interface. Packet reception/transmission functions replace read/write operations. An example with be a network interface like
eth0orwlan0.
Character and block devices have filesystem entries associated with them, called device nodes. These nodes behave as if they were regular files from the point of view of a user and can be used by user-level programs to communicate with the underlying device by using I/O system calls. Device nodes are normally placed in the /dev directory.
Let us consider a simple example and request information about the /dev/sda1 device node.

The first thing to notice is the b character before the permission bits. This indicates that the file is a block device.
Right after the group we can see two numbers, 8 and 1. These are referred to as the major and minor number, respectively.
- The major number identifies the driver associated with the device: all device nodes of the same type (block or character) with the same number use the same driver.
- The minor number is used only by the driver to differentiate between the different devices it may control. These may either be different instances of the same device (such as the first and second hard drive partition) or different modes of operation of a given device.

- Notice how all these devices have the same major number (and therefore are controlled by the same driver) but different minor numbers.
Device nodes can be explicitly created using the mknod program:
$ mknod [-m mode] /dev/name <type> <major> <minor>Alternatively, the mknod() system call can be used:
int mknod(const char* pathname, mode_t mode, dev_t dev);Device drivers
At its core, a Linux driver is “nothing more” than a set of functions that can be called by the kernel every time it is necessary to interact with a peripheral device. Linux drivers are almost always written in C, although Rust is starting to make its way into the kernel.
We will start our study of drivers by writing a simple character device driver and will progressively add more features to it. The basic skeleton of a Linux device driver is as follows:
#include <linux/init.h>
#include <linux/module.h>
static int cool_driver_init(void) {
printk(KERN_ALERT "COOL DRIVER: init\n");
return 0;
}
static void cool_driver_exit(void) {
printk(KERN_ALERT "COOL DRIVER: exit\n");
}
module_init(cool_driver_init);
module_exit(cool_driver_exit);
MODULE_AUTHOR("Modular MX");
MODULE_LICENSE("GPL v2");This constitutes the bare minimum to make a fully functional device driver.
Drivers cannot make use of the C standard library (because, well… it is not available at system startup). Therefore, they must use the interfaces provided by the Linux kernel itself. One such utility is the printk function, which is similar to printf. The KERN_ALERT prefix is a preprocessor macro that specific the priority of the message (it is possible to filter out messages during the initialization sequence).
The two functions, cool_driver_init and cool_driver_exit, are called by the kernel when the driver is loaded and when it is removed, respectively. At this point we are not doing anything particularly interesting here. The macros module_init and module_exit are used to tell the kernel the roles of these functions.MODULE_AUTHOR and MODULE_LICENSE provide extra information about the driver. Be aware that if you do not specify the license of the driver, or if it is a non-free license, the kernel will complain when loading the driver. (The driver will load, but it will taint the kernel.)
- Go to your copy of the Linux source tree and create a file in
drivers/charnamedcool_driver.c. Copy the previous code to it. - Edit
drivers/char/Kconfigand add an appropriate configuration entry for the driver.
config COOL_DRIVER
tristate "My cool driver"
default n
help
This is my cool driver- We will go over the exact meaning of the
tristatekeyword in a few minutes. - Edit
drivers/char/Makefileand add an appropriate entry for the object file associated with the driver.
obj-$(CONFIG_COOL_DRIVER) += cool_driver.o- Run
armmake menuconfigand find the new entry underDevice Drivers/Character devices. Enable it (that is, pressY). - Save the new configuration and rebuild the kernel image.
$ armmake zImage -j $(nproc)- Boot the BeagleBone Black with this kernel image. You should see the
COOL DRIVER: initmessage being displayed as part of the logs during the initialization sequence.

In this case, the driver is embedded into the kernel itself, that is, its code is part of the zImage file that is generated during the build process and then loaded by U-Boot. It is also possible to build a driver as a module, in which case it is built as a separate file that can be dynamically loaded (or “inserted”) into the kernel at runtime.
- Run
armmake menuconfigand locate the entry that corresponds to our driver. PressMto indicate that it will be built as a module.

- This is why we needed to specify
tristatein theKconfigfile (that is, the entry can have one of three possible states). The alternative would bebool. - Save the new configuration and rebuild the kernel. You will notice that the compiler itself is not involved here; instead, we are relinking object files without the definitions provided by
cool_driver.o. - Build the modules by running
armmake modules. You will notice that onlycool_driver.ois built.

- File
drivers/char/cool_driver.kois the kernel module. If you request information from it using thefileutility, you will notice that it is a regular relocatable object file. - Install the new module tree to the root filesystem used by the BeagleBone Black using
armmake module_install. Remember to specify a value forINSTALL_MOD_PATH! - Boot the BeagleBone Black. You can see that the driver was not loaded this time by running the
dmesgprogram, which prints the messages reported by the kernel. (Entry from the manual)$ dmesg | grep 'COOL DRIVER' - To work with kernel modules, you can use the
lsmod,insmodandrmmodutilities.lsmodshows a list of the modules currently loaded in the kernel.insmodcan be used to insert a module into the kernel.rmmodcan be used to remove a module from the kernel. - Examine the output of
lsmod. Insert our driver withinsmod. Check the output oflsmodagain and you should see our driver listed there.
$ lsmod
(...)
$ sudo insmod /lib/modules/6.11.2/kernel/drivers/char/cool_driver.ko
$ lsmod
Module Size Used by
cool driver 12288 0
(...)This driver can be made a bit more interesting by introducing the file_operations data structure, which is a type defined by the kernel that contains function pointers that will be called when a user-space application interacts with a device node managed by the driver. This type looks like this (note that this is not the entire definition):
struct file_operations {
int (*open)(struct inode*, struct file*);
int (*release)(struct inode*, struct file*);
ssize_t (*read)(struct file*, char __user*, size_t, loff_t*);
ssize_t (*write)(struct file*, const char __user*, size_t, loff_t*);
};A driver can define functions that match these signatures and then create a variable of type file_operations, initializing its members to the appropriate functions. After registering this instance of file_operations with the device driver, the functions will be called by the kernel.
As an example, let us assume that we have a character device node called /dev/my_device. If we do something like echo 'hello there' >/dev/my_device, the kernel will first call the open function associated with the driver that manages the node, and then write. The string hello there will be passed as the second argument to write.
The only thing that is missing at this point is how to actually register a driver with the kernel so that it can manage device nodes. There are a couple of functions provided by Linux that can be used for this purpose.
int register_chrdev_region(dev_t from, unsigned int count, const char* name);
This function will register a range of device numbers to be used with the driver from which it is called.void cdev_init(struct cdev* cdev, const struct file_operations* fops);
Initialize the data structure used by the kernel to represent a character device, associating a group offile_operationswith it.
Building on our first version of a character device driver, it can be extended as follows:
#include <linux/init.h>
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
static const char* const device_name = "cool_driver";
static const int major_number = 400;
static const int minor_number = 0;
static struct cdev* character_device;
static dev_t first_device_number;
static int cool_driver_open(struct inode* inode, struct file* file) {
printk(KERN_ALERT "COOL DRIVER: open\n");
return 0;
}
static int cool_driver_release(struct inode* inode, struct file* file) {
printk(KERN_ALERT "COOL DRIVER: release\n");
return 0;
}
static ssize_t cool_driver_read(struct file* file, char __user* buffer,
size_t count, loff_t* position) {
printk(KERN_ALERT "COOL DRIVER: read (%u bytes)\n", count);
return (ssize_t) count;
}
static ssize_t cool_driver_write(struct file* file, const char __user* buffer,
size_t length, loff_t* count) {
printk(KERN_ALERT "COOL DRIVER: write (%u bytes)\n", length);
return (ssize_t) length;
}
static const struct file_operations cool_driver_file_operations = {
.owner = THIS_MODULE,
.open = cool_driver_open,
.release = cool_driver_release,
.read = cool_driver_read,
.write = cool_driver_write
};
static int cool_driver_init(void) {
printk(KERN_ALERT "COOL DRIVER: init\n");
first_device_number = MKDEV(major_number, minor_number);
register_chrdev_region(first_device_number, 1, device_name);
character_device = cdev_alloc();
cdev_init(character_device, &cool_driver_file_operations);
cdev_add(character_device, first_device_number, 1);
return 0;
}
static void cool_driver_exit(void) {
printk(KERN_ALERT "COOL DRIVER: exit\n");
cdev_del(character_device);
unregister_chrdev_region(first_device_number, 1);
}
module_init(cool_driver_init);
module_exit(cool_driver_exit);
MODULE_AUTHOR("Modular MX");
MODULE_LICENSE("GPL v2");- Rebuild this driver as a module as install it to the root filesystem used by the BeagleBone Black.
- Boot the board and insert the module using
lsmod. - Create a character device node with appropriate major and minor numbers so that it is managed by our driver.
$ sudo mknod -m 666 /dev/my_node c 400 0- Feel free to inspect this file with
lsto confirm that the numbers are correct. - Write something to the device node. You will see that
open,writeandcloseare called, in that order.
$ echo 'hello there' >/dev/my_node
[ ... ] COOL DRIVER: open
[ ... ] COOL DRIVER: write (12 bytes)
[ ... ] COOL DRIVER: closeAllocation of device numbers
The Linux kernel uses the dev_t type to represent device numbers, that is, both the major and minor numbers. Its internal representation is meant to be an implementation-specific detail, but usually it is a 32-bit quantity that uses 12 bits for the major number and 20 for the minor number. Users are expected to manipulate these quantities through the following macros:
MAJOR(dev): Extracts the major number from adev_tquantity.MINOR(dev): Extracts the minor number from adev_tquantity.MKDEV(major, minor): Create adev_tquantity out of a major and minor number.
Under most circumstances, one of the first things that a Linux driver will do during initialization is to obtain one or more device numbers, which are needed to work with the devices managed by the driver. There are two main approaches for this:
- Static allocation of device numbers through the following function:
int register_chrdev_region(dev_t first, unsigned int count, const char* name);- The first parameter, which also happens to be called
first, is the beginning device number of the range to allocate. It is often the case thatMINOR(first)is 0, but this is not a requirement. - The second parameter,
count, is the number of contiguous device number that are being requested. - The third parameter,
name, is the name of the device that should be associated with the number range and will appear in/proc/devices. - The return type will be 0 if the allocation was successfully performed, and a negative number in case of an error.
- Dynamic allocation of device numbers through the following function:
int alloc_chrdev_region(dev_t* dev, unsigned int first_minor, unsigned int count, const char* name);- The first parameter is output-only and will, on successful completion, hold the first number in the allocated range.
- The second parameter is the first minor number to use in the allocated range; it is usually 0.
- The third and fourth parameters are similar to those provided to
register_chrdev_region. The same applies to the function’s return type.
Regardless of the approach used to allocate device numbers, they should be freed when no longer necessary:
void unregister_chrdev_region(dev_t first, unsigned int count);he kernel uses the type cdev to represent character devices. It is a struct whose internals are meant to be an implementation detail. Before the kernel invokes any operation associated with a character device (like read or write), it is necessary to allocate and register one of these struct objects, which can be done in two ways:
- By initializing a
cdevobject that already exists:
void cdev_init(struct cdev* cdev, const struct file_operations* fops);- By obtaining a standalone
cdevobject at runtime:
struct cdev* cdev_alloc(void);- Note that if only this function is used, the
file_operationsmust be directly set with something likedev->ops = &my_device_file_operations;.
The cdev data structure has another field that should also be initialized: owner should be set to THIS_MODULE.
Once the cdev data structure is set up, it can be registered with the kernel by using the following function:
int cdev_add(struct cdev* cdev, dev_t first_device_number, unsigned int count);As soon as cdev_add returns (with a successful return code) the device is “up and running” and its operations can be called by the kernel. This means that it is important to only call this function after the driver is completely ready to handle operations on the device.
Removing a character device from the kernel is done by calling the following function:
void cdev_del(struct cdev* cdev);Drivers and memory management
If a Linux driver requires dynamic memory allocation, it can use the kmalloc and kfree functions provided by the kernel.
void* kmalloc(size_t size, int flags);
void kfree(const void* address);This interface should be familiar to anyone who has used malloc and free.
One obvious difference is the flags parameter for kmalloc, though. It is used to indicate the kernel how the memory is supposed to be used, and in turn control the behavior of the allocator used by the kernel.
An in-depth discussion of the available options for this parameter is outside the scope of this module. You are encouraged to consult the official documentation. In practice, GFP_KERNEL is used most of the time, but be aware that there are more options which could be useful in certain scenarios.
The region allocated by kmalloc is contiguous in physical memory. This is important when, for example, allocating a buffer that can be accessed by a DMA device on a physically addressed bus.
The kernel provides another option for allocating memory in the form of vmalloc, which allocates a contiguous region in the virtual address space. The pages associated with this region don’t have to necessarily be contiguous in physical memory, but the kernel sees them as a contiguous range of addresses. Memory allocated with vmalloc should be freed with vfree.
void* vmalloc(unsigned long size);
void vfree(const void* address);Addresses allocated by vmalloc cannot be used outside of the microprocessor, because they make sense only on top of the processor’s MMU. A good rule of thumb is to use vmalloc when allocating memory for a large sequential buffer that exists only in software.
Closing thoughts
It is not humanly possible to cover all the available information about Linux device drivers in the limited amount of time we have. This module was meant to provide an introduction to the subject and (hopefully) give you enough context so that you can further investigate.
Linux is a moving target in many ways, so books written about it tend to become outdated in no time. That being said, the basic idea behind the way the kernel interacts with drivers is somewhat stable. If you would like to know more about drivers in general, a good starting point is the third edition of Linux Device Drivers. The authors have graciously made the chapters freely available online, but if you find the information in that book relevant, consider supporting them by actually buying it.
Labs
- Extend the character device driver we wrote to “remember” the data that is written into it. Requesting data from the driver should reset its state.
$ sudo mknod -m 666 /dev/my_node c 400 0
$ printf hello >/dev/my_node
$ printf ' there' >/dev/my_node
$ printf '!\n' >/dev/my_node
$ cat /dev/my_node
hello there!
$ printf 'first line\n' >/dev/my_node
$ printf 'second line\n' >/dev/my_node
$ cat /dev/my_node
first line
second line- So far, we have been building the driver inside the Linux source tree (that is, in-tree). Figure out how to do an out-of-tree build. Is there anything in particular that needs to be taken into consideration when doing so?
- Hint: The interfaces used internally by Linux are not guaranteed to be stable, so it is always a good idea to build a module using the header files that correspond to the particular version of the kernel for which they are meant to be a part.