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
readandwritefile operations, using the safeIovIterDestandIovIterSourcetypes.
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.
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.
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.
| Method | file_operations field | Notes |
|---|---|---|
open | open | Required. Returns the Ptr stored as file private data. |
release | release | Called when the last reference to the file is dropped. |
read_iter | read_iter | Streaming read via IovIterDest. |
write_iter | write_iter | Streaming write via IovIterSource. |
ioctl | unlocked_ioctl | See kernel::ioctl for building command numbers. |
compat_ioctl | compat_ioctl | 32-bit userspace on 64-bit kernel. Defaults to compat_ptr_ioctl if ioctl is provided. |
mmap | mmap | Receives a VmaNew to configure the mapping. |
show_fdinfo | show_fdinfo | Writes extra lines into /proc/<pid>/fdinfo/<fd>. |
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.
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 toread_iter. Data flows from the kernel to userspace: your driver writes into this destination.IovIterSource— passed towrite_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
| Method | What 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_ioctlin C receives astruct file *withprivate_dataon it; the Rust abstraction extractsprivate_databefore calling yourioctlhandler.read_iter/write_iterin C receive astruct kiocb *, which itself contains a pointer to thestruct file. The Rust abstraction therefore hands you the wholeKiocband 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.
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.
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 thaninput.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 readsppos, copies the appropriate sub-slice ofcontentsinto the iterator, advancesppos, and returns the number of bytes written. It handles the EOF case (returningOk(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)
}
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
- Write a device driver that registers a misoc device called
comamndsthat prints a message when receiving areadandwritecommand. - Write a device driver that registers a misoc device called
rzerothat behaves like/dev/zero. - Write a device driver that registers a misoc device called
rnullthat behaves like/dev/null.