Complete Linux Block Device Driver Project: Kernel Driver + Userspace Access Program

On: March 30, 2026
Linux Block Device Driver Project

Complete Linux Block Device Driver Project tutorial covering kernel module development and userspace access program. Learn how to build, register, and test a block device driver in Linux with step-by-step code examples, real-world concepts, and performance insights for beginners and advanced developers.

This is a complete, working project. By the end of this guide you will have a Linux block device driver running as a kernel module, a userspace C program that opens the device and performs raw read/write operations on it, a working Makefile that builds both, and a clear understanding of every line of code.

No hand-waving. No “left as an exercise.” Every file is here, complete and ready to compile.

This is the kind of project that actually belongs in a portfolio if you are looking for embedded Linux or Linux kernel driver roles. It touches kernel module development, block layer internals, device node access from userspace, raw sector I/O, and ioctl communication all in one project.

Before diving into implementation, it’s important to understand the fundamentals of how block device drivers work in Linux. If you’re new to this topic, check out this Linux Block Device Drivers: The Complete Beginner’s Guide, which explains core concepts like request queues, buffering, and kernel interactions in a simple and beginner-friendly way.

Linux Block Device Driver Project

1. Project Overview and Architecture

Here is what we are building and how the pieces connect:


+---------------------------+
|     Userspace Program     |  userapp.c
|  open() / read() / write()|
|  ioctl() / lseek()        |
+------------+--------------+
             |
             | /dev/myblkdev  (block device node)
             |
+------------+--------------+
|      Linux Block Layer    |  Kernel block layer
|  bio, request queue,      |
|  blk-mq, scheduler        |
+------------+--------------+
             |
+------------+--------------+
|   myblkdev.ko             |  Our kernel module
|   RAM-backed block driver |
|   16MB virtual disk       |
|   ioctl for device info   |
+---------------------------+
             |
     vmalloc'd RAM buffer
     (simulates disk storage)

The kernel module registers a block device backed by a 16MB chunk of kernel RAM. It handles read and write requests through the blk-mq interface. It also exposes an ioctl command so userspace can query device information like size and sector count.

The userspace program opens the device node directly using open(), writes a pattern to specific sectors using write(), reads it back and verifies it, and calls ioctl() to print device info.

This is exactly how tools like ddhdparm, and storage benchmark tools work at the lowest level.

2. Prerequisites and Environment Setup

Before you start, make sure your system has everything needed:

# Install kernel headers for your running kernel
sudo apt-get update
sudo apt-get install linux-headers-$(uname -r) build-essential

# Verify kernel headers are present
ls /lib/modules/$(uname -r)/build
# Should show a directory, not an error

# Check your kernel version
uname -r
# This project is tested on 5.15+ and 6.x kernels

You also need to run the load and test steps as root or with sudo. Block devices require root access for raw sector I/O from userspace.

If you are on a VM (recommended for kernel development), make sure you can do sudo insmod and that your VM has at least 256MB of RAM free for the driver’s buffer plus kernel overhead.

3. Project Directory Structure

Create this layout on your machine:

myblkdev/
├── myblkdev.c       # Kernel block device driver
├── myblkdev.h      # Shared header (kernel + userspace)
├── userapp.c         # Userspace test and access program
└── Makefile           # Builds both kernel module and userapp
mkdir myblkdev
cd myblkdev

All four files go in this single directory. The Makefile handles building the kernel module (which goes through the kernel build system) and the userspace binary (which is just a normal gcc compile) in one make command.

4. Shared Header File : myblkdev.h

This header is included by both the kernel driver and the userspace program. It defines the ioctl command numbers and the data structure used to exchange device information. Sharing this header between kernel and userspace is the standard kernel pattern — it ensures both sides agree on the exact binary layout of ioctl structures.

/* myblkdev.h
 * Shared header between kernel driver and userspace program.
 * Defines ioctl interface for myblkdev block device driver.
 */

#ifndef MYBLKDEV_H
#define MYBLKDEV_H

#include <linux/ioctl.h>

/* Magic number for our driver's ioctl commands.
 * Pick something unlikely to conflict. Check Documentation/userspace-api/ioctl/ioctl-number.rst
 */
#define MYBLKDEV_MAGIC  0xBD

/* Device info structure returned by MYBLKDEV_GET_INFO ioctl */
struct myblkdev_info {
    unsigned long total_bytes;    /* total device size in bytes */
    unsigned long sector_count;   /* total number of 512-byte sectors */
    unsigned int  sector_size;    /* sector size in bytes (always 512 here) */
    unsigned int  major;          /* device major number */
    unsigned int  minor;          /* device minor number */
};

