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 -20after insmod — timestamp helps correlate events - Use
journalctl -k -fin another terminal to watch kernel messages in real time modinfo hello.koshows vermagic string — must match running kernel exactly
Extension Challenges
- 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)
- Make the module print the system uptime in nanoseconds using
ktime_get_boottime_ns() - 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
- Add
llseeksupport so the device behaves like a file (seek to position, read from there) - Add
ioctlwith two commands:CLEAR_BUFandGET_BUF_LEN - 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.kolists allMODULE_PARM_DESCentries — use this to document your module's API- Permissions
0makes the parameter invisible in/sys/module/; useS_IRUGOto expose read-only S_IWUSRallows root to change parameter at runtime; useful for debug flags in production drivers
Extension Challenges
- Add a
notifiercallback usingmodule_param_cbthat logs a message whenever a parameter changes - Add validation in
_initthat returns-EINVALifrepeatis 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
- Add write support to
/proc/lab4_infothat accepts a command string ("reset" to reset the timer) - Walk the process list and print all running processes (use
for_each_process+rcu_read_lock) - Make a proc entry that outputs megabytes of data to test that
seq_filehandles 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 overdel_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
- Measure actual timer jitter: record
ktime_get()at each callback and compute mean/stddev over 1000 ticks - Implement a software watchdog using
timer_list: if a flag is not cleared within 5 seconds, print a warning - Use
workqueue_structto 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 |