Skip to main content

03 - Misc (Character) Device

In this lab you will write a kernel module in Rust that exposes a character device to userspace. Character devices are one of the oldest abstractions in Unix: they appear as files under /dev and let userspace interact with the kernel through the familiar open, read, write, ioctl, and close system calls. The miscellaneous device subsystem is a lightweight layer on top of character devices that removes the need to allocate and manage a dedicated major number. Instead, every misc device shares major 10 and receives a dynamically assigned minor, making it the natural starting point for new drivers that do not belong to an established subsystem.

The lab uses the kernel::miscdevice module, which provides safe Rust wrappers around the C struct miscdevice and struct file_operations APIs. By the end of the lab you will have a working driver that userspace can open, control through ioctl commands, and exchange data with through read and write.

Objectives

  • Understand what miscellaneous devices are, how they relate to character devices, and how they are registered and unregistered from the kernel.
  • Understand how to transfer data between the kernel and userspace through read and write file operations, using the safe IovIterDest and IovIterSource types.

Misc devices

The miscellaneous device subsystem is the simplest way to expose a character device node under /dev. Rather than allocating a dedicated major number, every misc device shares major 10 and receives a dynamically allocated minor. The kernel documentation for the subsystem lives at driver-api/misc_devices, and the Rust API is provided by the kernel::miscdevice module, whose C counterpart is include/linux/miscdevice.h.

Registration

A misc device is registered by creating a MiscDeviceRegistration<T> value. The generic parameter T is your driver type — the type that implements MiscDevice. Registration is done through MiscDeviceRegistration::register, which takes a MiscDeviceOptions struct (currently just a name) and returns a PinInit that must be stored in a pinned location for the lifetime of the device. Deregistration happens automatically when the MiscDeviceRegistration is dropped.

note

Pinning and PinInit are covered in detail further on in this guide.

use kernel::{
c_str,
miscdevice::{MiscDevice, MiscDeviceOptions, MiscDeviceRegistration},
prelude::*,
};

#[pin_data(PinnedDrop)]
struct MyModule {
#[pin]
dev: MiscDeviceRegistration<MyDeviceInstance>,
}

impl kernel::InPlaceModule for MyModule {
fn init(_module: &'static ThisModule) -> impl PinInit<Self, Error> {
let opts = MiscDeviceOptions { name: c_str!("my-device") };
try_pin_init!(Self {
dev <- MiscDeviceRegistration::register(opts),
})
}
}

#[pinned_drop]
impl PinnedDrop for MyModule {
fn drop(self: Pin<&mut Self>) {}
}

The minor number is always assigned dynamically (MISC_DYNAMIC_MINOR). After a successful register call, MiscDeviceRegistration::device returns a reference to the underlying struct device, which is useful for logging and devres allocations.

File permissions

MiscDeviceOptions currently only exposes the name field. The mode field of the underlying C struct miscdevice is not yet wired up, so the device node is created with the kernel default permissions (0600). Use a udev rule to set different permissions until this is addressed upstream.

Implementing MiscDevice

All driver behaviour is defined by implementing the MiscDevice trait and annotating it with #[vtable]. The only required method is open; all others have default implementations that return ENOTTY (or do nothing for release).

use kernel::{
alloc::KBox,
fs::File,
miscdevice::{MiscDevice, MiscDeviceRegistration},
prelude::*,
};

struct MyDeviceInstance {
// per-open-instance state
}

#[vtable]
impl MiscDevice for MyDeviceInstance {
type Ptr = KBox<Self>;

fn open(_file: &File, _reg: &MiscDeviceRegistration<Self>) -> Result<KBox<Self>> {
Ok(KBox::new(MyDeviceInstance {}, GFP_KERNEL)?)
}

fn release(device: KBox<Self>, _file: &File) {
drop(device);
}

fn ioctl(
device: <KBox<Self> as kernel::types::ForeignOwnable>::Borrowed<'_>,
_file: &File,
cmd: u32,
arg: usize,
) -> Result<isize> {
match cmd {
MY_GET_STATUS => { /* ... */ Ok(0) }
MY_SET_CONFIG => { /* ... */ Ok(0) }
_ => Err(ENOTTY),
}
}
}