/* ioctl command: get device info
 * Direction: kernel -> userspace (IOR = read from kernel's perspective)
 * Type: MYBLKDEV_MAGIC
 * Number: 1
 * Size: sizeof(struct myblkdev_info)
 */
#define MYBLKDEV_GET_INFO  _IOR(MYBLKDEV_MAGIC, 1, struct myblkdev_info)

/* ioctl command: reset device (zero all storage)
 * Direction: no data transfer
 * Type: MYBLKDEV_MAGIC
 * Number: 2
 */
#define MYBLKDEV_RESET     _IO(MYBLKDEV_MAGIC, 2)

#endif /* MYBLKDEV_H */

The _IOR and _IO macros encode the direction, magic number, command number, and data size into a single 32-bit integer. This encoding prevents different drivers from accidentally sharing the same ioctl number, which would cause the wrong driver to handle the command.

5. The Kernel Block Device Driver : myblkdev.c

This is the full kernel driver. Read through it section by section — every part is commented to explain what it does and why.

/* myblkdev.c
 * A RAM-backed Linux block device driver with ioctl support.
 * Registers /dev/myblkdev as a 16MB block device.
 *
 * Tested on Linux 5.15 - 6.x
 * License: GPL v2
 */

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/fs.h>
#include <linux/blkdev.h>
#include <linux/blk-mq.h>
#include <linux/genhd.h>
#include <linux/hdreg.h>
#include <linux/vmalloc.h>
#include <linux/uaccess.h>
#include "myblkdev.h"

MODULE_LICENSE("GPL");
MODULE_AUTHOR("myblkdev project");
MODULE_DESCRIPTION("RAM-backed block device driver with ioctl");
MODULE_VERSION("1.0");

/* ===== Configuration ===== */
#define MYBLKDEV_MAJOR       240        /* static major; 240-254 are for local use */
#define MYBLKDEV_NAME        "myblkdev"
#define MYBLKDEV_MINORS      1          /* 1 = no partitions; use 16 to allow partitions */
#define DISK_SIZE_MB         16
#define KERNEL_SECTOR_SIZE   512

/* ===== Device State ===== */
struct myblkdev_state {
    unsigned long    size;          /* device size in bytes */
    u8              *data;          /* vmalloc'd storage buffer */
    spinlock_t       lock;          /* protects data access */
    struct blk_mq_tag_set  tag_set;
    struct request_queue  *queue;
    struct gendisk        *gd;
    int              major;
    int              minor;
};

/* Single global device instance for this example */
static struct myblkdev_state *mydev;

/* ===== Request Handler ===== */
/*
 * This is the core of the driver. The block layer calls this function
 * for every I/O request. We iterate over each segment of the request,
 * copy data to/from our RAM buffer, and signal completion.
 */
static blk_status_t myblkdev_queue_rq(struct blk_mq_hw_ctx *hctx,
                                       const struct blk_mq_queue_data *bd)
{
    struct request        *req  = bd->rq;
    struct myblkdev_state *dev  = req->q->queuedata;
    struct bio_vec         bvec;
    struct req_iterator    iter;
    loff_t                 pos;
    unsigned long          flags;

    /* Mark request as started — required by blk-mq before any processing */
    blk_mq_start_request(req);

    /* Reject non-filesystem requests (SCSI passthrough, etc.) */
    if (blk_rq_is_passthrough(req)) {
        pr_notice(MYBLKDEV_NAME ": skip passthrough request\n");
        blk_mq_end_request(req, BLK_STS_IOERR);
        return BLK_STS_OK;
    }

    /* Convert starting sector to byte offset.
     * blk_rq_pos() returns offset in 512-byte units always. */
    pos = (loff_t)blk_rq_pos(req) * KERNEL_SECTOR_SIZE;

    spin_lock_irqsave(&dev->lock, flags);

    /* Iterate over each scatter-gather segment in the request */
    rq_for_each_segment(bvec, req, iter) {
        void   *buf = page_address(bvec.bv_page) + bvec.bv_offset;
        size_t  len = bvec.bv_len;

        /* Bounds check — never go past our buffer */
        if (pos + len > dev->size) {
            pr_err(MYBLKDEV_NAME ": I/O beyond device size "
                   "(pos=%lld len=%zu size=%lu)\n",
                   pos, len, dev->size);
            spin_unlock_irqrestore(&dev->lock, flags);
            blk_mq_end_request(req, BLK_STS_IOERR);
            return BLK_STS_OK;
        }

        if (rq_data_dir(req) == WRITE) {
            /* WRITE: copy from bio page into our RAM buffer */
            memcpy(dev->data + pos, buf, len);
        } else {
            /* READ: copy from our RAM buffer into bio page */
            memcpy(buf, dev->data + pos, len);
        }

        pos += len;
    }

    spin_unlock_irqrestore(&dev->lock, flags);

    /* Signal successful completion to the block layer */
    blk_mq_end_request(req, BLK_STS_OK);
    return BLK_STS_OK;
}

