A step-by-step guide for writing a Linux device driver for an ultrasonic sensor on a Raspberry Pi - Part 1

A step-by-step guide for writing a Linux device driver for an ultrasonic sensor HC-SR04 on a Raspberry Pi - Part 1

In this tutorial we will delve into the fascinating world of Linux and embedded systems. I will show how you can write your own Linux device driver for an ultrasonic sensor HC-SR04 and a Raspberry Pi 3 Model B+. However, the same driver could be run as well on other Linux platforms instead of a Raspberry Pi. The concepts can be transferred to other sensors and devices as well.

The tutorial is divided into two parts. This post contains the first part.

Part 1: In the first part I will briefly discuss the electric circuit and the hardware interface the sensor is providing. I will explain the role of the Linux kernel module and how it interfaces with the hardware and user applications. Then we'll dive into the code and:

  1. Cross-compile a hello world kernel module for Raspberry Pi using Raspbian 32-bit
  2. Register a char device under /dev/hc-sr04 that a user-space app can open, read bytes from and close
  3. Create a script to load the kernel module and create a char device with a major and minor number using mknod

Part 2: In the second part we'll continue with the topics:

  1. Setting up output GPIO lines in order to trigger a measurement
  2. Registering an interrupt on a GPIO for rising and falling edges
  3. Measuring time intervals and calculating the range in mm
  4. Synchronize the interrupts with the read-syscall by waiting on an event
  5. Handling concurrency in the driver

The code to this tutorial can be found on GitHub.

If you want to read more about Linux device drivers I can highly recommend the free book Linux Device Drivers, Third Edition. Another great book for getting an overview about the Linux kernel is Understanding the Linux Kernel, Third Edition.

Hardware, circuit and Linux kernel modules

First we'll have a look on the hardware part and the electric circuit. The ultrasonic sensor HC-SR04 needs a 5V supply which is connected to Raspberry Pi. The same applies for the GND pin.

The sensor has one digital trigger pin connected to the GPI 26 of the RPI (Raspberry Pi). This pin can be used to initiate a measurement. Setting the GPIO line to high for at least 10μs will start a measurement in the sensor.

The echo output pin of the sensor is connected to the GPIO 6 of the RPI via the voltage divider in order to have 3.3V at the Raspberry Pi input. The echo pin is set to high by the sensor for a time interval that is proportional to the measured distance, i.e. GPIO 6 must be configured as an input pin on the RPI.

Electric circuit with a Raspberry Pi 3 and a HC-SR04 ultrasonic range sensor

The timing diagram for one measurement is drawn below. First a measurement is started by setting GPIO 26 to high level for at least 10μs. Afterwards the HC-SR04 will set the echo signal to high for a specific time proportional to the measured distance. In the Linux device driver we will implement the measurement of the high level time and calculate the measured range.

Timing diagram of an HC-SR04 ultrasonic sensor showing the trigger and echo signals

The range can be calculated with the measured time and the speed of sound which is assumed to be 340 m/s:

Range = High Level Time * 340 m/s / 2

The role of the Linux kernel module

In order to communicate with the hardware device (the sensor) we will implement a driver for it. In Linux this is typically implemented as a loadable kernel module. A Linux kernel module is a piece of kernel code that can be loaded into the kernel during runtime using e.g. the "modprobe" or "insmod" commands. That means the kernel module code does not have to be compiled into the Linux kernel already, but can be loaded and removed dynamically later. This provides great flexibility of extending the kernel's and therefore a system's functionality.

Why a Linux kernel module is needed is due to the fact that a Linux kernel module runs in kernel space in contrast to usual applications running in user-space. While a user-space application has many restrictions for security and stability purposes and for example can only address its own virtual address space, code running in kernel space has special privileges. A Linux kernel module can access hardware and can e.g. register interrupts. For measuring exact times interrupts are essential. Measuring time intervals in user space is always under the influence of the scheduler and it may take some time until the user space process is scheduled again which will influence the measurement.

However, as Uncle Ben told the young Peter Parker: "With great power comes great responsibility", and while having a bug in a user-space application only leads to a single crashed process, bugs in kernel space can crash the whole system. For that reason we only want to implement a minimal functionality in the Linux kernel module. In this example we only want to be able to trigger a measurement and read the measured value from the sensor. Individual user applications, e.g. a localization and mapping algorithm, shall be kept out of the kernel and are better implemented in user space, e.g. in C++, Python, etc.