The associated type Ptr determines how per-open-instance data is owned and passed between callbacks. KBox<Self> is the most common choice, giving heap-allocated per-open state. Arc<Self> is appropriate when all open file handles should share the same data. The Ptr type must implement ForeignOwnable + Send + Sync.

Available callbacks

The #[vtable] macro only links a function pointer into the kernel's file_operations table when you actually provide an implementation. Providing a method for a callback you do not use wastes nothing.

Methodfile_operations fieldNotes
openopenRequired. Returns the Ptr stored as file private data.
releasereleaseCalled when the last reference to the file is dropped.
read_iterread_iterStreaming read via IovIterDest.
write_iterwrite_iterStreaming write via IovIterSource.
ioctlunlocked_ioctlSee kernel::ioctl for building command numbers.
compat_ioctlcompat_ioctl32-bit userspace on 64-bit kernel. Defaults to compat_ptr_ioctl if ioctl is provided.
mmapmmapReceives a VmaNew to configure the mapping.
show_fdinfoshow_fdinfoWrites extra lines into /proc/<pid>/fdinfo/<fd>.
32-bit compatibility

If your ioctl data structures have the same layout on 32-bit and 64-bit userspace — meaning no bare long, unsigned long, or pointer members — you do not need to implement compat_ioctl. The kernel will automatically use compat_ptr_ioctl as a fallback when ioctl is provided, which adjusts pointer-sized arguments and then calls your ioctl handler directly.

If your structures differ by ABI (e.g. you have a 32-bit pointer field represented as __u64 on 64-bit), implement compat_ioctl explicitly and handle the conversion there. See the kernel's guidance on structure layout for compat mode for the full list of problematic member types.

Module reference counting

The current Rust miscdevice abstraction does not set the owner field of file_operations. This means the kernel does not automatically increment your module's reference count when a file is opened, and rmmod can unload the module while file handles are still live.

BUG

This workaround can be removed once the owner field is wired up through a type-level ThisModule associated type, which is currently proposed upstream.

Until the upstream fix lands, the safest mitigation is to call try_module_get / module_put manually in open and release:

fn open(file: &File, reg: &MiscDeviceRegistration<Self>) -> Result<KBox<Self>> {
// Prevent the module from being unloaded while this file is open.
let ok = unsafe { kernel::bindings::try_module_get(kernel::THIS_MODULE.as_ptr()) };
if !ok {
return Err(ENODEV);
}
Ok(KBox::new(MyDeviceInstance {}, GFP_KERNEL)?)
}

fn release(device: KBox<Self>, _file: &File) {
drop(device);
unsafe { kernel::bindings::module_put(kernel::THIS_MODULE.as_ptr()) };
}

Read and write data

When userspace calls read() or write() on your device file, the kernel does not give you a raw pointer to the userspace buffer. Instead it hands you a struct iov_iter — a kernel abstraction that can represent many different kinds of buffer at once: a single flat userspace region, a scatter-gather list from readv()/ writev(), a kernel-space buffer, a pipe, and more. Your driver only needs to call one function regardless of which kind is actually in use.

The Rust bindings expose two typed wrappers around struct iov_iter, found in the kernel::iov module:

  • IovIterDest — passed to read_iter. Data flows from the kernel to userspace: your driver writes into this destination.
  • IovIterSource — passed to write_iter. Data flows from userspace to the kernel: your driver reads from this source.

The naming is from the kernel's point of view, which is the opposite of what userspace sees: a read() call in userspace creates an IovIterDest because the user's buffer is the destination for the kernel's data, while a write() call creates an IovIterSource because the user's buffer is the source of data for the kernel.

Both callbacks also receive a Kiocb argument. Kiocb holds the current file position (ppos) and a borrow of the per-open device data, making it the bridge between the IO operation and your device state.

Kiocb — the I/O control block