/* blk-mq operations table — only queue_rq is mandatory */
static const struct blk_mq_ops myblkdev_mq_ops = {
    .queue_rq = myblkdev_queue_rq,
};

/* ===== ioctl Handler ===== */
/*
 * Userspace programs can call ioctl() on the block device fd to
 * get device info or trigger a reset. We handle our custom commands
 * here and return -ENOTTY for anything we don't recognize.
 */
static int myblkdev_ioctl(struct block_device *bdev, fmode_t mode,
                           unsigned int cmd, unsigned long arg)
{
    struct myblkdev_state *dev = bdev->bd_disk->private_data;
    struct myblkdev_info   info;
    unsigned long          flags;

    switch (cmd) {

    case MYBLKDEV_GET_INFO:
        /* Fill info structure and copy to userspace */
        info.total_bytes  = dev->size;
        info.sector_count = dev->size / KERNEL_SECTOR_SIZE;
        info.sector_size  = KERNEL_SECTOR_SIZE;
        info.major        = dev->major;
        info.minor        = dev->minor;

        if (copy_to_user((void __user *)arg, &info, sizeof(info)))
            return -EFAULT;
        return 0;

    case MYBLKDEV_RESET:
        /* Zero the entire storage buffer */
        spin_lock_irqsave(&dev->lock, flags);
        memset(dev->data, 0, dev->size);
        spin_unlock_irqrestore(&dev->lock, flags);
        pr_info(MYBLKDEV_NAME ": device reset (zeroed)\n");
        return 0;

    default:
        /* Unknown command — return standard "not a typewriter" error */
        return -ENOTTY;
    }
}

/* ===== Block Device Operations Table ===== */
static const struct block_device_operations myblkdev_fops = {
    .owner          = THIS_MODULE,
    .ioctl          = myblkdev_ioctl,
};

/* ===== Module Init ===== */
static int __init myblkdev_init(void)
{
    int ret;

    pr_info(MYBLKDEV_NAME ": initializing %d MB RAM block device\n",
            DISK_SIZE_MB);

    /* Allocate our device state structure */
    mydev = kzalloc(sizeof(*mydev), GFP_KERNEL);
    if (!mydev) {
        pr_err(MYBLKDEV_NAME ": failed to allocate device state\n");
        return -ENOMEM;
    }

    mydev->size  = DISK_SIZE_MB * 1024 * 1024;
    mydev->major = MYBLKDEV_MAJOR;
    mydev->minor = 0;
    spin_lock_init(&mydev->lock);

    /* Allocate the RAM storage buffer using vmalloc.
     * We use vmalloc (not kmalloc) because we need a large contiguous
     * virtual mapping but don't need physically contiguous memory. */
    mydev->data = vmalloc(mydev->size);
    if (!mydev->data) {
        pr_err(MYBLKDEV_NAME ": failed to allocate %d MB storage\n",
               DISK_SIZE_MB);
        ret = -ENOMEM;
        goto err_free_dev;
    }
    memset(mydev->data, 0, mydev->size);

    /* Register the block device major number */
    ret = register_blkdev(MYBLKDEV_MAJOR, MYBLKDEV_NAME);
    if (ret < 0) {
        pr_err(MYBLKDEV_NAME ": register_blkdev failed (%d)\n", ret);
        goto err_free_data;
    }

    /* Configure blk-mq tag set.
     * nr_hw_queues=1: single hardware queue (fine for RAM, no real HW)
     * queue_depth=128: up to 128 in-flight requests at once */
    mydev->tag_set.ops          = &myblkdev_mq_ops;
    mydev->tag_set.nr_hw_queues = 1;
    mydev->tag_set.queue_depth  = 128;
    mydev->tag_set.numa_node    = NUMA_NO_NODE;
    mydev->tag_set.cmd_size     = 0;
    mydev->tag_set.flags        = BLK_MQ_F_SHOULD_MERGE;

    ret = blk_mq_alloc_tag_set(&mydev->tag_set);
    if (ret) {
        pr_err(MYBLKDEV_NAME ": blk_mq_alloc_tag_set failed (%d)\n", ret);
        goto err_unreg_blkdev;
    }

    /* Create the request queue linked to our tag set */
    mydev->queue = blk_mq_init_queue(&mydev->tag_set);
    if (IS_ERR(mydev->queue)) {
        ret = PTR_ERR(mydev->queue);
        pr_err(MYBLKDEV_NAME ": blk_mq_init_queue failed (%d)\n", ret);
        goto err_free_tag_set;
    }

    /* Store device pointer in queue for access from request handler */
    mydev->queue->queuedata = mydev;

    /* Set queue limits — tell the block layer what our "hardware" supports */
    blk_queue_logical_block_size(mydev->queue, KERNEL_SECTOR_SIZE);
    blk_queue_max_hw_sectors(mydev->queue, 256); /* max 128KB per request */

    /* Allocate the gendisk structure.
     * MYBLKDEV_MINORS=1 means no partition support.
     * Use 16 if you want to allow up to 15 partitions. */
    mydev->gd = alloc_disk(MYBLKDEV_MINORS);
    if (!mydev->gd) {
        pr_err(MYBLKDEV_NAME ": alloc_disk failed\n");
        ret = -ENOMEM;
        goto err_free_queue;
    }

    /* Fill in gendisk fields */
    mydev->gd->major        = MYBLKDEV_MAJOR;
    mydev->gd->first_minor  = 0;
    mydev->gd->fops         = &myblkdev_fops;
    mydev->gd->queue        = mydev->queue;
    mydev->gd->private_data = mydev;
    snprintf(mydev->gd->disk_name, sizeof(mydev->gd->disk_name),
             MYBLKDEV_NAME);

    /* Set disk capacity in 512-byte sectors */
    set_capacity(mydev->gd, mydev->size / KERNEL_SECTOR_SIZE);

    /* add_disk() makes the device visible — /dev/myblkdev appears after this */
    add_disk(mydev->gd);

    pr_info(MYBLKDEV_NAME ": ready — /dev/%s, major=%d, size=%lu MB\n",
            mydev->gd->disk_name, MYBLKDEV_MAJOR,
            mydev->size / (1024 * 1024));
    return 0;

/* Error cleanup — reverse order of initialization */
err_free_queue:
    blk_cleanup_queue(mydev->queue);
err_free_tag_set:
    blk_mq_free_tag_set(&mydev->tag_set);
err_unreg_blkdev:
    unregister_blkdev(MYBLKDEV_MAJOR, MYBLKDEV_NAME);
err_free_data:
    vfree(mydev->data);
err_free_dev:
    kfree(mydev);
    return ret;
}

