Device Drivers

Note: we will consider all commands from here on out as being run from the linux directory (source of your kernel tree).

1. Creating a shared folder

Let's make a shared folder between the host machine and the VM.

First we'll create a folder to be shared from our host machine:

mkdir ../shared_folder

Now we make a shared folder inside the virtual machine (we are going to add some more flags on our qemu command):

qemu-system-x86_64 \
    -drive file=../my_disk.raw,format=raw,index=0,media=disk \
    -m 2G -nographic \
    -kernel ./arch/x86_64/boot/bzImage \
    -append "root=/dev/sda rw console=ttyS0 loglevel=6" \
    -fsdev local,id=fs1,path=../shared_folder,security_model=none \
    -device virtio-9p-pci,fsdev=fs1,mount_tag=shared_folder \
    --enable-kvm
What do these new flags mean?
  • -fsdev local,id=fs1,path=<path to shared folder>,security_model=none: this will add a new file system device to our emulation. Make sure to put the right directory at path. Don’t worry about security_model=none, this argument will let the permission of creating/modifying files inside the guest to be the same as if was created by the host user.
  • -device virtio-9p-pci,fsdev=fs1,mount_tag=<shared folder name on mount>: this defines the name (tag) and type of the virtual device (virtio-9p-pci).

Now we need to determine the mountpoint of the shared folder, i.e., what directory inside the VM our shared folder will be mounted to. For this, inside the VM, edit the file /etc/fstab with your preferred editor (nano or vi), and add this line at the end of file:

# <device> <mountpoint> <type> <options> <dump> <pass>
shared_folder /root/host_folder 9p trans=virtio 0 0

Now reboot your VM and check if there is a /root/host_folder with the same files and folder of shared_folder in the host machine. For now, your shared_folder is still empty, so the existence of the /root/host_folder directory should be enough proof that it's working (you can create a random file inside it from the host just to check if it shows up in the VM as well).

2. Making a module

2.1 Compiling it out-of-tree

A module is a piece of kernel code that is loadable at runtime. On regular Linux systems, a sizeable chunk of the kernel's functionality is shipped as modules, instead of being embedded in the kernel image itself.

Let's look at the minimum boilerplate needed to make a kernel module. We'll start by creating a file hello.c inside the shared folder:

// SPDX-License-Identifier: GPL-2.0-only
/*  
 *  hello.c - The simplest kernel module.
 */
#include    <linux/init.h>      /* Needed for __init/__exit */
#include    <linux/module.h>    /* Needed for module_init/module_exit/MODULE_* */

static int __init hello_init(void)
{
    pr_info("Hello world\n");

    return 0; // A non 0 return value means init_module failed; module can't be loaded.
}

static void __exit hello_exit(void)
{
    pr_info("Goodbye world\n");
}

module_init(hello_init);
module_exit(hello_exit);

MODULE_AUTHOR("Your Name <your.email@domain.com>");
MODULE_DESCRIPTION("A simple hello / goodbye driver");
MODULE_LICENSE("GPL");

This module will print a 'Hello world' message to the kernel log when loaded, and a 'Goodbye' message when removed.

In the same folder, create a file called Makefile with the following content:

obj-m += hello.o

Compile out-of-tree module:

make -C . M=../shared_folder hello.ko
In case you're running this from a different directory

make -C path/to/linux M=path/to/shared/folder hello.ko

Now you have compiled your module, you can boot your VM with the shared folder:

Boot the VM
qemu-system-x86_64 \
    -drive file=../my_disk.raw,format=raw,index=0,media=disk \
    -m 4G -nographic \
    -kernel ./arch/x86_64/boot/bzImage \
    -append "root=/dev/sda rw console=ttyS0 loglevel=6" \
    -fsdev local,id=fs1,path=../shared_folder,security_model=none \
    -device virtio-9p-pci,fsdev=fs1,mount_tag=shared_folder \
    --enable-kvm

And inside the VM, load the module:

cd host_folder
insmod hello.ko

Check the log:

dmesg

Unload your module and check the logs again:

rmmod hello
dmesg

You can now exit the VM.

3.2 Compiling it in-tree

Now we are going to make the module inside the kernel tree. So we are going to copy our work to the drivers folder in the linux kernel project:

mkdir drivers/lkcamp
cp ../shared_folder/hello.c drivers/lkcamp
cp ../shared_folder/Makefile drivers/lkcamp