That leads to the question how a Linux kernel module running in kernel-space can interface with a user-space application written in any programming language. Following the UNIX idea that "everything is a file", a Linux kernel module can register a char device and expose its functionality via the filesystem interface. In this example we will register a character device under "/dev/hc-sr04". User-space processes can use Linux syscalls (open, close, read, write, seek, etc.) to communicate with the kernel.

The following picture shows the complete overview. We will implement the part of the Linux device driver. This part exposes the character device "/dev/hc-sr04". The user application can interface with the kernel module via syscalls like open, read, write.

Overview of how Linux kernel modules work together with user space applications via syscalls

The Linux driver itself uses different subsystems of the Linux kernel. These subsystems are abstractions over the actual hardware. For different boards and architectures of course the registers and physical memory addresses differ. The subsystem like the gpiolib or interrupt framework are abstractions of the hardware, so the Linux device drivers can use a stable API and do not care about the actual hardware. In this way the Linux device driver code stays platform independent and can be applied to many different projects. The Linux kernel subystems itself are usually configured via a device tree. The device tree is a description of the hardware layout and aims to keep the kernel code as platform independent as possible and configurable.

1. Cross-compile a hello kernel module for Raspberry Pi using Raspbian 32-bit

Now let's dive into the actual code. In the first step we will setup a blank Linux kernel module and cross-compile it for a Raspberry Pi. In a typical embedded development environment, software is cross-compiled on a development host machine, e.g. on a Ubuntu PC. Afterwards the cross-compiled binaries and eventually configuration files are transferred to the embedded device. We will follow this methodology as well.

1.1 Get Linux kernel headers by cross-compiling the Linux kernel

In order to cross-compile a Linux kernel module first the right Linux headers must be available which is achieved by cross-compiling the Linux kernel. These are the header files you need to be able to compile your Linux kernel module. These header files must exactly match the target device you are compiling for and therefore you cannot simply use the headers from the development machine (e.g. from Ubuntu). For a Raspberry Pi 3 Model B+ these are the steps:

First figure out which exact version of the Linux kernel you need to clone. If you compiled the Linux kernel by yourself, you should have it available already. If you e.g. use Raspbian, you need to login to the RPI via ssh and figure out the exact version:

apt-cache policy raspberrypi-kernel-headers

For me the output is the following:

raspberrypi-kernel-headers:
  Installed: (none)
  Candidate: 1:1.20220830-1
  Version table:
     1:1.20220830-1 500

The needed version of the kernel is therefore 1.20220830. Now clone the Linux kernel. For the RPI the sources are found in the repo https://github.com/raspberrypi/linux. Afterwards checkout the correct version you figured out in the first step. You can also use a "shallow" git clone in order to speed up the download from the server.

git clone https://github.com/raspberrypi/linux.git
cd linux
git checkout 1.20220830

Now you can cross-compile the Linux kernel in order to obtain the right header files. Dependent on your target architecture (32-bit or 64-bit) the steps might be different. You can follow the instructions on the Raspberry Pi page.

Here is the example for 32-bit on a Raspberry Pi 3 Model B+:

sudo apt install crossbuild-essential-armhf
sudo apt install git bc bison flex libssl-dev make libc6-dev libncurses5-dev

KERNEL=kernel7
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- bcm2709_defconfig
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- zImage modules dtbs

1.2 Create a Hello World loadable kernel module and cross-compile it

To verify our cross-compilation setup we will start with a hello-world kernel module. Unlike a usual C-program this kernel module does not have a main-function but instead it has a module_init and module_exit function. These two functions will be called by the Linux kernel when your module is loaded into the kernel using insmod or when it's unloaded by using rmmod. Inside the init- and exit-function we will just print a message for now.

Create a new file called "hc_sr04.c" and paste the code:

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

static int hc_sr04_init(void)
{
    pr_info("[HC-SR04]: Initializing HC-SR04\n");
    return 0;
}

static void hc_sr04_exit(void)
{
    pr_info("[HC-SR04]: Exit HC-SR04\n");
}

MODULE_AUTHOR("Christian");
MODULE_DESCRIPTION("Linux device driver for HC-SR04 ultrasonic distance sensor");
MODULE_LICENSE("GPL");

module_init(hc_sr04_init);
module_exit(hc_sr04_exit);

Next to the file "hc_sr04.c", also create a file Makefile. You can take the content from the existing file in the repository here. The Makefile defines a target called modules. As you can see in line 8, this will change first to the KERNELDIR which will be the location where the relevant Linux kernel headers are found. In this directory there is again a Makefile that will call your Makefile again. This is achieved by passing the M=$(PWD) variable. So in general you will call the kernel's Makefile which will later include your Makefile again at the proper point.

