Talk: Linux device drivers

(1) Implementing a Hello World module from scratch

Create a file named hello.c anywhere in your machine:

#include <linux/kernel.h>
#include <linux/module.h>

static int hello_init(void)
{
    printk(KERN_INFO "Hello World!\n");
    return 0;
}

static void hello_exit(void)
{
    printk(KERN_INFO "Goodbye!\n");
}

module_init(hello_init);
module_exit(hello_exit);

MODULE_LICENSE("GPL");

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

obj-m   +=  hello.o

Make sure that you have the linux-headers package installed. You should be able to install it with your package manager.

Compile your module (out of tree):

make -C /lib/modules/$(uname -r)/build M=$(pwd) hello.ko

Load your module to your kernel:

If you don't have sudo power

You can use virtme to be able to insert your module into the kernel. See appendix (A).

sudo insmod hello.ko

Check the logs:

sudo dmesg

Unload your module and check the logs again:

sudo rmmod hello
sudo dmesg

(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:

#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");

Compile and load this module, like you learned from the previous exercise.

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 need to know its major number, but since it was dynamically allocated through alloc_chrdev_region(&lkcamp_dev, 0, 1, "lkcamp");, how can we know it?

The answer is: 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).

Now, to talk to the driver, just create a character special file using mknod /dev/lkcamp c major 0. You should substitute major with the major number you found earlier.

The file you just created can now be used to read from and write to the driver. Query the driver's status with cat /dev/lkcamp.

Great, now change its status to OFF with echo '0' > /dev/lkcamp. You should see an error message.

To investigate that error let's take a little detour.

(2.1) Error investigation with strace

The file operations, including writing and reading, are done through system calls. So, to find out why that error ocurred we can use the very handy tool strace to monitor all system calls made by echo on our file. If you're not very comfortable with system calls (aka syscalls), don't worry about it for now, it will be the subject of a future meeting.

If you run strace echo '0' > /dev/lkcamp you should 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. When you're done investigating, open the following box for the explanation and answer.

Tip: we are interested only in what happens in the write() calls.

Tip 2: you should also take a look at the lkcamp_write function in our driver.

Tip 3: after you found out the problem, the solution can be found in the echo manual page.

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.

(2.2) Unloading the module

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

Remember that one awesome benefit of studying something that is open source, like the Linux kernel, is being able to look at any part of its code. If you're curious about any structure or function that we used in the driver, you can just find its implementation in the kernel source code, either through your cloned git tree or online through the excelent Elixir cross-referencer.

(A) Inserting your module into your virtualized kernel with virtme

(1) Using the installed kernel

You have to acquire busybox beforehand, since the installed kernel probably needs a initramfs to boot.

cd <path> # where path is the directory you built your module
# Download busybox binary
curl -LO https://busybox.net/downloads/binaries/1.28.1-defconfig-multiarch/busybox-x86_64
# Give execution permission to busybox
chmod a+x busybox-x86_64

# run virtme with the installed kernel, with the downloaded busybox,
# giving read permission to your current dir to the virtualized environment
# and changing to the current directory
virtme-run --installed-kernel --busybox ./busybox-x86_64 --pwd

Now you should be in the virtualized environment.

insmod hello.ko # insert your module
rmmod hello.ko # remove your module
dmesg

(2) Using your compiled kernel

If you noticed, the Makefile showed previously uses your installed kernel to compile your module. It is infered by the path /lib/modules/$(shell uname -r)/build, which links to the kernel source.

If you try to insert this module, compiled for one version of Linux Kernel, into a kernel with other version, the insertion will fail.

So you just need to change to which kernel source the Makefile is pointing.

Let's modify this Makefile with your path:

MY_KERNEL_ROOT=<path_to_your_kernel_tree_root>

all:
        make -C $(MY_KERNEL_ROOT) M=$(PWD) modules

clean:
        make -C $(MY_KERNEL_ROOT) M=$(PWD) clean

Now run make. And run virtme:

cd <path> # where path is the directory where your Makefile is located
make
# run virtme with the custom kernel source tree,
# giving read permission to your current dir to the virtualized environment
# and changing to the current directory
virtme-run --kdir <path_to_your_kernel_tree_root> --pwd

Now you should be in the virtualized environment.

insmod hello.ko # insert your module
rmmod hello.ko # remove your module
dmesg

Done?

Even the bonus exercises?

Try to understand the concepts and differences between a driver, a device, a bus, major and minor numbers in the context of the kernel.

Further material

A very good slide deck, in portuguese, which helped writing the base of the char driver code can be found at Embedded Labworks - Linux Device Drivers.

You can also watch a talk by the same people at "O modelo de desenvolvimento de drivers do kernel Linux - Sergio Prado".

An excelent, although long, deck slide in english is Bootlin - Embedded Linux kernel and driver development training.