/* ===== Module Exit ===== */
static void __exit myblkdev_exit(void)
{
    /* Cleanup in strict reverse order of init */
    del_gendisk(mydev->gd);          /* remove from block layer */
    put_disk(mydev->gd);             /* release gendisk reference */
    blk_cleanup_queue(mydev->queue); /* destroy request queue */
    blk_mq_free_tag_set(&mydev->tag_set);
    unregister_blkdev(MYBLKDEV_MAJOR, MYBLKDEV_NAME);
    vfree(mydev->data);              /* free RAM storage */
    kfree(mydev);                    /* free device state */

    pr_info(MYBLKDEV_NAME ": unloaded\n");
}

module_init(myblkdev_init);
module_exit(myblkdev_exit);

6. The Makefile

This single Makefile builds both the kernel module and the userspace program. Run make once and you get both.

# Makefile for myblkdev project
# Builds kernel module (myblkdev.ko) and userspace program (userapp)

# Kernel build directory — uses headers for currently running kernel
KDIR ?= /lib/modules/$(shell uname -r)/build

# Kernel module object
obj-m += myblkdev.o

# Default target: build kernel module then userspace app
all: module userapp

module:
	$(MAKE) -C $(KDIR) M=$(PWD) modules

# Userspace app: plain gcc, no kernel headers needed
# _GNU_SOURCE for pread/pwrite, O_DIRECT etc.
userapp: userapp.c myblkdev.h
	gcc -Wall -Wextra -O2 -D_GNU_SOURCE -o userapp userapp.c

clean:
	$(MAKE) -C $(KDIR) M=$(PWD) clean
	rm -f userapp

.PHONY: all module userapp clean

The kernel module build goes through the kernel’s own build system (make -C $(KDIR)). The userspace binary is just a normal gcc compile. The shared myblkdev.h header is included by both — that dependency is declared on the userapp line so it rebuilds if the header changes.

7. The Userspace Access Program : userapp.c

This program demonstrates every way a userspace application can interact with a raw block device. It opens the device node directly, queries info via ioctl, writes test patterns to specific sectors, reads them back, and verifies the data.

/* userapp.c
 * Userspace program to test and demonstrate myblkdev block driver access.
 * Performs: ioctl info query, raw sector write, raw sector read, verify.
 *
 * Run as root: sudo ./userapp /dev/myblkdev
 */

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <sys/ioctl.h>
#include <sys/types.h>