Now, let's add a flag of configuration for the module to appear on make menuconfig. Let's create a file named Kconfig on drivers/lkcamp folder, with the following contents:

config HELLO
    default m
    tristate "Hello module"
    help
        This module prints the following message when loaded: 'hello',
        as well as the following when removed: 'goodbye'.
        This module was created for educational purposes, as part
        of the Linux Kernel Development Workshop organized by LKCAMP.
        The default configuration is 'm': the module will be compiled
        and it will be available to be loaded in execution time.

The Kconfig file will create a config option that can be toggled from menuconfig.

About Kconfig files:

Kconfig files are where the kernel config options (e.g. the variables in your .config) are defined.

Our Kconfig entry creates a variable (CONFIG_HELLO) that can be referenced both from within Makefiles (to decide which features will be enabled when compiling the kernel) as well as from C code (as a preprocessor macro, mostly to achieve conditional compilation with e.g. #ifdef CONFIG_HELLO).

  • config HELLO: defines a variable that can be toggled from menuconfig and can be referenced within Makefiles as CONFIG_HELLO.
  • default m: means we have this feature compiled as a module by default
  • tristate "Hello module": defines that our variable will have an entry that says "Hello module" on menuconfig, and that our variable can assume one of the following 3 values (hence a "tristate"):
    • y means it will be embedded in our kernel image and therefore always enabled
    • m means it will be built as a loadable module (.ko file)
    • n means it will not be built at all (our kernel won't have support for this feature)
  • help: this is the message that will be shown when a user selects the < Help > option for our module on menuconfig

With the Kconfig file created, we need to make sure that this module configuration is actually being used when the driver is compiled. To do so, edit the drivers/lkcamp/Makefile file to replace the previous hard-coded m in obj-m with the value assigned to the CONFIG_HELLO variable:

obj-$(CONFIG_HELLO) += hello.o

We also need to include the reference to the new driver in the parent folder drivers/. To do this, add the following line to the drivers/Makefile file:

obj-y               += lkcamp/

And, in drivers/Kconfig, at the end of the file, before endmenu, add the line:

source "drivers/lkcamp/Kconfig"

Now you should be able to see your module on the menuconfig (from the root of the source tree):

make menuconfig

Search for your module: type /, followed by HELLO and then press Enter. You will notice that the entry for our newly added config option has a (1) next to it; that means you can press 1 and jump directly to it, instead of finding your way through the menus.

Or, if you prefer a more manual way, you can go into the Device Drivers option inside of menuconfig, then go down the list until you find your new module. If you added your driver source to the last line of Kconfig, the new module will be on the last line of the list.

You can see it is set by default with the m option. You can exit the menu and compile it.

Let's compile our kernel with the new module:

make -j$(nproc)

You should notice that at some point the following showed up while compiling the kernel:

  CC [M]  drivers/lkcamp/hello.o

Now we need to actually install the modules (e.g., copy them to the directory they live in, just like regular programs live in /bin, /usr/bin, etc). Normally, on Linux systems, they're installed to the /lib/modules/$(uname -r) folder (take a look inside that directory on your machine, for example).

The kernel already offers a convenient target for this: modules_install. However, if we run it on our host, the modules will get installed to our host's /lib/modules/, which is not what we want; we need them to live in our VM's filesystem. For this, we'll mount our VM disk again and use the INSTALL_MOD_PATH variable to define the path where the modules will be installed to:

sudo mount ../my_disk.raw ../mnt_partition
sudo make INSTALL_MOD_PATH=../mnt_partition modules_install
sudo umount ../mnt_partition

Now that we have added the modules to our VM rootfs, we can boot our machine:

qemu-system-x86_64 -drive file=../my_disk.raw,format=raw,index=0,media=disk -m 4G -nographic -kernel ./arch/x86_64/boot/bzImage -append "root=/dev/sda rw console=ttyS0 loglevel=6" -fsdev local,id=fs1,path=../shared_folder,security_model=none -device virtio-9p-pci,fsdev=fs1,mount_tag=shared_folder --enable-kvm

You can search for your module in /lib/modules to confirm that it was installed correctly:

find /lib/modules -name "hello*"

Now you can load/unload it and check the results:

modprobe hello
dmesg | tail
modprobe -r hello
dmesg | tail

Tips for dmesg

You can use tail on the command to print only the last messages:

dmesg | tail

3. Implementing more complex modules

3.1 Passing command line arguments to a module

Let's declare a variable to be passed as argument when initializing modules, add these lines on hello.c file:

int myint = -1;
module_param(myint, int, 0);

Then print the value when initialize the module, on init_module add this line:

    pr_info("Initialize with the value: %d\n", myint);

New hello.c
// SPDX-License-Identifier: GPL-2.0-only
/*
*  hello.c - The simplest kernel module.
*/
#include        <linux/init.h>          /* Needed for __init/__exit */
#include        <linux/module.h>        /* Needed for module_init/module_exit/MODULE_* */

int myint = -1;
module_param(myint, int, 0);

static int __init hello_init(void)
{
        pr_info("Hello world\n");
        pr_info("I received the following argument: myint=%d\n", myint);

        return 0; // A non 0 return means init_module failed; module can't be loaded.
}

static void __exit hello_exit(void)
{
        pr_info("Goodbye world\n");
}

module_init(hello_init);
module_exit(hello_exit);

MODULE_AUTHOR("Your Name <your.email@domain.com>");
MODULE_DESCRIPTION("A simple hello / goodbye driver");
MODULE_LICENSE("GPL");

Now you can compile and test it, same as before:

Compile and boot
make -j$(nproc)
sudo mount ../my_disk.raw ../mnt_partition
sudo make INSTALL_MOD_PATH=../mnt_partition modules_install
sudo umount ../mnt_partition

qemu-system-x86_64 -drive file=../my_disk.raw,format=raw,index=0,media=disk -m 4G -nographic -kernel ./arch/x86_64/boot/bzImage -append "root=/dev/sda rw console=ttyS0 loglevel=6" -fsdev local,id=fs1,path=../shared_folder,security_model=none -device virtio-9p-pci,fsdev=fs1,mount_tag=shared_folder --enable-kvm

And inside your VM:

modprobe hello myint=10
dmesg | tail

You can add other arguments types, check https://tldp.org/LDP/lkmpg/2.6/html/x323.html for more.

3.2 Implementing a char driver module

For the next exercise we will consider a simple character driver. It only stores a status, which can be ON or OFF. You can query the current status by reading from it, and set it to ON or OFF by writing 1 or 0 to it, respectively.

The following code implements this behavior:

// SPDX-License-Identifier: GPL-2.0-only
#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/uaccess.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/string.h>

static dev_t lkcamp_dev; // Holds the major and minor number for our driver
static struct cdev lkcamp_cdev; // Char device. Holds fops and device number

// Possible states for the driver
enum driver_state {
    STATUS_OFF = 0,
    STATUS_ON  = 1,
};

static enum driver_state status = STATUS_OFF;
static const char *status_strings[] = {"OFF\n", "ON\n"};

static ssize_t lkcamp_read(struct file *file, char __user *buf, size_t size,
               loff_t *ppos)
{
    // Return the string corresponding to the current driver state
    return simple_read_from_buffer(buf, size, ppos, status_strings[status],
                       strlen(status_strings[status]));
}

static ssize_t lkcamp_write(struct file *file, const char __user *buf,
                size_t size, loff_t *ppos)
{
    char value;

    // Copy the first character written to this device to 'value'
    if (copy_from_user(&value, buf, 1))
        return -EFAULT; // Something went very wrong

    if (value == '0')
        status = STATUS_OFF;
    else if (value == '1')
        status = STATUS_ON;
    else
        return -EINVAL;

    return 1; // We only read one character from the written string
}

// Define the functions that implement our file operations
static struct file_operations lkcamp_fops =
{
    .read = lkcamp_read,
    .write = lkcamp_write,
};

static int __init lkcamp_init(void)
{
    int ret;

    // Allocate a major and a minor number
    ret = alloc_chrdev_region(&lkcamp_dev, 0, 1, "lkcamp");
    if (ret)
        pr_err("Failed to allocate device number\n");

    // Initialize our character device structure
    cdev_init(&lkcamp_cdev, &lkcamp_fops);

    // Register our character device to our device number
    ret = cdev_add(&lkcamp_cdev, lkcamp_dev, 1);
    if (ret)
        pr_err("Char device registration failed\n");

    pr_info("LKCAMP driver initialized!\n");

    return 0;
}

static void __exit lkcamp_exit(void)
{
    // Clean up our mess
    cdev_del(&lkcamp_cdev);
    unregister_chrdev_region(lkcamp_dev, 1);

    pr_info("LKCAMP driver exiting!\n");
}

module_init(lkcamp_init); // Register our functions so they get called when our
module_exit(lkcamp_exit); // module is loaded and unloaded

MODULE_AUTHOR("LKCAMP");
MODULE_DESCRIPTION("LKCAMP's incredibly useful char driver");
MODULE_LICENSE("GPL");

You can add this code to a new file named lkcamp_char.c on your shared folder, let's compile it out-tree first. Just add this line to the Makefile:

obj-m       += lkcamp_char.o

You can compile it as before (out of tree):

make -C path/to/linux M=../shared_folder lkcamp_char.ko
Let's add some new debugging tools to our VM

sudo mount ../my_disk ../mnt_partition
sudo chroot ../mnt_partition
With chroot in your VM you can install some packages:
apt install strace man
You can exit (Ctrl-D) and umount the disk:
sudo umount ../mnt_partition

You can boot into your VM and play with your module.

qemu-system-x86_64 -drive file=../my_disk.raw,format=raw,index=0,media=disk -m 4G -nographic -kernel ./arch/x86_64/boot/bzImage -append "root=/dev/sda rw console=ttyS0 loglevel=6" -fsdev local,id=fs1,path=../shared_folder,security_model=none -device virtio-9p-pci,fsdev=fs1,mount_tag=shared_folder --enable-kvm

Inside your VM, let's load and test it.

cd host_folder
insmod lkcamp_char.ko

As you load the module, you should see the "LKCAMP driver initialized!" message print on the kernel log.

Now to talk to the driver we have to create a character special file using mknod /dev/lkcamp c <driver major> 0. You should substitute <driver major> to its major number. But since it was dynamically allocated through alloc_chrdev_region(&lkcamp_dev, 0, 1, "lkcamp");, how can we know it?

Answer

Ask the kernel! It stores a list of the devices and their major numbers, which can be queried through the /proc/devices file (so just type cat /proc/devices to see it).

The file you just created can now be used to read from and write to the driver. Query the drive's status with:

cat /dev/lkcamp

You can turn it off and on, respectively, with:

OFF:

echo -n '0' > /dev/lkcamp

ON:

echo -n '1' > /dev/lkcamp

You can try echo '0' > /dev/lkcamp, you will see an error message.

What's wrong?

The file operations, including writing and reading, are done through system calls. So, to find out why that error occurred we can use the very handy tool strace to monitor all system calls made by echo on our file.

Let's investigate it: strace echo '0' > /dev/lkcamp. You can see every syscall that happened in this command. With that, try to find out by yourself why the error occurred and how to solve it.

Tips: We are interested only in what happens in the write() calls. You can also take a look at the lkcamp_write function in our driver. And in the man echo for the flag -n.

Explanation and solution for the error

From the lkcamp_write() driver function, we can see that only the first character is read. From strace, we see that echo writes twice to our file, and also that it doesn't simply write 0: it adds a newline character after it!

write(1, "0\n", 2) = 1
write(1, "\n", 1)  = -1 EINVAL (Invalid argument)
Since it is writing two characters and our driver only reads the first one, echo calls a second write on the file, so that the remaining \n gets read, but our driver only accepts 0 or 1 as valid, so it returns the EINVAL error.

With all of this in mind, the solution is pretty simple: just make echo not print a newline at the end of the string, which can be done (as seen in man echo) with -n. So, to correctly turn our driver off, use echo -n '0' > /dev/lkcamp.

After you get bored of turning the driver on and off and checking its status, you can unload it with modprobe -r hello_char to see its exit message.

3.2.1 Add your char device in-tree

To add your char device in-tree follow similar steps for the previous device: you can add it on the same folder (in this case, just add new definitions on Makefile and Kconfig inside lkcamp folder), otherwise you can create a new folder and change configuration on the parent folder.

Makefile:

obj-$(CONFIG_LKCAMP_CHAR) += lkcamp_char.o

Kconfig:

config LKCAMP_CHAR
    default m
    tristate "LKCamp char module"
    help
        Provides a char module, to understand the 'read' and
        'write' functions of modules.
        This module was created for educational purposes, inside
        a lkcamp activity for linux kernel development workshop.
        The default configuration is 'm': the module will be compiled
        and it will be available to be loaded in execution time.