Arc II · The Camera Driver Lab
Chapter 09

V4L2 Capture Driver — videobuf2 + platform

A minimal but complete V4L2 capture driver built on top of the platform driver model. Registers a /dev/video50 node, manages buffers through videobuf2-vmalloc, and uses a kernel timer to simulate frame delivery at ~30fps — the full capture pipeline in one stub driver.

v4l2_device video_device vb2_queue vb2_ops vb2_vmalloc vidioc_* platform_driver timer_list spinlock

Why this driver?

Lab 08 proved the platform driver model — DT overlay, probe(), devm_, workqueue. But a real camera driver has two more layers on top: the V4L2 device registration layer that gives userspace a /dev/videoN node, and the buffer management layer (videobuf2) that handles the QBUF/DQBUF dance between the kernel and userspace. This lab adds both. No real sensor yet — a kernel timer simulates frame delivery — but every structural piece of a production capture driver is here.

The two paths in a V4L2 capture driver

Before touching any code, it helps to see the separation clearly. A V4L2 capture driver has two completely different paths that never touch each other:

PathWhat it doesKey structs
Control pathUserspace opens the device, negotiates format, requests buffers, starts/stops streamingvideo_device, v4l2_ioctl_ops, vidioc_*
Data pathKernel fills buffers with pixel data and hands them to userspacevb2_queue, vb2_ops, vb2_buffer

The control path is about configuration. The data path is about buffer ownership. Understanding which path a given function belongs to is the key to reading any V4L2 driver.

Device Tree overlay

The capture driver is still a platform driver underneath. The DT overlay creates the platform device node that triggers probe().

/dts-v1/;
/plugin/;

/ {
    compatible = "brcm,bcm2837";

    fragment@0 {
        target-path = "/";
        __overlay__ {
            sanath_capture: sanath-capture {
                compatible = "sanath-capture-lab";
                status = "okay";
            };
        };
    };
};

No GPIO or interrupt bindings here — this driver has no hardware interrupt. The compatible string "sanath-capture-lab" matches the driver's of_match_table and causes probe() to be called.

Per-device struct — everything in one place

The entire driver state lives in one struct allocated per device in probe(). No globals.

struct sanath_buffer {
    struct vb2_buffer   vb;    /* MUST be first — container_of arithmetic */
    struct list_head    list;
};

struct sanath_capture_dev {
    struct v4l2_device   v4l2_dev;   /* V4L2 registration handle */
    struct video_device  vdev;       /* the /dev/videoN node */
    struct vb2_queue     queue;      /* videobuf2 queue */
    struct mutex         lock;       /* serialises ioctl calls */
    struct list_head     buf_list;   /* queued buffers waiting for data */
    spinlock_t           buf_lock;   /* protects buf_list from IRQ context */
    struct timer_list    etx_timer;  /* simulates sensor frame interrupt */
    uint16_t             frame_count;
};
vb must be the first member of sanath_buffer. The container_of call in buffer_queue() recovers the sanath_buffer from a vb2_buffer * — this only works correctly when vb is at offset 0.

probe() — registration order matters

The order of operations in probe() is not arbitrary. Each step depends on the one before it.

static int sanath_v4l2_probe(struct platform_device *pdev)
{
    struct sanath_capture_dev *priv;
    int ret;

    priv = devm_kzalloc(&pdev->dev, sizeof(*priv), GFP_KERNEL);
    if (!priv)
        return -ENOMEM;

    platform_set_drvdata(pdev, priv);
    mutex_init(&priv->lock);
    spin_lock_init(&priv->buf_lock);
    INIT_LIST_HEAD(&priv->buf_list);
    timer_setup(&priv->etx_timer, timer_callback, 0);

    /* Step 1: register the V4L2 device — root of the V4L2 hierarchy */
    ret = v4l2_device_register(&pdev->dev, &priv->v4l2_dev);
    if (ret)
        goto err_reg;

    /* Step 2: initialise the vb2 queue */
    priv->queue.type             = V4L2_BUF_TYPE_VIDEO_CAPTURE;
    priv->queue.io_modes         = VB2_MMAP;
    priv->queue.ops              = &sanath_vb2_ops;
    priv->queue.mem_ops          = &vb2_vmalloc_memops;
    priv->queue.buf_struct_size  = sizeof(struct sanath_buffer);
    priv->queue.lock             = &priv->lock;
    priv->queue.drv_priv         = priv;
    priv->queue.dev              = &pdev->dev;
    priv->queue.timestamp_flags  = V4L2_BUF_FLAG_TIMESTAMP_MONOTONIC;

    ret = vb2_queue_init(&priv->queue);
    if (ret)
        goto err_init;

    /* Step 3: register the video_device node */
    strscpy(priv->vdev.name, "Sanath-Capture", sizeof(priv->vdev.name));
    priv->vdev.fops        = &sanath_vdev_fops;
    priv->vdev.ioctl_ops   = &sanath_vdev_ioctl_ops;
    priv->vdev.v4l2_dev    = &priv->v4l2_dev;
    priv->vdev.lock        = &priv->lock;
    priv->vdev.queue       = &priv->queue;
    priv->vdev.device_caps = V4L2_CAP_VIDEO_CAPTURE | V4L2_CAP_STREAMING;
    priv->vdev.release     = video_device_release_empty;
    video_set_drvdata(&priv->vdev, priv);

    ret = video_register_device(&priv->vdev, VFL_TYPE_VIDEO, 50);
    if (ret)
        goto err_vb2;

    return 0;

err_vb2:
    vb2_queue_release(&priv->queue);
err_init:
    v4l2_device_unregister(&priv->v4l2_dev);
err_reg:
    return ret;
}
StepWhy this order
v4l2_device_register firstvideo_device needs a parent v4l2_device — the pointer is set before video_register_device
vb2_queue_init before video_register_devicevdev.queue must point to an initialised queue before the node is exposed to userspace
video_set_drvdata before video_register_deviceOnce the node is live, fops can fire immediately — drvdata must be set first