/* Include our shared header.
 * For userspace, linux/ioctl.h is included via sys/ioctl.h already.
 * We redefine to use userspace-safe types. */
#include "myblkdev.h"

#define SECTOR_SIZE     512
#define TEST_SECTORS    8       /* write/read 8 sectors = 4KB at a time */
#define TEST_PATTERN    0xAB    /* byte pattern to write for verification */

/* ===== Helper: print device info from ioctl ===== */
static void print_device_info(int fd)
{
    struct myblkdev_info info;

    printf("\n--- Device Info (via ioctl) ---\n");

    if (ioctl(fd, MYBLKDEV_GET_INFO, &info) < 0) {
        perror("ioctl MYBLKDEV_GET_INFO failed");
        return;
    }

    printf("  Total Size   : %lu bytes (%lu MB)\n",
           info.total_bytes, info.total_bytes / (1024 * 1024));
    printf("  Sector Count : %lu sectors\n", info.sector_count);
    printf("  Sector Size  : %u bytes\n", info.sector_size);
    printf("  Major Number : %u\n", info.major);
    printf("  Minor Number : %u\n", info.minor);
    printf("-------------------------------\n\n");
}

/* ===== Helper: write pattern to a sector range ===== */
static int write_sectors(int fd, off_t start_sector,
                          int num_sectors, unsigned char pattern)
{
    size_t   buf_size = num_sectors * SECTOR_SIZE;
    unsigned char *buf;
    ssize_t  written;
    off_t    offset = start_sector * SECTOR_SIZE;

    buf = malloc(buf_size);
    if (!buf) {
        fprintf(stderr, "malloc failed\n");
        return -1;
    }

    /* Fill buffer with test pattern */
    memset(buf, pattern, buf_size);

    /* Seek to the correct byte offset on the block device */
    if (lseek(fd, offset, SEEK_SET) < 0) {
        perror("lseek failed");
        free(buf);
        return -1;
    }

    /* Write the buffer — this goes through VFS -> block layer -> our driver */
    written = write(fd, buf, buf_size);
    if (written != (ssize_t)buf_size) {
        fprintf(stderr, "write: expected %zu bytes, got %zd (%s)\n",
                buf_size, written, strerror(errno));
        free(buf);
        return -1;
    }

    printf("[WRITE] Sector %ld - %ld: wrote %zu bytes, pattern=0x%02X\n",
           (long)start_sector, (long)(start_sector + num_sectors - 1),
           buf_size, pattern);

    free(buf);
    return 0;
}

/* ===== Helper: read from sector range and verify pattern ===== */
static int read_and_verify(int fd, off_t start_sector,
                            int num_sectors, unsigned char expected_pattern)
{
    size_t    buf_size = num_sectors * SECTOR_SIZE;
    unsigned char *buf;
    ssize_t   bytes_read;
    off_t     offset = start_sector * SECTOR_SIZE;
    int       errors = 0;
    size_t    i;

    buf = malloc(buf_size);
    if (!buf) {
        fprintf(stderr, "malloc failed\n");
        return -1;
    }

    /* Seek to sector offset */
    if (lseek(fd, offset, SEEK_SET) < 0) {
        perror("lseek failed");
        free(buf);
        return -1;
    }

    /* Read back the data */
    bytes_read = read(fd, buf, buf_size);
    if (bytes_read != (ssize_t)buf_size) {
        fprintf(stderr, "read: expected %zu bytes, got %zd (%s)\n",
                buf_size, bytes_read, strerror(errno));
        free(buf);
        return -1;
    }

    /* Verify every byte matches the expected pattern */
    for (i = 0; i < buf_size; i++) {
        if (buf[i] != expected_pattern) {
            fprintf(stderr, "[VERIFY] Mismatch at byte %zu: "
                    "got 0x%02X, expected 0x%02X\n",
                    i, buf[i], expected_pattern);
            errors++;
            if (errors > 5) {
                fprintf(stderr, "[VERIFY] Too many errors, stopping\n");
                break;
            }
        }
    }

    if (errors == 0) {
        printf("[READ]  Sector %ld - %ld: read %zu bytes — VERIFIED OK "
               "(pattern=0x%02X)\n",
               (long)start_sector,
               (long)(start_sector + num_sectors - 1),
               buf_size, expected_pattern);
    } else {
        printf("[READ]  Sector %ld - %ld: VERIFICATION FAILED "
               "(%d errors)\n",
               (long)start_sector,
               (long)(start_sector + num_sectors - 1),
               errors);
    }

    free(buf);
    return errors ? -1 : 0;
}

