夜雨聆风学习资料网

ARTICLE · 1116416

V4L2 源码深度解析(三):同一个 /dev/videoX 被多次打开,状态如何管理?

V4L2 源码深度解析(三):同一个 /dev/videoX 被多次打开,状态如何管理?
int fd_a = open("/dev/video0", O_RDWR);int fd_b = dup(fd_a);int fd_c = open("/dev/video0", O_RDWR);

假设三次调用均成功,这段代码得到三个文件描述符,但它们并不对应三份完全独立的采集状态。

fd_a 和 fd_b 引用同一个打开文件对象;fd_c 来自另一次独立打开。在驱动为每次打开建立标准文件句柄的路径中,前两者共用一个 v4l2_fh,后者关联另一个 v4l2_fh。这些句柄又都可以指向同一个 video_device。

由此引出本文的核心问题:

同一个节点被多次打开以后,哪些状态分别保存,哪些对象继续共享,哪些资源只能由特定上下文使用,关闭时又怎样避免提前释放或重复释放?

本文以 media/v4l2-core/v4l2-fh.c 的实现为主线,配合同一源码树的 v4l2-dev.c、v4l2-ioctl.c、v4l2-event.c、VIMC 与 VB2 的直接关联路径。结构体声明和通用文件机制采用 Linux v6.1 的对应头文件、实现与接口文档辅助核对;这不意味着整棵源码树已经被认定为该版本。以下涉及具体顺序的结论,均限定于这里分析的实现。


1. 三个描述符,为什么只有两个打开上下文?

1.1 先区分四个对象

对象
它是什么
不应把它当成什么
fd
进程文件描述符表中的整数索引
不是内核对象地址,也不是节点编号
struct file
一次打开产生的文件对象,保存文件状态和操作入口
不一定只被一个 fd 引用
struct video_device
V4L2 节点对象,关联操作表、设备模型、队列等
不是每次打开都会重新创建的对象
struct v4l2_fh
标准 V4L2 打开上下文,保存框架需要的句柄级状态
不是视频帧缓冲区,也不是所有设备状态的独立副本

在普通 V4L2 文件路径中,节点对象通过 video_devdata(file) 找到;标准打开上下文则从 file->private_data 取得。这是两个不同入口。

图 1:两次独立 open 建立两个打开上下文;dup 只增加既有文件对象的引用。

上图假设所有调用成功,而且驱动采用与 v4l2_fh_open() 等价的标准上下文组织方式。它不是在保证任意驱动都允许任意数量的并发打开。

1.2 独立 open 与 dup 的本质区别

一次新的 open() 会走设备打开路径。在使用标准辅助函数时,这条路径分配并初始化新的 v4l2_fh。

dup() 则取出现有 struct file,把它安装到另一个描述符槽位,并维持相应引用。它不会再次执行 v4l2_open(),也不会再次执行 v4l2_fh_add()。因此,fd_a 和 fd_b 共享 file->private_data,也共享该文件对象中的 O_NONBLOCK 等文件状态。普通 fork() 继承的描述符同样引用原来的打开文件对象,并不会为继承的每个 fd 再创建一个视频句柄。

注意,FD_CLOEXEC 属于描述符自身的标志,不能把 O_NONBLOCK 的共享结论推广成“所有描述符标志都共享”。

这意味着:句柄不是按进程划分,也不是按 fd 的整数值划分,而是跟随实际的打开上下文。

两个进程可以共享一个上下文,一个进程也可以持有多个独立上下文。

1.3 能多次打开,不等于可以同时独立采集

打开成功,只表示成功建立了访问上下文。它并不自动为这次打开分配一套 DMA 通道、图像队列、设备格式或硬件流水线。

对于使用节点级 VB2 队列和标准辅助函数的驱动,一个上下文可以占用队列,另一个上下文虽然已经打开节点,却可能在申请或操作缓冲区时被拒绝。多次打开和数据流共享是两层问题。

更直接地说:

多次 open 成功    ≠ 每次打开都拥有独立硬件    ≠ 每次打开都能操作同一条队列    ≠ 同一帧会自动复制给每个打开者

实现对照:fs/file.c 的描述符复制、kernel/fork.c 的文件表继承、fs/fcntl.c 的文件状态处理;v4l2_fh_open() 与 VB2 归属辅助函数。


2. 先看全景:v4l2-fh.c 在整个调用框架的什么位置?

前两层已经各自解决了一类问题:v4l2-dev.c 负责找到节点、转发文件操作并管理节点引用;v4l2-ioctl.c 负责把命令和参数送到适当的处理路径。v4l2-fh.c 补上的,是这些操作所携带的打开级上下文。

图 2:句柄生命周期被包在普通文件的打开与最终释放之间。图中进入标准句柄辅助函数的连接由驱动提供。

对于采用标准句柄辅助函数的普通节点,核心关系是:

v4l2_open()    ├─ 找到 video_device、检查节点状态    ├─ video_get(vdev):取得节点引用    └─ vdev->fops->open(file)          └─ v4l2_fh_open(file),或驱动自己的组合实现                ├─ 分配打开上下文                ├─ v4l2_fh_init()                └─ v4l2_fh_add()后续 ioctl / 事件操作    └─ 通过 file->private_data 使用上下文struct file 最后一个引用被释放    └─ 文件最终释放路径          └─ v4l2_release()                ├─ vdev->fops->release(file)                │     ├─ 必要的驱动资源、队列清理                │     └─ v4l2_fh_del() → v4l2_fh_exit() → 释放存储                └─ video_put(vdev):归还节点引用

这条链中有两个容易混淆的边界。

第一,核心的 v4l2_open() 不会无条件调用 v4l2_fh_open()。 驱动必须在自己的打开回调中接入标准辅助函数,或者自行构造等价的标准句柄。

第二,v4l2_fh_init() 不会给 vdev 增加一个新的设备引用。 普通文件路径中,保护节点存活的引用由外层 v4l2_open() 取得,再由 v4l2_release() 归还。句柄中的 vdev 指针与节点引用计数不是同一件事。

实现对照:v4l2-dev.c 的 v4l2_open()、v4l2_release();v4l2-fh.c 的组合入口。


3. struct v4l2_fh:保存的是哪些状态?

3.1 先看完整结构

下面按 Linux v6.1 配套头文件列出结构体声明,去掉说明性注释,保留成员及顺序。源码包没有包含该头文件;以下成员解释同时与包内实际访问这些成员的代码交叉核对。

structv4l2_fh {structlist_headlist;structvideo_device *vdev;structv4l2_ctrl_handler *ctrl_handler;enum v4l2_priority prio;wait_queue_head_t wait;structmutexsubscribe_lock;structlist_headsubscribed;structlist_headavailable;unsignedint navailable;    u32 sequence;structv4l2_m2m_ctx *m2m_ctx;};

从用途上看,可以分成三部分:节点关联与操作状态、事件管理状态、M2M 上下文关联。

3.2 list 与 vdev:一端知道归属,一端允许被节点遍历

fh->vdev 指向当前节点。通过它可以访问节点的 fh_lock、fh_list、优先级统计对象和相关设备信息。

fh->list 则是当前句柄内嵌的链表节点。v4l2_fh_add() 把它挂到 vdev->fh_list,使框架能够从节点出发遍历已经挂接的句柄。

这是一种双向关联,但不是双向引用计数:fh->vdev 是指针关联,fh_list 是可遍历关系。加入链表本身不会自动获得独立的对象持有权。

还要分清:vdev->fh_list 是整条链表的头,fh->list 是其中一个成员。二者类型同为 struct list_head,角色却不同。

3.3 ctrl_handler:指向控制处理器,不是控制值快照

初始化时默认执行:

fh->ctrl_handler = vdev->ctrl_handler;

这里复制的是指针,不是曝光、增益等控制值。两个独立句柄默认可能关联同一个处理器;一个句柄修改共享控制对象后,不能期待另一个句柄继续看到独立的旧配置。