When the kernel dispatches a read_iter or write_iter call to your driver, it does not pass the file and position as separate arguments. Instead it wraps them together into a struct kiocb — the kernel I/O control block — and passes a pointer to that. The Rust abstraction for it is kernel::fs::Kiocb.

pub struct Kiocb<'a, T> { /* private fields */ }

The lifetime 'a ties the Kiocb to the underlying C struct, preventing it from outliving the kernel's stack frame. The type parameter T is the driver-specific private data type that was stored in the file — the same T that appears as Self::Ptr in your MiscDevice implementation.

Where you encounter it

Kiocb appears as the first argument of read_iter and write_iter in the MiscDevice trait:

fn read_iter(kiocb: Kiocb<'_, Self::Ptr>, iov: &mut IovIterDest<'_>) -> Result<usize>;
fn write_iter(kiocb: Kiocb<'_, Self::Ptr>, iov: &mut IovIterSource<'_>) -> Result<usize>;

These are the streaming I/O paths. Every time userspace calls read(2) or write(2) on your device file, the kernel routes it through read_iter/write_iter with a freshly constructed Kiocb.

Methods

MethodWhat it gives you
kiocb.file()The driver-specific data (T::Borrowed<'a>), equivalent to what open stored
kiocb.ki_pos()The current file position as an i64
kiocb.ki_pos_mut()A mutable reference to the file position, so you can advance it
kiocb.as_raw()The raw *mut kiocb pointer, for when you need to call a C helper directly

Reading and writing with ki_pos

For character devices it is common to use the position as a byte offset into some internal buffer. You are responsible for advancing ki_pos after each transfer so that sequential read calls make forward progress:

fn read_iter(
mut kiocb: Kiocb<'_, KBox<MyDevice>>,
iov: &mut IovIterDest<'_>,
) -> Result<usize> {
let device = kiocb.file();
let pos = kiocb.ki_pos() as usize;

// Build a response at the current position.
let data: &[u8] = device.buffer_at(pos)?;

// Copy bytes into userspace via the iov iterator.
let copied = iov.copy_to_iter(data)?;

// Advance the file position.
*kiocb.ki_pos_mut() += copied as i64;

Ok(copied)
}

For most misc devices that do not represent a seekable byte stream, the position is irrelevant and you can simply ignore it — userspace cannot meaningfully lseek a misc device anyway.

The file() accessor versus ioctl

You may notice that ioctl receives the driver data through a separate device argument, while read_iter/write_iter receive it through kiocb.file(). This is just an artifact of the C calling conventions for the two paths:

  • unlocked_ioctl in C receives a struct file * with private_data on it; the Rust abstraction extracts private_data before calling your ioctl handler.
  • read_iter/write_iter in C receive a struct kiocb *, which itself contains a pointer to the struct file. The Rust abstraction therefore hands you the whole Kiocb and lets you call .file() to retrieve your data.

Both give you the same driver-specific pointer; the difference is just how you get to it.

Current limitations

The Rust Kiocb abstraction is explicitly marked incomplete in the kernel source. At the moment it only exposes the file pointer and the ki_pos field. Fields used for asynchronous I/O (ki_complete, ki_flags, ki_ioprio, etc.) are not yet wrapped, so true async I/O from Rust drivers is not yet supported through this API.

note

Because Kiocb is !Send and !Sync, you cannot store it in a struct or send it across threads. It is a short-lived view into the kernel's call stack and must be consumed within the read_iter/write_iter call that received it.

Writing data to userspace with IovIterDest

IovIterDest is your tool for implementing read_iter. The two main methods are:

  • copy_to_iter(input: &[u8]) -> usize — copies a kernel byte slice into the userspace buffer. Returns the number of bytes actually written, which may be less than input.len() if the iterator is shorter than the data you are trying to write.
  • simple_read_from_buffer(ppos: &mut i64, contents: &[u8]) -> Result<usize> — a higher-level helper for the common case where your device exposes a fixed byte sequence (like a status string or a counter). It reads ppos, copies the appropriate sub-slice of contents into the iterator, advances ppos, and returns the number of bytes written. It handles the EOF case (returning Ok(0)) automatically.

The simplest possible read_iter implementation exposes a static string:

fn read_iter(kiocb: Kiocb<'_, Self::Ptr>, iov: &mut IovIterDest<'_>) -> Result<usize> {
iov.simple_read_from_buffer(kiocb.ppos(), b"hello from kernel\n")
}

For cases where you need more control — for example, copying data from a dynamically sized kernel buffer — use copy_to_iter directly and update the position yourself:

fn read_iter(kiocb: Kiocb<'_, Self::Ptr>, iov: &mut IovIterDest<'_>) -> Result<usize> {
let device = kiocb.device();
let ppos = kiocb.ppos();

let data = device.buffer.lock();

let pos = usize::try_from(*ppos).map_err(|_| EINVAL)?;
if pos >= data.len() {
return Ok(0); // EOF
}

let written = iov.copy_to_iter(&data[pos..]);
*ppos += written as i64;
Ok(written)
}

len() and is_empty() are available on IovIterDest to check how many bytes the caller has room for before committing to any copies, which is useful when building output in chunks.

Reading data from userspace with IovIterSource

IovIterSource is the mirror image, used in write_iter. The primary method is:

  • copy_from_iter(out: &mut [u8]) -> usize — copies bytes from the userspace buffer into a kernel slice. Returns the number of bytes actually read.

A simple write_iter that stores incoming bytes in a device buffer:

fn write_iter(kiocb: Kiocb<'_, Self::Ptr>, iov: &mut IovIterSource<'_>) -> Result<usize> {
let device = kiocb.device();
let mut buf = [0u8; 256];

// Read however many bytes the iterator holds, up to our buffer size.
let n = iov.copy_from_iter(&mut buf);
if n == 0 {
return Ok(0);
}

device.buffer.lock().extend_from_slice(&buf[..n]);
Ok(n)
}

If you need a buffer whose size matches exactly what the caller is trying to write, check iov.len() first and allocate accordingly:

fn write_iter(kiocb: Kiocb<'_, Self::Ptr>, iov: &mut IovIterSource<'_>) -> Result<usize> {
let incoming = iov.len();
if incoming == 0 {
return Ok(0);
}
if incoming > MAX_PAYLOAD {
return Err(EINVAL);
}

let mut buf = KVec::with_capacity(incoming, GFP_KERNEL)?;
buf.resize(incoming, 0, GFP_KERNEL)?;
let n = iov.copy_from_iter(&mut buf);

process(&buf[..n])?;
Ok(n)
}
Use the return value, not len()

iov.len() may over-estimate the available data. For example, if part of the userspace buffer spans a page that cannot be faulted in, the actual copy will stop short. Always use the return value of copy_from_iter — not iov.len() — as the authoritative byte count, and base your file position update on that value.

Registering the callbacks

Both read_iter and write_iter are opt-in. The #[vtable] macro only wires them into file_operations when you provide an implementation, so adding one does not affect the other:

#[vtable]
impl MiscDevice for MyDeviceInstance {
type Ptr = KBox<Self>;

fn open(_file: &File, _reg: &MiscDeviceRegistration<Self>) -> Result<KBox<Self>> {
Ok(KBox::new(MyDeviceInstance::new()?, GFP_KERNEL)?)
}

fn read_iter(kiocb: Kiocb<'_, Self::Ptr>, iov: &mut IovIterDest<'_>) -> Result<usize> {
// ...
}

fn write_iter(kiocb: Kiocb<'_, Self::Ptr>, iov: &mut IovIterSource<'_>) -> Result<usize> {
// ...
}
}

If neither method is provided, open() still works and userspace will receive EINVAL for any read() or write() attempt on the device file.

Exercises

  1. Write a device driver that registers a misoc device called comamnds that prints a message when receiving a read and write command.
  2. Write a device driver that registers a misoc device called rzero that behaves like /dev/zero.
  3. Write a device driver that registers a misoc device called rnull that behaves like /dev/null.