Skip to content

Kernel Module Labs

Five progressive labs for writing Linux kernel modules, from hello world through proc filesystem entries and high-resolution timers. Every lab includes compilable code, expected output, common errors, and extension challenges.

Prerequisites: - Linux kernel headers installed: sudo apt install linux-headers-$(uname -r) - Build tools: sudo apt install build-essential - Test environment: QEMU VM strongly recommended — a kernel bug can hang your machine - Kernel version: These labs target 5.x and 6.x kernels


Lab 1: Hello World Kernel Module

Objective: Compile, load, and unload the simplest possible kernel module. Understand the module lifecycle.

Complete Code

// hello.c
#include <linux/module.h>    /* module_init, module_exit macros */
#include <linux/kernel.h>    /* pr_info, KERN_INFO */
#include <linux/init.h>      /* __init, __exit annotations */

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name <you@example.com>");
MODULE_DESCRIPTION("Lab 1: Hello World kernel module");
MODULE_VERSION("1.0");

static int __init hello_init(void)
{
    pr_info("hello: module loaded — PID %d, CPU %d\n",
            current->pid, smp_processor_id());
    return 0;  /* non-zero return means init failed; module won't load */
}

static void __exit hello_exit(void)
{
    pr_info("hello: module unloaded\n");
}

module_init(hello_init);
module_exit(hello_exit);
# Makefile — must use tabs, not spaces, for recipe lines
obj-m += hello.o

KDIR := /lib/modules/$(shell uname -r)/build
PWD  := $(shell pwd)

all:
    $(MAKE) -C $(KDIR) M=$(PWD) modules

clean:
    $(MAKE) -C $(KDIR) M=$(PWD) clean

Build and Test

make                          # compile
sudo insmod hello.ko          # insert module
dmesg | tail -5               # check kernel log
lsmod | grep hello            # verify loaded
sudo rmmod hello              # remove module
dmesg | tail -5               # verify exit message
sudo modinfo hello.ko         # inspect module metadata

Expected Output

[12345.678901] hello: module loaded — PID 1234, CPU 0
[12346.789012] hello: module unloaded

Key Concepts

Item Explanation
__init Marks function as initialization code; freed from memory after boot/module load
__exit Marks function as cleanup code; omitted in static kernel builds
pr_info() Equivalent to printk(KERN_INFO ...) — preferred modern style
MODULE_LICENSE("GPL") Required for access to GPL-only exported symbols
Return value from _init Non-zero signals failure; kernel calls _exit automatically and rejects load

Common Errors and Fixes

Error Cause Fix
insmod: ERROR: could not insert module hello.ko: Invalid module format Module built for different kernel version Rebuild: make clean && make
insmod: ERROR: could not insert module hello.ko: Operation not permitted Secure Boot signing required Disable Secure Boot in UEFI or sign the module
Makefile:4: *** missing separator. Stop. Spaces instead of tabs in Makefile Replace spaces with a single tab before $(MAKE)
Module loads but no dmesg output printk log level filtering sudo dmesg -n 7 to enable INFO level

Debugging Tips

  • Always check dmesg -T | tail -20 after insmod — timestamp helps correlate events
  • Use journalctl -k -f in another terminal to watch kernel messages in real time
  • modinfo hello.ko shows vermagic string — must match running kernel exactly

Extension Challenges

  1. Add a counter that tracks how many times the module has been loaded/unloaded (persistent across reloads using a static global — but note it resets on rmmod)
  2. Make the module print the system uptime in nanoseconds using ktime_get_boottime_ns()
  3. Print all CPU IDs on an SMP system using for_each_online_cpu(cpu) + smp_call_function_single()

Lab 2: Character Device Driver

Objective: Create a character device that userspace can open, read, write, and close. Understand file_operations, cdev, and safe kernel-userspace data transfer.

Complete Code

// chardev.c
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/fs.h>           /* file_operations, register_chrdev_region */
#include <linux/cdev.h>         /* cdev_init, cdev_add */
#include <linux/uaccess.h>      /* copy_to_user, copy_from_user */
#include <linux/device.h>       /* class_create, device_create */
#include <linux/slab.h>         /* kmalloc, kfree */

MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("Lab 2: Character device driver");

#define DEVICE_NAME "chardev"
#define CLASS_NAME  "chardev_class"
#define BUF_SIZE    4096

static int    major;
static char  *kernel_buf;
static size_t buf_data_len = 0;