vb2_ops — the buffer lifecycle contract

videobuf2 manages buffer state through a set of callbacks. Each callback has a specific contract — violating it causes silent corruption or kernel oops.

static int queue_setup(struct vb2_queue *vq,
        unsigned int *num_buffers, unsigned int *num_planes,
        unsigned int sizes[], struct device *alloc_devs[])
{
    *num_planes = 1;
    sizes[0] = 640 * 480 * 2;   /* YUYV — hardcoded for stub */
    return 0;
}

static int buffer_prepare(struct vb2_buffer *vb)
{
    vb2_set_plane_payload(vb, 0, 640 * 480 * 2);
    return 0;
}

static void buffer_queue(struct vb2_buffer *vb)
{
    struct sanath_capture_dev *priv = vb2_get_drv_priv(vb->vb2_queue);
    struct sanath_buffer *buf =
        container_of(vb, struct sanath_buffer, vb);
    unsigned long flags;

    spin_lock_irqsave(&priv->buf_lock, flags);
    list_add_tail(&buf->list, &priv->buf_list);
    spin_unlock_irqrestore(&priv->buf_lock, flags);
}

static int start_streaming(struct vb2_queue *vq, unsigned int count)
{
    struct sanath_capture_dev *priv = vb2_get_drv_priv(vq);
    mod_timer(&priv->etx_timer, jiffies + msecs_to_jiffies(33));
    return 0;
}

static void stop_streaming(struct vb2_queue *vq)
{
    struct sanath_capture_dev *priv = vb2_get_drv_priv(vq);
    struct sanath_buffer *buf, *tmp;
    unsigned long flags;

    del_timer_sync(&priv->etx_timer);   /* wait for in-flight callback */

    spin_lock_irqsave(&priv->buf_lock, flags);
    list_for_each_entry_safe(buf, tmp, &priv->buf_list, list) {
        list_del(&buf->list);
        vb2_buffer_done(&buf->vb, VB2_BUF_STATE_ERROR);
    }
    spin_unlock_irqrestore(&priv->buf_lock, flags);
}
CallbackWhen calledContract
queue_setupVIDIOC_REQBUFSTell vb2 how many planes and the size of each
buf_prepareVIDIOC_QBUFValidate and set plane payload size — last chance to reject a bad buffer
buf_queueAfter buf_prepareTake ownership of the buffer — add it to driver's internal queue
start_streamingVIDIOC_STREAMONStart hardware (or timer). count = buffers already queued
stop_streamingVIDIOC_STREAMOFF or errorMUST return every buffer via vb2_buffer_done before returning
stop_streaming has a hard contract: every buffer that was handed to the driver via buf_queue must be returned via vb2_buffer_done before stop_streaming returns. Failure causes a vb2 hang — userspace blocks on DQBUF forever.

Timer callback — simulating a frame interrupt

In a real driver this would be a DMA completion interrupt. Here a kernel timer fires every 33ms (~30fps) and fills the next queued buffer with a solid colour that changes each frame — enough to verify the DQBUF path end-to-end.

void timer_callback(struct timer_list *data)
{
    struct sanath_capture_dev *priv =
        container_of(data, struct sanath_capture_dev, etx_timer);
    struct sanath_buffer *buf;
    unsigned long flags;

    spin_lock_irqsave(&priv->buf_lock, flags);
    if (!list_empty(&priv->buf_list)) {
        buf = list_first_entry(&priv->buf_list,
                               struct sanath_buffer, list);

        void *vaddr = vb2_plane_vaddr(&buf->vb, 0);
        memset(vaddr, priv->frame_count & 0xFF, 640 * 480 * 2);
        priv->frame_count++;

        list_del(&buf->list);
        vb2_buffer_done(&buf->vb, VB2_BUF_STATE_DONE);
    }
    spin_unlock_irqrestore(&priv->buf_lock, flags);

    mod_timer(&priv->etx_timer, jiffies + msecs_to_jiffies(33));
}

