In the last post I spent some time messing around with proc entries. That was a really good first step because it forced me to understand how the kernel exposes simple debug surfaces to userspace without dealing with any actual device semantics. But the next step in this journey is getting into something that behaves like a real device, something I can open from userspace and talk to. That means writing a character driver.
At first you might be thinking, isn’t this the same thing as making a proc entry? Both expose a "file", right? But once you start writing it, the difference becomes obvious. A proc entry is just a virtual file owned entirely by the procfs layer. It’s for state dumps, metrics, small control knobs. A character driver is an actual device. It shows up in /dev. You open() it. You write() to it. The kernel gives it a major and minor number. Userland treats it like a real hardware-backed device, even if the thing behind it is just your code and a chunk of RAM.
The Minimal Device
I wanted to start with the smallest possible character driver that still works like the real thing. Something that loads as a module, registers a device number, and shows up in /dev so userspace can interact with it. No hardware. No complicated concurrency. Just the basics.
Here’s the flow the kernel expects:
Allocate a major/minor number for the device.
Initialize a cdev and register it with the kernel.
Create a class so udev has something to hook into.
Create the actual device node so it appears under /dev.
Implement the basic file operations: open, release, read, write.
That’s it. Once that scaffolding is in place you’ve got a real Linux device. It's really nice to know that it's just a bunch of call backs set in the proper structure and your'e done.
The Code
I wrote a tiny driver called gs_char. It exposes a small 256 byte buffer. Whatever you write into it gets stored in kernel memory. When you read from it, you get the last thing you wrote. It’s not fancy, but that’s not the point. The point is understanding how the kernel expects a character device to behave.
The file operations look almost exactly like what you’d expect coming from userland. read() copies data from the kernel buffer out to userspace. write() copies data from userspace back into the kernel buffer. Nothing magic.
The initialization code is where the important stuff happens. alloc_chrdev_region() gives you a major/minor. cdev_add() tells the kernel about your driver. class_create() and device_create() make sure the device node shows up in /dev without you having to run mknod manually (in theory, not practice for me, more later). After that you can load the module and immediately see:
/dev/gs_char
And that part would honestly be really satisfying.
Testing It
With the module loaded inside QEMU, my /dev/gs_char entry was not created. Sad face. It seems though that at first glance I thought something was wrong with my module, but then quickly realized I didn't have anything running to handle the device entries. So a quick mknod later and I'm up and running.
echo "hello kernel" > /dev/gs_char
cat /dev/gs_char
And the driver prints out the open, read, write, and close messages using pr_info, so you get a nice trace in dmesg showing each step.
Why This Matters
This little driver is the doorway into all the real kernel work I want to do. Understanding the lifetime of a device, how file operations work, and how major/minor numbers get managed, it becomes way clearer how subsystems behave. This is also where a lot of the kernel’s design patterns start becoming familiar. You stop thinking of drivers as weird special things and you start seeing them as normal kernel code with a few well-defined entry points. A point that's always amazed me since my first OS class. That the operating system and the compilers are just pieces of software themselves. They just have a very special role.
What's next I don't know. Stay tuned!
Top comments (0)