驱动可以为某个句柄覆盖这个指针,但“有不同的 handler 指针”也不能单独证明硬件状态独立:处理器之间可能引用共同的控制对象,底层硬件也可能仍然共享。是否隔离,取决于控制对象和驱动资源的实际组织。

3.4 prio:当前句柄的操作优先级

fh->prio 保存当前句柄的 V4L2 优先级。它与 vdev->prio 指向的共享统计对象配合,用于某些命令的访问检查。

它不是线程调度优先级,不负责决定哪个线程先获得 CPU,也不表示哪一帧先编码。一个是命令访问规则,一个是系统调度规则。

3.5 事件成员:订阅、待取事件和等待位置各有职责

成员
具体含义
不能混淆的对象
subscribed
当前句柄订阅的事件列表
不是已经发生的事件列表
available
当前句柄等待取出的事件列表
不是图像完成缓冲区列表
navailableavailable
 中待取事件的数量
不是已打开句柄数,也不是采集帧数
wait
等待事件时使用的等待队列
不是 VB2 的图像完成等待队列
sequence
当前句柄的事件序号状态
不是硬件帧号,也不是 VB2 缓冲区的帧序号
subscribe_lock
串行化订阅变更及相关 add/del 回调
不是保护所有 ioctl 的总锁

等待队列本身并不存放事件载荷;事件对象通过事件框架和链表组织。wait 的作用,是让等待条件变化的任务有一个登记和唤醒位置。

3.6 m2m_ctx:仅建立关联,不创建编解码上下文

m2m_ctx 指向 M2M 上下文。标准 ioctl 层会在相关条件下使用它寻找队列锁,但 v4l2_fh_init() 并不创建 M2M 队列,v4l2_fh_exit() 也不会替驱动自动销毁完整的 M2M 上下文。

这个字段说明标准句柄能够携带专用上下文,不说明全部专用资源都由 v4l2-fh.c 管理。

3.7 一个重要的负面结论:结构体没有自己的通用引用计数

这份 struct v4l2_fh 中没有用于任意异步持有的通用 kref 或 refcount_t 成员。

因此,不能把 fh 指针交给工作队列以后,就假定核心会自动延长它的寿命。驱动需要在退出前停止相关异步访问,或者在外层上下文上建立明确的持有与释放协议。普通文件引用只能解释常规文件路径的存活关系,不能替任意裸指针补上所有权。

实现对照:include/media/v4l2-fh.h;包内 v4l2-fh.c、v4l2-event.c 与 v4l2-ioctl.c 的成员访问。


4. v4l2_fh_open():把分配、初始化、挂接组合起来

4.1 函数本身的执行过程

intv4l2_fh_open(struct file *filp){structvideo_device *vdev = video_devdata(filp);structv4l2_fh *fh = kzalloc(sizeof(*fh), GFP_KERNEL); filp->private_data = fh;if (fh == NULL)return -ENOMEM; v4l2_fh_init(fh, vdev); v4l2_fh_add(fh);return0;}

它先从文件定位节点,再用 kzalloc() 分配标准句柄,然后把结果写入 filp->private_data。

这里的赋值发生在空指针检查之前。因此,分配失败时,private_data 被设置为 NULL,函数返回 -ENOMEM;分配成功时,再执行 init 和 add。

GFP_KERNEL 对应可以睡眠的常规分配场景。这是文件打开路径,不是硬中断入口。

4.2 它没有完成哪些事情?

函数没有注册新的 video_device,没有分配像素缓冲区,没有把当前上下文设置成 VB2 队列所有者,也没有统一启动采集。

在 VIMC 中,.open = v4l2_fh_open 就是这种轻量打开方式。真正的缓冲区准备和流启动发生在后续接口中。其他驱动可以在自定义 open() 中做更多事情,但那属于驱动的组合行为。

4.3 file->private_data 为什么必须遵守统一布局?

一旦节点启用了 V4L2_FL_USES_V4L2_FH,标准框架就会按约定把 file->private_data 解释为 struct v4l2_fh *。

因此,即使句柄被内嵌在更大的结构中,也应该保存:

file->private_data = &ctx->fh;

而不是在 fh 不位于起始位置时保存:

/* 错误:框架会从错误的地址开始解释 struct v4l2_fh。 */file->private_data = ctx;

这里的风险不是“取不到某个私有字段”,而是后续可能把完全无关的内存解释成 ctrl_handler、prio 或 m2m_ctx,产生非法访问。

实现对照:v4l2_fh_open() 与标准 file->private_data 约定。


5. v4l2_fh_init():不仅初始化句柄,也声明节点采用标准机制

5.1 完整实现

voidv4l2_fh_init(struct v4l2_fh *fh, struct video_device *vdev){ fh->vdev = vdev;/* Inherit from video_device. May be overridden by the driver. */ fh->ctrl_handler = vdev->ctrl_handler; INIT_LIST_HEAD(&fh->list); set_bit(V4L2_FL_USES_V4L2_FH, &fh->vdev->flags);/*  * determine_valid_ioctls() does not know if struct v4l2_fh  * is used by this driver, but here we do. So enable the  * prio ioctls here.  */ set_bit(_IOC_NR(VIDIOC_G_PRIORITY), vdev->valid_ioctls); set_bit(_IOC_NR(VIDIOC_S_PRIORITY), vdev->valid_ioctls); fh->prio = V4L2_PRIORITY_UNSET; init_waitqueue_head(&fh->wait); INIT_LIST_HEAD(&fh->available); INIT_LIST_HEAD(&fh->subscribed); fh->sequence = -1; mutex_init(&fh->subscribe_lock);}

虽然这些语句都在初始化函数中,但它们修改的对象分属两个层级。

5.2 句柄级初始化:为后续操作准备可用状态

fh->vdev 建立节点关联,ctrl_handler 继承默认控制处理器。INIT_LIST_HEAD(&fh->list) 把这个链表成员初始化成自成一环的空状态,但还没有把它放进节点链表。

fh->prio 先被设成 V4L2_PRIORITY_UNSET。此时尚未登记默认优先级,登记动作在 v4l2_fh_add() 中完成。

事件等待队列、订阅链表、待取事件链表和订阅互斥锁也在这里建立。初始化这些容器,不等于自动订阅任何事件,更不等于节点已经支持所有事件命令。

5.3 节点级声明:V4L2_FL_USES_V4L2_FH

这次设置作用于 video_device:

set_bit(V4L2_FL_USES_V4L2_FH, &fh->vdev->flags);

该位表达“此节点采用标准文件句柄约定”,不是“现在打开了一个文件”,也不是打开计数。

这份实现中的 del、exit 和 release 都不会在最后一个句柄退出时把该位清掉。标准句柄全部退出以后,这个机制声明仍可以保留。下一次打开继续按相同约定工作。

因此,不应在同一个已启用该标志的节点上,让不同打开路径任意混用标准 v4l2_fh 和不相容的 private_data 布局。兼容分支的存在,也不等于新驱动可以随意破坏标准约定;对应文档要求新驱动使用标准句柄机制。

5.4 与上一篇衔接:为什么这里还修改 valid_ioctls?

初始化函数设置:

set_bit(_IOC_NR(VIDIOC_G_PRIORITY), vdev->valid_ioctls);set_bit(_IOC_NR(VIDIOC_S_PRIORITY), vdev->valid_ioctls);

节点注册时的 determine_valid_ioctls() 还不知道后续打开是否会采用 v4l2_fh。到了这里,框架才明确知道标准优先级状态已经具备相应组织方式,于是开放这两个命令。

这补充了对命令位图的理解:注册期计算是重要来源,但不能把它描述成整个生命周期中唯一可能修改位图的地方。

这两次设置也不是“为当前 fd 单独保存一张命令表”,修改的仍是节点级位图。运行时再结合当前文件句柄做具体处理。

5.5 为什么 sequence = -1?