/* ===== Helper: dump first 64 bytes of a sector as hex ===== */
static void hexdump_sector(int fd, off_t sector)
{
    unsigned char buf[64];
    ssize_t       n;
    size_t        i;

    lseek(fd, sector * SECTOR_SIZE, SEEK_SET);
    n = read(fd, buf, sizeof(buf));
    if (n <= 0) {
        perror("hexdump read failed");
        return;
    }

    printf("\n[HEXDUMP] First 64 bytes of sector %ld:\n", (long)sector);
    for (i = 0; i < (size_t)n; i++) {
        if (i % 16 == 0) printf("  %04zx: ", i);
        printf("%02x ", buf[i]);
        if (i % 16 == 15) printf("\n");
    }
    printf("\n");
}

/* ===== Helper: reset device via ioctl ===== */
static void reset_device(int fd)
{
    printf("[RESET] Sending MYBLKDEV_RESET ioctl...\n");
    if (ioctl(fd, MYBLKDEV_RESET) < 0) {
        perror("ioctl MYBLKDEV_RESET failed");
        return;
    }
    printf("[RESET] Device zeroed successfully\n");
}

/* ===== Main ===== */
int main(int argc, char *argv[])
{
    const char *devpath;
    int         fd;
    int         ret = 0;

    if (argc < 2) {
        fprintf(stderr, "Usage: %s \n", argv[0]);
        fprintf(stderr, "Example: sudo %s /dev/myblkdev\n", argv[0]);
        return EXIT_FAILURE;
    }

    devpath = argv[1];

    printf("===========================================\n");
    printf("  myblkdev Userspace Test Program\n");
    printf("  Device: %s\n", devpath);
    printf("===========================================\n");

    /* Open the block device.
     * O_RDWR: we need both read and write access.
     * O_SYNC: write() calls wait for I/O completion before returning.
     *         Important for testing — without this, writes may be
     *         cached by the page cache and not reach the driver immediately. */
    fd = open(devpath, O_RDWR | O_SYNC);
    if (fd < 0) {
        fprintf(stderr, "open(%s) failed: %s\n", devpath, strerror(errno));
        fprintf(stderr, "Did you load the module? Are you running as root?\n");
        return EXIT_FAILURE;
    }

    printf("[OPEN]  Opened %s successfully (fd=%d)\n\n", devpath, fd);

    /* Step 1: Query device info via ioctl */
    print_device_info(fd);

    /* Step 2: Write pattern 0xAB to sectors 0-7 (first 4KB) */
    printf("--- Test 1: Write and Read First 4KB ---\n");
    if (write_sectors(fd, 0, TEST_SECTORS, TEST_PATTERN) < 0) {
        ret = 1;
        goto out;
    }

    /* Step 3: Read back and verify */
    if (read_and_verify(fd, 0, TEST_SECTORS, TEST_PATTERN) < 0) {
        ret = 1;
        goto out;
    }

    /* Step 4: Hexdump sector 0 to visually confirm */
    hexdump_sector(fd, 0);

    /* Step 5: Write different pattern to a sector in the middle of the disk */
    printf("\n--- Test 2: Write and Read Middle of Disk ---\n");
    {
        off_t mid_sector = (DISK_SIZE_MB * 1024 * 1024 / SECTOR_SIZE) / 2;
        printf("[INFO]  Mid-disk sector: %ld\n", (long)mid_sector);

        if (write_sectors(fd, mid_sector, TEST_SECTORS, 0xCD) < 0) {
            ret = 1;
            goto out;
        }
        if (read_and_verify(fd, mid_sector, TEST_SECTORS, 0xCD) < 0) {
            ret = 1;
            goto out;
        }
        hexdump_sector(fd, mid_sector);
    }

    /* Step 6: Verify sector 0 still has original pattern
     * (mid-disk write must not have corrupted sector 0) */
    printf("\n--- Test 3: Re-verify Sector 0 (Isolation Check) ---\n");
    if (read_and_verify(fd, 0, TEST_SECTORS, TEST_PATTERN) < 0) {
        ret = 1;
        goto out;
    }

    /* Step 7: Reset device and verify sector 0 is now zeroed */
    printf("\n--- Test 4: Reset Device and Verify Zeroed ---\n");
    reset_device(fd);
    if (read_and_verify(fd, 0, TEST_SECTORS, 0x00) < 0) {
        ret = 1;
        goto out;
    }

    printf("\n===========================================\n");
    if (ret == 0)
        printf("  ALL TESTS PASSED\n");
    else
        printf("  SOME TESTS FAILED — check output above\n");
    printf("===========================================\n");

out:
    close(fd);
    return ret ? EXIT_FAILURE : EXIT_SUCCESS;
}

