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.
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.
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:
| Path | What it does | Key structs |
|---|---|---|
| Control path | Userspace opens the device, negotiates format, requests buffers, starts/stops streaming | video_device, v4l2_ioctl_ops, vidioc_* |
| Data path | Kernel fills buffers with pixel data and hands them to userspace | vb2_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.
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.
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.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;
}
| Step | Why this order |
|---|---|
| v4l2_device_register first | video_device needs a parent v4l2_device — the pointer is set before video_register_device |
| vb2_queue_init before video_register_device | vdev.queue must point to an initialised queue before the node is exposed to userspace |
| video_set_drvdata before video_register_device | Once the node is live, fops can fire immediately — drvdata must be set first |
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);
}
| Callback | When called | Contract |
|---|---|---|
| queue_setup | VIDIOC_REQBUFS | Tell vb2 how many planes and the size of each |
| buf_prepare | VIDIOC_QBUF | Validate and set plane payload size — last chance to reject a bad buffer |
| buf_queue | After buf_prepare | Take ownership of the buffer — add it to driver's internal queue |
| start_streaming | VIDIOC_STREAMON | Start hardware (or timer). count = buffers already queued |
| stop_streaming | VIDIOC_STREAMOFF or error | MUST 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.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.
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().
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.
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.
| State | Owner | How it got there |
|---|---|---|
| After REQBUFS | vb2 core | vb2 allocates buffers via mem_ops (vmalloc here) |
| After QBUF | Driver (buf_queue called) | vb2 calls buf_queue → driver adds to buf_list |
| After timer fills it | vb2 core | Driver calls vb2_buffer_done(VB2_BUF_STATE_DONE) |
| After DQBUF | Userspace | vb2 hands the mmap'd address to the application |
| After next QBUF | Driver again | Application re-queues the buffer for the next frame |
# 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