Block Device Drivers
A block device driver is the second of the three classic Linux driver archetypes (character, block, network), and it is structured fundamentally differently from a character driver. A character driver hangs a
struct file_operationstable off a/devnode and services userspace oneread()/write()byte-stream call at a time; a block driver instead registers astruct gendisk(“generic disk”) and plugs into the kernel’s block layer, which sits between the page cache / filesystem and the hardware. The block layer buffers I/O, merges and reorders adjacent requests into a per-device request queue, and hands the driver work either as fully-formedstruct requestobjects (the request-based,blk-mqmodel) or as rawstruct bioobjects (the bio-based model). This note owns the driver’s-eye view: how you allocate and register agendisk, what callback tables you fill in (struct block_device_operationsandstruct blk_mq_ops), the bio-based vs request-based split, and a minimal worked skeleton. The deep mechanics of the queue, the request, and the bio live in sibling notes — see The Multi-Queue Block Layer blk-mq, Request Queues and struct request, The bio Structure, and The Block IO Submission Path — and this note cross-links rather than re-derives them.
The single most important idea: a block driver does not implement a byte-stream interface at all. Its central job is to register a disk and provide one callback that consumes I/O work — .queue_rq() for a request-based driver, or .submit_bio() for a bio-based driver. Everything else (caching, the elevator/scheduler, partition handling, the /dev/sdaN nodes) is supplied by the block layer once the disk is added.
Uncertain
Version pinning: every API name, struct field, and function signature below was read from the Linux v6.12 LTS source tree (verified against the raw GitHub blobs listed in
sources:). The 6.18 LTS line (released 2025-11-30) is very close in this area but was not diffed field-by-field for this note. Treat 6.18-specific deltas (if any) as unverified. To resolve: diffinclude/linux/blkdev.h,include/linux/blk-mq.h, andblock/genhd.cbetween thev6.12andv6.18tags. uncertain
Mental Model — A Disk Plus One Work-Consuming Callback
Think of a block driver as registering a disk object with the block layer and then waiting to be handed I/O. The disk object is struct gendisk. Attached to it are two things: a request queue (struct request_queue) that the block layer owns and uses to buffer/merge/schedule I/O, and a block_device_operations table (disk->fops) that handles control-plane operations like open, release, and ioctl. The data plane — actually moving sectors — is delivered through one of two paths depending on which kind of driver you wrote.
flowchart TB subgraph US["Userspace / Filesystem"] APP["read()/write() syscall<br/>or mmap'd page"] end APP --> PC["Page cache + VFS"] PC --> BL subgraph BL["Block layer (kernel-owned)"] BIO["bio: 'these sectors,<br/>these pages, this op'"] Q["request_queue<br/>(merge + schedule)"] REQ["struct request<br/>(merged bios + a tag)"] BIO --> Q --> REQ end subgraph DRV["Your block driver"] GD["struct gendisk<br/>(the disk object)"] FOPS["block_device_operations<br/>.open .release .ioctl"] MQOPS["blk_mq_ops<br/>.queue_rq(request)"] SBIO[".submit_bio(bio)<br/>(bio-based only)"] end REQ -->|"request-based path"| MQOPS BIO -.->|"bio-based path<br/>(skips request building)"| SBIO MQOPS --> HW["Hardware / backing store"] SBIO --> HW GD -.owns.-> Q GD -.points to.-> FOPS
The two ways the block layer delivers I/O to a driver. What it shows: the filesystem/page-cache layer produces bio objects; a request-based driver lets the block layer collect and merge those bios into struct request objects (each carrying a hardware tag) and receives them through blk_mq_ops.queue_rq(); a bio-based driver instead registers a .submit_bio() hook and receives raw bios with no request-building or scheduling in between. The insight to take: choosing request-based vs bio-based is the single biggest structural decision in a block driver — request-based gets you the scheduler, merging, and tag management “for free” but is meant for real hardware with a command queue; bio-based is for software stacks (RAM disks, device-mapper-like layering) that want to handle each bio themselves with minimal overhead.
Mechanical Walk-through — Registering and Servicing a Disk
Step 1: Allocate the gendisk
A struct gendisk is never kmalloc’d by the driver directly; it is allocated by a block-layer helper that also sets up the request queue. There are two allocators, one per driver model.
For a request-based driver you first build a tag set (struct blk_mq_tag_set) describing your command depth and your blk_mq_ops, then call blk_mq_alloc_disk(set, lim, queuedata). This single call allocates the gendisk, creates a multi-queue request_queue bound to your tag set, and returns the disk (or an ERR_PTR on failure) — verified in include/linux/blk-mq.h v6.12, where blk_mq_alloc_disk is a macro wrapping __blk_mq_alloc_disk(set, lim, queuedata, &__key).
For a bio-based driver you have no tag set and no requests, so you call blk_alloc_disk(lim, node_id) (a macro over __blk_alloc_disk in include/linux/blkdev.h v6.12). Its kerneldoc says verbatim: “Allocate and pre-initialize a gendisk structure for use with BIO based drivers.” The lim argument in both is a struct queue_limits describing logical/physical block size, max segment size, discard support, and feature flags — the block layer copies it into the queue.
Step 2: Fill in the disk
Once you hold a gendisk, you set the fields the block layer reads when it registers the disk. From the brd (RAM disk) and null_blk drivers in v6.12, the standard set is:
disk->fops— pointer to yourstruct block_device_operations(mandatory; this is the control-plane table).disk->private_data— your driver’s per-device context, recovered later viabio->bi_bdev->bd_disk->private_dataorblk_mq_rq_to_pdu(rq).disk->disk_name— the kernel name ("ram0","nullb0"), copied withstrscpy.disk->major/disk->first_minor/disk->minors— optional for modern drivers. The comment at the top ofstruct gendiskin v6.12 reads: “major/first_minor/minors should not be set by any new driver, the block core will take care of allocating them automatically.” If you do setdisk->major,device_add_disk()requiresdisk->minorsto be non-zero (itWARN_ONs otherwise).set_capacity(disk, nr_sectors)— the size of the device in 512-byte sectors. This is not a field you poke directly; it’s a helper because capacity changes have to be published carefully.
Step 3: Add the disk — add_disk()
add_disk(disk) (which is device_add_disk(NULL, disk, NULL) per include/linux/blkdev.h) is the moment the device goes live. It is marked __must_check — you must test its return value, because as of the conversion that landed years ago add_disk can fail and return a negative errno. Inside device_add_disk() (in block/genhd.c v6.12) the block layer:
- Rejects nonsensical combinations — e.g. a request-based queue (
queue_is_mq) that also setfops->poll_bioreturns-EINVAL. - Initializes the I/O scheduler (
elevator_init_mq) for request-based queues so a default elevator is picked. - Detects the driver model: “Mark bdev as having a submit_bio, if needed” — it checks
disk->fops->submit_bioand, if present, sets theBD_HAS_SUBMIT_BIOflag on the disk’s whole-deviceblock_device. This single check is the kernel’s definition of “bio-based”: a disk is bio-based iff its fops providessubmit_bio. - Allocates the
dev_t(major/minor) if the driver didn’t supply one. - Registers the disk’s
struct deviceinto the device model (so it appears under/sys/block/<name>), creates the/devnode viadevtmpfs/uevents, and scans for partitions (see below).
After add_disk() returns success, userspace can open the device immediately, so all setup must be complete before you call it — exactly the same “register last” discipline as character and network drivers.
Step 4: Service I/O
Request-based: the block layer collects bios, merges them, attaches a free tag (an integer index into your command pool), and calls your blk_mq_ops.queue_rq(hctx, bd) where bd->rq is the struct request. Your callback typically calls blk_mq_start_request(rq) to start the timeout clock, issues the I/O to hardware, and returns a blk_status_t — BLK_STS_OK if it accepted the request, BLK_STS_RESOURCE to ask the block layer to back off and retry, or another error. Completion is asynchronous: when the hardware finishes you call blk_mq_end_request(rq, status) (often via the .complete callback after an interrupt).
Bio-based: there is no request and no tag. Your block_device_operations.submit_bio(bio) is called directly with each bio. You walk the bio’s segments (bio_for_each_segment), do the I/O, and call bio_endio(bio) to complete it.
Step 5: Tear down
On removal you call del_gendisk(disk) (which quiesces and unregisters the disk, in block/genhd.c) followed by put_disk(disk) to drop the final reference. Request-based drivers additionally call blk_mq_free_tag_set(set) after the disk is gone.
The Two Driver Models in Detail
Request-based (blk-mq) — the model for real hardware
Real storage hardware (SATA, SAS, NVMe, virtio-blk) has a finite command queue, benefits from merging adjacent I/O, and wants the I/O scheduler. For these you write a request-based driver: you describe your hardware to the block layer with a struct blk_mq_tag_set, and the block layer’s multi-queue machinery (The Multi-Queue Block Layer blk-mq) does merging, tagging, per-CPU software queues, and scheduling, then hands you finished struct requests.
The tag set from include/linux/blk-mq.h v6.12 carries:
struct blk_mq_tag_set {
const struct blk_mq_ops *ops; /* your callbacks */
unsigned int nr_hw_queues; /* hardware submission queues */
unsigned int queue_depth; /* tags per queue = max in-flight cmds */
unsigned int cmd_size; /* bytes of per-request driver scratch */
unsigned int flags; /* BLK_MQ_F_* */
void *driver_data;
/* ... */
};The two fields that matter most for structure are queue_depth (how many commands can be outstanding — this is your tag pool size) and cmd_size. cmd_size is a beautiful piece of design: the block layer allocates cmd_size extra bytes immediately after each struct request, and blk_mq_rq_to_pdu(rq) returns a pointer to that scratch area. So your per-command state (timers, DMA descriptors) rides along with the request for free, with no separate allocation. null_blk uses exactly this: set->cmd_size = sizeof(struct nullb_cmd) and then struct nullb_cmd *cmd = blk_mq_rq_to_pdu(rq) inside queue_rq.
The BLK_MQ_F_BLOCKING flag (bit 4 in v6.12) is the one structural switch every driver author must get right: set it if your queue_rq may sleep (e.g. it does network I/O or kmalloc(GFP_KERNEL)); leave it clear and queue_rq runs in a context that must not sleep. null_blk’s queue_rq calls might_sleep_if(hctx->flags & BLK_MQ_F_BLOCKING) to assert this contract.
Bio-based — the model for software stacks
If there is no command queue to manage and you just want to handle each I/O yourself — a RAM disk, a loopback over a file, a layering/virtual device — you write a bio-based driver. You skip the tag set entirely, allocate with blk_alloc_disk(), and provide block_device_operations.submit_bio. The block layer’s request-building, merging, and scheduling are bypassed: submit_bio_noacct() routes the bio straight to your hook. This is leaner but means you are responsible for everything the request layer would have done.
The classic example is drivers/block/brd.c (RAM disk) in v6.12, whose entire fops table is:
static const struct block_device_operations brd_fops = {
.owner = THIS_MODULE,
.submit_bio = brd_submit_bio,
};and whose brd_submit_bio recovers its context with struct brd_device *brd = bio->bi_bdev->bd_disk->private_data;, iterates segments with bio_for_each_segment, copies pages, and finishes with bio_endio(bio).
The historical “make_request_fn” function-pointer API for bio-based drivers is gone in modern kernels; bio-based drivers now register through
block_device_operations.submit_bio, which is why this section talks about fops rather than a separate registration call. (Verified against the brd/blkdev.h v6.12 sources; the olderblk_queue_make_requestAPI does not appear.)
Worked Example — A Minimal blk-mq Block Driver Skeleton
The following skeleton distills the registration shape used by null_blk and brd in v6.12 into the smallest request-based driver that compiles in your head. Each block is annotated.
#include <linux/module.h>
#include <linux/blk-mq.h>
#include <linux/blkdev.h>
struct mydev { /* per-device context */
struct gendisk *disk;
struct blk_mq_tag_set tag_set;
void *storage; /* backing memory, file, etc. */
};
/* (1) The data-plane callback: one request at a time. */
static blk_status_t mydev_queue_rq(struct blk_mq_hw_ctx *hctx,
const struct blk_mq_queue_data *bd)
{
struct request *rq = bd->rq; /* the merged I/O work */
sector_t pos = blk_rq_pos(rq); /* starting sector */
unsigned nsect = blk_rq_sectors(rq);
blk_mq_start_request(rq); /* start the timeout clock */
/* ... move nsect sectors at 'pos' to/from hardware ... */
blk_mq_end_request(rq, BLK_STS_OK); /* completion (sync here) */
return BLK_STS_OK; /* we accepted the request */
}
/* (2) Tell the block layer which callbacks we provide. */
static const struct blk_mq_ops mydev_mq_ops = {
.queue_rq = mydev_queue_rq,
};
/* (3) Control-plane table: open/release/ioctl live here, NOT read/write. */
static const struct block_device_operations mydev_fops = {
.owner = THIS_MODULE,
};
static struct mydev g;
static int __init mydev_init(void)
{
struct queue_limits lim = {
.logical_block_size = 512,
};
int err;
/* (4) Describe the hardware to blk-mq. */
g.tag_set.ops = &mydev_mq_ops;
g.tag_set.nr_hw_queues = 1;
g.tag_set.queue_depth = 128; /* 128 in-flight commands */
g.tag_set.numa_node = NUMA_NO_NODE;
g.tag_set.cmd_size = 0; /* no per-request scratch */
err = blk_mq_alloc_tag_set(&g.tag_set);
if (err)
return err;
/* (5) Allocate the disk + its request queue in one call. */
g.disk = blk_mq_alloc_disk(&g.tag_set, &lim, &g);
if (IS_ERR(g.disk)) {
err = PTR_ERR(g.disk);
goto out_free_tags;
}
/* (6) Fill in the disk before publishing it. */
g.disk->fops = &mydev_fops;
g.disk->private_data = &g;
strscpy(g.disk->disk_name, "mydev0", DISK_NAME_LEN);
set_capacity(g.disk, 2048); /* 2048 sectors = 1 MiB */
/* (7) Publish — must be LAST, and must be checked. */
err = add_disk(g.disk);
if (err)
goto out_put_disk;
return 0;
out_put_disk:
put_disk(g.disk);
out_free_tags:
blk_mq_free_tag_set(&g.tag_set);
return err;
}
static void __exit mydev_exit(void)
{
del_gendisk(g.disk); /* quiesce + unregister */
put_disk(g.disk); /* drop final ref */
blk_mq_free_tag_set(&g.tag_set);
}
module_init(mydev_init);
module_exit(mydev_exit);
MODULE_LICENSE("GPL");Line-by-line, the contrast with a character driver is stark. There is no read/write/llseek anywhere — block I/O never reaches the driver as a byte-stream syscall; it arrives as a struct request in queue_rq (block 1). The block_device_operations table (block 3) handles only control operations (open, release, ioctl, getgeo) — note it is legitimately allowed to be nearly empty. Registration is three calls in order: build the tag set (block 4), allocate the disk+queue (block 5), and add_disk last (block 7). The add_disk return value is checked because it is __must_check. Teardown mirrors construction in reverse.
Uncertain
Verify: that
struct queue_limitsrequires only.logical_block_sizeto be set for a trivial driver, and that omittingmax_hw_sectors/max_segmentsyields working defaults. Reason: the skeleton above is synthesized fromnull_blk/brd, which set more fields; I did not compile it. To resolve: build a minimal module against a v6.12 tree and confirm the defaults. uncertain
Partition Scanning
One thing the block layer gives a block driver for free — and that has no analogue in character or network drivers — is partition scanning. When device_add_disk() runs (in block/genhd.c v6.12), unless the disk opted out via the GENHD_FL_NO_PART flag or GD_SUPPRESS_PART_SCAN state bit, it sets GD_NEED_PART_SCAN and calls disk_scan_partitions(). That reads the partition table (MBR/GPT) off the start of the disk and creates a child struct block_device (and /dev/<disk>p1, /dev/<disk>p2, …) for each partition, each appearing under /sys/block/<disk>/<part>. The helper disk_has_partscan(disk) in v6.12 encodes the rule: scanning happens iff neither GENHD_FL_NO_PART nor GENHD_FL_HIDDEN is set and GD_SUPPRESS_PART_SCAN is clear. A driver for whole-device media (like null_blk by default) can set minors = 1 or the no-part flag; a driver for a real disk leaves scanning on so users get /dev/sda1. This is why you never write partition-handling code in a block driver: the block layer does it from the partition table the moment you add_disk.
Failure Modes and Common Misunderstandings
“My block driver should implement read/write like a char driver.” No. A block driver has no file_operations and no byte-stream callbacks. The closest thing, block_device_operations, is control-only. If you find yourself wanting read/write, you have either picked the wrong archetype or you are confusing the userspace /dev/sdX interface (which does support read/write, handled generically by the block layer via the page cache) with the driver interface (which does not).
Forgetting to check add_disk()’s return. add_disk is __must_check in v6.12. Ignoring it means a failed registration leaves a half-constructed disk that later crashes on teardown. The compiler will warn; do not silence it.
Sleeping in queue_rq without BLK_MQ_F_BLOCKING. If your queue_rq calls anything that may sleep (network I/O, GFP_KERNEL allocation, mutex) but you did not set BLK_MQ_F_BLOCKING, you get a “scheduling while atomic” splat. Conversely, setting the flag when you don’t need it costs performance (the block layer must use a sleepable context).
Returning the wrong blk_status_t. Returning BLK_STS_RESOURCE tells the block layer “I’m temporarily out of resources, requeue and retry shortly”; returning an error code completes the request as failed. Mixing these up causes either spurious I/O errors or busy-loops. null_blk’s requeue test alternates BLK_STS_RESOURCE (block-layer-driven retry) with blk_mq_requeue_request (driver-driven retry) to exercise both.
Calling set_capacity after add_disk without revalidation. Capacity changes after the disk is live must be published with the proper revalidate path, not a bare set_capacity, or userspace and the page cache disagree about the device size.
Alternatives and When to Choose Each
The decision tree for “which block driver model” is short:
- Real hardware with a command queue / you want the I/O scheduler and merging → request-based (
blk-mq). This is what NVMe, SCSI, virtio-blk, and loop use. You get tags, merging, multiple hardware queues, and a pluggable elevator (mq-deadline Scheduler, BFQ Budget Fair Queueing Scheduler) for free. - Software stack / per-bio handling / minimal overhead → bio-based (
submit_bio). RAM disks (brd), and stacking drivers fit here. You trade away merging and scheduling for the ability to inspect and route each bio yourself. - Not block at all → if your device is a byte stream or a control surface, you want Character Device Drivers (or The miscdevice Framework); if it’s a NIC, Network Device Drivers and net_device.
A related “alternative” that sometimes substitutes for writing a block driver at all is the device-mapper framework (The Device Mapper Framework), which lets you compose virtual block devices (dm-crypt, dm-linear, LVM) from targets without writing a fresh gendisk driver each time — see dm-crypt and LUKS Disk Encryption. And for the newest storage class, zoned block devices add sequential-write constraints handled through the same gendisk/blk-mq plumbing plus zone callbacks — see Zoned Block Devices and ZBD and Zone Append and the Zoned Block Interface.
Production Notes
The canonical reference drivers to read when writing a real one are both in drivers/block/ of any modern tree: null_blk (null_blk/main.c) is the request-based template — it exercises multiple hardware queues, polling, timeouts, fault injection, and is the standard blk-mq benchmarking/test vehicle — and brd (brd.c) is the bio-based template, about 480 lines and readable in one sitting. virtio-blk (see virtio-net and virtio-blk) is the production request-based driver to study for how a paravirtualized device maps queue_rq onto a virtqueue. NVMe (drivers/nvme/) is the most performance-tuned request-based driver and shows per-CPU hardware queues and polling at scale; the queue-pair structure is covered in NVMe Queue Pairs and the Driver.
For observability, the block layer exposes per-disk statistics under /sys/block/<disk>/stat and via iostat, all of which a correctly-registered driver gets automatically — see Block Layer Statistics and iostat. The I/O path a request takes after your queue_rq accepts it, and before it lands at hardware, is documented in The Block IO Submission Path and Software and Hardware Queues in blk-mq; the io_uring fast path that can bypass parts of the syscall overhead is in io_uring and the Block Layer.
A recurring production lesson: because the block layer owns caching, scheduling, merging, partitioning, and statistics, a correct block driver is mostly a thin shim — the bugs are almost always in the completion path (use-after-free of the request, missing blk_mq_end_request, wrong status code) rather than in registration. Get the registration boilerplate right once from null_blk/brd, and spend your attention on the I/O completion contract.
See Also
- The Multi-Queue Block Layer blk-mq — the deep mechanics of the request queue, tags, and hardware/software queue mapping that this note only frames
- Request Queues and struct request — what a
struct requestis and how the queue builds it from bios - The bio Structure — the
struct bioa bio-based driver receives, and that requests are built from - The Block IO Submission Path — end-to-end path from
submit_bioto driver completion - Software and Hardware Queues in blk-mq — per-CPU software queues and the hardware queue mapping
- Character Device Drivers — the byte-stream archetype this note contrasts against
- Network Device Drivers and net_device — the third archetype (the NIC), which registers a
net_deviceinstead of agendisk - struct gendisk (if written) / The Linux Device Model — the device-model object a disk’s
struct deviceplugs into - The Device Mapper Framework, Zoned Block Devices and ZBD, NVMe Queue Pairs and the Driver — adjacent block-driver topics
- Linux Block Layer and Storage MOC — the sibling MOC that owns block-layer internals
- Linux Device Drivers and Device Model MOC — the parent MOC (§5, the driver archetypes)