8. How the ioctl Interface Works

The ioctl path is worth understanding clearly because it is the standard way userspace programs communicate non-I/O control information to block and character drivers.

When userspace calls:

ioctl(fd, MYBLKDEV_GET_INFO, &info);

The kernel routes this through VFS to blkdev_ioctl() in the block layer, which checks if it is a standard block ioctl (like BLKGETSIZE, BLKRRPART). If not recognized, it calls your driver’s .ioctl function pointer — which is myblkdev_ioctl() in our driver.

Inside the driver, copy_to_user() safely copies data from kernel space to the userspace buffer pointer that was passed in as the third argument. You must always use copy_to_user and copy_from_user for this — never dereference a userspace pointer directly in kernel code. It will either fault or silently access wrong memory.

The _IOR_IOW_IOWR, and _IO macros encode direction and size into the command number so the kernel’s ioctl dispatch can do basic sanity checking on the argument size before even calling your handler.

9. Build, Load, and Run : Step by Step

# Step 1: Build everything
cd myblkdev
make

# You should see:
# CC [M] /path/to/myblkdev/myblkdev.o
# LD [M] /path/to/myblkdev/myblkdev.ko
# gcc -Wall -Wextra -O2 -D_GNU_SOURCE -o userapp userapp.c

# Step 2: Load the kernel module
sudo insmod myblkdev.ko

# Step 3: Verify it loaded and device node exists
dmesg | tail -5
# Expected: myblkdev: ready — /dev/myblkdev, major=240, size=16 MB

ls -la /dev/myblkdev
# Expected: brw-rw---- 1 root disk 240, 0 ... /dev/myblkdev

cat /proc/devices | grep myblkdev
# Expected: 240 myblkdev

# Step 4: Run the userspace test program
sudo ./userapp /dev/myblkdev

# Step 5: When done, unload the module
sudo rmmod myblkdev
dmesg | tail -3
# Expected: myblkdev: unloaded

10. Expected Output

When you run sudo ./userapp /dev/myblkdev you should see exactly this:

===========================================
  myblkdev Userspace Test Program
  Device: /dev/myblkdev
===========================================
[OPEN]  Opened /dev/myblkdev successfully (fd=3)

--- Device Info (via ioctl) ---
  Total Size   : 16777216 bytes (16 MB)
  Sector Count : 32768 sectors
  Sector Size  : 512 bytes
  Major Number : 240
  Minor Number : 0
-------------------------------

--- Test 1: Write and Read First 4KB ---
[WRITE] Sector 0 - 7: wrote 4096 bytes, pattern=0xAB
[READ]  Sector 0 - 7: read 4096 bytes — VERIFIED OK (pattern=0xAB)

[HEXDUMP] First 64 bytes of sector 0:
  0000: ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab
  0010: ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab
  0020: ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab
  0030: ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab ab

--- Test 2: Write and Read Middle of Disk ---
[INFO]  Mid-disk sector: 16384
[WRITE] Sector 16384 - 16391: wrote 4096 bytes, pattern=0xCD
[READ]  Sector 16384 - 16391: read 4096 bytes — VERIFIED OK (pattern=0xCD)

[HEXDUMP] First 64 bytes of sector 16384:
  0000: cd cd cd cd cd cd cd cd cd cd cd cd cd cd cd cd
  ...

--- Test 3: Re-verify Sector 0 (Isolation Check) ---
[READ]  Sector 0 - 7: read 4096 bytes — VERIFIED OK (pattern=0xAB)

--- Test 4: Reset Device and Verify Zeroed ---
[RESET] Sending MYBLKDEV_RESET ioctl...
[RESET] Device zeroed successfully
[READ]  Sector 0 - 7: read 4096 bytes — VERIFIED OK (pattern=0x00)

===========================================
  ALL TESTS PASSED
===========================================

11. How Userspace Actually Talks to the Block Driver

This is worth tracing through the full kernel call path for both the write and ioctl cases, because understanding this path is what separates someone who just ran the code from someone who actually understands it.

write() Call Path

userapp: write(fd, buf, 4096)
  |
  v
syscall: sys_write()
  |
  v
VFS: vfs_write() -> block_write_iter()
  |
  v
Page Cache: data written to page cache first
  |
  v (O_SYNC forces immediate flush)
Block Layer: submit_bio()
  -> bio created with our data pages
  -> bio submitted to request queue
  -> blk-mq merges/dispatches
  |
  v
myblkdev_queue_rq()  <-- OUR DRIVER
  -> rq_for_each_segment loops over bio_vec segments
  -> memcpy(dev->data + pos, buf, len)  for WRITE
  -> blk_mq_end_request(req, BLK_STS_OK)
  |
  v