So if you want to cross-compile your kernel module you need to specify the directory in which you cross-compiled the Linux kernel in the previous step and pass it using the KERNELDIR parameter. In this example I put it under ~/projects/rpi/linux/. Futhermore you need to specify the target architecture "arm" and the prefix of the cross-compilation tools.

make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- KERNELDIR=~/projects/rpi/linux/ modules

The output of the cross-compilation shall be hc_sr04.ko. You can transfer this to the Raspberry Pi and load the module with sudo insmod hc_sr04.ko. With sudo dmesg --follow you can view the last messages stored in the kernel's buffer. After loading the module our message "[HC-SR04]: Initializing HC-SR04" should appear. With sudo rmmod hc_sr04 you can unload the module again and the module's exit function is executed.

2. Registering a char device under /dev/hc-sr04

The next major topic is how to expose a char device from the kernel module under the Linux filesystem so that a user process can open and read from it. A char device is a special node in the filesystem and can be recognized by the the "c" when you list all devices under "/dev". When you use ls -l /dev you will see many files. In the first column before the permission flags you can see the "c" at e.g. the tty-devices. These are of course not actual files on the disk but entries created by the kernel. Opening, reading or writing to these files will call functions registered in the kernel for each operation. We will see this in a few steps in our own Linux kernel module.

crw--w---- 1 root tty 4,   0 Feb 18 13:17 tty0

Another important aspect of char devices is that they have a major and a minor number. For tty0 above the major number is 4 and the minor number is 0. Usually the major number is indicating which driver is managing the device. So if one device controls multiple devices the same major number might appear multiple times. The minor number is usually indicating the device. You could use the minor number to index multiple devices if your driver can manage multiple devices.

The major and minor numbers are set by the Linux device driver which leads to the question which major number needs to be used. If you choose a statically defined number there is a high chance to collide with numbers chosen by another programmer. Therefore the Linux kernel provides a function to dynamically allocate a major number for you called int alloc_chrdev_region(dev_t *dev, unsigned int firstmintor, unsigned int count, char *name). The function has an output parameter dev_t *dev that will contain the allocated major device. From the dev_t struct you can extract the major number using the macro MAJOR(dev_t dev). Once you have allocated the major device number you can allocate a character device structure of type cdev. All these steps happen in the module's init function.

Let's have a look on the code so far:

struct cdev *hc_sr04_cdev;
int drv_major = 0;

static int hc_sr04_init(void)
{
    int result;
    dev_t dev = MKDEV(drv_major, 0);

    pr_info("[HC-SR04]: Initializing HC-SR04\n");

    result = alloc_chrdev_region(&dev, 0, 1, "hc-sr04");
    drv_major = MAJOR(dev);

    if (result < 0) {
        pr_alert("[HC-SR04]: Error in alloc_chrdev_region\n");
        return result;
    }

    hc_sr04_cdev = cdev_alloc();
    
    // .. to be continued
}

static void hc_sr04_exit(void)
{
    dev_t dev = MKDEV(drv_major, 0);
    cdev_del(hc_sr04_cdev);
    unregister_chrdev_region(dev, 1);
}

The next major step is to add actual file operations on the character device like open, read, write, close, seek, etc. For associating functions to the char device you create a struct file_operations and assign it's location to the ops field of the character device.

In our case we register an open, release and a read function only:

struct file_operations hc_sr04_fops = {
	.owner =     THIS_MODULE,
	.read =	     hc_sr04_read,
	.open =	     hc_sr04_open,
	.release =   hc_sr04_release
};

...
hc_sr04_cdev = cdev_alloc();
hc_sr04_cdev->ops = &hc_sr04_fops;
...

The most interesting part is the read-function pointer. The read-function has the following function signature:

ssize_t hc_sr04_read(struct file *filp, char __user *buf, size_t count, loff_t *f_pos)

The return value of the function has to be either the number of bytes read or a negative value for indicating failure of the read operation. This is probably familiar to you from the read-function in C or C++. The passed file pointer represents the open file. We are not going to use it in this example. You could use it in the open function to e.g. associate some data to the opened file once and then use this data passed to the read function later. The char pointer *buf is the pointer to the user-space memory where you can copy the read-result data to. This is usually done with the copy_to_user function. The copy_to_user function can copy data from the kernel-space memory to the calling process's memory. count is the number of bytes that shall be read. loff_t *f_pos is the current read position in the file. This value needs to be updated in the registered read-function. E.g. when four bytes are read, f_pos is updated by four bytes so that the next read call starts from the updated position. The Linux kernel then keeps track of the current read position.