结构体中的 sequence 类型是 u32。赋值 -1 后,它表示无符号 32 位的最大值。

在匹配订阅并入队事件的路径中,事件框架先执行 fh->sequence++,再把这个值放进事件。于是,正常的第一个匹配事件取得序号 0。这是无符号整数的回绕行为,不是把负数作为有效帧号返回。事件序号的作用域是当前句柄,不能与图像帧序号混用。

5.6 最容易漏掉的初始化前提:这个函数不是完整清零器

v4l2_fh_init() 没有执行:

memset(fh, 0, sizeof(*fh));

在这份实现中,它也没有显式把 navailable 清零,或把 m2m_ctx 设为 NULL。标准 v4l2_fh_open() 使用 kzalloc(),因而提供了这些初始条件。

如果内嵌句柄由驱动自己分配,就必须确保整个对象的未显式设置成员具有正确初值。对普通非 M2M 场景,通常应使用清零分配,使 navailable == 0、m2m_ctx == NULL;需要 M2M 上下文时再由对应路径设置有效指针。

直接使用未初始化的栈对象或 kmalloc() 得到的脏内存,再只调用 v4l2_fh_init(),不能保证所有成员都正确。旧句柄复用也需要完整的退出和重新初始化协议,不能只重新初始化几个链表头。

实现对照:v4l2_fh_init();__v4l2_event_queue_fh() 的事件序号递增。


6. v4l2_fh_add():先登记优先级,再把完整对象交给节点遍历

voidv4l2_fh_add(struct v4l2_fh *fh){unsignedlong flags; v4l2_prio_open(fh->vdev->prio, &fh->prio); spin_lock_irqsave(&fh->vdev->fh_lock, flags); list_add(&fh->list, &fh->vdev->fh_list); spin_unlock_irqrestore(&fh->vdev->fh_lock, flags);}

6.1 “初始化完成”与“加入链表”是两个阶段

init() 使对象内部具备可用的初始状态;add() 才把它加入 vdev->fh_list。

分开这两个步骤,使驱动有机会在对象对节点遍历可见之前,完成自己的附加状态、控制处理器或 M2M 上下文准备。正常设计应在相关状态完整后再 add()。否则,另一个遍历句柄的路径可能看到尚未准备好的对象。

6.2 优先级登记不在自旋锁临界区内

实际顺序是:

v4l2_prio_open()    ↓获取 vdev->fh_lock    ↓list_add()    ↓释放 vdev->fh_lock

v4l2_prio_open() 将本地优先级从 UNSET 改到默认值,并增加共享优先级统计。随后才持锁修改句柄链表。

因此,优先级统计与链表挂接并不是由这一把自旋锁包住的不可分割事务。不能在并发场景中无条件把某个时刻的链表节点数与优先级计数总和当成严格同步快照。

6.3 list_add() 是头插,不是按打开先后排队服务

list_add(&fh->list, &vdev->fh_list) 把新成员插入链表头之后。

这条链用于组织和遍历句柄,不是一条按先来后到分配视频帧的调度队列。链表中某个成员靠前,不表示它比其他句柄更先收到采集权限。

6.4 节点的链表头在哪里初始化?

节点注册路径中已经执行:

spin_lock_init(&vdev->fh_lock);INIT_LIST_HEAD(&vdev->fh_list);

每次打开只初始化当前 fh->list,不应重新初始化全局的 vdev->fh_list。如果在第二次打开时重建节点链表头,先前已经挂接的句柄关系就会被破坏。

实现对照:v4l2_fh_add()、v4l2_prio_open()、__video_register_device();include/linux/list.h。


7. 优先级深挖:本地值、共享统计与命令检查怎样配合?

7.1 两种“prio”不在同一层

fh->prio    → 当前句柄的本地优先级vdev->prio    → 指向共享的 struct v4l2_prio_state    → 记录各优先级上有多少有效登记

默认情况下,vdev->prio 可以指向所属 v4l2_device 的优先级对象。如果同组的多个节点共享这个指针,优先级的作用域就可能跨越单个 /dev/videoX。

所以更准确的说法是“共享优先级组”,而不是永远限定成“一个节点里的一组计数”。

7.2 登记、修改和退出的对称关系

默认等级为 V4L2_PRIORITY_INTERACTIVE,即 V4L2_PRIORITY_DEFAULT;另外还有 BACKGROUND 和 RECORD 等等级。UNSET 用作尚未建立有效登记的状态,不是一个可正常申请的工作优先级。

共享统计的更新可以概括成:

fh_add    → prio_open    → 将本地优先级改为 DEFAULT    → 增加 DEFAULT 桶的计数S_PRIORITY    → 检查当前访问优先级与新值合法性    → 增加新等级计数    → 撤销旧等级计数    → 更新当前 fh->priofh_del    → prio_close    → 撤销当前有效等级的计数

计数使用原子操作,并不表示整个“判断最高等级、修改本地等级、执行硬件操作”天然成为一个原子事务;命令串行化和驱动同步仍有各自职责。

7.3 关键细节:G_PRIORITY 不是读取当前句柄自己的 prio

staticintv4l_g_priority(const struct v4l2_ioctl_ops *ops,    struct file *file, void *fh, void *arg){structvideo_device *vfd; u32 *p = arg; vfd = video_devdata(file); *p = v4l2_prio_max(vfd->prio);return0;}

本实现返回的是 v4l2_prio_max(vfd->prio),即当前共享优先级组的最高有效等级,而不是 ((struct v4l2_fh *)file->private_data)->prio。

因此,一个本地等级为 INTERACTIVE 的句柄,也可能通过 G_PRIORITY 读到 RECORD。这不表示它自己的等级已经被提升,而是别的句柄让共享组的最高等级发生了变化。

图 3:本地值与共享最高值必须分开理解。图中“最终退出”指执行了配对的 fh_del,而不只是关闭一个 dup 出来的描述符。

7.4 受保护命令如何被拒绝?

标准分发对带有 INFO_FL_PRIO 的命令执行:

ret = v4l2_prio_check(vfd->prio, vfh->prio);

其核心判断为:

return (local < v4l2_prio_max(global)) ? -EBUSY : 0;

当前句柄的本地等级低于共享最高等级时,请求可能在到达具体驱动回调之前就被拒绝。没有这个标志的命令,不能套用“全部都先比较优先级”的结论。

7.5 S_PRIORITY 自己也受到优先级检查

命令表中,VIDIOC_S_PRIORITY 带有 INFO_FL_PRIO。

假设 A、B 最初都是默认等级。A 先申请 RECORD,该次请求可以在前置检查通过后修改 A 的等级。此后 B 再尝试把自己提升到 RECORD,也可能先因为当前本地等级低于最高等级而得到 -EBUSY,还没有进入实际修改函数。

这与直接调用底层 v4l2_prio_change() 的效果不能混为一谈:分析应用接口,必须把外层分发检查也算进去。

7.6 优先级不等于队列所有权

即使优先级检查通过,VB2 仍可能因为队列已经属于另一个上下文而拒绝操作。

前者回答“是否可以执行受保护的命令”,后者回答“是否可以操作这条已经被占用的队列”。任何一项通过,都不能替代另一项检查。

实现对照:v4l2-dev.c 的优先级辅助函数;v4l2-ioctl.c 的 v4l_g_priority()、v4l_s_priority() 与标准命令表。


8. 独立上下文,到底独立到什么程度?

8.1 用一张图看清共享与独立

图 4:两个独立句柄可以分别维护事件状态,同时关联同一个控制处理器。图中的事件投递以订阅匹配为前提。

状态或对象
两次独立 open 的常见关系
dup 后的关系
struct file
 与文件状态
分别保存
共用同一个对象
标准 v4l2_fh
分别创建
共用同一个句柄
事件订阅与待取事件
分别维护
共同订阅、共同消费同一队列
fh->prio
各自保存,参与共享组比较
同一个本地值,不增加额外登记
默认 ctrl_handler
可以指向相同对象
同一个指针与对象
video_device
可以是同一个节点对象
同一个节点对象
节点级 VB2 队列
可能共享一条队列,并受 owner 限制
被视为同一 owner 上下文
硬件格式和像素流
不因多次打开自动复制
不会生成第二套硬件状态

