Block Device Drivers
Build a working /dev/ block device from scratch by mastering gendisk, blk-mq tag sets, and the complete I/O path from write() to your driver's queue_rq callback.
Overview
Block device drivers sit at one of the most fascinating intersections in the entire Linux kernel: they translate the abstract concept of "a place to store bytes" into concrete hardware operations on spinning platters, flash cells, or even plain RAM. Unlike character devices that stream data byte by byte, block devices expose a random-access array of fixed-size sectors, and the kernel's block layer adds a rich stack of machinery on top — request merging, scheduling, multi-queue dispatch — before a single byte ever reaches your driver.
This module tears open that machinery from the driver's point of view. You will learn what gendisk really is and why it exists, how the kernel packages I/O requests and routes them through blk-mq, what "sectors" and "segments" mean in hardware and kernel terms, and how to write a real (if tiny) block driver from scratch. Every concept is grounded in actual kernel source that you can grep and read yourself.
The ramdisk driver you will build by the end is the same concept powering brd (the in-kernel RAM disk at drivers/block/brd.c) and zram. It is a few hundred lines of C, it registers a fully functional /dev/mybrd0 that you can mkfs, mount, and write files to, and every line of it illuminates a different piece of the block layer architecture.
By the end of this module you will not just understand block drivers — you will have genuine mental models of the entire I/O path from write(2) in userspace down to the driver's queue_rq callback, which is the foundation you need to understand real-world storage drivers like nvme, virtio_blk, and dm device-mapper targets.
Block device drivers are the gatekeepers to persistent storage — the single most critical resource in any real computer system. Every database, every filesystem, every virtual machine image ultimately flows through this layer. Storage performance bottlenecks are among the most common real-world systems problems, and understanding the block layer lets you diagnose them with precision: you can read blktrace output, interpret iostat numbers, tune I/O schedulers, and know why a particular NVMe queue depth setting matters.
For kernel contribution specifically, the block layer and its drivers (nvme, virtio_blk, scsi, brd, nbd, loop) are extremely active development areas with many approachable entry points: adding new ioctl commands, fixing error handling, improving tracing, porting drivers to newer blk-mq APIs, or writing new targets for dm (device mapper). The concepts you learn here — gendisk, blk_mq_tag_set, bio, request — appear in hundreds of files across drivers/block/, drivers/nvme/, drivers/scsi/, and block/. Mastering this module means you can read, understand, and eventually modify production storage drivers that run on millions of servers worldwide.
Block I/O Path: From write() to queue_rq
Every write() from userspace flows down through the VFS, page cache, and block layer merging/scheduling before reaching the driver's queue_rq callback. The path is identical whether the driver is a ramdisk, NVMe SSD, or virtio disk — only queue_rq and the completion path differ.
The concepts
01What Is a Block Device? Sectors, Blocks, and Pages
Before writing a single line of driver code, you need to nail down the vocabulary, because the kernel uses three different units of storage and confuses them constantly.
Sector (512 bytes or 4096 bytes): The smallest addressable unit as seen by the hardware. Traditional spinning disks use 512-byte logical sectors. Modern "4Kn" (4K native) drives and most NVMe SSDs use 4096-byte sectors. The kernel's block layer uses a software-defined logical sector size of 512 bytes for all its internal arithmetic, regardless of the hardware. Sector numbers in the kernel are always 512-byte units, encoded as sector_t (a u64). Your driver tells the kernel the real hardware sector size via blk_queue_physical_block_size() and blk_queue_logical_block_size(), and the block layer handles the translation.
Block (filesystem block, typically 1 KB–4 KB): The unit used by filesystems. ext4 defaults to 4096-byte blocks. A filesystem block is always a multiple of the hardware sector size. The kernel's buffer cache works in terms of filesystem blocks via struct buffer_head (mostly legacy) or, more modernly, via struct folio / struct page.
Page (4096 bytes on x86/ARM64): The unit of the VM subsystem. A page is 4096 bytes on almost all modern architectures. When the page cache holds file data, it holds it in pages. I/O requests are built from page-sized (or sub-page) fragments.
The relationships:
1 page = 8 × 512-byte sectors (on typical hardware)
1 filesystem block = 1–8 × 512-byte sectors
1 filesystem block ≤ 1 page (block size must not exceed page size)
When a process calls write(fd, buf, 4096) to a file on an ext4 filesystem on an NVMe SSD:
- The VFS layer dirties a page in the page cache.
- When writeback fires, the page cache builds a
struct biodescribing that page. - The bio specifies a starting sector number (512-byte units) and a length.
- The block layer hands the bio to the NVMe driver.
- The NVMe driver translates into an NVMe command with a 512-byte-based LBA.
This is why sector_t is 64-bit: a 2 TB drive has 2×10^12 / 512 ≈ 4×10^9 sectors, which fits in 32 bits barely, but enterprise drives reach hundreds of terabytes.
/* In your driver's init, after creating a queue: */
blk_queue_logical_block_size(disk->queue, 512); /* what the kernel sees */
blk_queue_physical_block_size(disk->queue, 4096); /* what the HW uses */
blk_queue_io_min(disk->queue, 4096); /* minimum efficient I/O */
blk_queue_io_opt(disk->queue, 4096); /* optimal I/O size */
/* Sector arithmetic: convert byte offset to sector number */
sector_t sector = byte_offset >> 9; /* divide by 512 */
u64 bytes = (u64)sector << 9; /* multiply by 512 */
02struct gendisk — The Kernel's View of a Disk
struct gendisk (defined in include/linux/blkdev.h) is the kernel's representation of a block device. It is analogous to struct net_device for network cards: one instance per logical disk. When you run ls /dev/sda, ls /dev/nvme0n1, or ls /dev/loop0, each name corresponds to one gendisk.
The key fields you interact with as a driver author:
major,first_minor,minors: The device number scheme.majoridentifies your driver (allocated withregister_blkdev()).minorsis how many minor numbers this disk consumes — typically 1 (the whole disk) plus up to 15 for partitions, totaling 16. Each partition gets its own minor number.disk_name[DISK_NAME_LEN]: The base name:"sda","nvme0n1","mybrd0".fops: Pointer tostruct block_device_operations— your driver's vtable. Key callbacks:open,release,ioctl,getgeo(for geometry, legacy but sometimes needed).queue: Pointer tostruct request_queue. This is where all I/O scheduling, merging, and dispatch happens. In the modernblk-mqworld you do not create this manually; it is created byblk_mq_alloc_disk().private_data: Your driver's per-device state.part0: Embeddedstruct block_devicefor the whole-disk device.
Lifecycle:
blk_mq_alloc_disk() → allocates gendisk + request_queue together
set_capacity() → tells the kernel how many 512-byte sectors exist
add_disk() → registers with the kernel, creates /dev/ node
...
del_gendisk() → unregisters, removes /dev/ node
put_disk() → drops reference, frees memory
Before kernel 5.14, drivers called alloc_disk() + blk_alloc_queue() separately. Modern code uses blk_mq_alloc_disk() which does both atomically. Always use the modern API for new drivers.
#include <linux/blkdev.h>
#include <linux/blk-mq.h>
struct mybrd_dev {
struct gendisk *disk;
u8 *data; /* backing store */
spinlock_t lock;
struct blk_mq_tag_set tag_set;
};
static struct block_device_operations mybrd_fops = {
.owner = THIS_MODULE,
/* .open / .release / .ioctl can be NULL for a minimal driver */
};
static int mybrd_alloc(struct mybrd_dev *dev, int minor)
{
dev->disk = blk_mq_alloc_disk(&dev->tag_set, NULL, dev);
if (IS_ERR(dev->disk))
return PTR_ERR(dev->disk);
dev->disk->major = mybrd_major;
dev->disk->first_minor = minor;
dev->disk->minors = 1; /* no partition support */
dev->disk->fops = &mybrd_fops;
dev->disk->private_data = dev;
snprintf(dev->disk->disk_name, DISK_NAME_LEN, "mybrd%d", minor);
set_capacity(dev->disk, MYBRD_SECTORS); /* total 512-byte sectors */
return 0;
}
03struct bio — The Unit of I/O
struct bio (block I/O) is the fundamental currency of the block layer. Every I/O request that enters the block layer — whether from a filesystem, a direct I/O call, or a page cache writeback — starts as one or more bio structures. Understanding bio is non-negotiable.
Think of a bio as a scatter-gather descriptor for a single contiguous range of disk space, but backed by potentially non-contiguous memory pages. The key fields:
bi_iter.bi_sector: Starting sector (512-byte units) on the disk.bi_iter.bi_size: Total bytes remaining to transfer.bi_opf: Operation flags. The low bits encode the operation:REQ_OP_READ(0),REQ_OP_WRITE(1),REQ_OP_DISCARD(3),REQ_OP_FLUSH(7), etc. Higher bits carry hints likeREQ_SYNC,REQ_META,REQ_FUA(force unit access — don't cache in drive buffer).bi_io_vec: Array ofstruct bio_vec, each describing one memory segment:{struct page *bv_page, unsigned bv_len, unsigned bv_offset}.bi_vcnt/bi_max_vecs: Number of valid / maximumbio_vecentries.bi_end_io: Completion callback. When your driver finishes, it callsbio_endio(bio)which invokes this function.bi_private: Opaque pointer for the submitter's use.
Iterating over a bio's data — never access bi_io_vec directly. Use the provided iterators:
struct bio_vec bvec;
struct bvec_iter iter;
bio_for_each_segment(bvec, bio, iter) {
void *kaddr = kmap_local_page(bvec.bv_page);
/* kaddr + bvec.bv_offset is the data, bvec.bv_len bytes */
kunmap_local(kaddr);
}
bio vs. request: A bio is the raw I/O descriptor from the submitter. A struct request is what the block layer creates by merging adjacent bios from the same I/O stream. When you use blk-mq, your queue_rq callback receives a struct request which contains one or more chained bios. For a simple ramdisk that processes each request synchronously, you iterate over the request's bios using rq_for_each_segment().
/* Iterating over all data in a request (what your queue_rq sees) */
struct bio_vec bvec;
struct req_iterator iter;
sector_t sector = blk_rq_pos(rq); /* starting sector of the request */
rq_for_each_segment(bvec, rq, iter) {
unsigned long offset = bvec.bv_offset;
unsigned int len = bvec.bv_len;
struct page *page = bvec.bv_page;
void *src = kmap_local_page(page) + offset;
if (rq_data_dir(rq) == WRITE)
memcpy(mybrd_data + (sector << 9), src, len);
else
memcpy(src, mybrd_data + (sector << 9), len);
kunmap_local(src);
sector += len >> 9; /* advance sector pointer */
}
04The blk-mq Framework — Multi-Queue Block Layer
The blk-mq (multi-queue) framework, introduced in Linux 3.13 and now mandatory for all new drivers, is the architecture that lets NVMe SSDs saturate 3 GB/s or more while keeping CPU overhead low. To understand why it exists, you need the history.
The old single-queue problem: The original block layer had a single request_queue with a single spinlock. On a multicore machine, all CPUs serialized their I/O submissions through that one lock. A spinning hard disk doing 150 I/Os per second could never saturate that lock. But NVMe SSDs can do 1,000,000 I/Os per second. Suddenly the single queue was the bottleneck.
The blk-mq solution: One software queue per CPU (or per NUMA node), plus multiple hardware dispatch queues matching the device's actual hardware queues. Submissions from CPU 0 go to CPU 0's queue; submissions from CPU 3 go to CPU 3's queue. No cross-CPU serialization. Hardware queues map to the device's actual command submission rings.
Key data structures:
-
struct blk_mq_tag_set: The shared configuration for all hardware queues of one device. Fields:ops: Pointer tostruct blk_mq_ops(your callbacks, especiallyqueue_rq).nr_hw_queues: How many hardware queues. Use 1 for a simple ramdisk; NVMe uses one per CPU or as many as the hardware supports.queue_depth: How many requests can be in-flight per hardware queue (tag budget). 64–256 is typical.cmd_size: Extra bytes to allocate per request for driver-private data (accessed viablk_mq_rq_to_pdu(rq)).numa_node: NUMA affinity.
-
struct blk_mq_hw_ctx: One per hardware queue. Holds the CPU-to-queue mapping, the dispatcher state. -
struct blk_mq_queue_data: Passed toqueue_rq, contains therequestand alastflag.
The queue_rq callback contract:
This is the most important callback you write. The block layer calls it with a request that is ready to be dispatched to hardware. You must either:
- Start the I/O and return
BLK_STS_OK(success, I/O in flight). - Return a non-OK status to requeue or fail the request.
For a synchronous driver (like a ramdisk), you do the I/O inline and call blk_mq_end_request(rq, BLK_STS_OK) before returning. For a real async driver (NVMe, virtio), you ring a doorbell, and the completion interrupt calls blk_mq_complete_request() later.
static const struct blk_mq_ops mybrd_mq_ops = {
.queue_rq = mybrd_queue_rq,
};
static int mybrd_init_tag_set(struct mybrd_dev *dev)
{
dev->tag_set.ops = &mybrd_mq_ops;
dev->tag_set.nr_hw_queues = 1; /* one hardware queue */
dev->tag_set.queue_depth = 128; /* up to 128 in-flight requests */
dev->tag_set.numa_node = NUMA_NO_NODE;
dev->tag_set.cmd_size = 0; /* no per-request driver data */
dev->tag_set.flags = BLK_MQ_F_SHOULD_MERGE;
dev->tag_set.driver_data = dev;
return blk_mq_alloc_tag_set(&dev->tag_set);
}
static blk_status_t mybrd_queue_rq(struct blk_mq_hw_ctx *hctx,
const struct blk_mq_queue_data *bd)
{
struct request *rq = bd->rq;
struct mybrd_dev *dev = hctx->queue->queuedata;
blk_mq_start_request(rq);
mybrd_do_request(dev, rq); /* synchronous memcpy */
blk_mq_end_request(rq, BLK_STS_OK);
return BLK_STS_OK;
}
05The Block I/O Path — From write() to queue_rq
Let's trace the full path of a single write(fd, buf, 4096) call from userspace to your driver's queue_rq. This is the 'vertical slice' that makes the entire architecture click.
Step 1 — VFS / page cache: write() enters the kernel via the syscall table → ksys_write() → vfs_write() → file's f_op->write_iter(). For a regular file on ext4, this writes to the page cache and marks the page dirty. The page cache is the kernel's RAM-based cache of file contents.
Step 2 — Writeback: Either immediately (for O_SYNC) or lazily (writeback thread), the page cache asks the filesystem to flush dirty pages. ext4 calls mpage_writepages() which builds struct bio descriptors for each dirty page.
Step 3 — submit_bio(): The filesystem calls submit_bio(bio). This enters the block layer. The call stack:
submit_bio()
└─ submit_bio_noacct()
└─ blk_mq_submit_bio()
├─ [merging with existing requests if possible]
├─ [I/O scheduler, if any]
└─ blk_mq_dispatch_rq_list()
└─ ops->queue_rq(hctx, &bd)
Step 4 — Merging: The block layer tries to merge the new bio with an existing request already in the queue (extending a sequential I/O stream). If the new bio starts exactly where an existing request ends (or vice versa), they are merged into one larger request. This is why sequential I/O is faster than random I/O even on SSDs — the block layer batches more data per call to queue_rq.
Step 5 — Dispatch to queue_rq: If no merge is possible, a new struct request is allocated from the tag set's pre-allocated pool (the 'tag' is simply an index into this pool — it's how the driver matches a completion interrupt back to the right request). Your queue_rq is called.
Step 6 — Completion: For async drivers, when the device signals completion (interrupt → IRQ handler → blk_mq_complete_request() → blk_mq_end_request()), the block layer calls the bio's bi_end_io callback, which notifies the filesystem, which wakes up any processes waiting on O_SYNC.
The beauty of this design: your driver only sees struct request. It does not know whether the I/O came from a filesystem, O_DIRECT, mmap, or io_uring. The block layer abstracts all of that.
/* You can observe this path with ftrace:
echo 'submit_bio blk_mq_submit_bio blk_mq_dispatch_rq_list' \
> /sys/kernel/debug/tracing/set_ftrace_filter
echo function > /sys/kernel/debug/tracing/current_tracer
echo 1 > /sys/kernel/debug/tracing/tracing_on
dd if=/dev/zero of=/dev/mybrd0 bs=4096 count=1
cat /sys/kernel/debug/tracing/trace | head -40
*/
/* The request carries the merged bio chain:
Total bytes in request: blk_rq_bytes(rq)
Starting sector: blk_rq_pos(rq)
Direction (R/W): rq_data_dir(rq) -> READ or WRITE */
06The Tag Set and Request Allocation
The 'tag' in blk-mq is a brilliantly simple idea: instead of dynamically allocating struct request for every I/O, the kernel pre-allocates a fixed pool of request objects at queue setup time. Each request gets an integer index — its tag — from 0 to queue_depth - 1. When your driver submits an NVMe or virtio command to hardware, it encodes the tag into the command. When the device signals completion, it echoes back the tag, so the interrupt handler can immediately find the right struct request with an array lookup (O(1)) rather than a search.
This is the same idea as a free-list of pre-allocated objects, but exposed as an API:
blk_mq_alloc_tag_set(&ts): Allocates the pool of requests, the hardware queue structures, the per-CPU software queues.blk_mq_free_tag_set(&ts): Frees everything.blk_mq_rq_to_pdu(rq): Returns a pointer tocmd_sizebytes of driver-private data embedded right after thestruct request. This is how an NVMe driver stores the DMA address or a virtio driver stores the ring index — no dynamic allocation needed per request.blk_mq_tag_to_rq(tags, tag): Given a tag number, returns thestruct request. Used in interrupt handlers.
Tag set vs. per-driver context: The tag set is shared across all queues of one logical disk. The blk_mq_hw_ctx (one per hardware queue) holds a subset of tags (its tags field). For a simple single-queue driver, the whole tag set is one hardware queue.
Practical sizing: queue_depth should match what the hardware can handle. For a ramdisk, 128 is fine. For NVMe, the hardware reports its queue depth via identify controller; the driver sets queue_depth = min(hw_depth, BLK_MQ_MAX_DEPTH). Setting it too high wastes memory; setting it too low limits throughput under concurrent load.
/* Per-request driver data: cmd_size bytes embedded in each request */
struct mybrd_request_pdu {
ktime_t submit_time; /* for latency measurement */
};
/* Set cmd_size at tag_set init: */
dev->tag_set.cmd_size = sizeof(struct mybrd_request_pdu);
/* In queue_rq, access the per-request data: */
static blk_status_t mybrd_queue_rq(struct blk_mq_hw_ctx *hctx,
const struct blk_mq_queue_data *bd)
{
struct mybrd_request_pdu *pdu = blk_mq_rq_to_pdu(bd->rq);
pdu->submit_time = ktime_get();
/* ... do I/O ... */
pr_debug("latency: %lld ns\n",
ktime_to_ns(ktime_sub(ktime_get(), pdu->submit_time)));
blk_mq_end_request(bd->rq, BLK_STS_OK);
return BLK_STS_OK;
}
07A Complete Minimal Ramdisk Driver
Theory crystallizes into understanding when you write and test real code. Here is a complete, compilable, loadable kernel module that creates a /dev/mybrd0 backed by kernel memory. It is intentionally minimal — no partition support, no power management, no geometry — so every line is load-bearing and understandable.
What it does:
- Allocates a contiguous vmalloc region as the backing store.
- Registers a
blk_mq_tag_setwith 1 hardware queue. - Allocates a
gendiskand sets capacity. - Registers the disk with
add_disk(). - In
queue_rq, copies data between the vmalloc buffer and the bio segments. - On unload, cleans up in reverse order.
How to build: Put the source in a directory with a Makefile:
obj-m += mybrd.o
KDIR := /lib/modules/$(shell uname -r)/build
all:
$(MAKE) -C $(KDIR) M=$(PWD) modules
clean:
$(MAKE) -C $(KDIR) M=$(PWD) clean
Then: make && sudo insmod mybrd.ko && sudo mkfs.ext4 /dev/mybrd0 && sudo mount /dev/mybrd0 /mnt
The full driver is shown in the code example. Study each section — the init order, the cleanup order, the queue_rq implementation, and especially the rq_for_each_segment loop which is identical in spirit to what every block driver does.
// mybrd.c — Minimal ramdisk block driver
// Tested on Linux 6.6+
#include <linux/module.h>
#include <linux/blkdev.h>
#include <linux/blk-mq.h>
#include <linux/vmalloc.h>
#define MYBRD_CAPACITY_MB 16
#define MYBRD_SECTOR_SIZE 512
#define MYBRD_SECTORS ((MYBRD_CAPACITY_MB * 1024 * 1024) / MYBRD_SECTOR_SIZE)
static int mybrd_major;
struct mybrd_dev {
u8 *data;
struct gendisk *disk;
struct blk_mq_tag_set tag_set;
};
static struct mybrd_dev *mybrd_dev;
static void mybrd_do_bvec(struct mybrd_dev *dev, struct page *page,
unsigned int len, unsigned int off,
blk_opf_t opf, sector_t sector)
{
void *mem = kmap_local_page(page) + off;
unsigned long disk_off = (unsigned long)sector << 9;
if (op_is_write(opf))
memcpy(dev->data + disk_off, mem, len);
else
memcpy(mem, dev->data + disk_off, len);
kunmap_local(mem);
}
static blk_status_t mybrd_queue_rq(struct blk_mq_hw_ctx *hctx,
const struct blk_mq_queue_data *bd)
{
struct request *rq = bd->rq;
struct mybrd_dev *dev = hctx->queue->queuedata;
struct bio_vec bvec;
struct req_iterator iter;
sector_t sector = blk_rq_pos(rq);
blk_mq_start_request(rq);
rq_for_each_segment(bvec, rq, iter) {
mybrd_do_bvec(dev, bvec.bv_page, bvec.bv_len,
bvec.bv_offset, rq->cmd_flags, sector);
sector += bvec.bv_len >> 9;
}
blk_mq_end_request(rq, BLK_STS_OK);
return BLK_STS_OK;
}
static const struct blk_mq_ops mybrd_mq_ops = {
.queue_rq = mybrd_queue_rq,
};
static const struct block_device_operations mybrd_fops = {
.owner = THIS_MODULE,
};
static int __init mybrd_init(void)
{
int ret;
mybrd_dev = kzalloc(sizeof(*mybrd_dev), GFP_KERNEL);
if (!mybrd_dev)
return -ENOMEM;
mybrd_dev->data = vzalloc(MYBRD_SECTORS * MYBRD_SECTOR_SIZE);
if (!mybrd_dev->data) { ret = -ENOMEM; goto err_free_dev; }
mybrd_major = register_blkdev(0, "mybrd");
if (mybrd_major < 0) { ret = mybrd_major; goto err_free_data; }
mybrd_dev->tag_set.ops = &mybrd_mq_ops;
mybrd_dev->tag_set.nr_hw_queues = 1;
mybrd_dev->tag_set.queue_depth = 128;
mybrd_dev->tag_set.numa_node = NUMA_NO_NODE;
mybrd_dev->tag_set.flags = BLK_MQ_F_SHOULD_MERGE;
mybrd_dev->tag_set.driver_data = mybrd_dev;
ret = blk_mq_alloc_tag_set(&mybrd_dev->tag_set);
if (ret) goto err_unregister;
mybrd_dev->disk = blk_mq_alloc_disk(&mybrd_dev->tag_set,
NULL, mybrd_dev);
if (IS_ERR(mybrd_dev->disk)) {
ret = PTR_ERR(mybrd_dev->disk);
goto err_free_tagset;
}
mybrd_dev->disk->major = mybrd_major;
mybrd_dev->disk->first_minor = 0;
mybrd_dev->disk->minors = 1;
mybrd_dev->disk->fops = &mybrd_fops;
mybrd_dev->disk->private_data = mybrd_dev;
strscpy(mybrd_dev->disk->disk_name, "mybrd0", DISK_NAME_LEN);
set_capacity(mybrd_dev->disk, MYBRD_SECTORS);
ret = add_disk(mybrd_dev->disk);
if (ret) goto err_put_disk;
pr_info("mybrd: /dev/mybrd0 ready (%d MB)\n", MYBRD_CAPACITY_MB);
return 0;
err_put_disk: put_disk(mybrd_dev->disk);
err_free_tagset: blk_mq_free_tag_set(&mybrd_dev->tag_set);
err_unregister: unregister_blkdev(mybrd_major, "mybrd");
err_free_data: vfree(mybrd_dev->data);
err_free_dev: kfree(mybrd_dev);
return ret;
}
static void __exit mybrd_exit(void)
{
del_gendisk(mybrd_dev->disk);
put_disk(mybrd_dev->disk);
blk_mq_free_tag_set(&mybrd_dev->tag_set);
unregister_blkdev(mybrd_major, "mybrd");
vfree(mybrd_dev->data);
kfree(mybrd_dev);
pr_info("mybrd: unloaded\n");
}
module_init(mybrd_init);
module_exit(mybrd_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("Minimal ramdisk block driver");
08Error Handling and blk_status_t
blk_status_t is the block layer's error type — a thin wrapper around an integer, but with a well-defined set of values that the block layer knows how to propagate. Never return raw -ENOMEM or -EIO from queue_rq; always convert to blk_status_t.
The most important values:
BLK_STS_OK(0): Success.BLK_STS_IOERR: Generic I/O error (maps to-EIOin userspace).BLK_STS_TIMEOUT: The request timed out.BLK_STS_NOSPC: No space on device (for thin-provisioned volumes).BLK_STS_RESOURCE: Temporary resource shortage — the block layer will requeue the request and retry later. Use this when your driver is temporarily out of DMA buffers or descriptor ring space.BLK_STS_AGAIN: Similar toRESOURCE, used when the device is busy.BLK_STS_DEV_RESOURCE: Device-side resource shortage.
The requeue path: Returning BLK_STS_RESOURCE from queue_rq tells the block layer: "I cannot take this request right now, please give it back to me later." The block layer re-inserts the request into the hardware queue and calls queue_rq again when resources free up. This is how NVMe drivers handle submission ring full conditions without dropping requests.
Completing with error: blk_mq_end_request(rq, BLK_STS_IOERR) signals failure to the block layer, which propagates it up the bio chain to the filesystem or application as an -EIO on the syscall.
The timeout callback: You can register a struct blk_mq_ops.timeout callback. The block layer calls this if a request has been in flight for longer than the queue's rq_timeout (set via blk_queue_rq_timeout()). Your callback should decide whether to abort, reset, or retry.
/* Handling resource shortage (e.g., descriptor ring full in a real driver) */
static blk_status_t mydrv_queue_rq(struct blk_mq_hw_ctx *hctx,
const struct blk_mq_queue_data *bd)
{
struct request *rq = bd->rq;
int ret;
blk_mq_start_request(rq);
ret = mydrv_submit_to_hw(rq);
if (ret == -ENOSPC) {
/* Ring full: requeue without failing the request */
blk_mq_requeue_request(rq, true);
return BLK_STS_RESOURCE;
}
if (ret < 0) {
blk_mq_end_request(rq, BLK_STS_IOERR);
return BLK_STS_IOERR;
}
/* Completion will happen in IRQ handler */
return BLK_STS_OK;
}
/* In the IRQ handler / completion routine: */
void mydrv_complete(struct mydrv_dev *dev, u16 tag, bool success)
{
struct request *rq = blk_mq_tag_to_rq(dev->tag_set.tags[0], tag);
blk_mq_complete_request(rq); /* marks it done, schedules completion */
/* The block layer will call blk_mq_end_request internally */
}
09Request Merging and I/O Schedulers
The block layer does not simply pass every bio straight to your driver. It tries to merge adjacent requests to maximize throughput, especially important for spinning disks where seek time dominates. Understanding merging helps you write drivers that cooperate with the scheduler rather than fight it.
Front merge vs. back merge:
- Back merge: A new bio that starts exactly where an existing request ends. The new bio is appended to the request. This is the most common case for sequential writes.
- Front merge: A new bio that ends exactly where an existing request starts. Less common.
- No merge: Random I/O — each bio becomes its own request.
The BLK_MQ_F_SHOULD_MERGE flag in your tag set tells the block layer to attempt merging. Omit it for devices (like some NVMe) where you handle coalescing internally.
I/O schedulers (elevators): In the blk-mq era, schedulers are optional plugins that sit between submit_bio and queue_rq. They can reorder requests to minimize seek distance (for HDDs) or maximize fairness (BFQ). Available schedulers: none (FIFO, ideal for NVMe), mq-deadline (deadline guarantees, good balance), bfq (Budget Fair Queueing, best for desktop fairness). Check the active scheduler:
cat /sys/block/sda/queue/scheduler
# [mq-deadline] bfq none
For your ramdisk driver, none is correct — there is no seek cost, so any reordering is wasted CPU. Real NVMe drivers also default to none for the same reason. BFQ is the right choice for slow HDDs serving multiple users.
BLK_MQ_F_NO_SCHED: Set this flag in your tag set to explicitly opt out of all schedulers and use pure FIFO dispatch. Good for memory-backed or network-backed block devices.
/* Check and set the scheduler on your device from userspace: */
/* cat /sys/block/mybrd0/queue/scheduler */
/* echo none > /sys/block/mybrd0/queue/scheduler */
/* Tell the block layer the max request size your driver can handle.
This controls how large merged requests can grow. */
blk_queue_max_hw_sectors(disk->queue, MYBRD_SECTORS);
/* Tell the block layer how many scatter-gather segments per request */
blk_queue_max_segments(disk->queue, BLK_MAX_SEGMENTS); /* 128 */
/* For a ramdisk: no scheduler, pure FIFO */
disk->queue->limits.features |= BLK_FEAT_ROTATIONAL; /* clear = SSD */
/* In tag_set flags: */
dev->tag_set.flags = BLK_MQ_F_SHOULD_MERGE | BLK_MQ_F_NO_SCHED;
10Discard, Flush, and FUA — Beyond Simple Read/Write
Modern block devices support operations beyond simple read and write. Your driver needs to advertise which of these it supports and handle them correctly.
DISCARD (REQ_OP_DISCARD): Tells the device that a range of sectors no longer contains useful data. SSDs use this to reclaim flash blocks for wear-leveling (it is the block-layer equivalent of free()). For a ramdisk, you can implement discard by zeroing the backing store (as brd.c does) or by doing nothing. Advertise support by setting:
blk_queue_max_discard_sectors(disk->queue, UINT_MAX);
disk->queue->limits.discard_granularity = PAGE_SIZE;
If you do not set these, the block layer will refuse discard requests before they reach your driver.
FLUSH (REQ_OP_FLUSH) and FUA (Force Unit Access): Databases and filesystems that guarantee durability need to know that data is actually on persistent media, not just in a drive's volatile write cache. REQ_OP_FLUSH asks the drive to flush its write cache. REQ_FUA on a write request asks the drive to write directly to persistent storage, bypassing cache. For a ramdisk backed by RAM, there is no write cache, so you can safely handle flush as a no-op:
blk_queue_write_cache(disk->queue, false, false);
/* false, false = no volatile write cache, no FUA support needed */
For real hardware drivers (NVMe, SATA), getting flush/FUA wrong causes data corruption after a power failure. This is a major source of filesystem bugs.
WRITE ZEROES (REQ_OP_WRITE_ZEROES): Efficiently write zeros to a range without transferring data. SSDs can do this in firmware faster than writing actual zero bytes. For a ramdisk, implement it as memset(dev->data + offset, 0, len) and advertise:
blk_queue_max_write_zeroes_sectors(disk->queue, MYBRD_SECTORS);
/* Handling special operations in queue_rq */
static blk_status_t mybrd_queue_rq(struct blk_mq_hw_ctx *hctx,
const struct blk_mq_queue_data *bd)
{
struct request *rq = bd->rq;
struct mybrd_dev *dev = hctx->queue->queuedata;
blk_mq_start_request(rq);
switch (req_op(rq)) {
case REQ_OP_READ:
case REQ_OP_WRITE:
mybrd_xfer_request(dev, rq);
break;
case REQ_OP_DISCARD:
case REQ_OP_WRITE_ZEROES: {
/* Zero out the sector range */
unsigned long off = blk_rq_pos(rq) << 9;
unsigned long len = blk_rq_bytes(rq);
memset(dev->data + off, 0, len);
break;
}
case REQ_OP_FLUSH:
/* RAM has no write cache; nothing to do */
break;
default:
blk_mq_end_request(rq, BLK_STS_IOERR);
return BLK_STS_IOERR;
}
blk_mq_end_request(rq, BLK_STS_OK);
return BLK_STS_OK;
}
11Partitions and Capacity
When you call add_disk(), the kernel reads the disk's partition table (GPT or MBR) by issuing read requests to the first sector(s). If a valid partition table is found, the kernel creates additional block devices: /dev/mybrd0p1, /dev/mybrd0p2, etc. Each partition is a struct block_device with its own sector range, but backed by the same gendisk and driver.
From your driver's perspective, partitions are completely transparent. A request for /dev/mybrd0p1 arrives at your queue_rq with blk_rq_pos(rq) already translated to an absolute sector number on the underlying device. You do not need to know about partitions at all.
Setting capacity correctly: set_capacity(disk, sectors) sets the size in 512-byte sectors. The helper set_capacity_and_notify(disk, sectors) also notifies udev of capacity changes, useful for dynamic resize (as the loop driver does when you losetup a larger file).
Minor number allocation: For a disk that supports partitions, you need to allocate minors minor numbers. Convention is 16 per disk (1 for whole disk + 15 partitions). If you expect mybrd0, mybrd1, ..., mybrd7:
register_blkdev(0, "mybrd"); /* allocates major */
disk->first_minor = minor_number * 16; /* 0, 16, 32, ... */
disk->minors = 16;
GENHD_FL_NO_PART: Set this flag in disk->flags to completely disable partition scanning. This is correct for raw-access block devices like encrypted volumes or distributed block storage that manage their own internal structure. dm (device mapper) targets always set this flag.
/* For a ramdisk that does NOT want partition support: */
disk->flags |= GENHD_FL_NO_PART;
disk->minors = 1;
/* For a disk that DOES support partitions (conventional): */
disk->minors = 16; /* 1 whole-disk + 15 partitions */
disk->first_minor = 0 * 16; /* instance 0 */
/* After mounting a filesystem on /dev/mybrd0p1, reads to that
partition arrive in queue_rq with sector already offset:
If p1 starts at LBA 2048 (1 MiB offset) and the read is for
byte 0 of p1, blk_rq_pos(rq) == 2048, not 0.
Your driver sees absolute sectors only. */
/* Dynamic resize (e.g., to double the ramdisk): */
new_data = vzalloc(new_size);
memcpy(new_data, dev->data, old_size);
vfree(dev->data);
dev->data = new_data;
set_capacity_and_notify(dev->disk, new_sectors);
Key insights
- ✦The kernel's block layer always uses 512-byte sectors internally for all arithmetic, regardless of the physical hardware sector size — your driver bridges the gap via queue limit helpers.
- ✦struct bio is the unit of I/O built by the page cache; struct request is what your driver sees after the block layer has merged and scheduled one or more bios. You write a queue_rq that processes requests, not raw bios.
- ✦blk-mq's pre-allocated tag pool is the key to high-performance async I/O: tags enable O(1) completion lookup without dynamic allocation in the hot path — the tag IS the index into the request array.
- ✦The single most important cleanup rule: del_gendisk() must be called before blk_mq_free_tag_set(), and you must unregister_blkdev() last. Reversing this order causes use-after-free panics.
- ✦Returning BLK_STS_RESOURCE from queue_rq is a safe 'not right now' signal — the block layer will retry. Never drop a request silently; always end it with blk_mq_end_request() on the completion path.
- ✦From your driver's view, partitions do not exist — blk_rq_pos() always gives you an absolute sector on the physical device, with the partition offset already applied by the block layer.
- ✦Flush and FUA are your durability contracts to userspace. Getting them wrong does not crash the kernel immediately — it corrupts filesystems on power failure, which is much worse.
Common pitfalls
- ⚠Forgetting to call blk_mq_start_request(rq) before doing I/O in queue_rq. This causes the request timeout machinery to fire incorrectly and is a silent correctness bug.
- ⚠Calling blk_mq_free_tag_set() before del_gendisk(). The gendisk holds references into the tag set; freeing the tag set first causes a use-after-free when the last disk reference is dropped.
- ⚠Using vmalloc() for the backing store and then trying to use it in DMA operations. vmalloc() memory is not physically contiguous and cannot be used with DMA APIs — use alloc_pages() or dma_alloc_coherent() for real DMA.
- ⚠Accessing bi_io_vec directly instead of using bio_for_each_segment() or rq_for_each_segment(). The iterator handles cloned bios, partial completions, and bi_iter correctly; direct access does not.
- ⚠Not setting set_capacity() before add_disk(). The kernel performs partition scanning immediately on add_disk(); if capacity is zero, the scan reads nothing and your device appears empty.
- ⚠Confusing bytes and sectors in arithmetic. blk_rq_pos() returns sectors (512-byte units); blk_rq_bytes() returns bytes. Multiplying blk_rq_pos() by 512 and blk_rq_bytes() by 512 is a common off-by-512x bug.
- ⚠Calling blk_mq_end_request() from queue_rq for a request that was submitted asynchronously (e.g., to hardware). If your interrupt handler also calls it, you get a double-completion BUG().
Practice
- 01easy
Build and load the minimal ramdisk driver from this module. Format it with ext4, mount it, create files, unmount, and reload the module. Verify that data does NOT persist across rmmod/insmod (because it is RAM-backed). Then explain in writing exactly why the data is lost.
- 02easy
Add DISCARD support to the ramdisk driver. When the block layer sends a REQ_OP_DISCARD, zero out the corresponding range in the backing store. Test it by running fstrim -v /mnt after mounting the formatted ramdisk. Confirm via blktrace that DISCARD requests actually reach queue_rq.
- 03medium
Add a per-request latency counter to the ramdisk. Use cmd_size to embed a ktime_t in each request. In queue_rq, record the start time; before calling blk_mq_end_request, compute the elapsed nanoseconds and accumulate a global total_ns and request_count. Expose these via a sysfs attribute under /sys/block/mybrd0/. Read them with cat and compute average latency.
- 04medium
Modify the ramdisk to support multiple instances (mybrd0, mybrd1, mybrd2) loaded via a module parameter nr_devs. Each instance should have independent backing RAM. Test by formatting each with a different filesystem (ext4, vfat, xfs) and mounting all three simultaneously.
- 05hard
Write a userspace program that uses O_DIRECT to write a 1 MB pattern to /dev/mybrd0, then reads it back and verifies correctness. Then instrument the kernel with a tracepoint (add trace_mybrd_rw() calls in queue_rq) and observe the O_DIRECT I/O path vs. buffered I/O path differences using trace-cmd.
Resources
The in-kernel ramdisk — 500 lines that are the best real reference for everything in this module. Read every line.
Free Electrons / Bootlin training slides cover block drivers with annotated diagrams. Search for 'block' in the PDF.
The definitive introduction to blk-mq by Jonathan Corbet, written when the feature was introduced. Essential historical context.
A more recent overview of the block layer architecture that ties together bio, request, and blk-mq.
Chapter 16 covers block drivers. The blk-mq material is outdated (predates blk-mq) but the bio/request/gendisk conceptual foundation is still accurate and well-explained.
The definitive struct definitions for gendisk, request_queue, blk_mq_tag_set, and all the accessor macros. Read alongside writing code.
The blk-mq core implementation. Reading blk_mq_submit_bio() and blk_mq_dispatch_rq_list() will cement your understanding of the dispatch path.
The deepest available explanation of the page cache and I/O path, though predates blk-mq.
Essential tooling for observing the block layer in action. Every concept in this module has a corresponding blktrace event.
Community wiki with pointers to active block layer development projects — good for finding contribution opportunities.
Checkpoints — you can now…
- ✓You can explain the difference between a sector, a filesystem block, and a page, and convert between them in C without looking up the formula.
- ✓You can describe the fields of struct gendisk and struct blk_mq_tag_set and explain why each one exists.
- ✓You can trace a write() syscall through the kernel to queue_rq, naming each layer (VFS, page cache, submit_bio, blk-mq dispatch) and what data structure is passed between them.
- ✓You can write a compilable blk-mq block driver from memory that registers a /dev/ node, handles read and write requests via rq_for_each_segment, and cleans up without memory leaks.
- ✓You can explain what blk_mq_start_request(), blk_mq_end_request(), and BLK_STS_RESOURCE do and when each must be called.
- ✓You can use blktrace and blkparse to observe real I/O events on your ramdisk driver and identify READ, WRITE, and DISCARD event types.
- ✓You can explain why the cleanup order (del_gendisk before blk_mq_free_tag_set before unregister_blkdev) matters and what goes wrong if you reverse it.