static struct class  *chardev_class  = NULL;
static struct device *chardev_device = NULL;
static struct cdev    chardev_cdev;
static dev_t          devno;

/* Called when userspace opens /dev/chardev */
static int chardev_open(struct inode *inode, struct file *file)
{
    pr_info("chardev: device opened by PID %d\n", current->pid);
    return 0;
}

/* Called when userspace closes the fd */
static int chardev_release(struct inode *inode, struct file *file)
{
    pr_info("chardev: device closed\n");
    return 0;
}

/* Called on read() from userspace */
static ssize_t chardev_read(struct file *file, char __user *ubuf,
                            size_t count, loff_t *ppos)
{
    size_t to_copy;

    if (*ppos >= buf_data_len)
        return 0;  /* EOF */

    to_copy = min(count, buf_data_len - (size_t)*ppos);

    /* copy_to_user returns the number of bytes NOT copied */
    if (copy_to_user(ubuf, kernel_buf + *ppos, to_copy))
        return -EFAULT;

    *ppos += to_copy;
    pr_info("chardev: sent %zu bytes to userspace\n", to_copy);
    return to_copy;
}

/* Called on write() from userspace */
static ssize_t chardev_write(struct file *file, const char __user *ubuf,
                             size_t count, loff_t *ppos)
{
    size_t to_copy = min(count, (size_t)BUF_SIZE - 1);

    if (copy_from_user(kernel_buf, ubuf, to_copy))
        return -EFAULT;

    kernel_buf[to_copy] = '\0';
    buf_data_len = to_copy;
    pr_info("chardev: received %zu bytes: '%s'\n", to_copy, kernel_buf);
    return to_copy;
}

static const struct file_operations chardev_fops = {
    .owner   = THIS_MODULE,
    .open    = chardev_open,
    .release = chardev_release,
    .read    = chardev_read,
    .write   = chardev_write,
};

static int __init chardev_init(void)
{
    int ret;

    /* Allocate kernel buffer */
    kernel_buf = kmalloc(BUF_SIZE, GFP_KERNEL);
    if (!kernel_buf)
        return -ENOMEM;

    /* Dynamically allocate a major number */
    ret = alloc_chrdev_region(&devno, 0, 1, DEVICE_NAME);
    if (ret < 0) {
        pr_err("chardev: failed to allocate major number: %d\n", ret);
        kfree(kernel_buf);
        return ret;
    }
    major = MAJOR(devno);
    pr_info("chardev: registered with major number %d\n", major);

    /* Initialize and add the cdev */
    cdev_init(&chardev_cdev, &chardev_fops);
    chardev_cdev.owner = THIS_MODULE;
    ret = cdev_add(&chardev_cdev, devno, 1);
    if (ret < 0) {
        unregister_chrdev_region(devno, 1);
        kfree(kernel_buf);
        return ret;
    }

    /* Create /sys/class/chardev_class and trigger udev to create /dev/chardev */
    chardev_class = class_create(THIS_MODULE, CLASS_NAME);
    if (IS_ERR(chardev_class)) {
        cdev_del(&chardev_cdev);
        unregister_chrdev_region(devno, 1);
        kfree(kernel_buf);
        return PTR_ERR(chardev_class);
    }

    chardev_device = device_create(chardev_class, NULL, devno, NULL, DEVICE_NAME);
    if (IS_ERR(chardev_device)) {
        class_destroy(chardev_class);
        cdev_del(&chardev_cdev);
        unregister_chrdev_region(devno, 1);
        kfree(kernel_buf);
        return PTR_ERR(chardev_device);
    }

    pr_info("chardev: /dev/%s created\n", DEVICE_NAME);
    return 0;
}

static void __exit chardev_exit(void)
{
    device_destroy(chardev_class, devno);
    class_destroy(chardev_class);
    cdev_del(&chardev_cdev);
    unregister_chrdev_region(devno, 1);
    kfree(kernel_buf);
    pr_info("chardev: module unloaded, /dev/%s removed\n", DEVICE_NAME);
}

module_init(chardev_init);
module_exit(chardev_exit);

Build and Test

make
sudo insmod chardev.ko
ls -la /dev/chardev                        # udev created the node
echo "hello kernel" > /dev/chardev         # write to device
cat /dev/chardev                           # read back: should print "hello kernel"
dmesg | tail -10
sudo rmmod chardev

Expected Output