Block layer signals completion
  -> page cache marks page clean (O_SYNC path)
  |
  v
write() returns 4096 to userspace

ioctl() Call Path

userapp: ioctl(fd, MYBLKDEV_GET_INFO, &info)
  |
  v
syscall: sys_ioctl()
  |
  v
VFS: vfs_ioctl() -> blkdev_ioctl()
  |
  v (not a standard block ioctl)
myblkdev_ioctl()  <-- OUR DRIVER
  -> copy_to_user(&info, kernel_info, sizeof(info))
  -> return 0
  |
  v
ioctl() returns 0 to userspace
userspace info struct now has device data

The key thing to notice: userspace never directly accesses the driver’s memory. Everything goes through the kernel’s VFS and block layer. The copy_to_user() in the driver is the only point where kernel-owned data crosses into user-owned memory, and it does so safely through kernel-controlled copy routines that check permissions.

12. Extending the Project

Here are four concrete things you can add to this project to take it further:

Add Partition Support

Change MYBLKDEV_MINORS from 1 to 16. Then after loading, run fdisk /dev/myblkdev to create partitions. The kernel will automatically create /dev/myblkdev1/dev/myblkdev2, etc. Your driver handles them with zero additional code because the block layer translates offsets for you.

Add a Module Parameter for Disk Size

static int disk_size_mb = DISK_SIZE_MB;
module_param(disk_size_mb, int, 0444);
MODULE_PARM_DESC(disk_size_mb, "Disk size in MB (default 16)");

Then load with: sudo insmod myblkdev.ko disk_size_mb=64

Add Multiple Device Instances

Change the single global device to an array and support creating multiple devices at load time, like the real brd driver does with /dev/ram0/dev/ram1, etc.

Format and Mount It

sudo mkfs.ext4 /dev/myblkdev
sudo mkdir /mnt/myblkdev
sudo mount /dev/myblkdev /mnt/myblkdev
echo "data survives in RAM until rmmod" | sudo tee /mnt/myblkdev/note.txt
cat /mnt/myblkdev/note.txt
sudo umount /mnt/myblkdev

13. Troubleshooting Common Errors

insmod: ERROR: could not insert module myblkdev.ko: Invalid module format

Your kernel headers do not match your running kernel. Run uname -r and verify that /lib/modules/$(uname -r)/build exists and matches. On Ubuntu after a kernel update, run sudo apt-get install linux-headers-$(uname -r) again.

open(/dev/myblkdev) failed: No such file or directory

The module is not loaded or load failed. Run dmesg | tail -20 to see the error. Also check lsmod | grep myblkdev.

open(/dev/myblkdev) failed: Permission denied

Run with sudo. Block devices require root for raw access. Alternatively: sudo chmod 666 /dev/myblkdev for testing only.

ioctl MYBLKDEV_GET_INFO failed: Inappropriate ioctl for device

This means the ioctl command number does not match between userapp and the loaded driver. Make sure you rebuilt both after any header change. Run make clean && make, then sudo rmmod myblkdev && sudo insmod myblkdev.ko.

Kernel panic on rmmod

Almost always caused by a cleanup order bug. The most common one: calling put_disk() before del_gendisk(), or calling unregister_blkdev() while requests are still in flight. Make sure your exit function matches the exact order in the code above.

VERIFICATION FAILED in userapp output

Check dmesg for any error messages from the driver. The most common causes are wrong sector arithmetic (off-by-one in byte offset calculation) or a missing spinlock that lets two requests race on the same buffer region.

Project File Summary

FilePurposeLines
myblkdev.hShared ioctl definitions, magic numbers, info struct~50
myblkdev.cKernel block device driver — full blk-mq + ioctl~220
userapp.cUserspace test program — open, write, read, ioctl, verify~230
MakefileBuilds kernel module and userspace binary together~15

Final Thoughts

This project gives you a working end-to-end example of how the Linux block I/O stack operates from the userspace open() call all the way down to your driver’s request handler. Every layer in between VFS, page cache, block layer, blk-mq scheduler, bio structure, scatter-gather segments is exercised by running this code.

The most important habit to build from here: whenever something does not work, start with dmesg. The kernel logs everything your driver prints and every oops or warning. It is your primary debugging window and it will tell you exactly where something went wrong almost every time.

From this foundation, the next logical project is adding DMA support to talk to real hardware, or building a stacked driver using the Device Mapper framework that encrypts data before passing it to this RAM disk. Both use exactly the same block layer interfaces you just learned.

For detailed understanding of Platform Devices and Drivers on Linux, refer to the Linux documentation on Platform Devices and Drivers .

Leave a Comment