The spinlock is mandatory here — the timer callback runs in softirq context and the same list is touched from buf_queue (process context) and stop_streaming (process context). A spinlock with irqsave is the correct primitive when one side can be in interrupt context.

vb2_buffer_done(vb, VB2_BUF_STATE_DONE) hands the buffer back to vb2, which marks it ready for userspace to DQBUF. This is the moment the buffer changes ownership from driver to vb2.

vidioc_* handlers — the control path

The ioctl handlers answer userspace questions about format and capabilities. For a stub driver they are hardcoded — no negotiation.

static int vidioc_querycap(struct file *file, void *priv,
        struct v4l2_capability *cap)
{
    strscpy(cap->driver, "sanath-capture", sizeof(cap->driver));
    strscpy(cap->card,   "Sanath Capture", sizeof(cap->card));
    strscpy(cap->bus_info, "platform:sanath-capture", sizeof(cap->bus_info));
    return 0;
}

static int vidioc_enum_fmt(struct file *file, void *priv,
        struct v4l2_fmtdesc *f)
{
    if (f->index > 0)
        return -EINVAL;   /* only one format supported */
    f->pixelformat = V4L2_PIX_FMT_YUYV;
    return 0;
}

static int vidioc_g_fmt(struct file *file, void *priv,
        struct v4l2_format *f)
{
    f->fmt.pix.width        = 640;
    f->fmt.pix.height       = 480;
    f->fmt.pix.pixelformat  = V4L2_PIX_FMT_YUYV;
    f->fmt.pix.field        = V4L2_FIELD_NONE;
    f->fmt.pix.bytesperline = 640 * 2;
    f->fmt.pix.sizeimage    = 640 * 480 * 2;
    f->fmt.pix.colorspace   = V4L2_COLORSPACE_SRGB;
    return 0;
}

static int vidioc_s_fmt(struct file *file, void *priv,
        struct v4l2_format *f)
{
    return vidioc_g_fmt(file, priv, f);  /* ignore requested format, return ours */
}

Six of the ioctl ops delegate directly to vb2 helper functions — vb2_ioctl_reqbufs, vb2_ioctl_querybuf, vb2_ioctl_qbuf, vb2_ioctl_dqbuf, vb2_ioctl_streamon, vb2_ioctl_streamoff. These helpers call into the vb2 state machine, which eventually calls the driver's own vb2_ops. The driver never handles QBUF directly — it only sees buf_queue().

vdev_fops — delegating file operations to vb2

File operations on the /dev/videoN node are almost entirely handled by vb2 and V4L2 core helpers. The driver provides no custom read or write.

static const struct v4l2_file_operations sanath_vdev_fops = {
    .owner          = THIS_MODULE,
    .open           = v4l2_fh_open,      /* V4L2 file handle bookkeeping */
    .release        = vb2_fop_release,   /* releases buffers on close */
    .poll           = vb2_fop_poll,      /* wakes userspace when buffer ready */
    .mmap           = vb2_fop_mmap,      /* maps buffer memory into userspace */
    .unlocked_ioctl = video_ioctl2,      /* routes ioctls to vidioc_* table */
};

video_ioctl2 is the V4L2 core ioctl dispatcher — it validates ioctl arguments, acquires the device lock, and calls the appropriate vidioc_* handler from sanath_vdev_ioctl_ops.

remove() — teardown order is the reverse of probe()

static int sanath_v4l2_remove(struct platform_device *pdev)
{
    struct sanath_capture_dev *priv = platform_get_drvdata(pdev);
    video_unregister_device(&priv->vdev);   /* stop new fops from firing */
    vb2_queue_release(&priv->queue);        /* return all buffers */
    v4l2_device_unregister(&priv->v4l2_dev);
    return 0;
}

video_unregister_device must come first — it closes the /dev/videoN node so no new ioctl or mmap calls can arrive while teardown is happening. Only then is it safe to release the queue and unregister the V4L2 device.

Buffer ownership model — who holds the buffer when

StateOwnerHow it got there
After REQBUFSvb2 corevb2 allocates buffers via mem_ops (vmalloc here)
After QBUFDriver (buf_queue called)vb2 calls buf_queue → driver adds to buf_list
After timer fills itvb2 coreDriver calls vb2_buffer_done(VB2_BUF_STATE_DONE)
After DQBUFUserspacevb2 hands the mmap'd address to the application
After next QBUFDriver againApplication re-queues the buffer for the next frame

Verifying with v4l2-ctl

# Check the node registered
ls -la /dev/video50

# Query capabilities
v4l2-ctl -d /dev/video50 --info

# Capture 10 frames
v4l2-ctl -d /dev/video50 \
    --set-fmt-video=width=640,height=480,pixelformat=YUYV \
    --stream-mmap --stream-count=10