表中的“常见”对应本文的标准句柄与节点级 VB2 组织方式。M2M 或自行管理每次打开队列的驱动,可以采用不同资源模型。

8.2 事件订阅与待取事件不是同一条链表

subscribed 保存“关注哪些事件”;available 保存“哪些事件已经发生、正在等待取出”。

事件框架以 (type, id) 区分订阅。节点级 v4l2_event_queue() 遍历 fh_list,对各句柄检查是否匹配订阅,再把事件加入对应句柄的待取结构,并唤醒其 wait。

因此,当两个独立句柄都订阅同一种事件时,它们可以分别得到自己的待取事件;但两个 dup 描述符对应同一 fh,从一个描述符取走事件,会改变另一个描述符看到的同一事件队列。

这里并不深入事件环形存储、合并与替换算法,但需要记住:队列容量和事件合并策略仍会影响最终可取出的事件,不能把它理解成无限保留每一次变化。

8.3 有事件成员,不代表驱动已经支持事件订阅

标准句柄初始化只是建立容器。具体事件还需要相应命令入口、订阅回调和事件产生路径。

本文所用 VIMC 捕获节点的操作表没有提供完整的事件订阅接口。因此,不能在它上面直接照搬“订阅曝光变化事件”的演示,并假定一定成功。VIMC 用于验证打开、文件句柄和 VB2 的衔接;事件机制在这里依据事件框架自身的实现解释。

8.4 wait 与 poll 的连接:唤醒不是搬运事件载荷

VB2 的 vb2_poll() 可以在处理缓冲区就绪状态的同时,把 fh->wait 加入 poll 等待关系,并在存在待取事件时返回 EPOLLPRI。

这里是两种状态汇合到同一次 poll 结果中:缓冲区就绪由 VB2 处理,事件就绪由标准句柄和事件框架提供。poll() 报告可进行的操作,实际事件载荷仍通过事件出队接口取得。

还要留意这份实现的错误码差别:非阻塞 v4l2_event_dequeue() 在没有待取事件时可以返回 -ENOENT,不能机械套用非阻塞 DQBUF 中常见的 -EAGAIN。两个出队接口不是同一套返回规则。

实现对照:v4l2-event.c 的订阅、投递和出队;videobuf2-v4l2.c 的 vb2_poll() 与 owner 检查。


9. 锁的边界:为什么既有自旋锁,又有互斥锁?

9.1 fh_lock 保护短时间的共享结构修改

vdev->fh_lock 是节点级锁。句柄加入、摘除、部分事件队列操作和句柄遍历通过它保护相关链表状态。

在常规非 PREEMPT_RT 的自旋锁模型下,spin_lock_irqsave() 保存并屏蔽本 CPU 的中断状态,再配合自旋锁实现跨 CPU 互斥;对应的 irqrestore 恢复进入前的状态。这里不是“关闭整个系统所有 CPU 的中断”。自旋锁的具体执行语义受内核配置影响,但这段短临界区都不应塞入可睡眠的任意驱动清理。

9.2 subscribe_lock 负责更完整的订阅变更过程

订阅与退订既要修改链表,也可能调用对象相关的 add/del 回调。subscribe_lock 用来保证同一句柄上的这类操作有序执行。

事件实现会在需要时先持有 subscribe_lock,再短暂取得 fh_lock 修改共享结构;释放短期链表锁以后,仍可继续完成相应回调。不能把两把锁理解为作用完全重复。

9.3 四种锁不要混为一谈

锁
本篇涉及的职责
不能据此推导的结论
videodev_lock
节点表、编号与打开/注销引用交接
不会包住驱动的整个 open
vdev->fh_lock
句柄链表及相关事件结构的短临界区
不是设备的“独占打开锁”
fh->subscribe_lock
当前句柄的订阅变更和相关回调顺序
不覆盖全部 ioctl 或采集队列
vdev->lock
 / queue->lock
标准操作和队列路径按约定进行串行化
不会因为填入指针就自动保护所有异步硬件访问

尤其是:核心 v4l2_open() 在调用驱动 open() 前已经释放 videodev_lock;它也没有在这里自动获取 vdev->lock。需要把“判断是否第一次打开”和“开启共享硬件”作为一个整体的驱动,必须自己建立统一的外层同步协议。

9.4 阻塞 DQEVENT 使用自己的等待与解锁协议

标准 VIDIOC_DQEVENT 不带 INFO_FL_QUEUE。在本文的标准 ioctl 路径中,它通常由 vdev->lock 串行化,而不是走 VB2 队列锁的选择分支。

包内 v4l2_event_dequeue() 的阻塞分支可以概括为:

调用前:若 vdev->lock 非空,当前线程应已持有它    ↓暂时释放 vdev->lock    ↓等待 fh->navailable != 0    ↓在 fh_lock 保护下尝试摘取事件    ↓若被同句柄的另一调用先取走,返回等待    ↓结束等待后,重新取得 vdev->lock 再返回

它并不调用 vb2_ops_wait_prepare() 或 vb2_ops_wait_finish()。这些属于另一套缓冲区等待协作。两者都可能“等待前解锁、醒来后重新加锁”,但具体函数、条件和锁对象不同。

因此,驱动从自定义路径直接调用阻塞 v4l2_event_dequeue() 时,不能只看见锁指针非空,就认为函数会先替自己取得锁。它先做的是解锁;调用方必须满足实际持锁约定。非阻塞分支则直接尝试出队,不经过这段互斥锁释放与重获过程。

实现对照:v4l2_fh_add/del()、v4l2_event_subscribe/unsubscribe/dequeue()、__video_do_ioctl();固定版本的锁类型说明。


10. v4l2_fh_is_singular():看起来简单,却最容易被误用

intv4l2_fh_is_singular(struct v4l2_fh *fh){unsignedlong flags;int is_singular;if (fh == NULL || fh->vdev == NULL)return0; spin_lock_irqsave(&fh->vdev->fh_lock, flags); is_singular = list_is_singular(&fh->list); spin_unlock_irqrestore(&fh->vdev->fh_lock, flags);return is_singular;}

10.1 它检查的是句柄链表,不是引用计数

函数对 fh == NULL 或 fh->vdev == NULL 返回 0。句柄有效时,在 fh_lock 保护下检查链表关系。

它不读取 struct file 的引用计数,不统计 fd,也不关心有几个进程正在使用同一个文件对象。因此,一个句柄即使被多个 dup 描述符共享,仍然可能是节点上唯一挂接的句柄。

10.2 为什么参数是 &fh->list,不是 &vdev->fh_list?

Linux 链表是循环双向链表。辅助判断的逻辑是:

return !list_empty(head) && (head->next == head->prev);

当节点中只挂接一个句柄 A 时,环上只有节点链表头 H 与 A 两个链表对象。从 A 出发,它的 next 和 prev 都指向 H,同时 A 并不指向自己,所以判断为真。

如果先挂接 A,再用 list_add() 挂接 B,B 会被插到链表头 H 后面。沿 next 方向的真实顺序是 H → B → A → H。此时 A.prev == B、A.next == H,两者不同,判断变成假。不能把头插画成把 B 追加到 A 后面。

图 5:A 先挂接,B 随后头插,沿 next 的顺序变为 H → B → A → H;从成员 A 也能判断是否为唯一句柄。

初始化后但尚未 add() 的成员自成空环;执行 list_del_init() 后也回到空环。对这种已经初始化的脱链状态,!list_empty() 为假,因此不能把它当作节点中唯一的有效句柄。

10.3 返回 1 只是一个瞬时观察

考虑两个线程:

线程 A:is_singular() 返回 1,函数已经解开 fh_lock线程 B:随后完成另一句柄的 add()线程 A:继续执行“只有我打开,所以可以重置硬件”的动作

