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 dd, hdparm, 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 kernelsYou 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 userappmkdir myblkdev
cd myblkdevAll 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 cleanThe 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: unloaded10. 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 userspaceioctl() 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 dataThe 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/myblkdev13. 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
| File | Purpose | Lines |
|---|---|---|
| myblkdev.h | Shared ioctl definitions, magic numbers, info struct | ~50 |
| myblkdev.c | Kernel block device driver — full blk-mq + ioctl | ~220 |
| userapp.c | Userspace test program — open, write, read, ioctl, verify | ~230 |
| Makefile | Builds 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 .
Mr. Raj Kumar is a highly experienced Technical Content Engineer with 7 years of dedicated expertise in the intricate field of embedded systems. At Embedded Prep, Raj is at the forefront of creating and curating high-quality technical content designed to educate and empower aspiring and seasoned professionals in the embedded domain.
Throughout his career, Raj has honed a unique skill set that bridges the gap between deep technical understanding and effective communication. His work encompasses a wide range of educational materials, including in-depth tutorials, practical guides, course modules, and insightful articles focused on embedded hardware and software solutions. He possesses a strong grasp of embedded architectures, microcontrollers, real-time operating systems (RTOS), firmware development, and various communication protocols relevant to the embedded industry.
Raj is adept at collaborating closely with subject matter experts, engineers, and instructional designers to ensure the accuracy, completeness, and pedagogical effectiveness of the content. His meticulous attention to detail and commitment to clarity are instrumental in transforming complex embedded concepts into easily digestible and engaging learning experiences. At Embedded Prep, he plays a crucial role in building a robust knowledge base that helps learners master the complexities of embedded technologies.







