Modern Linux Drivers 3: Adding ioctl (and a std::expected) to Char Device
This is the third part of my previous posts about coding a Linux Driver from scratch. I strongly recommend you read Part 1 and Part 2 first.
So far, our CharacterDevice class can read() and write() a stream of bytes, and it manages its own registration lifetime through RAII. That covers the “data path” — but every real device eventually needs a way to say something that is not data. Today we give our driver such functionality, using ioctl, and we will use the opportunity to clean up our error handling with std::expected.
Why read() and write() Are not Enough
A byte stream is great for data, but terrible for commands. Imagine trying to tell the driver “switch to mode 2” by writing a byte sequence and hoping nobody ever wants to write that exact sequence as data. It works, but it is fragile, and it pollutes a channel that should carry payload.
This is exactly the gap ioctl (Input/Output Control) was built to fill: a side channel for commands and small structured exchanges that live outside the normal read/write path.
Extending the Interface
In the previous post, our CharacterDevice interface exposed on_open, on_release, on_read, and on_write, each backed by a static trampoline. Adding ioctl support means doing the same thing one more time:
class CharacterDevice
{
public:
virtual ~CharacterDevice() = default;
// The interface that subclasses will implement
virtual int on_open(struct inode *inode, struct file *file) = 0;
virtual int on_release(struct inode *inode, struct file *file) = 0;
virtual ssize_t on_read(struct file *file, char __user *buf, size_t len, loff_t *off) = 0;
virtual ssize_t on_write(struct file *file, const char __user *buf, size_t len, loff_t *off) = 0;
virtual long on_ioctl(struct file *file, unsigned int cmd, unsigned long arg) = 0;
// The static methods
static int fops_open(struct inode *inode, struct file *file);
static int fops_release(struct inode *inode, struct file *file);
static ssize_t fops_read(struct file *file, char __user *buf, size_t len, loff_t *off);
static ssize_t fops_write(struct file *file, const char __user *buf, size_t len, loff_t *off);
static long fops_ioctl(struct file *file, unsigned int cmd, unsigned long arg);
};The trampoline itself is nothing new — same private_data trick as before:
long CharacterDevice::fops_ioctl(struct file *file, unsigned int cmd, unsigned long arg)
{
auto* self = static_cast<CharacterDevice*>(file->private_data);
return self->on_ioctl(file, cmd, arg);
}Defining Our Commands
Linux ioctl numbers are not arbitrary integers. They are built with the _IO, _IOR, _IOW, and _IOWR macros, which encode a “magic” character, a sequence number, and (for _IOR/_IOW/_IOWR) the size of the data being transferred. This encoding lets the kernel catch a driver being called with the wrong argument type or size, rather than silently misinterpreting memory.
#define EDRV_MAGIC 'e'
#define EDRV_RESET _IO(EDRV_MAGIC, 1)
#define EDRV_GET_STATUS _IOR(EDRV_MAGIC, 2, int)
#define EDRV_SET_MODE _IOW(EDRV_MAGIC, 3, int)A Cleaner Internal Error Type with std::expected
A typical ioctl handler ends up as a switch statement full of copy_to_user/copy_from_user calls, each with its own -EFAULT/-EINVAL/-EAGAIN return dotted through the logic. It works, but it reads like a maze of early returns, and it is easy to lose track of the path.
std::expected<T, E> (C++23) gives us a value-or-error type we can build the actual decision logic around, and only translate to a raw kernel errno at the very boundary:
enum class DriverError
{
InvalidArgument,
NotReady,
HardwareFault,
};
constexpr int to_errno(DriverError err)
{
switch (err)
{
case DriverError::InvalidArgument: return -EINVAL;
case DriverError::NotReady: return -EAGAIN;
case DriverError::HardwareFault: return -EIO;
}
return -EINVAL;
}
std::expected<int, DriverError> EchoDriver::handle_ioctl(unsigned int cmd, unsigned long arg)
{
switch (cmd)
{
case EDRV_RESET:
{
mode = 0;
msg_size = 0;
return 0;
}
case EDRV_GET_STATUS:
{
int status = ready ? 1 : 0;
if (copy_to_user(reinterpret_cast<int __user *>(arg), &status, sizeof(status)))
return std::unexpected(DriverError::HardwareFault);
return 0;
}
case EDRV_SET_MODE:
{
int requested {};
if (copy_from_user(&requested, reinterpret_cast<int __user *>(arg), sizeof(requested)))
return std::unexpected(DriverError::HardwareFault);
if (requested < 0 || requested > 2)
return std::unexpected(DriverError::InvalidArgument);
mode = requested;
return 0;
}
default:
return std::unexpected(DriverError::InvalidArgument);
}
}The kernel-facing on_ioctl override becomes a two-line adapter. Same “Kernel Interface vs. Logic Interface” split we used for read/write in Part 2, now extended to error handling:
long EchoDriver::on_ioctl(struct file *filp, unsigned int cmd, unsigned long arg)
{
auto result = handle_ioctl(cmd, arg);
if (!result.has_value()) return to_errno(result.error());
return *result;
}A note on using std::expected in kernel space: kernel modules are built with -fno-exceptions -fno-rtti, so we deliberately avoid .value() here. It throws bad_expected_access on an empty result, and exceptions are not available in this environment. Sticking to has_value(), operator*, and error() keeps us entirely inside the non-throwing subset of std::expected, which is what makes it usable here at all.
Wiring It Into file_operations
One more field in our file_operations struct, and the driver understands commands:
static const struct file_operations my_fops = {
.owner = THIS_MODULE,
.read = CharacterDevice::fops_read,
.write = CharacterDevice::fops_write,
.open = CharacterDevice::fops_open,
.release = CharacterDevice::fops_release,
.unlocked_ioctl = CharacterDevice::fops_ioctl,
};Calling It from User Space
#include <sys/ioctl.h>
#include <fcntl.h>
#include <unistd.h>
#include <cerrno>
#include <cstring>
#include <print>
#define EDRV_MAGIC 'e'
#define EDRV_RESET _IO(EDRV_MAGIC, 1)
#define EDRV_GET_STATUS _IOR(EDRV_MAGIC, 2, int)
#define EDRV_SET_MODE _IOW(EDRV_MAGIC, 3, int)
int main()
{
int fd = open("/dev/echo_dev", O_RDWR);
if (fd < 0)
{
std::println(stderr, "Failed to open device. Try sudo.");
return 1;
}
int mode = 2;
if (ioctl(fd, EDRV_SET_MODE, &mode) < 0)
{
std::println(stderr, "SET_MODE failed: {}", std::strerror(errno));
close(fd);
return 1;
}
int status = 0;
if (ioctl(fd, EDRV_GET_STATUS, &status) < 0)
{
std::println(stderr, "GET_STATUS failed: {}", std::strerror(errno));
close(fd);
return 1;
}
std::println("Driver status: {}", status);
close(fd);
return 0;
}Run it the same way as before:
sudo insmod driver.ko
sudo chmod 666 /dev/echo_dev
g++ test_app.cpp -o test_app
./test_appConclusion
Our driver can now do more than move bytes. It can be reset, queried, and reconfigured through a proper command channel, and the messy validation logic behind that channel lives in one clean, testable function instead of being smeared across a switch statement.
Please let me know what you would like to see next.