有人问过我好几次,能不能把 V4L2 驱动开发那套东西整理成系列笔记。拖了挺久,这次终于动笔。第一篇先从最底层的 video_device 说起。这个结构体容易被当成“注册一下就完事”的模板代码,实际上它内部有不少值得掰开讲清楚的设计。它是用户空间看到的 /dev/videoX 节点背后那层抽象,也是整个 V4L2 驱动模型的中枢结构,后面要讲的 v4l2_device、vb2_queue 都跟它脱不了关系。把 video_device 的分配、初始化、注册、销毁这条链路弄明白,后续读其它驱动源码会轻松很多。这篇文章主要面向刚开始写 V4L2 驱动的开发者,也适合那些改过不少驱动但从没系统捋过框架的人。我会从整体层次讲到字段定义,再给出一个最小可用的注册流程,最后把实际项目中踩过的坑一并列出来。内容偏内核态开发,默认你有一些 Linux 驱动基础,对 file_operations、字符设备、kref 这类概念不陌生。1. 先把 V4L2 的层次结构看清楚1.1 从用户空间到底层的三层抽象V4L2 框架设计的核心目的很直接:让用户空间能用一套统一的 open/read/write/ioctl 接口,去操作各种完全不同的视频设备,从 USB 摄像头到 CSI 接口的 ISP,再到视频编解码硬件。要做到这一点,内核里必须解决几个问题:设备节点怎么创建、文件操作怎么分发、buffer 怎么管理、设备能力怎么上报。video_device 就是针对“设备节点和文件操作分发”这层问题的答案。你可以把整个 V4L2 驱动看成三层。最上层是字符设备层,负责在 /dev 下面生成 videoX 节点,并且把用户空间的 open/release/ioctl 调用转交给对应回调。中间一层是 V4L2 核心框架,它实现了 VIDIOC_QUERYCAP、VIDIOC_S_FMT 这类标准 ioctl 的公共逻辑,也维护着设备能力、优先级、控制处理等状态。最下层才是你的硬件驱动,负责操作 sensor、DMA、编解码器等具体硬件。video_device 处在最上层和中间层之间。它把字符设备的通用机制和 V4L2 核心框架绑在一起,既承载了一个设备节点的完整生命周期,也保存着 V4L2 核心逻辑需要的回调、锁、设备指针。换句话说,如果没有 video_device,VFS 层只知道这是一个字符设备,根本不知道它跟视频采集有什么关系。1.2 video_device 在驱动模型中的位置在具体驱动代码中,一个物理设备往往对应一个 struct v4l2_device,而这个 v4l2_device 下面可能挂多个 video_device。以我做过的一个图像采集项目为例,硬件同时支持采集和输出,驱动里就创建了两个 video_device 节点,一个对应 capture 路径,一个对应 output 路径,共用同一个 v4l2_device 和同一个物理设备结构。这种“一物理设备多节点”的组织方式很常见。video_device 里保留了指向 v4l2_device 的指针,同时 device_caps 字段会明确这个节点具体提供什么能力。用户空间打开 /dev/video0 时,VFS 通过 inode 里的 cdev 找到对应的 video_device,然后调用该 video_device 注册的 fops 回调。整个调用链大致是这样:open("/dev/video0") -> VFS -> cdev -> video_device->fops->open -> 你的驱动回调。这里有一个值得注意的设计:V4L2 核心框架并不强制所有 ioctl 都由你的驱动实现,很多标准 ioctl 已经由 v4l2_ioctl_ops 帮你处理掉了。video_device 的 fops 里,通常会把 unlocked_ioctl 指向 v4l2_ioctl,这个函数会在框架内部根据 cmd 分发到 video_device->ioctl_ops 里对应的回调。搞清楚这条路径,你才能理解为什么注册 video_device 时要同时给 fops、ioctl_ops、v4l2_dev 这三个关键成员全部赋值。2. video_device 结构体逐字段拆解2.1 必须手动初始化的核心字段video_device 定义在 include/media/v4l2-dev.h。这个结构体比较大,但真正要求驱动开发者手动初始化的字段并不算多,其余大多由框架内部维护。我按重要程度逐个说。第一个是 fops,它是 struct file_operations 指针,指向这个设备节点的文件操作集。正常情况下,open、release、read、write、mmap、poll、unlocked_ioctl 这几个回调都需要提供。框架提供了一组辅助函数,比如 v4l2_open、v4l2_release、v4l2_mmap、v4l2_poll,你可以在 fops 里直接引用它们,也可以从驱动自己的 fops 里先做一层封装再调用。第二个是 ioctl_ops,指向 struct v4l2_ioctl_ops。这个字段决定设备支持哪些标准控制命令。它包含几十个回调,分别对应 VIDIOC_QUERYCAP、VIDIOC_ENUM_FMT、VIDIOC_S_FMT、VIDIOC_REQBUFS、VIDIOC_QBUF、VIDIOC_DQBUF 等等。写驱动时,根据设备实际能力选择性实现,不支持的接口如果被用户空间调用,会返回 -ENOTTY。第三个是 v4l2_dev,指向包含该 video_device 的 v4l2_device 实例。这个字段对 sysfs 的拓扑结构很关键,建议在调用注册函数之前就把它设置好。如果置为 NULL,注册时接口内部会报错或者行为异常。第四个是 queue,指向 vb2_queue 实例。它把 video_device 和 videobuf2 框架连接起来,处理缓冲区分配、入队出队、DMA 映射等逻辑。如果你的驱动直接走 vb2 这套标准流程,这个字段必须指向已经初始化好的 vb2_queue;如果自己手动管理 buffer,可以不设置。第五个是 device_caps,是设备能力标志位,比如 V4L2_CAP_VIDEO_CAPTURE、V4L2_CAP_STREAMING、V4L2_CAP_READWRITE。用户空间调用 VIDIOC_QUERYCAP 时,这个字段会和 v4l2_caps 一起影响返回值。第六个是 lock,一个指向 struct mutex 的指针。V4L2 核心框架会在某些 ioctl 的公共路径中尝试获取这个锁,用来保护驱动内部状态。它可以和 vb2_queue 的锁是同一个锁,也可以分开,取决于你的驱动设计。第七个是 name,设备名称字符串。这个名称会出现在设备节点的 sysfs 属性里,也会在某些日志中出现,不代表 /dev 下节点的文件名。2.2 框架内部管理的隐藏字段除驱动主动设置的字段外,video_device 里还有一批由框架内部维护的成员。例如 minor,次设备号;num,v4l2 设备编号;cdev,内嵌的字符设备结构;dev,指向 struct device 的指针,注册成功后会与 v4l2_device 的 dev 建立关联。这些内部字段不建议驱动代码直接改动。尤其是 num,它决定 /dev/videoX 里的编号,虽然注册函数允许传入期望编号,但依赖自动分配更省事。框架内部会维护一张注册表,避免编号冲突。如果你在驱动里强行修改这些字段,很容易造成节点错乱,出了问题还特别难排查。另外还有 dev-parent 相关的逻辑。video_device 注册成功后,框架会把 video_device 的 device 注册成 v4l2_device 的子设备。这样在 sysfs 下,你能看到一个清晰的层级:物理设备 -> v4l2_device -> video_device。用户空间的 udev 也能根据这个层级创建稳定的符号链接。2.3 与 v4l2_device、vb2_queue 的协作关系这三个结构体的关系可以用一句话概括:v4l2_device 表示物理设备整体,video_device 表示一个功能节点,vb2_queue 表示该节点的缓冲队列。实际项目里,同一个 v4l2_device 通常对应一个物理总线地址和设备树节点,而 v4l2_device 内部的多个 video_device 共用同一个底层硬件。从结构体成员看,video_device 持有 v4l2_device 和 vb2_queue 的指针。而 v4l2_device 内部也有一个 video_device 列表,用于在注销时统一清理所有节点。这就产生了一个循环引用的感觉,但内核里并不存在内存管理上的循环依赖,因为生命周期的负责人是驱动代码,不是结构体之间的引用关系。很多人刚接触 V4L2 时,会把 v4l2_device 和 video_device 混为一谈。其实看名字就能理解:v4l2_device 是设备层面的抽象,video_device 是节点层面的抽象。一个 v4l2_device 可以没有 video_device,比如它可能只提供 subdev 接口;反之 video_device 一定属于某个 v4l2_device。写驱动的时候,顺序应该是先注册 v4l2_device,再注册各个 video_device,注销时顺序相反。3. 核心函数接口与源码级解读3.1 分配与释放:video_device_alloc 和 video_device_release最传统的分配方式是调用 video_device_alloc(),它内部通过 kzalloc 分配一个 video_device 结构体,并且把 release 回调初始化为 v4l2_device_release。kref 引用计数也会在这里初始化。对应的释放函数是 video_device_release(),但请注意,你不能在驱动里随意调用它,框架通常会在最后一个引用被释放时自动调用,这个引用计数机制和字符设备的打开次数、用户空间持有 fd 数量相关联。说句实话,我早期写驱动基本都是 video_device_alloc 一把梭,后来发现直接内嵌到自己的私有结构体里更顺手。你可以定义一个 struct my_dev { struct video_device vdev; int hw_index; ... },然后通过 container_of 拿到自己的私有结构体。这样做的好处是减少一次独立分配,也让 video_device 跟私有数据的生命周期天然绑定,不用担心漏释放。缺点是初始化时要用 video_device_release 保证 release 回调不为空。如果选择内嵌方式,需要在注册前设置 video_device 的 release 回调,把它指向一个自定义函数。这个函数会在设备最后一次引用被释放时被调用,通常你在里面完成 v4l2_device_unregister 后的一些收尾工作。3.2 注册与注销:两个版本函数怎么选注册函数有两代,老的是 video_register_device(struct video_device *vdev, int type, int nr),新的是 video_register_device_v2(struct video_device *vdev)。我现在写代码一律用 v2,原因很简单,老的注册函数要求你手动传 type 和 nr,type 还是 int 类型的 vfl_type,传错很隐蔽;v2 则直接从 video_device 结构体内部读取 vfl_type、vfl_dir、dev_parent 等字段,减少出错机会,而且内部实现更干净。老函数内部其实也就是把 type 和 nr 转存到 vdev->vfl_type、vdev->num 之后调用了 v2 版本。所以如果你看到一份老驱动源码还在用 video_register_device,完全可以无痛换成 v2,只需要在初始化时把 vfl_type 设好。注册过程大致做几件事:检查 v4l2_dev 是否需要设置 parent 设备、分配次设备号、注册字符设备、创建设备节点。注册成功后,/dev/videoX 就立即可用了。注销时调用 video_unregister_device(struct video_device *vdev),它会从框架注册表中移除节点,并释放字符设备相关资源。注意这个函数是异步语义的,它只移除用户空间可见节点,真正释放内存要等 kref 计数归零后触发 release 回调。3.3 辅助接口:drvdata 与设备转换video_set_drvdata(struct video_device *vdev, void *data) 和 video_get_drvdata(struct video_device *vdev) 是一对很常用的辅助接口。它们把私有数据指针存储到 video_device 内部某个字段里。很多驱动在 open 回调里拿不到自己的私有结构体,都会通过 video_get_drvdata(vdev) 来获取。这里我建议你把整个私有结构体指针存进去,而不是只存一个设备树节点指针,省得后面到处转换。还有一个常用宏是 to_video_device(struct device *dev),它从 struct device 指针反推出 video_device。这在你编写设备模型相关回调时很有用,比如 dev_get_drvdata、或者注册到 sysfs 的属性回调里。类似地,video_device_to_dev(struct video_device *vdev) 返回它内部的 struct device 指针,需要传给某些通用设备 API 时用它。另外还有一套优先级管理接口:v4l2_prio_init、v4l2_prio_open、v4l2_prio_close、v4l2_prio_change、v4l2_prio_check。它们用于控制多个文件描述符同时访问设备时的优先级。如果设备支持某些只能在独占模式下打开的功能,这些接口能帮你实现简单的仲裁。大部分简单驱动不会用到,但 v4l2_prio_open 在框架的 open 路径里其实会被调用。4. 实操:从零实现一个 video_device 注册流程4.1 驱动私有结构体设计与初始化下面我用一个典型的内嵌式写法,展示一个最简 v4l2 设备节点的完整注册过程。这个例子不涉及实际采集硬件,重点演示 video_device 生命周期管理。#include <media/v4l2-device.h> #include <media/v4l2-dev.h> #include <media/videobuf2-v4l2.h> struct demo_vdev { struct v4l2_device v4l2_dev; struct video_device vdev; struct vb2_queue queue; /* 其他私有成员 */ }; static void demo_video_device_release(struct video_device *vdev) { struct demo_vdev *demo = container_of(vdev, struct demo_vdev, vdev); /* 释放私有资源 */ kfree(demo); }先定义私有结构体,把 v4l2_device、video_device、vb2_queue 都内嵌进去。release 回调里使用 container_of 找回整个结构体并释放。这个写法实际上只需要一次 kalloc,就同时分配了物理设备、节点、队列三块核心对象。4.2 注册前的必要字段赋值初始化的顺序建议如下,先 v4l2_device_register,再初始化 vb2_queue,最后初始化 video_device 并注册。之所以这样排,是因为 video_device 的 v4l2_dev 和 queue 字段都要指向已经就绪的实例。v4l2_device_register(&parent_dev, &demo->v4l2_dev)注意 v4l2_device_register 的第一个参数通常是你的平台设备指针,这样 sysfs 拓扑才能挂到正确的父设备下。第二个参数是 v4l2_device 实例。接着初始化 vb2_queue,这里只列最关键的几个字段:demo->queue.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; demo->queue.io_modes = VB2_MMAP | VB2_USERPTR | VB2_DMABUF; demo->queue.ops = &demo_queue_ops; demo->queue.mem_ops = &vb2_dma_contig_memops; demo->queue.drv_priv = demo; demo->queue.lock = &demo->lock; vb2_queue_init(&demo->queue);然后再设置 video_device 字段:demo->vdev.v4l2_dev = &demo->v4l2_dev; demo->vdev.queue = &demo->queue; demo->vdev.fops = &demo_fops; demo->vdev.ioctl_ops = &demo_ioctl_ops; demo->vdev.device_caps = V4L2_CAP_VIDEO_CAPTURE | V4L2_CAP_STREAMING; demo->vdev.lock = &demo->lock; demo->vdev.release = demo_video_device_release; strscpy(demo->vdev.name, "demo-capture", sizeof(demo->vdev.name)); demo->vdev.vfl_type = VFL_TYPE_VIDEO; demo->vdev.vfl_dir = VFL_DIR_RX;有几点要特别说明。vfl_dir 表示数据流方向,采集设备用 VFL_DIR_RX,输出设备用 VFL_DIR_TX,内存到内存设备用 VFL_DIR_M2M。这个字段在旧版内核里不存在,而是通过 vfl_type 的 sub_type 判断,移植老代码时要注意。device_caps 里的 V4L2_CAP_VIDEO_CAPTURE 表示这个节点能采集视频,V4L2_CAP_STREAMING 表示支持 streaming I/O。如果你的驱动同时支持读写方式采集,还要加上 V4L2_CAP_READWRITE。不要无脑把所有能力都加上,用户空间工具会根据这个字段尝试探测功能,加错了会导致 v4l2-compliance 报错或者在应用程序里出现诡异行为。4.3 核心注册流程与错误处理字段都设置好之后,调用注册函数:ret = video_register_device_v2(&demo->vdev); if (ret < 0) { v4l2_device_unregister(&demo->v4l2_dev); /* 返回错误 */ }很多新手在注册失败后直接 return,结果把已经注册的 v4l2_device 留在系统里,下次 insmod 直接冲突。建议在任何失败路径上都做一个统一的清理 goto 逻辑,把之前申请的资源全部释放掉。我在实际项目中吃过一次亏,注册 video_device 失败但 v4l2_device 没注销,导致驱动重载时崩溃,从那以后再也没敢跳过清理步骤。成功注册后,你可以立刻用如下命令验证:ls -l /dev/video*正常情况下会看到 /dev/video0,而且它的主设备号是 81,次设备号是框架自动分配的。同时 sysfs 里会出现 /sys/class/video4linux/video0/,目录下的 name 文件内容就是你在 vdev.name 里设置的字符串。4.4 卸载流程的时序控制卸载驱动时,注意时序:video_unregister_device(&demo->vdev); v4l2_device_unregister(&demo->v4l2_dev);先注销节点,再注销物理设备。video_unregister_device 调用后,即使内核中还有对这个 video_device 的引用,也不会再接受新的 open 操作。已经打开的 fd 仍然可以继续使用,等最后一个 fd close 后,release 回调被触发,然后释放整个 demo 结构体。如果你用的是 video_device_alloc 而不是内嵌方式,释放时机则在 release 回调里调用 video_device_release。一定要保证 release 回调存在,否则内核在释放节点时找不到回调函数,直接产生一个异常,这个坑很经典。5. 常见问题与实战排查技巧5.1 注册失败且 dmesg 无明确报错遇到 video_register_device_v2 返回负值但 dmesg 只有零散提示的情况,首先检查 v4l2_dev 字段是否已经赋值。这个字段在注册流程里会被用来设置设备的 parent,如果它为 NULL,注册函数内部可能出现空指针解引用或者直接返回 -EINVAL。其次检查 vfl_type 是否合法。内核里 vfl_type 有 VFL_TYPE_VIDEO、VFL_TYPE_VBI、VFL_TYPE_RADIO、VFL_TYPE_SDR、VFL_TYPE_TOUCH、VFL_TYPE_SUBDEV 等。如果你设置成 VFL_TYPE_SUBDEV,注册出来的就不是 /dev/videoX,而是 /dev/v4l-subdevX,并且它走的是 subdev 的设备节点逻辑,和普通视频采集节点的用户空间接口完全不同。我第一次写 subdev 驱动时就踩过这个,花了大半天才反应过来。还有一个常见因素是设备编号耗尽。系统里已注册的 video_device 数量达到上限,新注册就会失败。检查 /sys/class/video4linux/ 下已经有多少个 videoX 节点,如果很多,考虑给注册函数传入期望编号,或者手动在 sysfs 中去掉不用的节点。5.2 打开节点后 ioctl 返回 -ENOTTY这个错误几乎都是 ioctl_ops 里没有实现对应的回调。比如 VIDIOC_S_FMT 对应 vidioc_s_fmt_vid_cap,你没有实现它,框架就会返回 -ENOTTY。排查办法是把 ioctl_ops 中所有回调列出来,对照 v4l2-compliance 的输出,逐个补齐。也遇到过一种特殊情况:ioctl_ops 实现了,但 fops 里的 unlocked_ioctl 没有指向 v4l2_ioctl。这种情况下 ioctl 请求根本不会走到 V4L2 框架,而是被你的自定义逻辑拦截或者直接返回 -ENOTTY。在 fops 初始化时务必写上 .unlocked_ioctl = v4l2_ioctl。5.3 多个 video_device 节点的编号不符合预期硬件同时有采集和输出功能时,框架分配编号并不保证 video0 一定是 capture、video1 一定是 output,它只保证不同节点编号不冲突。想要固定映射,有两个办法:一是注册前设置 vdev.num,但框架可能因为编号已被占用而返回错误;二是在用户空间通过 VIDIOC_QUERYCAP 检查 device_caps 来判断节点的功能,这也是正规做法。驱动层面,如果你想让 udev 自动建立稳定的别名,可以在 video_device 注册后设置 dev 的 devt 或者创建自定义 attribute。多数项目不需要这么麻烦,用户空间通过 v4l2-ctl 的 --list-devices 一般能直接看到每个节点对应的驱动名称。5.4 卸载模块时崩溃或卡死这个现象最常见的原因是在 open 期间尝试卸载驱动,导致 release 回调被并发调用。检查 vdev.lock 是否设置。如果不设置锁,V4L2 框架中的公共路径会以无锁状态运行,而你的驱动又用自旋锁或者互斥锁保护私有数据,就会形成锁顺序不一致。另一个原因是 container_of 用错了。如果你把 video_device 内嵌在某个子结构里,release 回调里 container_of 的类型参数必须写对,否则释放的是错误的偏移地址,轻则内存损坏,重则内核崩溃。这块建议写一个 debugfs 或打印日志验证一下偏移量。还有一个容易被忽略的点:如果你在内核模块的 exit 里手动调用了 video_device_release,会和框架的 kref 释放机制打架,导致双重释放。确保只通过 video_unregister_device 注销节点,让 kref 计数自然归零后自动调用 release,不要手动提前释放。5.5 调试工具与日志技巧看 V4L2 驱动问题,我常用的套路是先跑 v4l2-compliance。这个工具能帮你把 ioctl 相关的兼容性、能力上报、格式协商等问题自动测一遍,比肉眼查代码快得多。跑的时候加上 -s 参数还能做流式测试,把 buffer 提交和 dequeue 的路径也覆盖到。测试结果里凡是 FAIL 的项,基本都能映射到 ioctl_ops 里某个没实现好的回调。内核日志方面,可以在 video_register_device_v2 调用前后加 pr_info 打印关键返回值,也能在 fops 的 open/release 里打印进程 pid 和设备节点编号。V4L2 框架本身不提供太多动态调试输出,需要自己加。可以用 dev_dbg 配合 dynamic_debug,按模块动态打开日志,比随时留一堆 printk 干净。如果碰到用户空间行为怪异的现象,比如某个 app 拿到了错误的格式或分辨率,优先查看 VIDIOC_QUERYCAP 返回的 device_caps,以及 VIDIOC_ENUM_FMT 返回的格式列表。很多时候不是 video_device 的问题,而是驱动对格式的描述跟实际硬件能力不一致。用户空间全是基于这些描述做决策的,描述错了,后面就全错。6. 从 video_device 出发的扩展思路写了这么多,最后还是想分享一个我自己的体会:video_device 虽然是 V4L2 驱动里最小的入口结构体,但把它理解透了对后续工作帮助特别大。因为它几乎把所有关键交互点都集中在一起了,fops 连接 VFS,ioctl_ops 连接控制逻辑,queue 连接数据流,v4l2_dev 连接物理设备,锁字段连接并发模型。你读高通、NXP 这些平台的 V4L2 驱动时,会发现它们本质都是围绕这几组关联在展开。另外可以动手做个实验,在你的驱动里实现两个 video_device,一个走采集,一个走输出,然后分别用 v4l2-ctl 打开,你会发现两个节点的 file_operations 可以完全不一样。这个灵活性就是 video_device 独立存在的意义。后续这个系列如果继续的话,我会沿着这个思路往 v4l2_device 和 vb2_queue 两个方向深入,把数据流的完整路径串起来讲。再补充一个小技巧,也是我踩过几次坑之后总结的:初始化 video_device 时,尽量用一个专门的 init 函数把所有字段集中在同一个地方赋值,不要分散在驱动的不同阶段里。因为这类结构体字段多、关联复杂,分散赋值很容易漏掉一两个关键点,而且出问题的时候排查范围会被扩大很多。哪怕代码看起来啰嗦一点,也值得为可调试性买单。
有人问过我好几次,能不能把 V4L2 驱动开发那套东西整理成系列笔记。拖了挺久,这次终于动笔。第一篇先从最底层的 video_device 说起。这个结构体容易被当成“注册一下就完事”的模板代码,实际上它内部有不少值得掰开讲清楚的设计。它是用户空间看到的 /dev/videoX 节点背后那层抽象,也是整个 V4L2 驱动模型的中枢结构,后面要讲的 v4l2_device、vb2_queue 都跟它脱不了关系。把 video_device 的分配、初始化、注册、销毁这条链路弄明白,后续读其它驱动源码会轻松很多。这篇文章主要面向刚开始写 V4L2 驱动的开发者,也适合那些改过不少驱动但从没系统捋过框架的人。我会从整体层次讲到字段定义,再给出一个最小可用的注册流程,最后把实际项目中踩过的坑一并列出来。内容偏内核态开发,默认你有一些 Linux 驱动基础,对 file_operations、字符设备、kref 这类概念不陌生。1. 先把 V4L2 的层次结构看清楚1.1 从用户空间到底层的三层抽象V4L2 框架设计的核心目的很直接:让用户空间能用一套统一的 open/read/write/ioctl 接口,去操作各种完全不同的视频设备,从 USB 摄像头到 CSI 接口的 ISP,再到视频编解码硬件。要做到这一点,内核里必须解决几个问题:设备节点怎么创建、文件操作怎么分发、buffer 怎么管理、设备能力怎么上报。video_device 就是针对“设备节点和文件操作分发”这层问题的答案。你可以把整个 V4L2 驱动看成三层。最上层是字符设备层,负责在 /dev 下面生成 videoX 节点,并且把用户空间的 open/release/ioctl 调用转交给对应回调。中间一层是 V4L2 核心框架,它实现了 VIDIOC_QUERYCAP、VIDIOC_S_FMT 这类标准 ioctl 的公共逻辑,也维护着设备能力、优先级、控制处理等状态。最下层才是你的硬件驱动,负责操作 sensor、DMA、编解码器等具体硬件。video_device 处在最上层和中间层之间。它把字符设备的通用机制和 V4L2 核心框架绑在一起,既承载了一个设备节点的完整生命周期,也保存着 V4L2 核心逻辑需要的回调、锁、设备指针。换句话说,如果没有 video_device,VFS 层只知道这是一个字符设备,根本不知道它跟视频采集有什么关系。1.2 video_device 在驱动模型中的位置在具体驱动代码中,一个物理设备往往对应一个 struct v4l2_device,而这个 v4l2_device 下面可能挂多个 video_device。以我做过的一个图像采集项目为例,硬件同时支持采集和输出,驱动里就创建了两个 video_device 节点,一个对应 capture 路径,一个对应 output 路径,共用同一个 v4l2_device 和同一个物理设备结构。这种“一物理设备多节点”的组织方式很常见。video_device 里保留了指向 v4l2_device 的指针,同时 device_caps 字段会明确这个节点具体提供什么能力。用户空间打开 /dev/video0 时,VFS 通过 inode 里的 cdev 找到对应的 video_device,然后调用该 video_device 注册的 fops 回调。整个调用链大致是这样:open("/dev/video0") -> VFS -> cdev -> video_device->fops->open -> 你的驱动回调。这里有一个值得注意的设计:V4L2 核心框架并不强制所有 ioctl 都由你的驱动实现,很多标准 ioctl 已经由 v4l2_ioctl_ops 帮你处理掉了。video_device 的 fops 里,通常会把 unlocked_ioctl 指向 v4l2_ioctl,这个函数会在框架内部根据 cmd 分发到 video_device->ioctl_ops 里对应的回调。搞清楚这条路径,你才能理解为什么注册 video_device 时要同时给 fops、ioctl_ops、v4l2_dev 这三个关键成员全部赋值。2. video_device 结构体逐字段拆解2.1 必须手动初始化的核心字段video_device 定义在 include/media/v4l2-dev.h。这个结构体比较大,但真正要求驱动开发者手动初始化的字段并不算多,其余大多由框架内部维护。我按重要程度逐个说。第一个是 fops,它是 struct file_operations 指针,指向这个设备节点的文件操作集。正常情况下,open、release、read、write、mmap、poll、unlocked_ioctl 这几个回调都需要提供。框架提供了一组辅助函数,比如 v4l2_open、v4l2_release、v4l2_mmap、v4l2_poll,你可以在 fops 里直接引用它们,也可以从驱动自己的 fops 里先做一层封装再调用。第二个是 ioctl_ops,指向 struct v4l2_ioctl_ops。这个字段决定设备支持哪些标准控制命令。它包含几十个回调,分别对应 VIDIOC_QUERYCAP、VIDIOC_ENUM_FMT、VIDIOC_S_FMT、VIDIOC_REQBUFS、VIDIOC_QBUF、VIDIOC_DQBUF 等等。写驱动时,根据设备实际能力选择性实现,不支持的接口如果被用户空间调用,会返回 -ENOTTY。第三个是 v4l2_dev,指向包含该 video_device 的 v4l2_device 实例。这个字段对 sysfs 的拓扑结构很关键,建议在调用注册函数之前就把它设置好。如果置为 NULL,注册时接口内部会报错或者行为异常。第四个是 queue,指向 vb2_queue 实例。它把 video_device 和 videobuf2 框架连接起来,处理缓冲区分配、入队出队、DMA 映射等逻辑。如果你的驱动直接走 vb2 这套标准流程,这个字段必须指向已经初始化好的 vb2_queue;如果自己手动管理 buffer,可以不设置。第五个是 device_caps,是设备能力标志位,比如 V4L2_CAP_VIDEO_CAPTURE、V4L2_CAP_STREAMING、V4L2_CAP_READWRITE。用户空间调用 VIDIOC_QUERYCAP 时,这个字段会和 v4l2_caps 一起影响返回值。第六个是 lock,一个指向 struct mutex 的指针。V4L2 核心框架会在某些 ioctl 的公共路径中尝试获取这个锁,用来保护驱动内部状态。它可以和 vb2_queue 的锁是同一个锁,也可以分开,取决于你的驱动设计。第七个是 name,设备名称字符串。这个名称会出现在设备节点的 sysfs 属性里,也会在某些日志中出现,不代表 /dev 下节点的文件名。2.2 框架内部管理的隐藏字段除驱动主动设置的字段外,video_device 里还有一批由框架内部维护的成员。例如 minor,次设备号;num,v4l2 设备编号;cdev,内嵌的字符设备结构;dev,指向 struct device 的指针,注册成功后会与 v4l2_device 的 dev 建立关联。这些内部字段不建议驱动代码直接改动。尤其是 num,它决定 /dev/videoX 里的编号,虽然注册函数允许传入期望编号,但依赖自动分配更省事。框架内部会维护一张注册表,避免编号冲突。如果你在驱动里强行修改这些字段,很容易造成节点错乱,出了问题还特别难排查。另外还有 dev-parent 相关的逻辑。video_device 注册成功后,框架会把 video_device 的 device 注册成 v4l2_device 的子设备。这样在 sysfs 下,你能看到一个清晰的层级:物理设备 -> v4l2_device -> video_device。用户空间的 udev 也能根据这个层级创建稳定的符号链接。2.3 与 v4l2_device、vb2_queue 的协作关系这三个结构体的关系可以用一句话概括:v4l2_device 表示物理设备整体,video_device 表示一个功能节点,vb2_queue 表示该节点的缓冲队列。实际项目里,同一个 v4l2_device 通常对应一个物理总线地址和设备树节点,而 v4l2_device 内部的多个 video_device 共用同一个底层硬件。从结构体成员看,video_device 持有 v4l2_device 和 vb2_queue 的指针。而 v4l2_device 内部也有一个 video_device 列表,用于在注销时统一清理所有节点。这就产生了一个循环引用的感觉,但内核里并不存在内存管理上的循环依赖,因为生命周期的负责人是驱动代码,不是结构体之间的引用关系。很多人刚接触 V4L2 时,会把 v4l2_device 和 video_device 混为一谈。其实看名字就能理解:v4l2_device 是设备层面的抽象,video_device 是节点层面的抽象。一个 v4l2_device 可以没有 video_device,比如它可能只提供 subdev 接口;反之 video_device 一定属于某个 v4l2_device。写驱动的时候,顺序应该是先注册 v4l2_device,再注册各个 video_device,注销时顺序相反。3. 核心函数接口与源码级解读3.1 分配与释放:video_device_alloc 和 video_device_release最传统的分配方式是调用 video_device_alloc(),它内部通过 kzalloc 分配一个 video_device 结构体,并且把 release 回调初始化为 v4l2_device_release。kref 引用计数也会在这里初始化。对应的释放函数是 video_device_release(),但请注意,你不能在驱动里随意调用它,框架通常会在最后一个引用被释放时自动调用,这个引用计数机制和字符设备的打开次数、用户空间持有 fd 数量相关联。说句实话,我早期写驱动基本都是 video_device_alloc 一把梭,后来发现直接内嵌到自己的私有结构体里更顺手。你可以定义一个 struct my_dev { struct video_device vdev; int hw_index; ... },然后通过 container_of 拿到自己的私有结构体。这样做的好处是减少一次独立分配,也让 video_device 跟私有数据的生命周期天然绑定,不用担心漏释放。缺点是初始化时要用 video_device_release 保证 release 回调不为空。如果选择内嵌方式,需要在注册前设置 video_device 的 release 回调,把它指向一个自定义函数。这个函数会在设备最后一次引用被释放时被调用,通常你在里面完成 v4l2_device_unregister 后的一些收尾工作。3.2 注册与注销:两个版本函数怎么选注册函数有两代,老的是 video_register_device(struct video_device *vdev, int type, int nr),新的是 video_register_device_v2(struct video_device *vdev)。我现在写代码一律用 v2,原因很简单,老的注册函数要求你手动传 type 和 nr,type 还是 int 类型的 vfl_type,传错很隐蔽;v2 则直接从 video_device 结构体内部读取 vfl_type、vfl_dir、dev_parent 等字段,减少出错机会,而且内部实现更干净。老函数内部其实也就是把 type 和 nr 转存到 vdev->vfl_type、vdev->num 之后调用了 v2 版本。所以如果你看到一份老驱动源码还在用 video_register_device,完全可以无痛换成 v2,只需要在初始化时把 vfl_type 设好。注册过程大致做几件事:检查 v4l2_dev 是否需要设置 parent 设备、分配次设备号、注册字符设备、创建设备节点。注册成功后,/dev/videoX 就立即可用了。注销时调用 video_unregister_device(struct video_device *vdev),它会从框架注册表中移除节点,并释放字符设备相关资源。注意这个函数是异步语义的,它只移除用户空间可见节点,真正释放内存要等 kref 计数归零后触发 release 回调。3.3 辅助接口:drvdata 与设备转换video_set_drvdata(struct video_device *vdev, void *data) 和 video_get_drvdata(struct video_device *vdev) 是一对很常用的辅助接口。它们把私有数据指针存储到 video_device 内部某个字段里。很多驱动在 open 回调里拿不到自己的私有结构体,都会通过 video_get_drvdata(vdev) 来获取。这里我建议你把整个私有结构体指针存进去,而不是只存一个设备树节点指针,省得后面到处转换。还有一个常用宏是 to_video_device(struct device *dev),它从 struct device 指针反推出 video_device。这在你编写设备模型相关回调时很有用,比如 dev_get_drvdata、或者注册到 sysfs 的属性回调里。类似地,video_device_to_dev(struct video_device *vdev) 返回它内部的 struct device 指针,需要传给某些通用设备 API 时用它。另外还有一套优先级管理接口:v4l2_prio_init、v4l2_prio_open、v4l2_prio_close、v4l2_prio_change、v4l2_prio_check。它们用于控制多个文件描述符同时访问设备时的优先级。如果设备支持某些只能在独占模式下打开的功能,这些接口能帮你实现简单的仲裁。大部分简单驱动不会用到,但 v4l2_prio_open 在框架的 open 路径里其实会被调用。4. 实操:从零实现一个 video_device 注册流程4.1 驱动私有结构体设计与初始化下面我用一个典型的内嵌式写法,展示一个最简 v4l2 设备节点的完整注册过程。这个例子不涉及实际采集硬件,重点演示 video_device 生命周期管理。#include <media/v4l2-device.h> #include <media/v4l2-dev.h> #include <media/videobuf2-v4l2.h> struct demo_vdev { struct v4l2_device v4l2_dev; struct video_device vdev; struct vb2_queue queue; /* 其他私有成员 */ }; static void demo_video_device_release(struct video_device *vdev) { struct demo_vdev *demo = container_of(vdev, struct demo_vdev, vdev); /* 释放私有资源 */ kfree(demo); }先定义私有结构体,把 v4l2_device、video_device、vb2_queue 都内嵌进去。release 回调里使用 container_of 找回整个结构体并释放。这个写法实际上只需要一次 kalloc,就同时分配了物理设备、节点、队列三块核心对象。4.2 注册前的必要字段赋值初始化的顺序建议如下,先 v4l2_device_register,再初始化 vb2_queue,最后初始化 video_device 并注册。之所以这样排,是因为 video_device 的 v4l2_dev 和 queue 字段都要指向已经就绪的实例。v4l2_device_register(&parent_dev, &demo->v4l2_dev)注意 v4l2_device_register 的第一个参数通常是你的平台设备指针,这样 sysfs 拓扑才能挂到正确的父设备下。第二个参数是 v4l2_device 实例。接着初始化 vb2_queue,这里只列最关键的几个字段:demo->queue.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; demo->queue.io_modes = VB2_MMAP | VB2_USERPTR | VB2_DMABUF; demo->queue.ops = &demo_queue_ops; demo->queue.mem_ops = &vb2_dma_contig_memops; demo->queue.drv_priv = demo; demo->queue.lock = &demo->lock; vb2_queue_init(&demo->queue);然后再设置 video_device 字段:demo->vdev.v4l2_dev = &demo->v4l2_dev; demo->vdev.queue = &demo->queue; demo->vdev.fops = &demo_fops; demo->vdev.ioctl_ops = &demo_ioctl_ops; demo->vdev.device_caps = V4L2_CAP_VIDEO_CAPTURE | V4L2_CAP_STREAMING; demo->vdev.lock = &demo->lock; demo->vdev.release = demo_video_device_release; strscpy(demo->vdev.name, "demo-capture", sizeof(demo->vdev.name)); demo->vdev.vfl_type = VFL_TYPE_VIDEO; demo->vdev.vfl_dir = VFL_DIR_RX;有几点要特别说明。vfl_dir 表示数据流方向,采集设备用 VFL_DIR_RX,输出设备用 VFL_DIR_TX,内存到内存设备用 VFL_DIR_M2M。这个字段在旧版内核里不存在,而是通过 vfl_type 的 sub_type 判断,移植老代码时要注意。device_caps 里的 V4L2_CAP_VIDEO_CAPTURE 表示这个节点能采集视频,V4L2_CAP_STREAMING 表示支持 streaming I/O。如果你的驱动同时支持读写方式采集,还要加上 V4L2_CAP_READWRITE。不要无脑把所有能力都加上,用户空间工具会根据这个字段尝试探测功能,加错了会导致 v4l2-compliance 报错或者在应用程序里出现诡异行为。4.3 核心注册流程与错误处理字段都设置好之后,调用注册函数:ret = video_register_device_v2(&demo->vdev); if (ret < 0) { v4l2_device_unregister(&demo->v4l2_dev); /* 返回错误 */ }很多新手在注册失败后直接 return,结果把已经注册的 v4l2_device 留在系统里,下次 insmod 直接冲突。建议在任何失败路径上都做一个统一的清理 goto 逻辑,把之前申请的资源全部释放掉。我在实际项目中吃过一次亏,注册 video_device 失败但 v4l2_device 没注销,导致驱动重载时崩溃,从那以后再也没敢跳过清理步骤。成功注册后,你可以立刻用如下命令验证:ls -l /dev/video*正常情况下会看到 /dev/video0,而且它的主设备号是 81,次设备号是框架自动分配的。同时 sysfs 里会出现 /sys/class/video4linux/video0/,目录下的 name 文件内容就是你在 vdev.name 里设置的字符串。4.4 卸载流程的时序控制卸载驱动时,注意时序:video_unregister_device(&demo->vdev); v4l2_device_unregister(&demo->v4l2_dev);先注销节点,再注销物理设备。video_unregister_device 调用后,即使内核中还有对这个 video_device 的引用,也不会再接受新的 open 操作。已经打开的 fd 仍然可以继续使用,等最后一个 fd close 后,release 回调被触发,然后释放整个 demo 结构体。如果你用的是 video_device_alloc 而不是内嵌方式,释放时机则在 release 回调里调用 video_device_release。一定要保证 release 回调存在,否则内核在释放节点时找不到回调函数,直接产生一个异常,这个坑很经典。5. 常见问题与实战排查技巧5.1 注册失败且 dmesg 无明确报错遇到 video_register_device_v2 返回负值但 dmesg 只有零散提示的情况,首先检查 v4l2_dev 字段是否已经赋值。这个字段在注册流程里会被用来设置设备的 parent,如果它为 NULL,注册函数内部可能出现空指针解引用或者直接返回 -EINVAL。其次检查 vfl_type 是否合法。内核里 vfl_type 有 VFL_TYPE_VIDEO、VFL_TYPE_VBI、VFL_TYPE_RADIO、VFL_TYPE_SDR、VFL_TYPE_TOUCH、VFL_TYPE_SUBDEV 等。如果你设置成 VFL_TYPE_SUBDEV,注册出来的就不是 /dev/videoX,而是 /dev/v4l-subdevX,并且它走的是 subdev 的设备节点逻辑,和普通视频采集节点的用户空间接口完全不同。我第一次写 subdev 驱动时就踩过这个,花了大半天才反应过来。还有一个常见因素是设备编号耗尽。系统里已注册的 video_device 数量达到上限,新注册就会失败。检查 /sys/class/video4linux/ 下已经有多少个 videoX 节点,如果很多,考虑给注册函数传入期望编号,或者手动在 sysfs 中去掉不用的节点。5.2 打开节点后 ioctl 返回 -ENOTTY这个错误几乎都是 ioctl_ops 里没有实现对应的回调。比如 VIDIOC_S_FMT 对应 vidioc_s_fmt_vid_cap,你没有实现它,框架就会返回 -ENOTTY。排查办法是把 ioctl_ops 中所有回调列出来,对照 v4l2-compliance 的输出,逐个补齐。也遇到过一种特殊情况:ioctl_ops 实现了,但 fops 里的 unlocked_ioctl 没有指向 v4l2_ioctl。这种情况下 ioctl 请求根本不会走到 V4L2 框架,而是被你的自定义逻辑拦截或者直接返回 -ENOTTY。在 fops 初始化时务必写上 .unlocked_ioctl = v4l2_ioctl。5.3 多个 video_device 节点的编号不符合预期硬件同时有采集和输出功能时,框架分配编号并不保证 video0 一定是 capture、video1 一定是 output,它只保证不同节点编号不冲突。想要固定映射,有两个办法:一是注册前设置 vdev.num,但框架可能因为编号已被占用而返回错误;二是在用户空间通过 VIDIOC_QUERYCAP 检查 device_caps 来判断节点的功能,这也是正规做法。驱动层面,如果你想让 udev 自动建立稳定的别名,可以在 video_device 注册后设置 dev 的 devt 或者创建自定义 attribute。多数项目不需要这么麻烦,用户空间通过 v4l2-ctl 的 --list-devices 一般能直接看到每个节点对应的驱动名称。5.4 卸载模块时崩溃或卡死这个现象最常见的原因是在 open 期间尝试卸载驱动,导致 release 回调被并发调用。检查 vdev.lock 是否设置。如果不设置锁,V4L2 框架中的公共路径会以无锁状态运行,而你的驱动又用自旋锁或者互斥锁保护私有数据,就会形成锁顺序不一致。另一个原因是 container_of 用错了。如果你把 video_device 内嵌在某个子结构里,release 回调里 container_of 的类型参数必须写对,否则释放的是错误的偏移地址,轻则内存损坏,重则内核崩溃。这块建议写一个 debugfs 或打印日志验证一下偏移量。还有一个容易被忽略的点:如果你在内核模块的 exit 里手动调用了 video_device_release,会和框架的 kref 释放机制打架,导致双重释放。确保只通过 video_unregister_device 注销节点,让 kref 计数自然归零后自动调用 release,不要手动提前释放。5.5 调试工具与日志技巧看 V4L2 驱动问题,我常用的套路是先跑 v4l2-compliance。这个工具能帮你把 ioctl 相关的兼容性、能力上报、格式协商等问题自动测一遍,比肉眼查代码快得多。跑的时候加上 -s 参数还能做流式测试,把 buffer 提交和 dequeue 的路径也覆盖到。测试结果里凡是 FAIL 的项,基本都能映射到 ioctl_ops 里某个没实现好的回调。内核日志方面,可以在 video_register_device_v2 调用前后加 pr_info 打印关键返回值,也能在 fops 的 open/release 里打印进程 pid 和设备节点编号。V4L2 框架本身不提供太多动态调试输出,需要自己加。可以用 dev_dbg 配合 dynamic_debug,按模块动态打开日志,比随时留一堆 printk 干净。如果碰到用户空间行为怪异的现象,比如某个 app 拿到了错误的格式或分辨率,优先查看 VIDIOC_QUERYCAP 返回的 device_caps,以及 VIDIOC_ENUM_FMT 返回的格式列表。很多时候不是 video_device 的问题,而是驱动对格式的描述跟实际硬件能力不一致。用户空间全是基于这些描述做决策的,描述错了,后面就全错。6. 从 video_device 出发的扩展思路写了这么多,最后还是想分享一个我自己的体会:video_device 虽然是 V4L2 驱动里最小的入口结构体,但把它理解透了对后续工作帮助特别大。因为它几乎把所有关键交互点都集中在一起了,fops 连接 VFS,ioctl_ops 连接控制逻辑,queue 连接数据流,v4l2_dev 连接物理设备,锁字段连接并发模型。你读高通、NXP 这些平台的 V4L2 驱动时,会发现它们本质都是围绕这几组关联在展开。另外可以动手做个实验,在你的驱动里实现两个 video_device,一个走采集,一个走输出,然后分别用 v4l2-ctl 打开,你会发现两个节点的 file_operations 可以完全不一样。这个灵活性就是 video_device 独立存在的意义。后续这个系列如果继续的话,我会沿着这个思路往 v4l2_device 和 vb2_queue 两个方向深入,把数据流的完整路径串起来讲。再补充一个小技巧,也是我踩过几次坑之后总结的:初始化 video_device 时,尽量用一个专门的 init 函数把所有字段集中在同一个地方赋值,不要分散在驱动的不同阶段里。因为这类结构体字段多、关联复杂,分散赋值很容易漏掉一两个关键点,而且出问题的时候排查范围会被扩大很多。哪怕代码看起来啰嗦一点,也值得为可调试性买单。