A 作出动作时,“只有一个句柄”的条件可能已经不成立。

因此,is_singular() 不是独占授权,不会阻止后续打开,也不会替驱动锁住整个首开、末关过程。需要此类策略时,相关打开与关闭路径必须遵循同一把外层操作锁,并明确检查与硬件动作的顺序。

也不要先手工拿着 vdev->fh_lock,再调用这个辅助函数;它内部还会取得同一把锁,这会造成递归加锁问题。

10.4 首开与末关的检查位置不同

在完整外层同步成立的前提下,首次打开的判断通常应在当前句柄已经挂接后观察;最后关闭的判断通常应在当前句柄尚未摘除时观察。

如果先 del() 再对当前 fh 调用 is_singular(),它已经是脱链空环,不能再用这个结果判断“刚才是否最后一个”。该辅助函数只提供链表关系判断,具体资源策略属于驱动设计。

10.5 v4l2_fh_is_singular_file() 只是参数适配

头文件中还提供一个内联包装:

staticinlineintv4l2_fh_is_singular_file(struct file *filp){return v4l2_fh_is_singular(filp->private_data);}

它只是从 file 取出当前上下文,仍然遵循完全相同的链表与同步前提;不会因为参数换成 struct file 就改为统计文件引用。

实现对照:v4l2_fh_is_singular();include/linux/list.h 的 list_add()、list_is_singular() 和 list_del_init()。


11. 退出为什么分成 del、exit、free 三步?

11.1 v4l2_fh_del():摘链并撤销优先级登记

voidv4l2_fh_del(struct v4l2_fh *fh){unsignedlong flags; spin_lock_irqsave(&fh->vdev->fh_lock, flags); list_del_init(&fh->list); spin_unlock_irqrestore(&fh->vdev->fh_lock, flags); v4l2_prio_close(fh->vdev->prio, fh->prio);}

顺序为:持锁摘除链表成员,解锁,再调用 v4l2_prio_close()。

list_del_init() 既摘除当前成员,也把它重新初始化为空环。但它没有释放 fh,也没有把 fh->vdev 设为 NULL。这是有意保留的中间状态:后面的 exit() 仍需通过 vdev 完成媒体源和事件相关清理。

摘链之后,遵循同一 fh_lock 的节点遍历不会再通过 fh_list 找到这个句柄。但已经由驱动额外保存的裸指针,不会因此自动失效或被自动等待;对应异步访问仍需单独停止。

11.2 list_del_init() 不代表整个 del 可以重复调用

第一次 del() 之后再次调用,链表操作本身可能仍表现为空环摘除,但 v4l2_prio_close() 会再执行一次。因为本函数没有把 fh->prio 改回 UNSET,共享计数可能被多减一次。

因此,正确规则是:一个成功的 add 对应一个 del,而不是“清理函数多调用几次也没关系”。 重复 add 同样不是合法用法,可能破坏链表和优先级统计。

11.3 v4l2_fh_exit():清理关联资源,不释放句柄存储

voidv4l2_fh_exit(struct v4l2_fh *fh){if (fh->vdev == NULL)return; v4l_disable_media_source(fh->vdev); v4l2_event_unsubscribe_all(fh); mutex_destroy(&fh->subscribe_lock); fh->vdev = NULL;}

这个函数按顺序处理:

检查 fh->vdev    ↓v4l_disable_media_source()    ↓v4l2_event_unsubscribe_all()    ↓销毁 subscribe_lock    ↓fh->vdev = NULL

把 vdev 清空放在最后,是因为事件退订等操作仍需要它。

v4l2_event_unsubscribe_all() 不只是把链表头清空;它沿事件框架的退订路径撤销订阅,移除相关待取事件,并在适用时调用相应的退订回调。跳过这些流程直接 kfree(fh),会留下关联资源或失效引用。

11.4 媒体源清理不是“统一关闭 Sensor 和 DMA”

v4l_disable_media_source() 在适用的媒体控制器配置下,进入媒体设备的源关闭钩子;没有相应媒体设备或钩子时,可以没有实质动作。

更重要的是,v4l2_fh_exit() 并没有先调用 is_singular()。每个有效句柄执行到这里,都可能调用这层钩子。不能据此推导“核心只会在最后一个打开者退出时关硬件”,也不能把它等同于 STREAMOFF 或完整的 VB2 队列清理。具体共享资源语义必须看媒体设备和驱动实现。

11.5 exit() 不自动释放控制处理器和 M2M 上下文

函数没有调用 kfree(fh),没有通用地释放 fh->ctrl_handler,也没有自动销毁 fh->m2m_ctx。

默认控制处理器可能被多个句柄共享,不能在每次句柄退出时都释放它。若驱动为句柄单独创建了控制处理器或专用上下文,则必须在自己的退出流程中,在相关引用和订阅清理完成后,再按其所有权释放。

11.6 v4l2_fh_release():标准独立分配路径的组合清理

intv4l2_fh_release(struct file *filp){structv4l2_fh *fh = filp->private_data;if (fh) {  v4l2_fh_del(fh);  v4l2_fh_exit(fh);  kfree(fh);  filp->private_data = NULL; }return0;}

这条路径与 v4l2_fh_open() 的独立 kzalloc() 分配方式配对:摘链、清理、释放句柄内存,再清空文件中的私有指针。

当 private_data == NULL 时,它什么也不做并返回 0。但这种保护并不使任意重复清理合法:对已经释放的裸指针调用其他句柄函数,仍然是非法访问;fh_exit() 的 vdev == NULL 检查也不能修复一个已经失效的指针。

这份 v4l2_fh_release() 始终返回 0。外层 v4l2_release() 无条件归还当前打开所持有的节点引用。通用 __fput() 调用文件 release 时不使用其返回值,所以驱动不能依赖“release 返回错误,应用便会重试关闭”完成后续清理;close() 的错误还可能来自其他关闭阶段。

11.7 顺序不能颠倒,也不能在链表锁内执行完整退出

对已经 add() 的句柄,必须先 del(),再 exit()。若先 exit(),fh->vdev 已被清空;随后 del() 还会通过这个指针访问 fh_lock 与优先级对象,顺序因此不成立。exit() 的空指针保护也不意味着可以跳过摘链。

fh_exit() 会走可能睡眠的媒体源和事件退订路径,不能在持有 fh_lock 的自旋锁临界区内直接调用。正常 fh_del() 只在摘链时短暂持有该锁,返回时已经解锁。

此外,摘链只切断 vdev->fh_list 这条遍历关系。控制事件订阅或驱动自己的工作项还可能通过其他关系关联该句柄;这些关系要由退订及驱动自己的同步协议继续清理。因此“已经摘链”不等于“任何路径都不可能再访问”。

实现对照:v4l2_fh_del/exit/release()、事件退订路径、媒体源钩子;fs/file_table.c 的 __fput()。


12. 内嵌句柄:为什么不能机械复用通用 release?

12.1 真正分配的对象可能比 v4l2_fh 大

实际驱动往往还需要保存打开级状态:

structdemo_context {    u32 marker;structv4l2_fhfh;void *scratch;};

这里真正分配的是 struct demo_context。file->private_data 应指向其中的 fh,而需要释放的完整对象起点是 ctx。

图 6:标准框架取得成员地址,驱动通过 container_of 找回真正拥有存储的外层对象。

12.2 container_of 解决的是地址还原,不是生命周期管理

structv4l2_fh *fh = file->private_data;structdemo_context *ctx =container_of(fh, structdemo_context, fh);

它根据成员在外层结构中的偏移,计算外层对象地址。它不会检查对象是否还活着,也不会增加引用计数。

若 fh 不是结构体首成员,直接调用会执行 kfree(fh) 的通用释放辅助函数,释放地址就与原始分配地址不一致。即使 fh 恰好是首成员、地址数值相同,也仍可能遗漏 scratch、控制处理器或其他专属资源的清理。