[100.0] chardev: registered with major number 240
[100.1] chardev: /dev/chardev created
[101.0] chardev: device opened by PID 1234
[101.1] chardev: received 12 bytes: 'hello kernel'
[101.2] chardev: device closed
[102.0] chardev: device opened by PID 1235
[102.1] chardev: sent 12 bytes to userspace

Debugging Tips

  • Always check the return value of copy_to_user / copy_from_user — they can fail if the user pointer is invalid
  • IS_ERR() + PTR_ERR() is the kernel pattern for returning errors embedded in pointers
  • If udev doesn't create /dev/chardev, create it manually: sudo mknod /dev/chardev c <major> 0

Common Errors and Fixes

Error Cause Fix
Kernel panic on write Null pointer dereference in copy_from_user Validate ubuf is not NULL; check count > 0
/dev/chardev not created udev not running or rules need update sudo udevadm trigger or use mknod
cdev_add returns -EBUSY Major number conflict Use alloc_chrdev_region instead of fixed major

Extension Challenges

  1. Add llseek support so the device behaves like a file (seek to position, read from there)
  2. Add ioctl with two commands: CLEAR_BUF and GET_BUF_LEN
  3. Add a mutex to make read/write thread-safe and test with two concurrent writers

Lab 3: Kernel Module with Parameters

Objective: Accept runtime parameters via insmod and expose them in /sys/module/.

Complete Code

// params.c
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/moduleparam.h>
#include <linux/stat.h>          /* S_IRUGO, S_IWUSR */

MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("Lab 3: Module parameters");

/* Parameters with defaults */
static int    repeat   = 1;
static char  *message  = "default message";
static bool   verbose  = false;
static int    values[8];
static int    nvalues;

/* module_param(name, type, permissions)
 * permissions: 0 = not visible in sysfs; S_IRUGO = world-readable */
module_param(repeat,  int,  S_IRUGO | S_IWUSR);
MODULE_PARM_DESC(repeat, "Number of times to print message (default: 1)");

module_param(message, charp, S_IRUGO);
MODULE_PARM_DESC(message, "Message to print on load");

module_param(verbose, bool, S_IRUGO | S_IWUSR);
MODULE_PARM_DESC(verbose, "Enable verbose output (default: false)");

module_param_array(values, int, &nvalues, S_IRUGO);
MODULE_PARM_DESC(values, "Array of integers (up to 8 values)");

static int __init params_init(void)
{
    int i;

    for (i = 0; i < repeat; i++)
        pr_info("params: [%d/%d] %s\n", i + 1, repeat, message);

    if (verbose) {
        pr_info("params: verbose mode enabled\n");
        pr_info("params: %d array values provided\n", nvalues);
        for (i = 0; i < nvalues; i++)
            pr_info("params:   values[%d] = %d\n", i, values[i]);
    }
    return 0;
}

static void __exit params_exit(void)
{
    pr_info("params: unloaded (repeat=%d, verbose=%s)\n",
            repeat, verbose ? "true" : "false");
}

module_init(params_init);
module_exit(params_exit);

Build and Test

make
sudo insmod params.ko repeat=3 message="hello from lab 3" verbose=1
dmesg | tail -10

# Inspect sysfs exposure
ls /sys/module/params/parameters/
cat /sys/module/params/parameters/repeat
cat /sys/module/params/parameters/verbose

# Change parameter at runtime (only writable params)
echo 5 | sudo tee /sys/module/params/parameters/repeat

# Load with array
sudo rmmod params
sudo insmod params.ko values=10,20,30,40 verbose=1
dmesg | tail -10
sudo rmmod params

Expected Output

[200.1] params: [1/3] hello from lab 3
[200.2] params: [2/3] hello from lab 3
[200.3] params: [3/3] hello from lab 3
[200.4] params: verbose mode enabled
[200.5] params: 0 array values provided

Debugging Tips

  • modinfo params.ko lists all MODULE_PARM_DESC entries — use this to document your module's API
  • Permissions 0 makes the parameter invisible in /sys/module/; use S_IRUGO to expose read-only
  • S_IWUSR allows root to change parameter at runtime; useful for debug flags in production drivers

Extension Challenges

  1. Add a notifier callback using module_param_cb that logs a message whenever a parameter changes
  2. Add validation in _init that returns -EINVAL if repeat is outside [1, 100]

Lab 4: Proc Filesystem Entry

Objective: Create a /proc entry that exposes kernel data to userspace. Use the seq_file interface for correct handling of large outputs.