So far we registered the open, release and read operations on the char device. In the read-function we will just return the same value over and over for now. Later we will return the real measured range by the sensor.

ssize_t hc_sr04_read(struct file *filp, char __user *buf, size_t count, loff_t *f_pos)
{
    unsigned int range_mm;

    if (*f_pos > 0) {
        return 0; // EOF
    }
    if (copy_to_user(buf, &range_mm, sizeof(range_mm))) {
        return -EFAULT;
    }
    *f_pos += sizeof(range_mm);
    return sizeof(range_mm);
}

After registering the file operations on the char device struct you can register the actual char device in the kernel by using cdev_add.

...
    hc_sr04_cdev = cdev_alloc();
    hc_sr04_cdev->ops = &hc_sr04_fops;

    result = cdev_add(hc_sr04_cdev, dev, 1);
...
}

Also make sure that in the module's exit function you cleanup the registered char device by using cdev_del and also unregister the dynamically allocated major number by using unregister_chrdev_region. You can check the whole init and exit routine in the GitHub repository here.

3. Script for loading the kernel module and create a char device with the right major number

In the last step inside the C code we added a char device called "hc-sr04". So when cross-compiling the kernel module again and deploying it to the Raspberry Pi we would expect now that a char device will appear under /dev/hc-sr04. When you try that out you will notice that there is actual nothing. This is due to the fact that the functions we called only register a char device in the kernel but there is no filesystem node. The filesystem node has to be created with the mknod command.

The mknod command for char devices takes the pathname (here: "/dev/hc-sr04") and the major and minor number of the character device file. And there comes the next challenge. The major driver number is dynamically allocated inside the kernel module (alloc_chrdev_region) and only stored there in a C variable. How should one know from outside which is the dynamically major driver number?

Luckily there is the /proc-filesystem in Linux that provides the needed information. Under /proc/devices you can read all the devices currently configured in the kernel, e.g.:

pi@raspberrypi:/ $ cat /proc/devices
Character devices:

 1: mem
...
 238: hc-sr04

The first column of the output contains the major number of the device. If you use the awk program to extract the first column you can put everything together in a single bash-script where you first load the kernel module using insmod, then you read the major driver number from /proc/devices using awk and then call the mknod command to create the actual filesystem. Again you can find the full script in the GitHub repo. Here is the most important part of the script:

#!/bin/bash
...
# Load the kernel module
insmod hc_sr04.ko

# Parse major driver number from /proc/devices output
drv_major=$(awk "/hc-sr04/ {print \$1}" /proc/devices)

mknod /dev/hc-sr04 c $drv_major 0

Now you can put the script next to the kernel module hc_sr04.ko on the target device and call it using:

sudo ./load_module.sh

Afterwards under /dev you will finally see your character device:

pi@raspberrypi:/ $ ls -l /dev/ | grep hc-sr04
crw-r--r-- 1 root root   238,   0 Feb 18 20:38 hc-sr04

What we have learned so far and what we will learn in the second part

So far we've already learned a lot. You may already have heard that "everything is a file in Linux" and this guide should give you really a deeper understanding of that concept.

We started to cross-compile a Hello World Linux kernel module that you can load during runtime and extend the functionality of the Linux kernel. We could load the module using insmod and unload it again via rmmod. In the kernel ring buffer we could see our print statement output.

After that we investigated character devices which act as the interface between kernel modules and user-space applications. The user-space application will issue syscalls like open and read on the file /dev/hc-sr04. The Linux device driver registered functions with the open, release and read syscalls. In the read syscall we can copy data from kernel space to the user space memory. Later we will use that in order to return the measured range from the sensor to the calling user application. Another important topic was dynamically allocating a major driver number. For that we also created a wrapping shell script in order to load the kernel module and create a filesystem node using mknod in the same step.

In part 2 of the tutorial we will delve more into the hardware part of the Linux device driver. We will setup the GPIOs, register an interrupt and measure time intervals. Also we will have a look into synchronization between kernel code and interrupts. The last interesting topic will be handling concurrency in the device driver, e.g. when two user processes want to open the file /dev/hc-sr04 at the same time. Make sure to check it out here!

Again here is the link to the repository containing the code for this tutorial. And again the recommendation to the free book Linux Device Drivers, Third Edition.