因此,释放策略必须跟随实际分配与持有关系设计,而不是看某个地址“恰好能被 kfree”就认为生命周期正确。

12.3 一个最小的内嵌上下文示例

下面只演示上下文管理,不注册设备、不创建 VB2 队列,也不启动异步任务。示例没有自行编写硬件操作,但调用的 v4l2_fh_exit() 仍可能间接进入媒体源关闭钩子。scratch 只是用于演示可失败分配的打开级附加存储,不是图像缓冲区。集成到真实驱动时,需要协调源钩子、共享状态与实际资源。

// SPDX-License-Identifier: GPL-2.0-only/* Context-management example, not a complete device driver. */#include<linux/fs.h>#include<linux/kernel.h>#include<linux/slab.h>#include<media/v4l2-dev.h>#include<media/v4l2-fh.h>structdemo_context {    u32 marker;structv4l2_fhfh;void *scratch;};intdemo_open(struct file *file){structvideo_device *vdev = video_devdata(file);structdemo_context *ctx;    file->private_data = NULL;    ctx = kzalloc(sizeof(*ctx), GFP_KERNEL);if (!ctx)return -ENOMEM;    ctx->marker = 0x46484354;    v4l2_fh_init(&ctx->fh, vdev);/* 附加存储仅用于演示失败回滚,不是图像缓冲区。 */    ctx->scratch = kzalloc(256, GFP_KERNEL);if (!ctx->scratch)goto err_exit;    file->private_data = &ctx->fh;    v4l2_fh_add(&ctx->fh);return0;err_exit:/* 尚未 add,不能执行与 add 配对的 del。 */    v4l2_fh_exit(&ctx->fh);    kfree(ctx);return -ENOMEM;}intdemo_release(struct file *file){structv4l2_fh *fh = file->private_data;structdemo_context *ctx;if (!fh)return0;    ctx = container_of(fh, struct demo_context, fh);/* 本例没有工作队列、流任务或其他异步持有者。 */    v4l2_fh_del(fh);    v4l2_fh_exit(fh);    kfree(ctx->scratch);    file->private_data = NULL;    kfree(ctx);return0;}

这段示例有三个有意安排的细节。

fh 不在结构体起点,避免把成员地址与外层地址相等当成必然条件。

可失败的附加分配发生在 init() 之后、add() 之前,因此本例失败时执行 exit(),不调用尚未配对的 del()。这里的 exit() 不只是回收内存,还可能调用媒体源钩子;若实际附加准备只是与框架无关的内存分配,也可以将它前移到 init() 之前,减少失败时需要撤销的框架状态。

成功路径最后才设置 private_data 并挂接句柄。关闭时,先摘链和清理框架关联,再释放附加存储与外层对象。若真实上下文还会被异步路径访问,应在释放相关状态之前先终止或同步这些访问。

实现对照:v4l2_fh_init/add/del/exit() 与标准内嵌上下文约定;示例中的附加存储由示例自己管理。


13. 失败回滚:不能期待 open 失败后再走正常 close 清理

13.1 核心会归还节点引用,不会替驱动撤销所有中间资源

外层 v4l2_open() 在驱动打开回调失败时,执行 video_put(vdev)。

但驱动已经分配的上下文、事件关联和附加资源,不能因此自动视为已经释放。失败的打开没有形成正常可用的文件句柄,不能把配对清理留给以后某个常规 release()。

特别是自定义打开函数先调用 v4l2_fh_open() 成功,随后又执行一项可能失败的准备工作时,失败分支必须主动清理已经建立的标准句柄。

13.2 回滚按“已经完成到哪里”决定

图 7:不同错误出口按已完成阶段回滚;退订回调仍依赖的资源保留到 fh_exit 之后,尚未 add 的句柄不执行 del。

失败发生的位置
已经成立的关系
必要清理
完整对象分配失败
没有句柄对象
返回分配错误
对象已分配,但尚未 init
只有存储
释放已经分配的存储
init 后、add 前
框架状态已初始化,但未登记入链
先停止额外异步访问;保留退订回调依赖的资源,执行 exit 后再释放它们和外层对象;不要 del
add 后
已登记优先级并挂入节点链表
停止额外异步访问,del、exit,再按所有权释放
正常打开后最终退出
活跃的打开上下文
走完整的驱动 release 组合路径

这份 v4l2_fh_init() 和 v4l2_fh_add() 的返回类型都是 void。它们不是“通过返回负数报告部分失败”的接口;可失败阶段通常来自存储分配或驱动额外准备。表中的分类用于解释驱动组合路径,不是给这两个函数虚构错误返回。

13.3 为什么强调清理 private_data?

如果指针已经写入文件对象,再清理它所指向的存储,就必须避免文件对象中残留悬空指针。

对于内嵌上下文,用自己的释放函数清空它;对于标准独立句柄,v4l2_fh_release() 已包含清空操作。不要既手工释放句柄,又再次调用这个组合辅助函数。

13.4 资源释放顺序要服从依赖,而不是一律“先释放附加资源”

v4l2_event_unsubscribe_all() 最终可能调用订阅对象的 ops->del()。若退订回调还要访问控制处理器、驱动上下文或某块附加存储,就必须让这些对象活到回调结束。

可以用下面的顺序作为分析框架:

停止或同步额外异步访问,确保不再发布新的外部访问    ↓已经 add 的句柄先 del;尚未 add 的句柄跳过 del    ↓fh_exit:撤销事件订阅和框架关联    ↓释放不再被回调引用的控制处理器或附加存储    ↓清空 private_data,释放实际分配的外层对象

这不是所有驱动资源都必须按相同顺序处理的万能模板。例如某个源钩子或专用上下文还有额外依赖,需要按实际实现插入相应同步与清理。关键不变量是:任何清理回调正在使用的对象,都不能提前销毁;任何尚未建立的登记,都不能按已经建立来撤销。

实现对照:v4l2_open() 的错误出口、v4l2_fh_exit()、v4l2_event_unsubscribe() 的退订回调。


14. VIMC 验证:为什么 open 用 fh 辅助函数,release 却用 VB2?

14.1 操作表把三个专题连到一起

VIMC 捕获节点提供:

staticconststructv4l2_file_operationsvimc_capture_fops = { .owner  = THIS_MODULE, .open  = v4l2_fh_open, .release = vb2_fop_release, .read           = vb2_fop_read, .poll  = vb2_fop_poll, .unlocked_ioctl = video_ioctl2, .mmap           = vb2_fop_mmap,};

三个关键入口分别是:

open → v4l2_fh_open    建立打开上下文unlocked_ioctl → video_ioctl2    使用这个上下文执行标准命令release → vb2_fop_release    先按需要清理队列,再清理标准上下文

这里并没有要求打开和关闭的回调名称必须同属一个文件。真正需要对称的是资源与状态,而不是函数名字。

本例初始化时,q->lock 和 vdev->lock 都指向同一个 vcapture->lock。两个字段名称不同,不代表一定是两把不同的锁;后文仍按实际取得和释放的位置分析持锁范围。

14.2 队列 owner 不是在 open 时设置的

在标准 vb2_ioctl_reqbufs() 路径中,核心缓冲区申请成功后执行:

if (res == 0)    vdev->queue->owner = p->count ? file->private_data : NULL;

判断用的是返回的 p->count。成功后仍有缓冲区时,当前上下文成为队列 owner;成功释放全部缓冲区、返回数量为零时,owner 被清空。

CREATE_BUFS 和某些文件 I/O 辅助路径也会设置队列 owner,所以不能把 REQBUFS 当成唯一入口。但它足以说明:只执行 open,不会由 v4l2_fh_open() 自动取得这条队列的所有权。

14.3 owner 比较的是上下文,不是 fd 或 PID

对应的队列占用检查以“owner 非空且不同于 file->private_data”判断是否被另一上下文占用。