Complete Code

// procfs.c
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/proc_fs.h>
#include <linux/seq_file.h>
#include <linux/uaccess.h>
#include <linux/jiffies.h>
#include <linux/sched.h>

MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("Lab 4: Proc filesystem entry");

#define PROC_NAME "lab4_info"
static struct proc_dir_entry *proc_entry;
static unsigned long load_time_jiffies;

/* seq_show is called for each "record" — in our case just once */
static int lab4_seq_show(struct seq_file *m, void *v)
{
    unsigned long uptime_secs;

    uptime_secs = (jiffies - load_time_jiffies) / HZ;

    seq_printf(m, "Lab 4 Proc Entry\n");
    seq_printf(m, "================\n");
    seq_printf(m, "Module loaded:     %lu seconds ago\n", uptime_secs);
    seq_printf(m, "Current PID:       %d\n", current->pid);
    seq_printf(m, "Current comm:      %s\n", current->comm);
    seq_printf(m, "Jiffies:           %lu\n", jiffies);
    seq_printf(m, "HZ:                %d\n", HZ);
    seq_printf(m, "nr_cpu_ids:        %d\n", nr_cpu_ids);

    return 0;
}

/* single_open calls seq_show exactly once — correct for small outputs */
static int lab4_open(struct inode *inode, struct file *file)
{
    return single_open(file, lab4_seq_show, NULL);
}

static const struct proc_ops lab4_proc_ops = {
    .proc_open    = lab4_open,
    .proc_read    = seq_read,     /* provided by seq_file */
    .proc_lseek   = seq_lseek,    /* provided by seq_file */
    .proc_release = single_release,
};

static int __init procfs_init(void)
{
    load_time_jiffies = jiffies;

    proc_entry = proc_create(PROC_NAME, 0444, NULL, &lab4_proc_ops);
    if (!proc_entry) {
        pr_err("procfs: failed to create /proc/%s\n", PROC_NAME);
        return -ENOMEM;
    }
    pr_info("procfs: created /proc/%s\n", PROC_NAME);
    return 0;
}

static void __exit procfs_exit(void)
{
    proc_remove(proc_entry);
    pr_info("procfs: removed /proc/%s\n", PROC_NAME);
}

module_init(procfs_init);
module_exit(procfs_exit);

Build and Test

make
sudo insmod procfs.ko
cat /proc/lab4_info
sleep 5 && cat /proc/lab4_info     # uptime increments
sudo rmmod procfs
cat /proc/lab4_info                # should return: no such file

Expected Output

Lab 4 Proc Entry
================
Module loaded:     0 seconds ago
Current PID:       1256
Current comm:      cat
Jiffies:           4298765432
HZ:                250
nr_cpu_ids:        4

Key Concepts

Item Purpose
seq_file Correctly handles partial reads and large outputs that span multiple read() calls
single_open Simplified seq_file for single-pass output (no iterator needed)
proc_ops Replaced file_operations for proc entries in kernel 5.6+
0444 permissions World-readable, no write — standard for informational entries

Extension Challenges

  1. Add write support to /proc/lab4_info that accepts a command string ("reset" to reset the timer)
  2. Walk the process list and print all running processes (use for_each_process + rcu_read_lock)
  3. Make a proc entry that outputs megabytes of data to test that seq_file handles multi-read correctly

Lab 5: Kernel Timer — Periodic Work

Objective: Use both timer_list (coarse, jiffies-based) and hrtimer (fine, nanosecond-based) for periodic work.

Complete Code

// timer_lab.c
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/timer.h>
#include <linux/hrtimer.h>
#include <linux/ktime.h>
#include <linux/jiffies.h>

MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("Lab 5: Kernel timers — timer_list and hrtimer");

/* ===== Part A: timer_list (jiffies-based, coarse) ===== */
static struct timer_list coarse_timer;
static atomic_t coarse_count = ATOMIC_INIT(0);

static void coarse_timer_callback(struct timer_list *t)
{
    int count = atomic_inc_return(&coarse_count);
    pr_info("timer_lab: coarse tick #%d (jiffies=%lu)\n", count, jiffies);

    if (count < 5)
        mod_timer(&coarse_timer, jiffies + HZ);  /* reschedule 1 second later */
}

/* ===== Part B: hrtimer (nanosecond precision) ===== */
static struct hrtimer hr_timer;
static atomic_t hr_count = ATOMIC_INIT(0);
#define HR_INTERVAL_NS  500000000UL   /* 500 ms in nanoseconds */

