Learn how to write, build and load a custom Linux kernel module that exposes hardware metrics through a character device, proc and sysfs interfaces, then sign it for Secure Boot and package it with DKMS for distribution across kernel updates.
Prerequisites
- Root or sudo access on a test/VM system, not production hardware
- Kernel headers matching the running kernel version
- Basic C programming experience
- Familiarity with Linux command line and systemd
What this solves
Monitoring custom hardware often requires code running in kernel space: polling a register, handling an interrupt line, or exposing low-level counters that userspace cannot reach through standard sysfs drivers. This tutorial builds a complete character device kernel module from scratch, covers safe concurrency with mutexes and spinlocks, and shows how to sign and package it with DKMS so it survives kernel updates.
This is advanced work. A mistake in kernel space can panic the machine, so test everything on a disposable VM before touching production hardware.
Step-by-step installation
Install kernel headers and build tools
You need the kernel headers matching your running kernel, plus the standard build toolchain, to compile modules against the correct kernel ABI.
sudo apt update
sudo apt install -y build-essential linux-headers-$(uname -r) git bc kmod libssl-dev libelf-dev dkmssudo dnf groupinstall -y "Development Tools"
sudo dnf install -y kernel-devel-$(uname -r) kernel-headers-$(uname -r) elfutils-libelf-devel openssl-devel dkmsConfirm the headers match your running kernel version before continuing.
uname -r
ls /lib/modules/$(uname -r)/buildCreate the module source tree
Use a dedicated working directory owned by your own user, not root, to keep source editing safe and auditable.
mkdir -p ~/hwmon-mod
cd ~/hwmon-modWrite the character device driver
This minimal driver registers a character device, implements open, read and release, and will later be extended with proc and sysfs interfaces for a hardware monitor.
#include <linux/init.h>
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/uaccess.h>
#include <linux/mutex.h>
#define DEVICE_NAME "hwmon0"
#define CLASS_NAME "hwmon_cls"
static int major_number;
static struct class *hwmon_class = NULL;
static struct device *hwmon_device = NULL;
static struct cdev hwmon_cdev;
static DEFINE_MUTEX(hwmon_lock);
static int reading_value = 42;
static int dev_open(struct inode *inodep, struct file *filep) {
if (!mutex_trylock(&hwmon_lock))
return -EBUSY;
return 0;
}
static int dev_release(struct inode *inodep, struct file *filep) {
mutex_unlock(&hwmon_lock);
return 0;
}
static ssize_t dev_read(struct file *filep, char *buffer, size_t len, loff_t *offset) {
char msg[32];
int msg_len;
if (*offset > 0)
return 0;
msg_len = scnprintf(msg, sizeof(msg), "%d\n", reading_value);
if (copy_to_user(buffer, msg, msg_len))
return -EFAULT;
*offset += msg_len;
return msg_len;
}
static struct file_operations fops = {
.open = dev_open,
.read = dev_read,
.release = dev_release,
};
static int __init hwmon_init(void) {
dev_t dev_num;
int ret;
ret = alloc_chrdev_region(&dev_num, 0, 1, DEVICE_NAME);
if (ret < 0)
return ret;
major_number = MAJOR(dev_num);
cdev_init(&hwmon_cdev, &fops);
ret = cdev_add(&hwmon_cdev, dev_num, 1);
if (ret < 0) {
unregister_chrdev_region(dev_num, 1);
return ret;
}
hwmon_class = class_create(CLASS_NAME);
if (IS_ERR(hwmon_class)) {
cdev_del(&hwmon_cdev);
unregister_chrdev_region(dev_num, 1);
return PTR_ERR(hwmon_class);
}
hwmon_device = device_create(hwmon_class, NULL, dev_num, NULL, DEVICE_NAME);
if (IS_ERR(hwmon_device)) {
class_destroy(hwmon_class);
cdev_del(&hwmon_cdev);
unregister_chrdev_region(dev_num, 1);
return PTR_ERR(hwmon_device);
}
pr_info("hwmon: device registered with major %d\n", major_number);
return 0;
}
static void __exit hwmon_exit(void) {
device_destroy(hwmon_class, MKDEV(major_number, 0));
class_destroy(hwmon_class);
cdev_del(&hwmon_cdev);
unregister_chrdev_region(MKDEV(major_number, 0), 1);
pr_info("hwmon: device unregistered\n");
}
module_init(hwmon_init);
module_exit(hwmon_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Binadit");
MODULE_DESCRIPTION("Hardware device monitor character driver");Add a /proc interface
A proc entry gives userspace tools like cat quick read access without opening a device node. Add this to the same source file above the init function, and register it in hwmon_init.
#include <linux/proc_fs.h>
#include <linux/seq_file.h>
static int hwmon_proc_show(struct seq_file *m, void *v) {
seq_printf(m, "reading=%d\n", reading_value);
return 0;
}
static int hwmon_proc_open(struct inode *inode, struct file *file) {
return single_open(file, hwmon_proc_show, NULL);
}
static const struct proc_ops hwmon_proc_ops = {
.proc_open = hwmon_proc_open,
.proc_read = seq_read,
.proc_lseek = seq_lseek,
.proc_release = single_release,
};
/* call this inside hwmon_init(): */
/* proc_create("hwmon_status", 0444, NULL, &hwmon_proc_ops); */
/* call this inside hwmon_exit(): */
/* remove_proc_entry("hwmon_status", NULL); */Add a sysfs attribute
Sysfs attributes are the modern, structured way to expose device state. This adds a read-write attribute under /sys/class/hwmon_cls/hwmon0.
static ssize_t reading_show(struct device *dev, struct device_attribute *attr, char *buf) {
return scnprintf(buf, PAGE_SIZE, "%d\n", reading_value);
}
static ssize_t reading_store(struct device *dev, struct device_attribute *attr,
const char *buf, size_t count) {
int val;
if (kstrtoint(buf, 10, &val) < 0)
return -EINVAL;
mutex_lock(&hwmon_lock);
reading_value = val;
mutex_unlock(&hwmon_lock);
return count;
}
static DEVICE_ATTR_RW(reading);
/* in hwmon_init(), after device_create succeeds: */
/* device_create_file(hwmon_device, &dev_attr_reading); */
/* in hwmon_exit(), before device_destroy: */
/* device_remove_file(hwmon_device, &dev_attr_reading); */Handle interrupts and concurrency safely
If the real hardware raises an interrupt line, register a handler and defer heavy work to a tasklet or workqueue since interrupt context cannot sleep. Use spinlocks for data touched inside the interrupt handler and mutexes for anything touched only in process context.
#include <linux/interrupt.h>
#include <linux/spinlock.h>
static DEFINE_SPINLOCK(hwmon_irq_lock);
static int irq_number = -1;
static irqreturn_t hwmon_irq_handler(int irq, void *dev_id) {
unsigned long flags;
spin_lock_irqsave(&hwmon_irq_lock, flags);
reading_value++;
spin_unlock_irqrestore(&hwmon_irq_lock, flags);
return IRQ_HANDLED;
}
/* request_irq(irq_number, hwmon_irq_handler, IRQF_SHARED, "hwmon0", &hwmon_device); */
/* free_irq(irq_number, &hwmon_device); on exit */spin_lock_irqsave instead.Write the Makefile and build the module
Kernel modules use the kbuild system, which invokes the kernel's own build rules against your source file.
obj-m += hwmon_dev.o
all:
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules
clean:
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) cleancd ~/hwmon-mod
makeA successful build produces hwmon_dev.ko alongside the usual .o and .mod build artifacts.
Load, test and unload the module
Use insmod for quick iteration during development, and modprobe once the module is installed under /lib/modules with dependency resolution.
sudo insmod hwmon_dev.ko
ls -l /dev/hwmon0
cat /proc/hwmon_status
cat /sys/class/hwmon_cls/hwmon0/reading
sudo rmmod hwmon_devCheck load order and confirm the module appears correctly before relying on it.
lsmod | grep hwmon_dev
modinfo hwmon_dev.koDebugging kernel modules
Read kernel log output with dmesg
Every pr_info, pr_err and pr_warn call lands in the kernel ring buffer. This is your primary debugging tool for module init, exit and interrupt paths.
sudo dmesg -w | grep hwmonEnable dynamic debug and ftrace for deeper tracing
Ftrace lets you trace function calls and latency inside the kernel without recompiling, which is invaluable for diagnosing interrupt handler timing or lock contention.
sudo mount -t debugfs none /sys/kernel/debug 2>/dev/null
echo function_graph | sudo tee /sys/kernel/debug/tracing/current_tracer
echo hwmon_irq_handler | sudo tee /sys/kernel/debug/tracing/set_ftrace_filter
echo 1 | sudo tee /sys/kernel/debug/tracing/tracing_on
cat /sys/kernel/debug/tracing/traceFor step-through debugging of a live kernel, kgdb requires a serial or network debug connection set up at boot, typically through kernel command-line parameters like kgdboc=ttyS0,115200 and a second machine or VM running gdb.
Signing modules for Secure Boot
Generate a signing key and sign the module
Systems with Secure Boot enabled refuse to load unsigned kernel modules. Generate a Machine Owner Key (MOK), sign your module, and enroll the key so the firmware trusts it.
openssl req -new -x509 -newkey rsa:2048 -keyout MOK.priv -outform DER -out MOK.der -nodes -days 3650 -subj "/CN=Binadit HWMon Module Signing/"
sudo /usr/src/linux-headers-$(uname -r)/scripts/sign-file sha256 MOK.priv MOK.der hwmon_dev.koEnroll the public key with the kernel's MOK manager. This requires a reboot and a manual confirmation step in the firmware's blue MOK enrollment screen.
sudo mokutil --import MOK.der
sudo rebootAfter reboot, confirm the key enrolled and the module loads cleanly.
sudo mokutil --list-enrolled | grep -A2 "Binadit"
sudo insmod hwmon_dev.ko
dmesg | tail -5MOK.priv outside version control and restrict its permissions to the owning user only, since anyone with this key can sign modules your systems will trust.chmod 600 MOK.priv
chown $(whoami):$(whoami) MOK.privPackaging with DKMS for distribution
Create the DKMS configuration
DKMS (Dynamic Kernel Module Support) automatically rebuilds your module against new kernel versions after updates, which is essential for distributing a module beyond a single machine.
sudo mkdir -p /usr/src/hwmon-dev-1.0
sudo cp hwmon_dev.c Makefile /usr/src/hwmon-dev-1.0/PACKAGE_NAME="hwmon-dev"
PACKAGE_VERSION="1.0"
BUILT_MODULE_NAME[0]="hwmon_dev"
DEST_MODULE_LOCATION[0]="/kernel/drivers/misc"
AUTOINSTALL="yes"
MAKE[0]="make -C $kernel_source_dir M=$dkms_tree/$PACKAGE_NAME/$PACKAGE_VERSION/build"
CLEAN="make clean"Register, build and install with DKMS
These commands hand the module over to DKMS, which will rebuild it automatically on every kernel update going forward.
sudo dkms add -m hwmon-dev -v 1.0
sudo dkms build -m hwmon-dev -v 1.0
sudo dkms install -m hwmon-dev -v 1.0
sudo dkms statusIf you sign modules for Secure Boot, add the signing step into the DKMS post-build hook so every rebuilt module stays signed automatically.
#!/bin/sh
/usr/src/linux-headers-$(uname -r)/scripts/sign-file sha256 \
/usr/src/hwmon-dev-1.0/MOK.priv /usr/src/hwmon-dev-1.0/MOK.der "$1"Verify your setup
lsmod | grep hwmon_dev
modinfo hwmon_dev.ko
dkms status
cat /proc/hwmon_status
cat /sys/class/hwmon_cls/hwmon0/reading
sudo dmesg | tail -20Common issues
| Symptom | Cause | Fix |
|---|---|---|
| insmod fails with "Invalid module format" | Module built against a different kernel version than the running one | Rebuild with make after confirming uname -r matches the headers package |
| insmod fails with "Required key not available" | Secure Boot is enabled and the module is unsigned or signed with an unenrolled key | Sign the module and enroll the MOK key with mokutil --import, then reboot to confirm |
| Kernel panic or hang on module load | Sleeping call (mutex, kmalloc with GFP_KERNEL) inside interrupt context | Use spinlocks and GFP_ATOMIC inside interrupt handlers, defer heavy work to a workqueue |
| DKMS build fails after kernel update | Missing kernel-devel/headers package for the new kernel | Install headers for the new kernel before running dkms autoinstall |
| Device node missing after load | udev rule or device class not created correctly | Check dmesg for device_create errors and confirm class_create succeeded |
If your module interacts with userspace services that read from /proc or /sys, you can supervise those consumer processes with systemctl, and track which process is consuming CPU while polling your device with process monitoring tools. For deeper tuning of the kernel parameters your module depends on, see configuring kernel parameters with sysctl.
Next steps
- Configure Linux kernel parameters with sysctl for system optimization
- Set up Linux performance monitoring with perf tools and flame graphs
- Build a custom Linux kernel with hardened security patches
- Debug kernel oops and panics with kdump and crash