因此,A 打开的上下文成为 owner 后,A 的 dup 描述符仍使用相同的 owner 身份;B 的独立 open 则携带另一个 private_data,受标准队列归属检查限制。

同一个 owner 上下文内,两个线程或 dup 描述符同时出队,也是在消费同一队列,不是各自自动收到一份相同帧。调用次序和缓冲区使用仍需要应用侧组织。

queue->owner 在这里是身份指针,赋值本身不会给 v4l2_fh 增加通用引用计数。这也是关闭路径必须在释放句柄之前处理队列并清空 owner 的原因,不能让队列保留一个已经释放的上下文地址。

14.4 关闭路径先看 owner,再释放句柄

int _vb2_fop_release(struct file *file, struct mutex *lock){structvideo_device *vdev = video_devdata(file);if (lock)  mutex_lock(lock);if (file->private_data == vdev->queue->owner) {  vb2_queue_release(vdev->queue);  vdev->queue->owner = NULL; }if (lock)  mutex_unlock(lock);return v4l2_fh_release(file);}intvb2_fop_release(struct file *file){structvideo_device *vdev = video_devdata(file);structmutex *lock = vdev->queue->lock ? vdev->queue->lock : vdev->lock;return _vb2_fop_release(file, lock);}

vb2_fop_release() 选择 queue->lock,不存在时再使用 vdev->lock,然后把它传给 _vb2_fop_release()。

后者先在适用的锁保护下判断当前上下文是否为队列 owner。若是,清理队列并清空 owner;若不是,就跳过这一步。两条分支最终都会进入 v4l2_fh_release(),因为每个打开上下文都有自己的句柄需要清理。

图 8:队列清理、标准句柄清理与节点引用归还依次衔接;图中的 Request 锁是有条件的外层保护。

14.5 非 owner 关闭,不等于所有硬件状态必然毫无变化

在这条辅助路径中,非 owner 不调用 vb2_queue_release()。这是代码能够直接保证的范围。

但它仍会执行自己的 fh_exit(),而 fh_exit() 包含前面分析的媒体源钩子。因此,不应把“跳过队列释放”夸大成“关闭绝对不会影响任何共享硬件”。实际媒体源和硬件行为仍取决于相关驱动实现。

同样,vb2_queue_release() 的职责是清理队列拥有的状态和资源,不是在这里释放 struct video_device,也不能推断所有被映射或导出的底层内存都不再有其他持有者。

14.6 这里的锁并没有包住整个 fh_release

实际代码先释放 _vb2_fop_release() 取得的队列或节点互斥锁,再调用 v4l2_fh_release()。

随后,句柄清理会按需要取得 fh_lock、subscribe_lock 或媒体图的锁。外层核心 v4l2_release() 还可能在支持 Media Request 时持有 req_queue_mutex,直到驱动 release 返回后再释放。

因此,不能把关闭过程画成“始终持着同一把队列锁,直到所有句柄和节点都释放”。锁的作用域必须按实际代码区分。

还有一个与内嵌对象直接相关的结论:vb2_fop_release() 最终会调用释放独立 fh 的通用辅助函数。对自定义内嵌上下文,不能未经检查就复用这条完整关闭路径;应编写与外层对象资源匹配的关闭组合。

实现对照:vimc-capture.c 的操作表与锁初始化;videobuf2-v4l2.c 的队列归属和关闭辅助函数。


15. 最后一个 fd 关闭,为什么仍不一定立刻执行 release?

15.1 真正触发边界是 struct file 的最后一个引用

通用文件机制在最后一个文件引用释放后进入 __fput(),其中调用 file->f_op->release。这条路径可以通过 task work 或延后处理完成,不能把它简化成每次 close(fd) 都同步直接调用设备 release。

dup、继承的描述符、映射或进行中的操作,都可能让文件对象仍有引用。普通文件映射可以通过 vma->vm_file 持有文件;具体驱动也可能改变映射关联,应按实际映射实现确认。

因此:

某个 fd 被 close    ≠ 当前 struct file 已经没有引用    ≠ 当前 v4l2_fh 已经释放    ≠ 整个 video_device 已经释放

15.2 回到最初的三个描述符

假设只有最初示例中的描述符引用,没有额外映射或并发持有:

动作
文件对象变化
句柄变化
关闭 fd_a
file A 仍被 fd_b 引用
fh A 保留
关闭 fd_b
file A 的最后一个引用退出后进入最终释放
清理 fh A
fd_c
 仍打开
file C 仍存在
fh C 保留
最后关闭 fd_c
file C 最终释放
清理 fh C

句柄退出后是否进一步触发整个节点对象的最终释放,还要看节点是否已经注销以及是否仍有其他设备引用。节点引用计数与文件引用计数是相关但不同的层次。

15.3 注销节点不会替每个打开者立即销毁 fh

video_unregister_device() 清除节点注册状态,并在采用标准句柄机制时唤醒相应事件等待者,然后执行设备注销。

它没有在这里遍历句柄并无条件 kfree()。既有打开上下文仍需要通过自己的退出路径清理。唤醒等待队列本身也不等于替所有等待函数补齐退出条件,驱动必须正确处理断开状态和硬件同步。

所以,fh->vdev 指针仍然指向存活对象,与设备硬件仍然可以访问,是两个不同事实。

15.4 唤醒并不保证阻塞事件出队立刻返回

这份源码的阻塞事件出队使用的等待条件是:

wait_event_interruptible(fh->wait, fh->navailable != 0);

条件本身没有包含 !video_is_registered(fh->vdev)。而注销时的 v4l2_event_wake_all() 只唤醒等待队列,不会凭空增加 navailable 或生成一个断开事件。

因此,按这份实现的控制流推导:若一个调用已经越过 ioctl 的注册状态检查,阻塞在空事件队列上,此时注销仅触发唤醒,而待取事件仍为零,等待任务重新检查条件后可能继续睡眠。不能把“注销会唤醒”画成“所有阻塞 DQEVENT 都自动返回 ENODEV”。

同理,另一个线程关闭某个描述符,也不是取消所有正在进行的阻塞操作的通用接口;进行中的系统调用仍可保持文件对象存活。断开处理需要把等待条件、唤醒机制、已有调用、硬件停止与最终释放一起设计。这里指出的是当前实现的边界,不是对所有内核版本的结论。

实现对照:fs/file_table.c、mm/mmap.c 的文件引用;包内 video_unregister_device()、v4l2_event_wake_all() 与事件等待条件。


16. 验证练习:观察独立 open 与 dup 的差别

16.1 先选择不会启动采集的观察方式

下面的程序使用两次独立 open 和一次 dup,查询能力,并短暂修改当前文件对象的 O_NONBLOCK 状态,再恢复原值。

它不设置格式,不申请图像缓冲区,不启动数据流,也不调整优先级。需要在空闲测试节点运行:某些驱动可能在 open 中执行额外操作,不能因为程序不调用 STREAMON 就认为对所有设备都完全没有副作用。

F_GETFL/F_SETFL 观察的是 struct file 的共享关系;它不是直接读取内核 fh_list。将观察结果与已知的标准句柄打开路径结合,才能解释对应的 v4l2_fh 关系。

