Tag: Device Driver Development

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

    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 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 .

  • How to Write an I2C Driver in Linux: Master Complete Beginner-Friendly Guide

    This guide walks you through writing an I2C driver in Linux from scratch. Whether you are a beginner touching Linux kernel driver development for the first time or preparing for an embedded systems interview, you will find practical code, clear explanations, and real-world context here.

    What Is an I2C Driver and Why Does It Matter?

    If you have been working with microcontrollers or Linux-based embedded systems for a while, you have almost certainly bumped into I2C. It stands for Inter-Integrated Circuit, and it is one of the most popular communication protocols in the embedded world. Sensors, EEPROM chips, real-time clocks, display controllers a huge chunk of the hardware you interact with on a daily basis talks over I2C.

    But here is the thing: most tutorials show you how to use I2C. Very few show you how to write an I2C driver from the ground up inside the Linux kernel. That is exactly what we are going to do today.

    By the end of this guide, you will understand what an I2C driver is, how the I2C subsystem works in Linux, how to write a basic I2C client driver, how to read and write registers on a real device, and how to test everything using common kernel debugging tools. No hand-waving, no magic just real code and real explanations.

    Understanding the I2C Protocol First

    Before you write a single line of kernel code, you need to understand what I2C actually is at the hardware level. Trust me — this saves you hours of debugging later.

    I2C is a synchronous, multi-master, multi-slave serial communication protocol. It uses just two wires: SDA (Serial Data) and SCL (Serial Clock). One device acts as the master and controls the clock. The other devices are slaves and respond when addressed.

    Each slave device has a unique 7-bit address (or sometimes 10-bit). The master kicks off every transaction by sending the slave address along with a read/write bit. If the slave acknowledges, the data transfer begins. This is the core of how I2C works.

    Why I2C Is So Common in Embedded Systems

    I2C only needs two signal lines, which makes it incredibly hardware-friendly. You can chain multiple devices on the same bus as long as each has a unique address. This matters when board space and pin count are limited which is basically always in embedded design. Common devices you would connect over I2C include temperature sensors like the LM75, accelerometers like the MPU6050, OLED displays driven by the SSD1306, and I2C EEPROMs like the AT24C series. If you are learning how to write an I2C device driver, working with one of these is a great starting point

    How the Linux I2C Subsystem Works

    Linux has a very clean layered architecture for I2C. Understanding this architecture is crucial before you write your I2C driver. Here is how it breaks down:

    The Three Layers You Need to Know

    The first layer is the I2C adapter driver. This is the low-level driver that controls the physical I2C bus controller on your SoC or board. On a Raspberry Pi, BeagleBone, or any ARM-based platform, this is already written for you. You do not need to touch it unless you are writing drivers for custom silicon.

    The second layer is the I2C core. This lives in drivers/i2c/i2c-core-base.c in the kernel source tree. It is the glue that sits between adapters and client drivers. It provides the API that you, as a driver writer, will call.

    The third layer is the I2C client driver. This is what you write. A client driver talks to a specific I2C device — your sensor, your EEPROM, your display. This is the layer we focus on in this entire guide.

    Key Data Structures in the I2C Subsystem

    There are four structures you will use constantly when writing an I2C driver. Get comfortable with all of them.

    struct i2c_adapter

    This represents the physical I2C bus controller. Think of it as the hardware that drives the SDA and SCL lines. When your driver calls i2c_transfer(), it is using the adapter underneath. You typically do not define this — the platform or SoC driver does.

    struct i2c_client

    This represents the slave device sitting on the bus. It holds the device address, the adapter it belongs to, and the device name. When the kernel instantiates your device, it creates an i2c_client for you and passes it to your probe function.

    struct i2c_client {
        unsigned short flags;
        unsigned short addr;       /* 7-bit I2C address */
        char name[I2C_NAME_SIZE];
        struct i2c_adapter *adapter;
        struct device dev;
        int irq;
        /* ... */
    };

    struct i2c_driver

    This is the structure you define in your driver. It ties your probe, remove, and id_table together. When the kernel matches a device to your driver (via the id_table or device tree), it calls your probe function.

    struct i2c_driver {
        int (*probe)(struct i2c_client *client,
                     const struct i2c_device_id *id);
        int (*remove)(struct i2c_client *client);
        struct device_driver driver;
        const struct i2c_device_id *id_table;
        /* ... */
    };

    struct i2c_msg

    This represents a single I2C message — a read or a write transaction on the bus. When you call i2c_transfer(), you pass an array of these messages. Each message has a slave address, flags, a byte count, and a buffer pointer.

    struct i2c_msg {
        __u16 addr;    /* Slave address */
        __u16 flags;   /* 0 = write, I2C_M_RD = read */
        __u16 len;     /* Data length */
        __u8 *buf;     /* Data buffer */
    };
    

    Setting Up Your Development Environment

    Before you write your I2C kernel module, you need a working kernel build environment. Here is what you need:

    • A Linux machine (native or VM — Ubuntu 22.04 or later works great)
    • Kernel headers or the full kernel source tree
    • A Makefile for building out-of-tree modules
    • Cross-compilation tools if you are targeting ARM (arm-linux-gnueabihf-gcc)
    • An actual I2C device to test with — an MPU6050 or AT24C02 EEPROM works perfectly

    Install Required Packages

    • sudo apt update
    • sudo apt install build-essential linux-headers-$(uname -r)
    • sudo apt install i2c-tools  # Very useful for testing

    Verify Your I2C Bus

    Before writing any driver code, check that your I2C bus is working and your device is detected. On a Raspberry Pi or BeagleBone:

    i2cdetect -y 1

    This command scans I2C bus 1 and prints a grid of detected device addresses. If your sensor shows up (say at address 0x68 for an MPU6050), you are good to go.

    Writing Your First I2C Driver: Step by Step

    Now we get to the real work. We are going to write a complete, working I2C client driver for a simple device. We will use the AT24C02 I2C EEPROM as our example because it is cheap, easy to get, and the register access pattern is straightforward. The full driver will have: module init/exit, an i2c_driver struct, a probe function, a remove function, basic read/write functions using i2c_transfer, and a sysfs interface so you can read/write from userspace.

    Step 1: Include the Right Headers

    #include <linux/module.h>
    
    #include <linux/init.h>
    
    #include <linux/i2c.h>
    
    #include <linux/kernel.h>
    
    #include <linux/slab.h>
    
    #include <linux/fs.h>
    
    #include <linux/device.h>

    Step 2: Define the Device ID Table

    The id_table tells the kernel which devices your driver supports. The kernel uses this to match your driver to a device — either from a platform file or from the device tree.

    static const struct i2c_device_id myeeprom_id[] = {
        { "at24c02", 0 },
        { }   /* Sentinel */
    };
    MODULE_DEVICE_TABLE(i2c, myeeprom_id);

    Step 3: Write the Probe Function

    The probe function is called by the kernel when it finds a matching device. This is where you initialize your device, allocate memory, and set up any interfaces (like sysfs or character device files). Think of probe as your driver’s constructor.

    static int myeeprom_probe(struct i2c_client *client,
    
                              const struct i2c_device_id *id)
    
    {
    
        dev_info(&client->dev, "myeeprom: probing device at 0x%02x\n",
    
                 client->addr);
    
        /* Check if adapter supports everything we need */
    
        if (!i2c_check_functionality(client->adapter,
    
                                      I2C_FUNC_SMBUS_BYTE_DATA)) {
    
            dev_err(&client->dev, "Adapter does not support SMBus\n");
    
            return -ENODEV;
    
        }
    
        return 0;  /* Probe success */
    
    }

    Step 4: Write the Remove Function

    The remove function is called when the device is removed or the driver is unloaded. Clean up whatever you allocated in probe. Release memory, unregister interfaces, and free IRQs if you grabbed any.

    static int myeeprom_remove(struct i2c_client *client)
    
    {
    
        dev_info(&client->dev, "myeeprom: removing device\n");
    
        return 0;
    
    }

    Step 5: Define the i2c_driver Struct

    static struct i2c_driver myeeprom_driver = {
    
        .driver = {
    
            .name  = "myeeprom",
    
            .owner = THIS_MODULE,
    
        },
    
        .probe    = myeeprom_probe,
    
        .remove   = myeeprom_remove,
    
        .id_table = myeeprom_id,
    
    };

    Step 6: Module Init and Exit

    These two functions register and unregister your driver with the I2C core. The module_i2c_driver macro is a shorthand that does exactly this — it expands into the init and exit boilerplate for you.

    module_i2c_driver(myeeprom_driver);
    
    MODULE_LICENSE("GPL");
    
    MODULE_AUTHOR("Raj Kumar <embeddedprep.com/>");
    
    MODULE_DESCRIPTION("Simple AT24C02 I2C EEPROM Driver");

    Reading and Writing Registers Over I2C

    Having a probe function is great, but your I2C driver needs to actually communicate with the device. Let us look at the two main ways to do that in the Linux kernel.

    Method 1: Using SMBus Functions

    SMBus is a subset of I2C, and Linux has a very clean API for it. If your device is SMBus-compatible (most simple sensors and EEPROMs are), use these. They are simpler and handle a lot of the transaction overhead for you.

    /* Read a single byte from a register */
    
    s32 i2c_smbus_read_byte_data(struct i2c_client *client, u8 command);
    
    /* Write a single byte to a register */
    
    s32 i2c_smbus_write_byte_data(struct i2c_client *client,
    
                                   u8 command, u8 value);
    
    Here is a real example. Say you want to read the temperature from an LM75 sensor (register 0x00):
    
    s32 temp_raw = i2c_smbus_read_byte_data(client, 0x00);
    
    if (temp_raw < 0) {
    
        dev_err(&client->dev, "Failed to read temperature\n");
    
        return temp_raw;
    
    }
    
    dev_info(&client->dev, "Raw temp: %d\n", temp_raw);

    Method 2: Using i2c_transfer for Raw I2C Transactions

    When SMBus functions are not enough — for example, when you need combined write-then-read transactions or non-standard protocols — you drop down to i2c_transfer. This gives you full control over every byte on the bus.

    Here is how to write a register address and then read back two bytes from it:

    int mydevice_read_register(struct i2c_client *client,
    
                                u8 reg, u8 *val, int len)
    
    {
    
        struct i2c_msg msgs[2];
    
        int ret;
    
        /* First message: write register address */
    
        msgs[0].addr  = client->addr;
    
        msgs[0].flags = 0;          /* Write */
    
        msgs[0].len   = 1;
    
        msgs[0].buf   = &reg;
    
        /* Second message: read the data back */
    
        msgs[1].addr  = client->addr;
    
        msgs[1].flags = I2C_M_RD;  /* Read */
    
        msgs[1].len   = len;
    
        msgs[1].buf   = val;
    
        ret = i2c_transfer(client->adapter, msgs, 2);
    
        if (ret != 2) {
    
            dev_err(&client->dev, "i2c_transfer failed: %d\n", ret);
    
            return ret < 0 ? ret : -EIO;
    
        }
    
        return 0;
    
    }

    The key insight here: i2c_transfer returns the number of messages successfully transferred, not the number of bytes. So for 2 messages, you check ret == 2.

    Adding Device Tree Support to Your I2C Driver

    Modern Linux systems use the device tree to describe hardware. If you want your I2C driver to work on ARM platforms (Raspberry Pi, BeagleBone, i.MX boards, etc.), you need to add device tree support. This is not optional — it is the standard approach for any driver you want to upstream or deploy on real hardware.

    Add an of_match_table

    static const struct of_device_id myeeprom_of_match[] = {
    
        { .compatible = "atmel,at24c02" },
    
        { }
    
    };
    
    MODULE_DEVICE_TABLE(of, myeeprom_of_match);
    
    Then add it to your i2c_driver struct:
    
    .driver = {
    
        .name           = "myeeprom",
    
        .of_match_table = myeeprom_of_match,
    
        .owner          = THIS_MODULE,
    
    },

    Device Tree Node Example

    In your board’s .dts or .dtsi file, add a node like this to describe your I2C device:

    &i2c1 {
    
        status = "okay";
    
        eeprom@50 {
    
            compatible = "atmel,at24c02";
    
            reg = <0x50>;
    
        };
    
    };

    When the kernel boots and parses this device tree, it will instantiate an i2c_client for your device at address 0x50 and call your probe function automatically.

    Writing the Complete Makefile

    You need a Makefile to build your kernel module. Here is a clean, minimal one that works for any out-of-tree I2C driver:

    obj-m += myeeprom.o
    
    KDIR := /lib/modules/$(shell uname -r)/build
    
    PWD  := $(shell pwd)
    
    all:
    
       $(MAKE) -C $(KDIR) M=$(PWD) modules
    
    clean:
    
       $(MAKE) -C $(KDIR) M=$(PWD) clean

    Build it with:

    make

    Load it with:

    sudo insmod myeeprom.ko

    Check kernel logs:

    dmesg | tail -20

    Testing and Debugging Your I2C Driver

    Writing the code is half the battle. Testing and debugging is where you really learn what is happening. Here are the tools and techniques that actually work.

    i2c-tools for Quick Hardware Verification

    Before you even load your driver, use i2c-tools to verify communication with the device:

    # Scan the bus for devices

    i2cdetect -y 1

    # Read a register directly

    i2cget -y 1 0x50 0x00

    # Write a value to a register

    i2cset -y 1 0x50 0x00 0xAB

    These tools bypass your driver entirely and talk to the hardware directly through the i2c-dev interface. If these work but your driver does not, the problem is in your driver code, not the hardware.

    Using dmesg for Kernel Log Messages

    Always use the dev_info, dev_warn, and dev_err macros in your driver rather than printk. They prefix your messages with the device name automatically, which makes log filtering much easier:

    dev_info(&client->dev, “Driver probed at address 0x%02x\n”, client->addr);

    dev_err(&client->dev, “Read failed with error %d\n”, ret);

    Then check your messages:

    dmesg | grep myeeprom

    Using /sys/bus/i2c/devices

    After your driver loads and probe succeeds, check the sysfs tree:

    ls /sys/bus/i2c/devices/

    cat /sys/bus/i2c/devices/1-0050/name

    This tells you that the kernel has matched your driver to the device and probe was called successfully.

    Common Errors and What They Mean

    If probe returns -ENODEV, it means either the device is not on the bus or i2c_check_functionality failed. Run i2cdetect to verify the device is present.

    If i2c_transfer returns -ETIMEDOUT, the device is not acknowledging. Check your wiring, pull-up resistors, and bus speed.

    If i2c_transfer returns -EIO, there was a bus error. This often points to missing or wrong pull-up resistors on SDA and SCL lines.

    If you see -EBUSY, something else already has the device — either another driver is bound to it or i2c-dev is holding it open.

    Adding a Sysfs Interface to Your I2C Driver

    Sysfs is the standard way to expose driver attributes to userspace in Linux. Adding a sysfs attribute to your I2C driver lets you read sensor data or write configuration values directly from the command line or a Python script — without writing a separate userspace application.

    Define a Sysfs Attribute

    static ssize_t temperature_show(struct device *dev,
    
                                      struct device_attribute *attr,
    
                                      char *buf)
    
    {
    
        struct i2c_client *client = to_i2c_client(dev);
    
        s32 val = i2c_smbus_read_byte_data(client, 0x00);
    
        if (val < 0)
    
            return val;
    
        return sprintf(buf, "%d\n", val);
    
    }
    
    static DEVICE_ATTR_RO(temperature);

    Register the Attribute in Probe

    ret = device_create_file(&client->dev, &dev_attr_temperature);
    
    if (ret) {
    
        dev_err(&client->dev, "Failed to create sysfs file\n");
    
        return ret;
    
    }

    After loading, read the value from userspace:

    cat /sys/bus/i2c/devices/1-0048/temperature

    Full Working I2C Driver Example (Complete Code)

    Here is the complete, minimal I2C driver putting everything together. You can use this as a template for any new I2C device driver you write:

    #include <linux/module.h>
    
    #include <linux/init.h>
    
    #include <linux/i2c.h>
    
    #include <linux/kernel.h>
    
    #include <linux/slab.h>
    
    struct mydevice_data {
    
        struct i2c_client *client;
    
    };
    
    static ssize_t value_show(struct device *dev,
    
                               struct device_attribute *attr, char *buf)
    
    {
    
        struct i2c_client *client = to_i2c_client(dev);
    
        s32 val = i2c_smbus_read_byte_data(client, 0x00);
    
        if (val < 0) return val;
    
        return sprintf(buf, "%d\n", val);
    
    }
    
    static DEVICE_ATTR_RO(value);
    
    static int mydevice_probe(struct i2c_client *client,
    
                               const struct i2c_device_id *id)
    
    {
    
        struct mydevice_data *data;
    
        int ret;
    
        if (!i2c_check_functionality(client->adapter,
    
                                      I2C_FUNC_SMBUS_BYTE_DATA))
    
            return -ENODEV;
    
        data = devm_kzalloc(&client->dev, sizeof(*data), GFP_KERNEL);
    
        if (!data) return -ENOMEM;
    
        data->client = client;
    
        i2c_set_clientdata(client, data);
    
        ret = device_create_file(&client->dev, &dev_attr_value);
    
        if (ret) return ret;
    
        dev_info(&client->dev, "mydevice probed at 0x%02x\n", client->addr);
    
        return 0;
    
    }
    
    static int mydevice_remove(struct i2c_client *client)
    
    {
    
        device_remove_file(&client->dev, &dev_attr_value);
    
        return 0;
    
    }
    
    static const struct i2c_device_id mydevice_id[] = {
    
        { "mydevice", 0 }, { }
    
    };
    
    MODULE_DEVICE_TABLE(i2c, mydevice_id);
    
    static const struct of_device_id mydevice_of_match[] = {
    
        { .compatible = "myvendor,mydevice" }, { }
    
    };
    
    MODULE_DEVICE_TABLE(of, mydevice_of_match);
    
    static struct i2c_driver mydevice_driver = {
    
        .driver = {
    
            .name           = "mydevice",
    
            .of_match_table = mydevice_of_match,
    
            .owner          = THIS_MODULE,
    
        },
    
        .probe    = mydevice_probe,
    
        .remove   = mydevice_remove,
    
        .id_table = mydevice_id,
    
    };
    
    module_i2c_driver(mydevice_driver);
    
    MODULE_LICENSE("GPL");
    
    MODULE_AUTHOR("Raj Kumar");
    
    MODULE_DESCRIPTION("Generic I2C Device Driver Template");

    I2C Driver vs SPI Driver: Key Differences

    A question that comes up a lot for beginners: when should you use I2C versus SPI, and how does the driver structure differ?

    I2C uses two wires and supports multiple devices on the same bus. SPI uses four wires (MOSI, MISO, SCLK, CS) per device but is faster. For I2C device driver programming, addressing is built into the protocol. For SPI, you manage chip-select lines manually.

    The driver structure in Linux is similar — both use probe/remove, both support device tree. The main difference is the bus-specific APIs: i2c_transfer and i2c_smbus_* for I2C, versus spi_transfer and spi_sync for SPI. If you understand one, picking up the other takes maybe a day.

    Common I2C Driver Interview Questions

    If you are studying for embedded Linux interviews, these are the questions you should be ready to answer about I2C driver development:

    What is the difference between i2c_transfer and i2c_smbus_read_byte_data?

    i2c_smbus_read_byte_data is a higher-level function that handles a standard SMBus read of one byte from a register. i2c_transfer is the low-level function that gives you direct control over the I2C messages sent on the bus. Use SMBus functions when they fit your device’s protocol; use i2c_transfer when you need custom multi-message transactions.

    What does i2c_check_functionality do and why is it important?

    i2c_check_functionality queries the adapter to see if it supports a specific set of features — like SMBus byte data, block reads, or I2C combined transactions. Calling it in probe before you do any transfers prevents cryptic failures later when you try to use a feature the adapter does not support.

    What is devm_kzalloc and why should you use it?

    devm_kzalloc is a device-managed version of kzalloc. Memory allocated with it is automatically freed when the device is removed or the driver is unbound. This prevents memory leaks in your driver without requiring explicit cleanup code in the remove function. Always prefer devm_ variants in modern kernel driver code.

    How does the kernel match an I2C driver to a device?

    The kernel uses three matching mechanisms: the i2c_device_id table (for platform-instantiated devices), the of_device_id table (for device tree nodes), and ACPI IDs. When a match is found, the kernel calls the driver’s probe function with the corresponding i2c_client.

    Best Practices for Writing I2C Drivers in Linux

    After writing several I2C drivers, you start to notice patterns that save you from bugs. Here are the practices worth adopting from day one:

    • Always call i2c_check_functionality in probe — do not assume the adapter supports what you need.
    • Use devm_ prefixed allocation functions (devm_kzalloc, devm_request_irq) to avoid resource leaks.
    • Store per-device state in a private structure and use i2c_set_clientdata / i2c_get_clientdata to attach and retrieve it.
    • Check return values from every i2c_smbus_ and i2c_transfer call — never ignore them.
    • Use dev_err / dev_info with &client->dev rather than plain printk — it gives you device-specific log context.
    • Handle -EPROBE_DEFER in probe if your driver depends on another driver (like a clock or regulator) that might not be ready yet.
    • Add device tree support with an of_match_table even if you are initially using i2c_device_id — it makes your driver portable.

    Registering an I2C Device Without Device Tree (Legacy Method)

    On older kernels or bare-metal-style BSPs, devices are sometimes registered programmatically using i2c_board_info. You will see this in older platform files:

    static struct i2c_board_info my_board_info[] = {
    
        {
    
            I2C_BOARD_INFO("at24c02", 0x50),
    
        },
    
    };
    
    i2c_register_board_info(1, my_board_info, ARRAY_SIZE(my_board_info));

    This approach is deprecated for new code. Use device tree on all modern Linux platforms. But you will encounter this in legacy codebases, so it is worth knowing.

    Wrapping Up: What You Have Learned

    If you followed this guide all the way through, you now understand the full picture of how to write an I2C driver in Linux. You know how the I2C protocol works at the hardware level, how the Linux I2C subsystem is layered, what the key data structures are (i2c_adapter, i2c_client, i2c_driver, i2c_msg), how to write probe and remove functions, how to perform read and write operations using both SMBus and raw i2c_transfer, how to add device tree support, how to test your driver with i2c-tools and dmesg, and how to expose your driver’s data through sysfs.

    That is not a small amount of knowledge. Most embedded developers I know learned this over months of trial and error. Now you have it in one place.

    The best next step from here: grab an I2C sensor (an MPU6050 is perfect), connect it to a Raspberry Pi or BeagleBone, and write a real driver from scratch using this guide as your reference. Nothing cements kernel driver concepts faster than actually debugging a real driver on real hardware.

    Next Steps: Check out our guides on SPI driver development, platform device drivers, and writing device tree overlays on embeddedprep.com/.
  • Why Need for Kernel Programming is Essential for Beginners 10 Powerful Reasons

    Learn why Need for Kernel Programming is essential for embedded systems, device drivers, and OS optimization. A beginner-friendly guide with examples.

    When we talk about kernel programming, it might sound like something very advanced and complicated. But in reality, it’s a crucial part of how modern computers, operating systems, and devices work. Whether you’re a beginner in embedded systems or a curious software developer, understanding why kernel programming matters will give you a strong foundation.

    Let’s break it down in simple terms.

    What is the Kernel?

    Think of the kernel as the heart of an operating system. It acts as a bridge between software and hardware. Whenever your program needs to use a hardware component — like memory, CPU, disk storage, or communication interfaces — the kernel steps in to manage these requests safely and efficiently.

    In short, the kernel is responsible for:

    • Resource management (CPU scheduling, memory allocation)
    • Device control (drivers, hardware interaction)
    • System security (managing permissions and access)
    • Process management (creating, scheduling, and terminating processes)

    Why Kernel Programming is Important

    1. Better Control Over Hardware

    For embedded systems, operating systems like Linux run close to the hardware level. If you want to optimize performance, control peripherals directly, or implement custom device drivers, you need kernel programming skills.

    2. Custom Functionality

    Sometimes, the existing operating system cannot meet the exact requirements of a project. Kernel programming lets developers add custom features to the OS, enabling specific functionalities. For example, real-time systems often require kernel modifications for better timing control.

    3. Performance Optimization

    Kernel-level code runs faster because it works directly with hardware and system resources. For applications requiring high speed and low latency, kernel programming can make a huge difference.

    4. Building Device Drivers

    Device drivers are a key part of kernel programming. Without drivers, hardware devices won’t work with the OS. Writing custom drivers allows integration of new hardware with the system.

    5. Understanding System Internals

    Learning kernel programming gives you deep insight into how operating systems work internally — knowledge that is valuable for any systems programmer or embedded engineer.

    Real-Life Example

    Imagine you are developing an IoT device like a smart thermostat. The device needs to read sensor data in real time and control heating/cooling without delay. To achieve this, you might need to write a kernel module that directly interacts with hardware sensors, bypassing unnecessary overhead.

    How to Get Started with Kernel Programming

    If you’re new, here’s a simple roadmap:

    1. Learn C programming — most kernels (like Linux) are written in C.
    2. Understand operating system concepts — processes, memory management, interrupts.
    3. Explore Linux kernel source code — a treasure trove for learning.
    4. Write simple kernel modules — start with “Hello World” kernel module examples.
    5. Experiment with device drivers — build a basic driver for a peripheral.

    Linux Kernel Module Example

    Example: Simple Kernel Module (“Hello World”)

    A kernel module is a piece of code that can be loaded into the kernel at runtime. It’s often used to add features without rebuilding the kernel.

    Hello World Kernel Module Code

    // hello_module.c
    #include <linux/init.h>    // Required for init and exit macros
    #include <linux/module.h>  // Core header for loading modules
    #include <linux/kernel.h>  // Kernel log functions
    
    MODULE_LICENSE("GPL");
    MODULE_AUTHOR("Nish");
    MODULE_DESCRIPTION("A Simple Hello World Kernel Module");
    MODULE_VERSION("1.0");
    
    // Initialization function
    static int __init hello_init(void) {
        printk(KERN_INFO "Hello, Kernel World!\n");
        return 0; // Return 0 means success
    }
    
    // Cleanup function
    static void __exit hello_exit(void) {
        printk(KERN_INFO "Goodbye, Kernel World!\n");
    }
    
    // Macros for registering functions
    module_init(hello_init);
    module_exit(hello_exit);
    

    How to Compile the Kernel Module

    1. Create a Makefile:
    obj-m += hello_module.o
    
    all:
    	make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules
    
    clean:
    	make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean
    
    1. Compile the module:
    make
    
    1. Insert the module into the kernel:
    sudo insmod hello_module.ko
    
    1. Check kernel messages:
    dmesg | tail
    

    You should see:

    Hello, Kernel World!
    
    1. Remove the module:
    sudo rmmod hello_module
    

    And in dmesg:

    Goodbye, Kernel World!

    How This Code Helps Understand Kernel Programming

    • printk: Similar to printf in C, but used for kernel logs.
    • Module initialization and cleanup functions: Show how kernel modules are loaded and unloaded.
    • Macros (module_init and module_exit): Connect functions to kernel events.
    • Makefile: Demonstrates how to compile a kernel module outside the kernel tree.

    FAQs – Understanding the Need for Kernel Programming

    Q1: Do I need kernel programming for every software project?
    No. Kernel programming is mainly needed for system-level development, embedded systems, and drivers. Regular application development doesn’t require it.

    Q2: Which programming language is used for kernel programming?
    C is the most common language for kernel development. Knowledge of assembly language can also be beneficial.

    Q3: Is kernel programming difficult?
    It can be challenging, but with the right learning path, even beginners can start with simple kernel modules and gradually build expertise.

    Q4: Why is kernel programming important in embedded systems?
    Embedded systems interact closely with hardware. Kernel programming allows customization, optimization, and driver development for these systems.