The "rnull" Rust block driver
The null block driver (null_blk) is a small driver that is mostly useful for benchmarking block-layer implementations. It accepts all requests and marks them complete as quickly as possible, doing as little work as possible. In June 2026, Andreas Hindborg shared a patch set implementing the same functionality in Rust, in order to show that a simple block driver is now possible to write using the kernel's Rust APIs and to enable comparisons between the C and Rust implementations. A minimal version of the "rnull" driver is already present in the mainline kernel, but Hindborg's patch set brings it up to feature parity with the C version.
The rnull driver
Most of Hindborg's patch set is dedicated to adding the last few Rust abstractions required to build a block driver at all. The implementation follows the same general pattern as the last Rust driver that LWN looked at, for the AX88796B networking device. It starts by declaring a new kernel module using the module!() macro, and then creates a new Rust type to represent the module. Unlike the Rust AX88796B driver, Hindborg's driver uses pinning to store a reference to the configfs interface, which complicates the module's initialization function.
The bulk of the function is wrapped in a call to pin_init_scope(), which takes a block of code describing how to initialize a piece of memory and uses that to construct a pinned (immovable) object in-place, without exposing raw uninitialized memory to the Rust code. Other than that one complication, the initialization function is straightforward: it reads module parameters, creates the requested number of disk-device objects, and then stores the configfs interface and list of disks in the module's pinned data:
pin_init::pin_init_scope(move || -> Result<_, Error> {
// Read module parameters:
let blocking = module_parameters::blocking.value();
// Other parameters elided
...
// Build a tag set (part of how requests are mapped to hardware queues)
// for the module-wide configfs settings to use
let shared_tag_set = NullBlkDevice::build_tag_set(...);
// Create disks
let mut disks = KVec::new();
for i in 0..module_parameters::nr_devices.value() {
// Some local variables elided
...
let disk = NullBlkDevice::new(...)?;
disks.push(disk, GFP_KERNEL)?;
}
// Store pointers in pinned module data
Ok(try_pin_init!(Self {
configfs_subsystem <- configfs::subsystem(shared_tag_set),
param_disks <- new_mutex!(disks),
}))
})
Each block device is represented by a NullBlkDevice structure, which includes various configuration parameters to implement support for bad blocks, bandwidth limits, device zones, and the other testing options that null_blk supports. The structure implements the Operations trait, which describes how disk operations for the device should be performed. The #[vtable] macro is used to package the functions of the trait into a vtable (a structure full of function pointers) that looks indistinguishable from the C implementation to the rest of the kernel. The block I/O subsystem structures used to track the known block devices on the system store a pointer to that vtable. The actual implementations of the functions themselves accept arbitrary requests and immediately mark them as complete.
The core function for this is queue_rq_internal(), which takes (among other things) a pointer to a Request structure that wraps struct request from the kernel's C code. The code is slightly complicated by the need to handle fault injection, but when that is turned off the core logic to handle a request (with editorialized comments) looks like this:
// Acquire the lock on the request
let mut rq = rq.start();
if rq.command() == mq::Command::Flush {
// If the rnull device has been configured to save writes in memory,
// flush that storage.
if this.memory_backed {
this.storage.flush(&hw_data);
}
// Regardless, mark the request as handled and return.
this.complete_request(rq);
return Ok(());
}
if rq.is_poll() {
// If the request is trying to poll for completion of something,
// put it in the poll queue so that a later poll will complete it.
hw_data.lock().poll_queue.push_head(rq)
// ... discarding the potential error about a full queue because it is
// sized such that it should never be overfilled.
.expect("Buffer is sized to hold all in-flight requests")
} else {
// Otherwise, just mark it as complete
this.complete_request(rq);
}
return Ok(());
The more interesting part, for other people wishing to write block drivers, will be the scaffolding around that core logic, which shows how to make a driver configurable and register it as a provider of block devices. The initialization code to read module parameters and expose configfs settings, especially, should be similar between different drivers.
The configfs code exposes the same settings that the null_blk driver does, controlling the size of the emulated block device, whether it reports itself to be a rotational or solid-state drive, and so on. The configfs interface also uses #[vtable], but in a slightly more confusing way: rather than constructing a single vtable that encapsulates the definition of a Rust trait, the code generates multiple vtables, one for each defined configfs attribute, distinguished via const generics. Some of the simpler settings instead use macros such as configfs_simple_bool_field!() to reduce the amount of boilerplate necessary.
One of the configfs attributes that cannot be simplified with those macros is the "badblocks" setting, which controls which sectors report errors when using error injection. The code for that attribute shows how to use the Rust configfs interface. The show() function controls how the setting is displayed to the user; the store() function is used to update the configuration when the user writes to the appropriate configfs file. The "12" in the impl line associates this implementation with the twelfth configfs attribute, and the #[vtable] macro packages it all up as a vtable in the format that configfs's C code expects:
#[vtable]
impl configfs::AttributeOperations<12> for DeviceConfig {
type Data = DeviceConfig;
fn show(this: &DeviceConfig, page: &mut [u8; PAGE_SIZE]) -> Result<usize> {
let ret = this.data.lock().bad_blocks.show(page, false);
if ret < 0 {
Err(Error::from_errno(ret as c_int))
} else {
Ok(ret as usize)
}
}
fn store(this: &DeviceConfig, page: &[u8]) -> Result {
for line in core::str::from_utf8(page)?.lines() {
// Parse each line and set the appropriate settings in the
// DeviceConfig.
...
}
Ok(())
}
}
When the kernel module is loaded, the Rust configfs wrapper automatically registers the configfs::Subsystem structure containing those vtables with configfs. When the user invokes the proper incantations:
# mount -t configfs none /config
# mkdir /config/rnull/<block_device_name>
# echo 1 > /config/rnull/<block_device_name>/power
The configfs code creates a new GenDisk structure, which corresponds to struct gendisk on the C side of the kernel. That structure stores a pointer to the NullBlkDevice vtable.
Future block drivers
Given that the main purpose of null_blk is benchmarking, a natural question is how its performance stacks up against the C implementation. Exactly how much overhead do the Rust bindings introduce? The Rust for Linux project's documentation on the driver shows a benchmark comparing performance between the two drivers on the 6.19-rc5 kernel. As is frequently the case in benchmarking, the comparison is not straightforward. Single-threaded performance is a little worse, as is the performance at higher queue depths, but performance is actually better for some sizes of request, and most tested setups have no statistically distinguishable difference.
Of course, the real point of Hindborg's patch set is not to supplant the C-based null block driver; it is to demonstrate that it is now possible to write new, more interesting block drivers in Rust. Currently, device drivers in Rust can be divided into two general categories: big projects, which have developers willing to implement all of the new Rust abstractions required for them, and little drivers that someone has contributed for a specific device. The former category, which includes Android's Rust binder, Nova, and Tyr, receives a lot of the attention, but given that there are a long tail of tiny, obscure, neglected drivers for niche devices in the kernel, it is arguably the latter category that has more to gain from adopting Rust.
As of August 2026, the kernel has Rust drivers in the latter category for two families of networking devices (the AX88796B and QT2025), TH1520 pulse-width modulation controllers, and CPU-frequency management. That is not much, but these small drivers provide a blueprint for users who want to add support for their own niche devices and who may not be confident in their ability to write correct kernel C. Hindborg's rnull driver provides the same kind of blueprint for adding new block devices to the kernel. Perhaps we will soon see drivers for niche block devices coming to the mailing lists.
The LWN site is currently under high scraper load, so comment display has been suppressed for anonymous users. If you are a human, you may read the comments by clicking the button below:
Note: you can avoid this step in the future by logging into your LWN account.
