Into the Kernel — Writing, Debugging & Cross-Compiling My First Character Driver
After my first „Hello Kernel“ experiment, I wanted to move one step deeper — from printing a message in the kernel log to actually building a working driver.
Every tutorial and book (including the classic Linux Device Drivers, 3rd Edition) pointed me toward the same next step: write a character driver. That’s exactly what I did — and in this article, I want to share what I learned, what surprised me, and why kernel development is unlike anything else I’ve done before.
From “Hello World” to a Real Driver
Character drivers are the simplest kind of device drivers in Linux. They handle data as a stream of bytes — think of serial ports, keyboards, or /dev/null.
In Linux, everything is a file, and devices are no exception. When you cat or echo something into a file under /dev, the kernel routes that call through the matching driver. Building my own driver meant I could add my own entry to /dev — my first tiny extension of the Linux filesystem.
My goal wasn’t to write a new UART or SPI driver; those are already perfected. What I wanted was understanding:
how data moves from user space into the kernel, how drivers register themselves, and how the kernel protects itself from bad behavior.
Working in Kernel Space — a Different World
Kernel programming looks familiar because it’s C, but it’s C with the training wheels removed.
You can’t use printf or malloc, because standard libraries don’t exist in kernel space. Instead, the kernel provides its own versions — printk, kmalloc, and so on. Every line of code runs with full system privileges, meaning that even a small mistake can crash the machine or corrupt memory.
That’s also what makes it fascinating. When you write user applications, you live on top of a giant safety net. In kernel space, you are the safety net.
This separation — user space vs kernel space — is what makes Linux stable. User programs can crash without harming the system; kernel code can’t afford that luxury.
Debugging Without a Debugger
Debugging kernel code forces you to slow down and think differently. There’s no stepping through with GDB or printing to stdout. Instead, you rely on tools like:
printk()— the kernel’s version ofprintf, logging messages to the system log (dmesg)trace_printk()and/sys/kernel/debug/tracing— for detailed tracing of function calls and timings
At first it felt primitive, but I started to enjoy it. Each log message became a clue, a breadcrumb through the system’s behavior.
And yes — I caused a few kernel panics. It’s humbling, but also deeply satisfying when the system finally behaves as you expect.
Registering a Device and Seeing It Come Alive
The real breakthrough came when I registered my driver and created a matching device node under /dev.
By assigning a major and minor number and using mknod, I could echo text into my driver and read it back.
It was such a small interaction — write a few bytes, read them again — but it felt monumental. For the first time, data was flowing through code I’d written inside the Linux kernel itself.
Here you can get the full source listing of my driver:
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/sched.h>
#include <linux/poll.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/mm.h>
static int major = 100;
static int debug = 1;
static char* name = "chardriver";
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Stefan Herndlbauer");
MODULE_VERSION("1.0.0");
MODULE_DESCRIPTION("This a simple character driver");
ssize_t chardriver_read (struct file *, char *, size_t, loff_t *);
ssize_t chardriver_write (struct file *, const char *, size_t, loff_t *);
#define NUMMINORS 5
static struct file_operations chardriver_fops = {
.read = chardriver_read,
.write = chardriver_write,
.owner = THIS_MODULE,
};
#define BUFFER_SIZE 1000
static char char_buffer[BUFFER_SIZE];
static int buffer_length = 0;
static dev_t mydev;
static struct cdev *my_cdev;
int sh_init_module(void)
{
int retval;
my_cdev = cdev_alloc();
mydev = MKDEV(major,0);
retval = register_chrdev_region(mydev,NUMMINORS,name);
if (retval < 0)
return retval;
if (!major)
major = retval;
if (debug)
printk("chardriver registered with major = %d\n",major);
my_cdev->ops = &chardriver_fops;
my_cdev->owner = THIS_MODULE;
retval = cdev_add(my_cdev,mydev,NUMMINORS);
return retval;
}
void sh_cleanup_module(void)
{
/* Unregister character driver. */
unregister_chrdev_region(mydev,NUMMINORS);
cdev_del(my_cdev);
}
ssize_t chardriver_read (struct file *filp, char *buf, size_t nbytes, loff_t* ppos)
{
int avail;
int failed, transferred;
if (debug)
printk("READ: %d bytes\n",(int)nbytes);
if (*ppos >= buffer_length || !nbytes)
return 0;
avail = buffer_length - *ppos;
if (nbytes > avail)
nbytes = avail;
failed = copy_to_user(buf, char_buffer + *ppos, nbytes);
transferred = nbytes - failed;
if (!transferred)
return -EFAULT;
*ppos += transferred;
return transferred;
}
ssize_t chardriver_write (struct file *filp, const char *buf, size_t nbytes, loff_t* ppos)
{
int failed, transferred;
if (debug)
printk("WRITE: %d bytes\n",(int)nbytes);
if (!nbytes)
return 0;
if (nbytes >=BUFFER_SIZE)
nbytes = BUFFER_SIZE-1;
failed = copy_from_user(char_buffer, buf, nbytes);
transferred = nbytes - failed;
if (!transferred)
return -EFAULT;
char_buffer[transferred] = '\0';
printk("%s\n",char_buffer);
buffer_length = transferred;
return transferred;
}
module_init(sh_init_module);
module_exit(sh_cleanup_module);
Cross-Compiling for the Raspberry Pi
Once it worked on my Ubuntu desktop, I wanted to run it on a real embedded system — my Raspberry Pi. Compiling directly on the Pi is possible, but painfully slow. So I decided to cross-compile the driver on my main machine using the arm-linux-gnueabihf- toolchain.
The Raspberry Pi documentation has excellent instructions for setting up the right headers and compiler. The key is to match your build to the exact kernel version running on your Pi. Otherwise, the module simply won’t load — Linux is strict about version compatibility.
Transferring the .ko file to the Pi and loading it with insmod worked on the first try. That moment felt like moving an engine piston from one machine to another — and watching both run smoothly.
What This Project Taught Me
Working in kernel space changes how you see computers. You begin to understand the invisible machinery — interrupts, buffers, syscalls — and how the operating system keeps chaos at bay. It also gives you a new appreciation for the engineers who live in this world every day. Kernel development demands precision, patience, and an unusual respect for failure.
For me, this project wasn’t about becoming a kernel developer. It was about curiosity: learning how the systems we rely on actually work. And that, in turn, makes me a better maker — because every layer I understand expands what I can build.
What’s Next
My next steps will likely lead deeper into embedded Linux integration — exploring Buildroot, Yocto, and how kernel modules become part of complete system images.
But I’ll definitely revisit kernel space again. Especially when combining a single-board computer with a microcontroller, writing a small custom driver can make a big difference in performance and reliability.
If you’d like to follow along, I’ll share commands, configs, and troubleshooting notes in upcoming blog posts — and as always, everything will be documented here and in my newsletter.
📬 Subscribe to The Maker’s Logbook
Get weekly behind-the-scenes updates, progress notes, and extra resources:
👉 herndlbauer.com/pages/newsletter
🎧 Listen to the full episode:
Let’s keep building, creating, and learning — together.