Modern Linux Drivers 1: From Scratch Using C
Introduction
In the world of embedded systems, whether you are building custom hardware on an FPGA or optimizing data transfer for a high-performance ARM or RISC-V processor, the bridge between hardware and software is the Linux Device Driver. Understanding how to write a Linux Device Driver is your gateway to the hardware.
A device driver is a specialized software module that implements standardized routines to control and access hardware modules. While the goal of a driver is to provide a uniform interface for users, the internal mechanics involve a sophisticated dance between two isolated execution environments: Kernel Space and User Space. Before we write code, we must understand the boundary we are crossing. Linux operates in two distinct modes:
- Kernel Space: Here, the kernel manages the machine’s hardware directly, simply, and efficiently, providing a uniform API for developers. Any subroutine that is part of a driver or a kernel module is considered part of the kernel space. Interaction with hardware is done through direct reads and writes to reserved memory addresses or ports.
- User Space: This is where user-focused applications (shells, browsers, custom software) live. To prevent accidental or malicious hardware damage, these applications are strictly prohibited from interacting with hardware directly. Instead, they must rely on the kernel to act as a mediator, communicating through system calls (syscalls) or by interacting with files in the filesystem.
Everything is a File
It is important to remember that the power of Linux development lies in the philosophy that “everything is a file”. From a user’s perspective, communicating with a sophisticated hardware module should be as simple as writing to or reading from a standard text file. In modern Linux systems, these hardware “files” are located in the /dev/ directory.
When a user application operates on a file — such as open(), read(), or write()—the kernel looks up the driver associated with that specific file. It then executes the corresponding kernel-level functions implemented by the driver developer. This bridge allows information to flow safely and predictably between the two spaces.
To link a file to a kernel module, two numbers are used: a Major and a Minor Number. Of these two, the Major Number is used by the kernel to link the file to the driver, and the Minor Number is used internally by the device.
The Modern Standard: miscdevice
Historically, developers manually managed Major Numbers to link files to drivers. In modern system development, we use the miscdeviceAPI. This API automates much of the mainstream code — such as dynamic device node creation — allowing engineers to focus on the driver’s core logic rather than administrative kernel tasks.
Load and Remove Driver From User Space
In this first driver, I’m showing you how to load and remove a driver without functionality. For this, we are using a nothing.c file with the following content:
#include <linux/kernel.h>
MODULE_LICENSE("Dual BSD/GPL");Before loading the driver, it must be compiled against the headers of the current Linux kernel. We use a standard Makefile that points to the build directory of the specific kernel version.
obj-m := nothing.oRun the make command in the terminal. This will generate a .ko (kernel object) file, which is the binary format for the driver.
make -C /usr/src/linux-headers-$(uname -r) M=$(pwd modules)In case you have any errors because it is not able to find a kernel, run this command:
sudo apt-get install linux-headers-$(uname -r)Among other files, the compilation should have created a file called nothing.ko, which corresponds to the driver module.
In the user space, the module can be installed/loaded within the kernel with the command:
sudo insmod nothing.koTo verify that the module has been loaded correctly, you can use the command:
lsmodFinally, the module can be removed from the kernel using the command:
sudo rmmod nothingLoad and Remove Driver From Kernel Space
When you load a driver into the kernel, it must perform preliminary tasks such as reserving memory, setting up concurrency locks, or registering the device within the system. These tasks are implemented in space-specific functions: module_init() and module_exit(), which correspond to the user-space commands insmod and rmmod respectively.
The initialization function is the driver’s entry point. In modern development, this is where we register our device using the miscdevice structure, which automatically handles the creation of the /dev/ file entry. The exit function is responsible for undoing everything the initialization function did. This ensures that the system remains stable and that no “zombie” resources are left in memory.
The following code hello.c demonstrates the initialization and exit routines:
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/miscdevice.h>
MODULE_LICENSE("Dual BSD/GPL");
// The initialization function
static int __init hello_init(void)
{
// pr_info works similarly to printf but at the kernel level
pr_info("Driver: Hello world! Initializing module...\n");
// In a full driver, this is where you would call
// misc_register() to link the driver to /dev/
return 0; // Success
}
// The exit function
static void __exit hello_exit(void)
{
pr_info("Driver: Bye, cruel world. Cleaning up...\n");
// In a full driver, this is where you would call
// misc_deregister()
}
// Macros to register the entry and exit points with the kernel
module_init(hello_init);
module_exit(hello_exit);To compile this module, the Makefile must be modified to look like this:
obj-m := hello.oThe compilation command is the same as the previous section:
make -C /usr/src/linux-headers-$(uname -r) M=$(pwd modules)When inserting (using insmod) the newly compiled hello.ko driver module, it must call the hello_init function, which will print a message to the system console, not the shell terminal. To view the system console, you can use the command:
dmesg | tailInstall/load the kernel with the command:
sudo insmod hello.koAnd remove it from the kernel using the command:
sudo rmmod helloVerify that the corresponding messages are shown.
Full Driver: A Virtual Character Device
Finally, we transition from the basic “Nothing” or “Hello World” modules to a functional 64-bit Virtual Memory Driver. This driver allows a user-space application to store and retrieve an 8-byte value directly within the kernel’s memory. We also include a Mutex, which is mandatory in modern, multi-core environments to prevent “race conditions” where two programs try to access the data simultaneously.
Linking the Device with its File
As I mentioned previously, historically, developers had to manually manage Major Numbers to link a driver to a file. Since we now use the miscdevice API, this modern standard automatically assigns a major number and creates the device file as soon as the module is loaded, eliminating the need for manual commands like mknod.
Defining the File Operations
To bridge the gap between spaces, we define a file_operations structure. This structure tells the kernel which of our internal functions should execute when a user-space program tries to read or write to our device.
static const struct file_operations memory_fops =
{
.owner = THIS_MODULE,
// Link to our read function
.read = memory_read,
// Link to our write function
.write = memory_write,
};This code will make more sense once we put it all together.
Opening and Closing the Device
Opening and closing a device in user space (using functions like fopen or fclose) triggers the open and release members of our driver. For this simple memory driver, these functions merely return success, but in hardware drivers, they are used to power on or reset physical modules.
Reading from the Device
When a user-space application reads from the device, the kernel calls memory_read. We use the standard copy_to_user function to safely transfer our 64-bit value from the kernel's protected memory into the user's application buffer.
/**
* memory_read - Transfers data from Kernel to User Space
* Equivalent to 'fread' or 'cat' in User Space.
*/
static ssize_t memory_read(struct file *filp, char __user *buf, size_t count, loff_t *f_pos)
{
// End of File
if (*f_pos > 0) return 0;
// Protect against concurrent access
mutex_lock(&memory_lock);
// Standard safe transfer to User Space buffer
if (copy_to_user(buf, &memory_value, sizeof(u64)))
{
mutex_unlock(&memory_lock);
return -EFAULT;
}
mutex_unlock(&memory_lock);
*f_pos += sizeof(u64);
// Return 8 bytes read
return sizeof(u64);
}Writing to the Device
To store data, the user-space application writes to the device file. The kernel calls our memory_write function, where we use copy_from_user to receive the data and save it into our 64-bit kernel variable.
/**
* memory_write - Transfers data from User to Kernel Space
* Equivalent to 'fwrite' or 'echo' in User Space.
*/
static ssize_t memory_write(struct file *filp, const char __user *buf, size_t count, loff_t *f_pos)
{
mutex_lock(&memory_lock);
// Standard safe transfer from User Space buffer
if (copy_from_user(&memory_value, buf, sizeof(u64)))
{
mutex_unlock(&memory_lock);
return -EFAULT;
}
mutex_unlock(&memory_lock);
// Return 8 bytes written
return sizeof(u64);
}Concurrency and Safety
Modern systems are highly parallel. Unlike the single-byte drivers of the past, a modern driver must handle multiple programs accessing it at once. We use a Mutex (memory_lock) to ensure that if two processes try to write to our 64-bit memory simultaneously, the data remains consistent and the kernel does not crash.
Testing the Driver
Now we can put all our code together in a memory.c file.
#include <linux/module.h>
#include <linux/init.h>
#include <linux/kernel.h> // pr_info(), pr_err()
#include <linux/fs.h> // file_operations structure
#include <linux/miscdevice.h> // Modern registration API
#include <linux/uaccess.h> // copy_to_user/copy_from_user
#include <linux/mutex.h> // Standard for handling concurrency
MODULE_LICENSE("Dual BSD/GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("Modern 64-bit Memory Driver");
// Storage for 64-bit value
static u64 memory_value = 0;
// Lock to prevent race conditions
static DEFINE_MUTEX(memory_lock);
/**
* memory_read - Transfers data from Kernel to User Space
* Equivalent to 'fread' or 'cat' in User Space.
*/
static ssize_t memory_read(struct file *filp, char __user *buf, size_t count, loff_t *f_pos)
{
// If the user has already read the value, return 0 (End of File)
if (*f_pos > 0) return 0;
mutex_lock(&memory_lock);
// Standard safe transfer to User Space buffer
if (copy_to_user(buf, &memory_value, sizeof(u64)))
{
mutex_unlock(&memory_lock);
return -EFAULT;
}
mutex_unlock(&memory_lock);
*f_pos += sizeof(u64);
// Return 8 bytes read
return sizeof(u64);
}
/**
* memory_write - Transfers data from User to Kernel Space
* Equivalent to 'fwrite' or 'echo' in User Space.
*/
static ssize_t memory_write(struct file *filp, const char __user *buf, size_t count, loff_t *f_pos) {
mutex_lock(&memory_lock);
// Standard safe transfer from User Space buffer
if (copy_from_user(&memory_value, buf, sizeof(u64)))
{
mutex_unlock(&memory_lock);
return -EFAULT;
}
mutex_unlock(&memory_lock);
// Return 8 bytes written
return sizeof(u64);
}
// Link standard file operations to our functions
static const struct file_operations memory_fops =
{
.owner = THIS_MODULE,
// Link to our read function
.read = memory_read,
// Link to our write function
.write = memory_write,
};
// Modern registration structure
static struct miscdevice memory_dev =
{
// Kernel assigns a free minor number
.minor = MISC_DYNAMIC_MINOR,
// Creates /dev/memory automatically
.name = "memory",
.fops = &memory_fops,
};
static int __init memory_init(void)
{
// Register driver
int ret = misc_register(&memory_dev);
if (ret)
{
pr_err("memory: Failed to register device\n");
return ret;
}
pr_info("memory: Modern 64-bit driver loaded\n");
return 0;
}
static void __exit memory_exit(void)
{
// Unregister and clean up
misc_deregister(&memory_dev);
pr_info("memory: Driver unloaded\n");
}
module_init(memory_init);
module_exit(memory_exit);To compile this module, the Makefile must be modified again to look like this:
obj-m := memory.oThe compilation command is the same as the previous sections:
make -C /usr/src/linux-headers-$(uname -r) M=$(pwd modules)Before starting the testing, the file permissions should be modified:
sudo chmod 666 /dev/memoryNext, you will need to write four bytes to the device file /dev/memory. A simple way to do this is with the echo or printf the command:
printf "64-BITS!" > /dev/memoryAfter writing, the device could be read using the catcommand:
cat /dev/memoryTo finish the tutorial, unload/remove it from the kernel using the command:
sudo rmmod memoryNext steps
You can go to the second post about Linux Drivers: