Linux Drivers: A Simple Character Device

Published: September 14, 2025 | Tags: #c #linux #kernel #systems

A cat wearing driving a small car.

Diving into kernel development can seem intimidating, but creating a simple driver is a fantastic way to understand the architecture of a modern operating system. In this guide, we’ll start with some essential theory before writing our very own “character device” driver from scratch.

Theory First: What Are We Building?

Before we touch any code, let’s understand a few key concepts.

Kernel Space vs. User Space

A core principle of Linux is the separation between Kernel Space and User Space.

  • User Space is where your applications run (your browser, terminal, text editor). These programs have limited access to hardware and system resources.
  • Kernel Space is the protected area where the kernel, the core of the OS, runs. It has direct access to hardware and manages the system. The boss.

A driver is a piece of code that runs in kernel space and acts as a bridge

This allows the user space applications to securely communicate with a piece of hardware or a kernel feature.

A diagram illustrating the separation between User Space, where applications run, and the protected Kernel Space.

What is a Device File?

In Linux, almost everything is treated as a file. This includes hardware devices.

When you interact with a webcam, printer, or your hard drive, you’re often doing so through a “weird” file located in the /dev directory.

So, you can use standard commands and functions like cat, echo, open(), read(), and write() to interact with hardware (quite a powerful abstraction).

We are going to create a character device. This is a type of device file that handles data as a continuous stream of bytes, like a keyboard.

The Core Components

Our driver will have three main parts:

  1. An init function: This is the entry point. It’s responsible for “registering” the driver with the system so the kernel knows it exists.
  2. An exit function: This is the cleanup function that runs when you unload the module.
  3. A file_operations struct: A structure that links system calls made on our device file to the real functions inside our driver that will handle those calls. It is “the driver’s API”.

Building the Driver

Now that we have the theory, let’s write the code.

Prerequisites

  • A Linux machine (a VM is fine too).
  • Kernel headers installed (sudo apt install linux-headers-$(uname -r) on Debian/Ubuntu).
  • Basic knowledge of C and the command line. I already assume this, but just in case.

The Driver Code (mydevice.c)

This C code implements the concepts we just discussed.

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

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Zuhaitz");
MODULE_DESCRIPTION("A simple character device driver.");

#define DEVICE_NAME "mydevice"  // No creativity, I know.
#define MSG_BUFFER_LEN 150

static int major_num;
static char msg_buffer[MSG_BUFFER_LEN] = "Hello from the kernel! :3\n";
static char *msg_ptr;

static int device_open(struct inode *, struct file *);
static int device_release(struct inode *, struct file *);
static ssize_t device_read(struct file *, char *, size_t, loff_t *);
static ssize_t device_write(struct file *, const char *, size_t, loff_t *);

static struct file_operations fops = {
    .read = device_read,
    .write = device_write,
    .open = device_open,
    .release = device_release,
};

static int __init driver_init(void)
{
    printk(KERN_INFO "mydevice: Initializing the module.\n");
    
    // We pass 0 to let the kernel assign a major number.
    major_num = register_chrdev(0, DEVICE_NAME, &fops);
    if (major_num < 0)
    {
        printk(KERN_ALERT "mydevice: failed to register a major number\n");
        return major_num;
    }
    
    printk(KERN_INFO "mydevice: registered correctly with major number %d\n", major_num);
    return 0;
}

static void __exit driver_exit(void)
{
    unregister_chrdev(major_num, DEVICE_NAME);
    printk(KERN_INFO "mydevice: Goodbye from the module!\n");
}

// For when a process opens our device file.
static int device_open(struct inode *inode, struct file *file)
{
    msg_ptr = msg_buffer; // Reset the message pointer for reading
    return 0;
}

// For closing our device file.
static int device_release(struct inode *inode, struct file *file)
{
    return 0;
}

// When the process reads from our device file.
static ssize_t device_read(struct file *filp, char *buffer, size_t length, loff_t *offset)
{
    int bytes_read = 0;

    if (0 == *msg_ptr) // Did we send the whole thing?
    {
        return 0; // Signal of EOF
    }

    // Copy our kernel data to the user's buffer
    while (length && *msg_ptr)
    {
        put_user(*(msg_ptr++), buffer++);
        length--;
        bytes_read++;
    }

    return bytes_read;
}

// For when a process writes to our device file.
static ssize_t device_write(struct file *filp, const char *buff, size_t len, loff_t *off)
{
    printk(KERN_INFO "mydevice: Received %zu characters from the user\n", len);
    // We don't store the data, just log.
    return len;
}

module_init(driver_init);
module_exit(driver_exit);

The Makefile

To compile this into a kernel module (.ko file), we need this specific Makefile.

obj-m += mydevice.o

all:
	make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules

clean:
	make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean

Compiling and Using the Driver

  1. Save the files.
  2. Compile.
make
  1. Load the driver.
sudo insmod mydevice.ko
  1. Check that it’s loaded.
dmesg | tail
  1. Create the device file. Use the major number you just saw. If it was 243, for example, run:
# mknod <name> <type> <major> <minor>
sudo mknod /dev/mydevice c 243 0
  1. Time for testing!
echo "Hello, driver" | sudo tee /dev/mydevice

cat /dev/mydevice
  1. Check the kernel logs again.
dmesg | tail
  1. Unload the driver.
sudo rmmod mydevice
sudo rm /dev/mydevice

We’ve successfully built a Linux kernel module based on the core principles of communication between user space and the Linux kernel. Yipee!