// SPDX-License-Identifier: MIT#define _POSIX_C_SOURCE 200809L#include<errno.h>#include<fcntl.h>#include<stdbool.h>#include<stdio.h>#include<string.h>#include<sys/ioctl.h>#include<time.h>#include<unistd.h>#include<linux/videodev2.h>staticintquery_cap(int fd, constchar *label){structv4l2_capabilitycap = {0};int ret;do {        ret = ioctl(fd, VIDIOC_QUERYCAP, &cap);    } while (ret < 0 && errno == EINTR);if (ret < 0) {fprintf(stderr, "%s QUERYCAP: %s\n", label, strerror(errno));return-1;    }printf("%s: fd=%d, driver=%.*s\n", label, fd,           (int)sizeof(cap.driver), (constchar *)cap.driver);return0;}staticintshow_flags(constchar *stage, int a, int b, int c){int fa, fb, fc;    fa = fcntl(a, F_GETFL);if (fa < 0) { perror("F_GETFL A"); return-1; }    fb = fcntl(b, F_GETFL);if (fb < 0) { perror("F_GETFL dup(A)"); return-1; }    fc = fcntl(c, F_GETFL);if (fc < 0) { perror("F_GETFL C"); return-1; }printf("%s: NONBLOCK A=%d, dup(A)=%d, independent C=%d\n", stage,           !!(fa & O_NONBLOCK), !!(fb & O_NONBLOCK), !!(fc & O_NONBLOCK));return0;}intmain(int argc, char **argv){constchar *path;int a = -1, b = -1, c = -1, old_flags = -1;int status = 1;bool restore = false;if (argc > 2 || (argc == 2 && !strcmp(argv[1], "--help"))) {fprintf(stderr, "Usage: %s [/dev/videoX]\n", argv[0]);return argc > 2 ? 2 : 0;    }    path = argc == 2 ? argv[1] : "/dev/video0";    a = open(path, O_RDWR | O_NONBLOCK | O_CLOEXEC);if (a < 0) { perror("open A"); goto out; }if (query_cap(a, "A") < 0)goto out;    b = dup(a); /* 不会重新执行设备 open。 */if (b < 0) { perror("dup A"); goto out; }    c = open(path, O_RDWR | O_NONBLOCK | O_CLOEXEC);if (c < 0) { perror("independent open C"); goto out; }if (query_cap(c, "C") < 0)goto out;    old_flags = fcntl(a, F_GETFL);if (old_flags < 0) { perror("get original flags"); goto out; }if (show_flags("before", a, b, c) < 0)goto out;if (fcntl(a, F_SETFL, old_flags ^ O_NONBLOCK) < 0) {        perror("toggle O_NONBLOCK");goto out;    }    restore = true;if (show_flags("after toggle on A", a, b, c) < 0)goto out;if (fcntl(a, F_SETFL, old_flags) < 0) {        perror("restore original flags");goto out;    }    restore = false;/* Linux 上不因 close 报错而重复关闭可能已被释放的 fd。 */    {int closing = a;        a = -1;if (close(closing) < 0) { perror("close A"); goto out; }    }if (query_cap(b, "dup(A) after close(A)") < 0)goto out;puts("Independent-open/dup observation completed.");    status = 0;out:if (restore && a >= 0 && fcntl(a, F_SETFL, old_flags) < 0) {        perror("cleanup restore flags");        status = 1;    }if (c >= 0 && close(c) < 0) { perror("close C"); status = 1; }if (b >= 0 && close(b) < 0) { perror("close dup(A)"); status = 1; }if (a >= 0 && close(a) < 0) { perror("close A"); status = 1; }return status;}

编译与执行:

cc -std=c11 -Wall -Wextra -Werror -O2 multiopen_probe.c -o multiopen_probe./multiopen_probe /dev/video0

预期应关注关系,而不是固定的 fd 数值:A 的 O_NONBLOCK 变化会在 dup 描述符上反映;独立打开的 C 不应因此自动改变该文件状态。关闭 A 后,dup 描述符仍能发起查询,也说明只关闭一个别名并不会自动销毁共享的文件对象。

如果第二次 open 失败,应先检查具体驱动策略和返回错误,而不是直接认定 v4l2-fh.c 禁止多次打开。如果查询期间节点注销或下层返回错误,也应结合实际返回点解释,不能把实验期望写成任何硬件环境下的保证。

示例已通过 C11 严格编译检查;未在目标视频硬件上实测。

16.2 更直接的源码验证位置

调试时可以观察四组位置:

观察点
要核对的关系
v4l2_fh_open()
 或驱动自己的 open
两次独立打开是否建立两个上下文;dup 是否没有再次进入
v4l2_fh_add()
 / v4l2_fh_del()
挂接与摘除是否成对;失败分支有没有遗漏
v4l_s_priority()
 / v4l_g_priority()
修改当前本地等级与查询共享最高值是否被区分
_vb2_fop_release()
owner 比较结果、队列清理与句柄清理的实际顺序

能够进入调试内核时,断点或短时日志可以记录 file、fh、vdev 的身份关系及进入次数;不要只统计应用执行了几次 close(),就断言设备 release 应执行相同次数。

实现对照:fs/fcntl.c 与 fs/file.c;实际节点操作仍由 v4l2-dev.c 和驱动回调决定。


17. 典型错误:从现象反推生命周期边界

现象或写法
容易隐藏的问题
正确检查方向
第二次打开后事件遍历异常
在每次 open 中重新初始化了 vdev->fh_list
节点链表头只在对应节点初始化阶段建立
只调用 init,忘记 add
对象没有加入节点遍历,也没有完成默认优先级登记
检查成功路径是否完整配对
直接 kfree(fh)
仍在链表中,订阅、优先级或专用引用未撤销
先 del、exit,再按所有权释放
反复调用 del“确保清理”
优先级统计被重复递减
记录阶段,保证 add/del 一一配对
内嵌上下文使用通用 fh_release
释放地址错误,或遗漏外层专属资源
保存成员地址,container_of 后释放外层对象
G_PRIORITY 返回 RECORD,就认为自己的 prio 已提升
混淆共享最高值与本地值
对照 v4l_g_priority 的实际赋值
is_singular 返回 1 后立即无锁重置硬件
检查与动作之间出现另一打开
对完整首开/末关策略统一同步
非 owner 关闭就认为完全没有硬件动作
只看了 VB2 分支,忽略 fh_exit 的源钩子
继续检查实际媒体源实现
所有 fd 都关了,release 日志却没立即出现
仍有文件引用,或最终释放被延后执行
检查继承、映射、并发持有与最终 fput
独立 fh 就认为曝光、格式、队列都独立
将上下文对象误当成完整设备副本
分别追踪控制对象、格式存储位置和 queue owner

这些问题的共同点,是把“有一个指针”“在一条链表中”“拥有资源”“仍有引用”混成同一件事。源码解析必须把它们拆开,才能看懂失败和并发路径。

实现对照:上述句柄、文件入口、事件、VB2 与文件最终释放路径的组合关系。


18. 最后收束:v4l2-fh.c 管理的不是所有视频状态,而是打开级状态的关联入口

把本文件中的核心函数按生命周期排列:

函数
负责的动作
不负责的动作
v4l2_fh_open()
独立分配句柄,组合 init 与 add
不统一取得队列 owner,不启动采集
v4l2_fh_init()
初始化标准状态,声明标准句柄机制
不全面清零,不加入节点链表
v4l2_fh_add()
登记默认优先级,挂接句柄链表
不申请独占硬件,不增加独立 vdev 引用
v4l2_fh_is_singular()
在锁内观察是否为唯一挂接句柄
不授予独占,不锁住未来打开
v4l2_fh_del()
摘链,撤销优先级登记
不销毁事件资源,不释放句柄内存
v4l2_fh_exit()
源钩子、事件退订和内部清理
不通用地释放控制处理器、M2M 或外层对象
v4l2_fh_release()
组合 del、exit 和独立句柄内存释放
不适用于未经适配的任意外层上下文

对应的三个层次可以这样记:

video_device 描述节点,struct file 描述打开文件对象,v4l2_fh 提供 V4L2 框架需要的打开级状态。

多次独立打开可以得到不同的标准句柄,但这些句柄仍可能共享控制处理器、优先级组、队列与硬件。dup 则连打开上下文本身都不会复制。

理解这套关系以后,后续进入 videobuf2-v4l2.c,就能准确追踪 REQBUFS 为什么设置 owner、QBUF/DQBUF 为什么检查上下文,以及关闭时为什么必须先处理队列,再释放它引用的句柄。

实现对照:v4l2-fh.c 的全部核心入口;与节点、标准 ioctl 和 VB2 的直接衔接。

相关学习资料