static enum hrtimer_restart hr_timer_callback(struct hrtimer *timer)
{
    int count = atomic_inc_return(&hr_count);
    ktime_t now = ktime_get();
    pr_info("timer_lab: hrtimer tick #%d (ktime=%lld ns)\n",
            count, ktime_to_ns(now));

    if (count < 6) {
        hrtimer_forward_now(timer, ns_to_ktime(HR_INTERVAL_NS));
        return HRTIMER_RESTART;
    }
    return HRTIMER_NORESTART;
}

static int __init timer_init(void)
{
    pr_info("timer_lab: starting timers\n");

    /* Setup coarse timer — fires in 1 second */
    timer_setup(&coarse_timer, coarse_timer_callback, 0);
    mod_timer(&coarse_timer, jiffies + HZ);

    /* Setup hrtimer — fires in 500 ms, repeats 6 times */
    hrtimer_init(&hr_timer, CLOCK_MONOTONIC, HRTIMER_MODE_REL);
    hr_timer.function = hr_timer_callback;
    hrtimer_start(&hr_timer, ns_to_ktime(HR_INTERVAL_NS), HRTIMER_MODE_REL);

    return 0;
}

static void __exit timer_exit(void)
{
    del_timer_sync(&coarse_timer);
    hrtimer_cancel(&hr_timer);
    pr_info("timer_lab: timers cancelled, coarse=%d hr=%d\n",
            atomic_read(&coarse_count), atomic_read(&hr_count));
}

module_init(timer_init);
module_exit(timer_exit);

Build and Test

make
sudo insmod timer_lab.ko
watch -n 0.2 'dmesg | tail -20'    # watch output in real time
# After ~6 seconds both timers complete
sudo rmmod timer_lab

Expected Output

[300.000] timer_lab: starting timers
[300.500] timer_lab: hrtimer tick #1 (ktime=1234567890000 ns)
[301.000] timer_lab: coarse tick #1 (jiffies=4298765682)
[301.000] timer_lab: hrtimer tick #2 (ktime=1234568390000 ns)
[301.500] timer_lab: hrtimer tick #3
[302.000] timer_lab: coarse tick #2
...

Timer Comparison

Property timer_list hrtimer
Resolution 1/HZ seconds (4ms at HZ=250) Nanosecond (hardware dependent)
Clock source jiffies CLOCK_MONOTONIC or CLOCK_REALTIME
Callback context Softirq (TIMER_SOFTIRQ) Softirq or hardirq
Use case Coarse timeouts, watchdogs Precise intervals, audio, scheduling
Reschedule mod_timer() hrtimer_forward_now()

Common Errors and Fixes

Error Cause Fix
Kernel panic on rmmod Timer fires after module unloaded Always call del_timer_sync / hrtimer_cancel in exit
Timer never fires mod_timer called with past jiffies value Ensure jiffies + HZ not jiffies - HZ
Timer fires only once Forgot to call mod_timer inside callback Call mod_timer with new expiry in the callback

Debugging Tips

  • del_timer_sync() blocks until a running callback completes — always prefer this over del_timer()
  • Timer callbacks run in softirq context: no sleeping, no mutex (spinlock only)
  • Use ktime_get_ns() inside callbacks to measure actual vs. expected firing time — jitter is typically 100–500µs

Extension Challenges

  1. Measure actual timer jitter: record ktime_get() at each callback and compute mean/stddev over 1000 ticks
  2. Implement a software watchdog using timer_list: if a flag is not cleared within 5 seconds, print a warning
  3. Use workqueue_struct to defer work from the hrtimer callback to process context (required if you need to sleep or allocate memory)

Lab Appendix: Kernel Coding Rules

Rule Reason
Never use printk() directly; use pr_info(), pr_err(), etc. Consistent log level prefix
Always check return values of kmalloc, alloc_chrdev_region, etc. Allocation can fail, especially under memory pressure
Free in reverse order of allocation Prevents use-after-free in error paths
Use goto for error unwinding in init functions Canonical kernel style for multi-step cleanup
Never sleep in interrupt context No mutex, no schedule(), no msleep() in softirq/hardirq
Use THIS_MODULE in file_operations.owner Prevents rmmod while file descriptors are open
Annotate pointers with __user Enables sparse to catch missed copy_to/from_user