夜雨聆风学习资料网

ARTICLE · 1127872

V4L2 源码深度解析(六):从缓冲区编号到可访问地址——QUERYBUF 与 mmap

V4L2 源码深度解析(六):从缓冲区编号到可访问地址——QUERYBUF 与 mmap

上一节中,REQBUFS 已经把缓冲区池建立起来。假设这次成功得到四个缓冲区,VIMC 使用 640 × 480 的 RGB24 格式,每块需要 921600 字节。内核能够通过队列找到这些存储,应用手里却仍然只有设备文件描述符和返回的缓冲区数量。

应用知道“有四块”,并不知道“每块应当怎样访问”。下一步需要先问清某一块的容量和映射标识,再把它接入本进程的地址空间。这两件事分别由 QUERYBUF 和 mmap 完成。

本篇沿普通 CAPTURE、MMAP、vmalloc 后端展开。先把编号、偏移和地址的含义讲清,再从 VIMC 的回调连接进入 VB2,走到映射建立和解除。结束时,应用已经有了访问地址,但尚未提交缓冲区,也没有启动采集。

1. 内存已经分配,为什么还不能直接读取?

1.1 一个编号只能指出“是哪一块”

REQBUFS 成功返回的 count 决定了可使用的索引范围。返回 4,索引就是 0、1、2、3。index = 1 表示查询池中的第二个缓冲区,不表示第 1 帧,也不是字节地址。

VB2 通过 q->bufs[1] 找到这块缓冲区的管理对象;在当前单平面场景中,再通过 vb->planes[0].mem_priv 找到 vmalloc 后端对象。后端对象保存了 vaddr,即内核访问这组像素存储时使用的虚拟地址。

这条关系已经存在,但它只解决了内核怎样管理和访问存储。不能把 vaddr 这个数值直接填给应用,让应用照着解引用。

1.2 指针必须放回它所在的地址空间理解

启用 MMU 的系统中,应用使用本进程的虚拟地址;内核也有自己的虚拟地址。CPU 访问某个虚拟地址时,需要通过地址转换找到实际存储页。

同一组页可以有不同的访问入口。内核地址 K 和应用地址 A 可以数值不同,却对应同一份像素数据。映射建立后,应用通过 A 访问,不需要为了得到一个可用指针,再复制一整帧到另一块内存。

图 1:左右是两套虚拟地址,中间是共同的存储页。P0、P1、P2 表示逻辑上的先后页,不要求物理位置相邻。

mmap() 做的就是建立这类访问关系。它返回本进程中的起始地址,而不是把内核指针公开出去。页表、映射区间和引用管理仍然需要开销;这里避免的是为应用访问而额外复制整帧,不是保证整个视频链路没有任何复制。

1.3 应用还需要一个“选择哪块存储”的参数

同一个 /dev/videoX 后面有多个缓冲区,单个缓冲区还可能包含多个平面。mmap 接口只有一个 offset 参数,没有单独的 index 和 plane 参数。

VB2 因此为各平面安排映射偏移。应用先用 QUERYBUF 查询,得到长度和偏移;再把这两个值原样交给 mmap。VB2 根据偏移选中平面,内存后端随后建立映射。

先记住三件事就够了:index 选择查询对象;offset 选择映射对象;mmap 的返回值才是应用访问的起点。 后面会逐一看到它们怎样进入源码。

2. 先查询 buffer 1,看应用真正拿到了什么

2.1 一次单平面查询

下面的代码假定 MMAP 缓冲区池已经建立,而且返回数量至少为 2:

structv4l2_bufferb = {0};

b.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
b.memory = V4L2_MEMORY_MMAP;
b.index = 1;

if (ioctl(fd, VIDIOC_QUERYBUF, &b) < 0) {
    perror("VIDIOC_QUERYBUF");
/* 本次没有取得可用的查询结果,应进入错误清理。 */
}

type 与申请缓冲区时的队列类型保持一致,index 指定池中的对象。把结构体清零,可以让保留字段有确定初值。这里也显式填写 memory,让代码意图清楚;稍后会看到,当前 QUERYBUF 实现会用内部对象的实际内存方式覆盖返回字段,并不会因为填写这个字段就切换队列模式。

对本文的单平面 MMAP 场景,成功后最需要读取的是:

b.length       /* 这块缓冲区的字节容量 */
b.m.offset     /* 交给 mmap 的映射偏移 */

时间戳和帧序号不是此阶段的采集结果。接口文档也没有承诺 QUERYBUF 返回的所有其他元数据都具有帧结果意义。后续真正处理图像时,应使用完成出队得到的结果。

2.2 用同一个 RGB24 例子观察返回值

假设页大小为 4096 字节,每块长度都是 921600 字节,即恰好 225 页;四块均按当前实现成功建立,则这次分配形成如下偏移:

查询索引
返回的 length / 字节
返回的 m.offset / 字节
0
921600
0
1
921600
921600
2
921600
1843200
3
921600
2764800

这是按本实现计算的例子,不是应该写死到程序里的接口常量。真正的程序逐块查询,以每次返回结果为准。其他格式、平面布局和页大小可能得到不同的长度与偏移。

offset = 0 完全可能有效。它表示当前队列中某个平面的选择值为零,不表示得到空指针,也不表示把物理地址零映射给应用。

3. QUERYBUF 的源码:按索引找对象,再把描述填回来

3.1 先确认 VIMC 建立了哪些连接

VIMC 的文件操作表和 ioctl 操作表分别提供了下面几项连接。这里将原本分散在两张表中的成员集中列出,便于观察:

/* struct v4l2_file_operations 中的成员 */
.unlocked_ioctl = video_ioctl2,
.mmap           = vb2_fop_mmap,

/* struct v4l2_ioctl_ops 中的成员 */
.vidioc_querybuf = vb2_ioctl_querybuf,

QUERYBUF 走标准 ioctl 分发,mmap 走文件映射入口。它们最终访问同一条队列,但不是同一种系统调用,也不经过相同的参数处理函数。

QUERYBUF 的正常执行关系是:

video_ioctl2()
  → video_usercopy() 准备参数
    → __video_do_ioctl() 标准分发
      → v4l_querybuf() 检查格式类型
        → vidioc_querybuf = vb2_ioctl_querybuf()
          → vb2_querybuf()
            → vb2_core_querybuf()
              → fill_user_buffer = __fill_v4l2_buffer()

描述填好以后,函数逐层返回。标准分发层释放实际持有的操作锁,参数层再把结果回写到应用结构体中。

图 2:实线向下进入处理,右侧虚线概括返回。这里是缓冲区描述的往返,不是图像数据的往返。

3.2 vb2_ioctl_querybuf() 取得当前节点的队列

intvb2_ioctl_querybuf(struct file *file, void *priv, struct v4l2_buffer *p)
{
structvideo_device *vdev = video_devdata(file);

/* No need to call vb2_queue_is_busy(), anyone can query buffers. */
return vb2_querybuf(vdev->queue, p);
}

这个包装函数只从文件找到当前 video_device,再把队列和描述交给 vb2_querybuf()。参数 priv 在这个函数里没有被使用。

源码特意注明:这里不调用 vb2_queue_is_busy()。也就是说,不能把 REQBUFS 中的 owner 检查原样套到查询入口。一个已经合法打开节点的其他上下文,也可能查询现有缓冲区的描述;其他前置检查仍然有效。

3.3 vb2_querybuf() 先验证,再访问 bufs[]

intvb2_querybuf(struct vb2_queue *q, struct v4l2_buffer *b)
{
structvb2_buffer *vb;
int ret;

if (b->type != q->type) {
        dprintk(q, 1, "wrong buffer type\n");
return -EINVAL;
    }

if (b->index >= q->num_buffers) {
        dprintk(q, 1, "buffer index out of range\n");
return -EINVAL;
    }
    vb = q->bufs[b->index];
    ret = __verify_planes_array(vb, b);
if (!ret)
        vb2_core_querybuf(q, b->index, b);
return ret;
}

第一项检查保证描述中的队列类型与 q->type 一致。第二项检查保证索引落在已建立对象的范围内。只有两项都通过,才访问 q->bufs[b->index]。

这里用的是 REQBUFS 最终建立的 q->num_buffers,不是应用最初期望的数量。请求 4 个、实际返回 3 个时,索引 3 就不合法。

接着检查平面描述数组。单平面会直接通过这一层;多平面还要检查数组地址及容量,下一节再展开。验证成功以后,核心才填返回描述。

3.4 vb2_core_querybuf() 调用的不是采集驱动的 queue_setup

voidvb2_core_querybuf(struct vb2_queue *q, unsignedint index, void *pb)
{
    call_void_bufop(q, fill_user_buffer, q->bufs[index], pb);
}

fill_user_buffer 来自 q->buf_ops,并在 V4L2 队列初始化时接到 __fill_v4l2_buffer()。它负责把通用缓冲区对象转换成 V4L2 接口描述。

这里不会再次执行 queue_setup(),不会重新分配像素存储,也不会调用负责采集的 buf_queue()。当前对象已经存在,查询只是在回答它的描述是什么。

3.5 __fill_v4l2_buffer() 的三段工作

函数先取得通用对象、V4L2 扩展对象及队列,再填写公共字段。关键代码如下:

b->index = vb->index;
b->type = vb->type;
b->memory = vb->memory;
b->bytesused = 0;

b->flags = vbuf->flags;
b->field = vbuf->field;
v4l2_buffer_set_timestamp(b, vb->timestamp);
b->timecode = vbuf->timecode;
b->sequence = vbuf->sequence;
b->reserved2 = 0;
b->request_fd = 0;

这段代码说明,返回的 index、type、memory 来自内部对象。它们不是把应用的输入原样抄回去。时间戳、序号、场信息等则来自通用对象和 V4L2 扩展的当前记录。

随后按单平面或多平面填写容量、载荷与内存相关描述。本文单平面 MMAP 分支中,最关键的赋值可以概括为:

b->length = vb->planes[0].length;
b->bytesused = vb->planes[0].bytesused;
b->m.offset = vb->planes[0].m.offset;

最后根据内部状态重新组织状态标志,并处理时间戳类型、映射持有等信息。这里没有把整帧像素复制进 struct v4l2_buffer。该结构只是一个接口描述,实际图像页仍留在原处。

对应实现:v4l_querybuf()、vb2_ioctl_querybuf()、vb2_querybuf()、vb2_core_querybuf()、__fill_v4l2_buffer()。

4. 多平面:一个描述里为什么还要带一个数组?

4.1 先把两层数量分开

单平面接口用一组长度和偏移描述缓冲区。多平面接口则可能需要分别描述同一帧的几块存储,例如 NV12M 的亮度区域和色度区域。

这里有两个数量:池中有多少个 buffer,每个 buffer 又有多少个 plane。b.index 仍然选择缓冲区,不是平面;b.m.planes 指向用于接收平面信息的数组。

structv4l2_bufferb = {0};
structv4l2_planeplanes[VIDEO_MAX_PLANES] = {0};

b.type = V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE;
b.memory = V4L2_MEMORY_MMAP;
b.index = 1;
b.m.planes = planes;
b.length = VIDEO_MAX_PLANES;

planes[] 是一组小型描述结构,不是用于存放亮度、色度的像素数组。VIMC 的本文主线仍是单平面;多平面在此解释通用接口,并不表示该 VIMC 捕获节点已经支持 NV12M。

图 3:一次 QUERYBUF 查询一个缓冲区,返回这个缓冲区内各平面的信息。之后对每个平面单独建立映射。

4.2 length 的含义由所在位置决定

进入多平面 QUERYBUF 时,b.length 表示应用数组能容纳多少个元素;成功返回后,框架把它改成实际填写的平面数。每项 planes[p].length 才表示该平面的字节容量。

例如应用提供了可容纳 8 项的数组,当前缓冲区实际有 2 个平面。输入 b.length = 8,成功后返回 b.length = 2,程序只映射前两项。

字段
单平面接口
多平面接口
b.index
缓冲区索引
仍是缓冲区索引
b.length
缓冲区字节容量
输入是数组容量,输出是实际平面数
映射长度
b.lengthplanes[p].length
映射偏移
b.m.offsetplanes[p].m.mem_offset

多平面 API 也可以描述只有一个内存平面的格式。循环次数必须使用返回的平面数,不能根据 _MPLANE 后缀推定一定有两个或三个平面。

4.3 __verify_planes_array() 验证的是什么

staticint __verify_planes_array(struct vb2_buffer *vb, const struct v4l2_buffer *b)
{
if (!V4L2_TYPE_IS_MULTIPLANAR(b->type))
return0;

/* Is memory for copying plane information present? */
if (b->m.planes == NULL) {
        dprintk(vb->vb2_queue, 1,
"multi-planar buffer passed but planes array not provided\n");
return -EINVAL;
    }

if (b->length < vb->num_planes || b->length > VB2_MAX_PLANES) {
        dprintk(vb->vb2_queue, 1,
"incorrect planes array length, expected %d, got %d\n",
            vb->num_planes, b->length);
return -EINVAL;
    }

return0;
}

这里检查的是已经进入内核处理路径的描述:需要非空数组入口,数组容量不能少于实际平面数,也不能超过内部上界。

它不是一个检查任意应用地址是否可读写的通用函数。标准 ioctl 参数层在此之前,已经为外层结构和已知平面数组准备了内核临时副本。因此,地址不可访问可能在更早的复制阶段就返回 EFAULT,并不一定走到这里得到 EINVAL。

4.4 两级复制处理的是描述,不是像素

多平面查询的参数准备大致为:先复制 v4l2_buffer,再根据它的数组容量复制 v4l2_plane[];内核临时结构中的 m.planes 随后被替换为内核数组地址。

返回时,参数层在进入正常回写分支后先恢复原来的应用数组地址,再回写平面数组和主结构。这样,应用看到的仍是自己提供的 planes[],不会拿到只在本次调用中有效的内核临时指针。

QUERYBUF 表项带有 INFO_FL_CLEAR(v4l2_buffer, length)。本机 ABI 的输入处理据此保留到 length 字段末尾的必要前缀,后面的尾部先清零;数组仍由单独的处理分支准备。兼容 ABI 有相应转换,不应直接假设所有平台上的结构布局相同。

对于本文的 QUERYBUF,常规错误不会继续走成功回写路径。即使内部填充已经成功,最终回写仍可能因为目标地址不可写而失败。只有系统调用成功返回,应用才应使用本次查询结果。

对应实现:__verify_planes_array()、check_array_args()、video_get_user()、video_usercopy() 中的数组替换与恢复,以及 __fill_v4l2_buffer() 的多平面分支。

5. offset 从哪里来:它是在逻辑空间里给平面排号

5.1 偏移早在分配阶段就已经安排好

QUERYBUF 不会临时计算一个物理地址。MMAP 缓冲区建立时,__setup_offsets() 已经为各平面填写 m.offset:

staticvoid __setup_offsets(struct vb2_buffer *vb)
{
structvb2_queue *q = vb->vb2_queue;
unsignedint plane;
unsignedlong off = 0;

if (vb->index) {
structvb2_buffer *prev = q->bufs[vb->index - 1];
structvb2_plane *p = &prev->planes[prev->num_planes - 1];

        off = PAGE_ALIGN(p->m.offset + p->length);
    }

for (plane = 0; plane < vb->num_planes; ++plane) {
        vb->planes[plane].m.offset = off;

        dprintk(q, 3, "buffer %d, plane %d offset 0x%08lx\n",
                vb->index, plane, off);

        off += vb->planes[plane].length;
        off = PAGE_ALIGN(off);
    }
}

第一块缓冲区从零开始。若不是第一块,就找到前一个缓冲区的最后一个平面,把“原偏移加原长度”按页对齐,作为本块的开始位置。

同一缓冲区有多个平面时,每写入一个平面的起点,就加上该平面的逻辑长度,再按页对齐,留给下一个平面。

这些偏移组成的是用于匹配的逻辑空间。两个偏移相邻,不证明对应存储页物理连续;两块 vmalloc 存储也不必处在同一段内核虚拟区间中。

5.2 用非整页长度看清页对齐的作用

RGB24 主例的 921600 字节恰好是 4096 的整数倍,容易掩盖页对齐。另设一个只用于计算的双平面例子:每块的两个平面长度分别为 5000 和 3000 字节,页大小为 4096。

第一平面需要覆盖两页;第二平面的逻辑起点因此安排在 8192。再向后给下一个缓冲区排号,就得到:

图 4:这里的 5000 和 3000 只是解释偏移算法的长度,不是前面 RGB24 格式的另一套容量,也不是某个指定 YUV 格式。

应用要映射 buffer 1 的 plane 0,就使用查询得到的 offset = 12288、length = 5000。在 4096 字节页大小下,VMA 中的页偏移是 3;VB2 将它还原成字节偏移,再与各平面的选择值匹配。

5.3 offset 的三个使用约束

保持原值。 程序应使用 QUERYBUF 返回的偏移,不自行按索引乘长度计算。不同平面长度、页对齐和实现变化都会影响排号。

不要把它当成平面内部位移。 给某个返回值加一页,可能没有匹配项,也可能恰好选中了别的平面。本实现按起点精确匹配,不支持用这种方法任意选择同一平面的中间区域。

不要把它当成 DMA 地址。 设备访问地址属于设备地址空间,由相应内存后端和 DMA API 提供。本例的 offset 只为 mmap 选择对象服务。

还要留意名称相似的 mem_ops->cookie。它返回后端约定的信息,与 planes[p].m.offset 这种应用映射标识不是同一个接口。本篇不使用前者来解释 mmap。

6. mmap 的参数怎样进入内核?

6.1 应用真正执行的调用

单平面查询成功后,可以这样建立映射:

void *addr = mmap(NULL, b.length,
                  PROT_READ | PROT_WRITE,
                  MAP_SHARED, fd, (off_t)b.m.offset);

if (addr == MAP_FAILED) {
    perror("mmap");
/* 只解除此前已经成功建立的映射。 */
}

第一个参数为 NULL,让系统选择虚拟地址。length 和 offset 使用查询结果。这里选择共享映射,并请求读写权限;文件也相应以 O_RDWR 打开。

MAP_SHARED 表达对目标存储的共享映射语义,不是把四个缓冲区合并起来,也不是调用一次就为所有打开者建立地址。每次调用仍然针对当前进程中的一个映射区间。

失败判断必须使用 MAP_FAILED,不能用 addr == NULL 代替。普通应用应按接口约定映射完整平面,不主动依赖某个后端可能接受的短映射行为。

6.2 VMA 记录这次访问区间

通用内存管理根据调用参数准备一个 struct vm_area_struct,通常简称 VMA。它记录的是映射区间的管理信息,不是图像本身。

这条路径中重要的成员为:

成员
在本次映射中的作用
vm_start
、vm_end
应用虚拟区间的起点与末尾,末尾不包含在区间中
vm_pgoff
以页为单位保存偏移,进入 VB2 时用于选择平面
vm_flags
记录共享、读写等映射属性
vm_file
关联映射对应的文件对象
vm_ops
、vm_private_data
后端随后安装映射生命周期回调和私有信息

应用传给 mmap 的 offset 是字节数,而 vm_pgoff 是页数。VB2 入口通过左移 PAGE_SHIFT 将其还原。页大小不应写死到通用应用代码里;本篇数字例子中的 4096 均为明确假设。

6.3 文件入口只负责检查与转发

当前 V4L2 核心的映射包装如下:

staticintv4l2_mmap(struct file *filp, struct vm_area_struct *vm)
{
structvideo_device *vdev = video_devdata(filp);
int ret = -ENODEV;

if (!vdev->fops->mmap)
return -ENODEV;
if (video_is_registered(vdev))
        ret = vdev->fops->mmap(filp, vm);
if (vdev->dev_debug & V4L2_DEV_DEBUG_FOP)
        dprintk("%s: mmap (%d)\n",
            video_device_node_name(vdev), ret);
return ret;
}

它检查是否有驱动映射回调,以及节点是否仍处于注册状态,然后转发。回调缺失或当前已注销时,本函数返回 -ENODEV;其他错误可能由更早的通用内存管理或后面的 VB2、后端产生。

VIMC 将映射回调接到 vb2_fop_mmap():

intvb2_fop_mmap(struct file *file, struct vm_area_struct *vma)
{
structvideo_device *vdev = video_devdata(file);

return vb2_mmap(vdev->queue, vma);
}

于是完整的入口关系是:

mmap 系统调用的通用内存管理路径
  → file->f_op->mmap,即 v4l2_mmap
    → vdev->fops->mmap,即 vb2_fop_mmap
      → vb2_mmap
        → q->mem_ops->mmap,即当前的 vb2_vmalloc_mmap

这条调用链不进入 video_ioctl2(),也不使用 struct v4l2_buffer 作为映射回调参数。查询所得的描述,已经被应用转换成了 mmap 的长度、偏移等参数。

7. vb2_mmap():怎样从 offset 找到正确平面

图 5:权限检查失败时尚未持有队列的映射锁;进入锁内以后,无论查找、范围检查还是后端失败,都经过统一的解锁出口。

7.1 先检查映射属性

函数开头还原偏移:

unsignedlong off = vma->vm_pgoff << PAGE_SHIFT;

接着要求 VM_SHARED。对于 CAPTURE,至少需要 VM_READ;对于 OUTPUT,至少需要 VM_WRITE。不满足时返回 -EINVAL,这部分发生在获取 q->mmap_lock 之前。

这是当前 VB2 核心的检查条件,不是完整的应用兼容性建议。V4L2 mmap 接口建议使用 PROT_READ | PROT_WRITE;通用文件权限、其他内存后端也可能带来额外限制。示例程序使用接口建议的组合。

7.2 获取 q->mmap_lock 后,再查找对象

核心取得内部映射互斥锁,再调用:

ret = __find_plane_by_offset(q, off, &buffer, &plane);
if (ret)
goto unlock;

查找函数的完整实现如下:

staticint __find_plane_by_offset(struct vb2_queue *q, unsignedlong off,
unsignedint *_buffer, unsignedint *_plane)
{
structvb2_buffer *vb;
unsignedint buffer, plane;

/*
     * Sanity checks to ensure the lock is held, MEMORY_MMAP is
     * used and fileio isn't active.
     */

    lockdep_assert_held(&q->mmap_lock);

if (q->memory != VB2_MEMORY_MMAP) {
        dprintk(q, 1, "queue is not currently set up for mmap\n");
return -EINVAL;
    }

if (vb2_fileio_is_active(q)) {
        dprintk(q, 1, "file io in progress\n");
return -EBUSY;
    }

/*
     * Go over all buffers and their planes, comparing the given offset
     * with an offset assigned to each plane. If a match is found,
     * return its buffer and plane numbers.
     */

for (buffer = 0; buffer < q->num_buffers; ++buffer) {
        vb = q->bufs[buffer];

for (plane = 0; plane < vb->num_planes; ++plane) {
if (vb->planes[plane].m.offset == off) {
                *_buffer = buffer;
                *_plane = plane;
return0;
            }
        }
    }

return -EINVAL;
}

lockdep_assert_held() 用于检查持锁约定,不会代替调用方获取这把锁。

接下来先确认当前队列采用 MMAP,且没有活动的 fileio 辅助流,再遍历有效缓冲区及其平面。只有 m.offset == off 才是匹配,不会把传入值除以某个固定长度来猜索引。

队列中没有缓冲区时,遍历不到任何匹配项,最终返回 -EINVAL。此函数也服务于相关的其他入口;本篇限定启用 MMU 的普通文件 mmap 路径,不将无 MMU 的 get_unmapped_area 分支混入图中。

7.3 检查的是页级范围是否越界

找到对象以后,核心执行:

length = PAGE_ALIGN(vb->planes[plane].length);
if (length < (vma->vm_end - vma->vm_start)) {
    dprintk(q, 1,
"MMAP invalid, as it would overflow buffer length\n");
    ret = -EINVAL;
goto unlock;
}

vb->planes[plane].length 仍然是逻辑容量;分配时传给后端的大小已经页对齐,因此这里也按相同边界比较 VMA 范围。

前面的 5000 字节平面会覆盖 8192 字节的页级区间。应用按查询值传入 5000,通用内存管理按页建立 VMA,这与此处检查相容。

但需要区分接口约定与实现条件:V4L2 应用应当原样使用查询返回的完整长度;这份核心代码只比较是否越界,并没有要求长度严格相等,所以不能宣称“所有短映射一定在这里失败”。接受某种参数组合,不等于它就是可移植的使用方式。

页尾多出来的空间也不是另一段有效图像。处理图像仍然以容量、实际载荷和格式约定为准。

7.4 为什么进入后端以前把 vm_pgoff 改成零?

vma->vm_pgoff = 0;

ret = call_memop(vb, mmap, vb->planes[plane].mem_priv, vma);

因为选择工作已经完成。VB2 已经根据队列级 offset 找到了具体平面,并把这个平面的 mem_priv 交给后端。后端应该从该平面存储的起点建立映射,不能再次把 12288 之类的队列级选择值当成平面内部位移。

这是两个连续步骤:先选对象,再映射对象。 把两种偏移混在一起,会从错误的位置开始映射,或者让后端错误地判断容量不足。

7.5 后端返回以后,先解锁再返回

unlock:
    mutex_unlock(&q->mmap_lock);
if (ret)
return ret;

    dprintk(q, 3, "buffer %d, plane %d successfully mapped\n", buffer, plane);
return0;
}

后端的成功与失败都经过 unlock。此处返回的 0 是文件映射回调的成功状态,不是应用最后得到的映射地址。

通用内存管理还要完成映射登记和返回。它可以在驱动回调之后继续检查并在失败时回滚,所以“后端打印成功”与“应用一定取得地址”也不是同一个时间点。

对应实现:vb2_mmap()、__find_plane_by_offset();通用 VMA 和后续错误回滚用 Linux v6.1 的 mm/mmap.c 补充解释,具体架构系统调用入口不在本篇固定。

8. vb2_vmalloc_mmap():把已有存储页接进应用地址空间

8.1 后端收到的是哪个对象?

mem_priv 指向 struct vb2_vmalloc_buf。这个对象已经在前一节的分配过程中建立,其中 vaddr 保存内核虚拟地址,size 保存后端分配大小,refcount 记录后端存储持有关系。

因此,下面的映射函数并没有再次调用 vmalloc_user():

staticintvb2_vmalloc_mmap(void *buf_priv, struct vm_area_struct *vma)
{
structvb2_vmalloc_buf *buf = buf_priv;
int ret;

if (!buf) {
        pr_err("No memory to map\n");
return -EINVAL;
    }

    ret = remap_vmalloc_range(vma, buf->vaddr, 0);
if (ret) {
        pr_err("Remapping vmalloc memory, error: %d\n", ret);
return ret;
    }

/*
     * Make sure that vm_areas for 2 buffers won't be merged together
     */

    vma->vm_flags       |= VM_DONTEXPAND;

/*
     * Use common vm_area operations to track buffer refcount.
     */

    vma->vm_private_data    = &buf->handler;
    vma->vm_ops     = &vb2_common_vm_ops;

    vma->vm_ops->open(vma);

return0;
}

空后端对象会返回 -EINVAL。正常情况下,它先调用 remap_vmalloc_range(),成功后才设置 VMA 的回调与私有关联,并增加初次映射引用。

8.2 remap_vmalloc_range() 不是一次整帧 memcpy

vmalloc 建立的是连续的内核虚拟区间,其底层物理页不必连续。映射辅助函数需要把这段虚拟区间所关联的页,接到应用的 VMA 中。

在用于补充解释的 Linux v6.1 通用实现里,这条路径验证 vmalloc 区域、映射资格、地址对齐及范围,再逐页通过 vmalloc_to_page() 取得页,调用 vm_insert_page() 建立对应的应用映射。

它操作页的关联,不把每个像素读取后再写入另一幅图像。成功后,图 1 中的 K 和 A 可以访问同一组存储页。某些错误可能发生在部分页已经处理以后,通用内存管理负责撤销失败的映射范围,应用不能使用 MAP_FAILED 对应的地址。

这段说明止于后端调用的通用映射契约,不把 v6.1 的整棵内存管理实现视为本文 VB2 源码的版本证明。

8.3 vm_ops->open() 管的是映射引用

图 6:左侧为本次映射过程,右侧为安装的关联和后续关闭回调。虚拟区间没有再申请一份完整像素存储。

后端在成功映射后设置 VM_DONTEXPAND,限制这类映射扩展;这不是存储引用计数。vm_private_data 和 vm_ops 则用于安排后续的持有与释放。

分配阶段已将 handler 的成员指向相应对象:

buf->handler.refcount = &buf->refcount;
buf->handler.put = vb2_vmalloc_put;
buf->handler.arg = buf;

因此,VMA 保存的 &buf->handler 能够找到计数器、释放函数和作为释放参数的后端对象。

vb2_common_vm_open() 的核心动作是:

structvb2_vmarea_handler *h = vma->vm_private_data;
refcount_inc(h->refcount);

首次建立映射时,后端显式调用一次 vm_ops->open(vma),取得这次映射的持有关系。以后如果产生映射继承或拆分等额外 VMA,通用内存管理还可能再次调用对应回调。

这里的 open 不是再次打开 /dev/videoX,不会额外创建一个 v4l2_fh,也不会重新申请缓冲区池。

8.4 解除映射怎样归还引用

关闭回调的核心为:

structvb2_vmarea_handler *h = vma->vm_private_data;
h->put(h->arg);

对应的后端释放函数如下:

staticvoidvb2_vmalloc_put(void *buf_priv)
{
structvb2_vmalloc_buf *buf = buf_priv;

if (refcount_dec_and_test(&buf->refcount)) {
        vfree(buf->vaddr);
        kfree(buf);
    }
}

每次 put 归还一份后端持有关系。只有计数归零,才释放像素存储和后端对象。这里的计数不是 fd 数量,也不是 struct file 的引用计数,更不是缓冲区已经采集过多少帧。

9. 映射建立以后,还要理解它怎样结束

9.1 一次映射的最简单引用过程

只看一个平面,没有导出、继承或拆分时:分配后后端引用为 1;mmap 成功增加到 2;解除映射降回 1;最后队列释放自己的引用,才归零。

当前实现还声明支持 V4L2_BUF_CAP_SUPPORTS_ORPHANED_BUFS。在允许释放池的状态下,也可以先解除队列对存储的持有,让仍存在的映射继续保留旧存储。

图 7:这张图特意采用“先释放池、后解除映射”的顺序,解释孤儿缓冲区。示例程序采用更容易管理的“先解除映射、再释放池”。

此时,旧队列管理对象已经退出,不代表映射背后的页也已经释放。反过来,映射仍然存在,也不代表旧索引还能用于新的队列操作。

9.2 新的 index 0 不会自动接管旧映射

释放旧池再申请新池时,index 和 offset 可能再次出现相同数字,但它们属于新建的对象。旧映射仍关联旧存储,不会因为编号复用就自动改到新缓冲区。

重新建立池以后,应重新查询、重新安排映射,并让后续队列操作使用新一轮的描述。用旧地址处理新 index,只会把两轮生命周期混在一起。

对其他驱动,是否允许存在映射时释放池,应查看实际能力和返回值。不能把当前支持孤儿缓冲区的行为推广到全部设备。

9.3 close 也不等于所有映射已经解除

映射除了持有后端存储,也通常通过 vma->vm_file 持有文件对象。当前 vmalloc 后端没有替换这项文件关联。因此,关闭某个 fd 不一定立即触发最终的设备 release,也不会等价于对全部映射执行 munmap。

这里有两条不同的持有链:一条保护打开文件对象,另一条通过 VMA handler 保护后端存储。排查释放延后时,要分别检查;不能只统计执行了几次 close。

正常准备阶段的清理顺序是:解除已经成功建立的映射,释放已申请的缓冲区池,最后关闭 fd。已经进入采集运行阶段的程序,还需要在这之前正确停止实际访问与数据流。

10. owner、锁和状态标志分别保证什么?

10.1 QUERYBUF 不转移缓冲区使用权

查询不会把 QUEUED 对象取回,也不会像 DQBUF 那样交付一个完成结果。运行期间调用 QUERYBUF,可以观察接口定义的状态,但不能据此接管正在由设备使用的存储。

__fill_v4l2_buffer() 根据内部状态生成 QUEUED、DONE、ERROR 等标志。当前实现还通过 vb2_buffer_in_use() 判断是否设置 V4L2_BUF_FLAG_MAPPED。

这个辅助函数检查各平面的后端引用情况:存在 mem_priv,且 num_users() 大于 1,就认为有额外持有者。它不是一张按当前 fd 记录“是否映射过”的表;导出等其他额外引用也可能影响计数。

因此,MAPPED 不证明当前进程拥有某个有效地址,更不证明此刻可以安全读写图像。应用自身的映射表与后续 QBUF/DQBUF 交接仍然必要。状态查询也不应当被当成冻结硬件的原子快照。

10.2 owner 不是所有访问的统一开关

vb2_ioctl_querybuf() 明确不检查 owner;vb2_fop_mmap() 也没有 vb2_queue_is_busy() 这一关。它们仍受节点访问权限、标准入口、队列类型、索引、映射属性和后端约束限制。

不能因为 owner 已被设置,就断言其他已打开的上下文一定无法查询或映射这些存储。owner 用于协调特定队列操作,并不是完整的内存访问隔离机制。

本篇不重新展开文件句柄的构造,只需要将 file->private_data 看作这些队列归属判断使用的身份。多次映射同一平面也不会创造多套采集硬件或独立图像流。

10.3 QUERYBUF 的操作锁与映射锁保护不同区间

标准 QUERYBUF 带有 INFO_FL_QUEUE。在本文节点级队列路径中,标准分发优先使用非空的队列操作锁,否则按其选择规则回到节点锁。VIMC 将两者指向同一个 vcapture->lock。

vb2_querybuf() 本身不重新取得这把锁。绕过标准 ioctl 层直接调用辅助函数的代码,需要满足相应串行化前提。

mmap 不经过该 ioctl 分发。vb2_mmap() 自己获取的是 q->mmap_lock,它保护查找对象、检查映射范围和进入后端这一段,配合缓冲区池的相关管理操作。它也不同于通用内存管理中保护进程地址空间的锁,不能因为名字相似就混成一把。

10.4 两次系统调用之间仍然可能重建池

图 8:这是需要由调用方协调的并发场景,不是建议的准备顺序。另一执行路径只有满足相应 owner、状态和同步条件,才可能进行重建。

QUERYBUF 返回以后,其操作锁已经释放;mmap 后来查找的是那一刻的当前池。如果另一个允许操作同一队列的执行路径恰好释放并重建池,旧 offset 可能不再有效,也可能恰好命中新池中的某个平面。

内部锁保证单段操作的必要同步,不会自动把“查询、映射、使用、重建”合成一个跨多个系统调用的事务。应用需要让这些阶段遵循一致的生命周期安排。

11. 一次走完:从查询结果到可访问地址

仍以 buffer 1 为例,假设前面四块 RGB24 均已建立,页大小为 4096,准备期间没有池重建:

应用查询 index = 1
    → VB2 在 q->bufs[1] 找到管理对象
    → 返回 length = 921600,offset = 921600

应用 mmap(length = 921600, offset = 921600)
    → 通用 MM 准备 VMA,vm_pgoff = 225
    → VB2 将页偏移还原成 921600 字节
    → 匹配 buffer 1 / plane 0
    → 检查长度,清 vm_pgoff,调用 vmalloc 后端
    → 后端映射已有页,安装 VMA 回调,增加存储引用
    → 逐层返回;系统调用成功后,应用得到起始地址 A

保存 A 和查询返回的容量,后续才能根据 DQBUF 返回的 index 找回对应映射。多平面时保存的是 index → 每个 plane 的地址与容量,不是只有一个地址的数组。

到此仍没有一帧完成结果。VIMC 的新分配存储来自 vmalloc_user(),其初始内容清零;看见零不能用来判断图像采集是否正常。本篇没有 QBUF、STREAMON 或 DQBUF,实际数据交接留到后面的文章。

12. 实验程序:只准备映射,不启动采集

下面的完整程序读取当前格式,申请 MMAP 缓冲区,逐个查询并映射,然后清理。它兼容单平面和多平面 CAPTURE,不执行 S_FMT,不读写像素,也不启动数据流。

程序应在空闲测试节点运行。即使不启动采集,open 和 REQBUFS 也可能影响设备资源;不要在另一个程序正在使用的生产链路上随意运行。

// SPDX-License-Identifier: MIT
#define _POSIX_C_SOURCE 200809L
#define _FILE_OFFSET_BITS 64
#include<errno.h>
#include<fcntl.h>
#include<inttypes.h>
#include<stdbool.h>
#include<stdint.h>
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<sys/ioctl.h>
#include<sys/mman.h>
#include<time.h>
#include<unistd.h>
#include<linux/videodev2.h>

structmapping {
void *addr;
size_t length;
bool established;
};

structbuffer_map {
structmappingplane[VIDEO_MAX_PLANES];
};

staticintxioctl(int fd, unsignedlong cmd, void *arg)
{
int ret;
do {
        ret = ioctl(fd, cmd, arg);
    } while (ret < 0 && errno == EINTR);
return ret;
}

intmain(int argc, char **argv)
{
structv4l2_capabilitycap = {0};
structv4l2_formatfmt = {0};
structv4l2_requestbuffersreq = {0};
structbuffer_map *maps = NULL;
constchar *path;
uint32_t caps, count = 0;
bool multi = false, pool_created = false;
int fd = -1, status = EXIT_FAILURE;

if (argc > 2 || (argc == 2 && !strcmp(argv[1], "--help"))) {
fprintf(stderr, "Usage: %s [/dev/videoX]\n", argv[0]);
return argc > 2 ? EXIT_FAILURE : EXIT_SUCCESS;
    }
    path = argc == 2 ? argv[1] : "/dev/video0";
    fd = open(path, O_RDWR | O_NONBLOCK | O_CLOEXEC);
if (fd < 0) { perror("open"); goto out; }
if (xioctl(fd, VIDIOC_QUERYCAP, &cap) < 0) {
        perror("QUERYCAP"); goto out;
    }
    caps = (cap.capabilities & V4L2_CAP_DEVICE_CAPS)
         ? cap.device_caps : cap.capabilities;
if (!(caps & V4L2_CAP_STREAMING)) {
fprintf(stderr, "Streaming-buffer API is not advertised.\n");
goto out;
    }
if (caps & V4L2_CAP_VIDEO_CAPTURE_MPLANE) {
        fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE;
        multi = true;
    } elseif (caps & V4L2_CAP_VIDEO_CAPTURE) {
        fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
    } else {
fprintf(stderr, "A supported CAPTURE queue is required.\n");
goto out;
    }
if (xioctl(fd, VIDIOC_G_FMT, &fmt) < 0) {
        perror("G_FMT"); goto out;
    }
printf("Using current format, type=%u; no S_FMT.\n", fmt.type);

    req.type = fmt.type;
    req.memory = V4L2_MEMORY_MMAP;
    req.count = 4;
if (xioctl(fd, VIDIOC_REQBUFS, &req) < 0) {
        perror("REQBUFS"); goto out;
    }
    count = req.count;
    pool_created = count != 0;
printf("REQBUFS returned %u buffers.\n", count);
if (!count) {
fprintf(stderr, "No buffers returned.\n");
goto out;
    }
    maps = calloc(count, sizeof(*maps));
if (!maps) { perror("calloc"); goto out; }

for (uint32_t i = 0; i < count; ++i) {
structv4l2_bufferb = {0};
structv4l2_planeplanes[VIDEO_MAX_PLANES] = {0};
unsignedint nplanes;

        b.type = fmt.type;
        b.memory = V4L2_MEMORY_MMAP;
        b.index = i;
if (multi) {
            b.m.planes = planes;
            b.length = VIDEO_MAX_PLANES;
        }
if (xioctl(fd, VIDIOC_QUERYBUF, &b) < 0) {
            perror("QUERYBUF"); goto out;
        }
if (b.index != i || b.type != fmt.type ||
            b.memory != V4L2_MEMORY_MMAP) {
fprintf(stderr, "Unexpected buffer identity or mode.\n");
goto out;
        }
        nplanes = multi ? b.length : 1;
if (!nplanes || nplanes > VIDEO_MAX_PLANES) {
fprintf(stderr, "Invalid plane count: %u\n", nplanes);
goto out;
        }
printf("QUERYBUF index=%u, planes=%u, flags=%#x\n",
               i, nplanes, b.flags);

for (unsignedint p = 0; p < nplanes; ++p) {
size_t len = multi ? planes[p].length : b.length;
uint32_t off = multi
                         ? planes[p].m.mem_offset : b.m.offset;
void *addr;

if (!len) {
fprintf(stderr, "Zero plane length.\n");
goto out;
            }
            addr = mmap(NULL, len, PROT_READ | PROT_WRITE,
                        MAP_SHARED, fd, (off_t)off);
if (addr == MAP_FAILED) { perror("mmap"); goto out; }
            maps[i].plane[p].addr = addr;
            maps[i].plane[p].length = len;
            maps[i].plane[p].established = true;
printf("  plane=%u length=%zu offset=%" PRIu32
" address=%p\n", p, len, off, addr);
        }
    }
puts("All mappings established. No pixel access, QBUF or STREAMON.");
    status = EXIT_SUCCESS;

out:
if (maps) {
/* 只解除已经成功的映射;长度来自对应的查询结果。 */
for (uint32_t i = count; i > 0; --i) {
for (unsignedint p = VIDEO_MAX_PLANES; p > 0; --p) {
structmapping *m = &maps[i - 1].plane[p - 1];
if (m->established && munmap(m->addr, m->length) < 0) {
                    perror("munmap");
                    status = EXIT_FAILURE;
                }
            }
        }
free(maps);
    }
if (pool_created) {
structv4l2_requestbuffersrelease = {0};
        release.type = fmt.type;
        release.memory = V4L2_MEMORY_MMAP;
if (xioctl(fd, VIDIOC_REQBUFS, &release) < 0) {
            perror("REQBUFS(0)");
            status = EXIT_FAILURE;
        }
    }
/* 没有启动流;Linux 上不因 close 报错而再次 close 同一个 fd。 */
if (fd >= 0 && close(fd) < 0) {
        perror("close");
        status = EXIT_FAILURE;
    }
return status;
}

编译与运行:

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

_FILE_OFFSET_BITS=64 让支持该特性的 C 库使用宽文件偏移接口,避免把较大的无符号 offset 误转成窄的有符号值。循环次数使用实际返回数量,平面循环使用 QUERYBUF 返回的实际平面数;所有映射都保留自己的长度和成功标记。

某次 mmap 失败时,只解除此前已经成功的映射,不对 MAP_FAILED 调用 munmap。解除映射后,再释放成功建立的池,最后关闭设备。程序把成功条件定义为完成这一套准备与清理,不以某个固定地址或像素值判断结果。

配套程序完成了编译和受控错误路径检查,但没有在真实视频设备上执行采集或并发验证。

13. 排查失败时,先确认停在哪一层

QUERYBUF 和 mmap 不共享同一套参数检查。把错误放回具体层次,才能避免从一个 errno 猜整个硬件状态。

现象
首先核对的位置
QUERYBUF 返回 EINVAL
队列类型、实际数量与索引、多平面数组容量;还需看更早的格式类型检查
QUERYBUF 返回 EFAULT
主结构或描述数组的输入复制与结果回写;未必已经进入 vb2_querybuf
mmap 返回 ENODEV
V4L2 映射回调是否存在、节点注册状态;也应检查是否有其他下层同码返回
mmap 返回 EACCES
文件打开方式、映射权限与通用 MM 的检查
mmap 返回 EINVAL
共享属性、读写属性、内存模式、cookie、VMA 长度,或后端对范围的拒绝
mmap 返回 EBUSY
当前 VB2 查找路径中的活动 fileio 等冲突;不能简单归因于“另一个进程打开了节点”
mmap 返回 ENOMEM
地址空间、页表或后端映射资源不足;并不说明第五篇的像素分配从未成功
QUERYBUF 后仍没有图像
查询与映射只完成准备;继续核对后续提交、启动和完成过程
释放池后还有内存占用
是否仍有映射或导出持有存储,以及是否遗漏了配对清理

调试时可先在 vb2_ioctl_querybuf()、__fill_v4l2_buffer()、vb2_mmap()、vb2_vmalloc_mmap() 观察是否进入和返回,再核对 index、平面号、逻辑长度、VMA 范围与 offset。不要用 QUERYBUF 的状态轮询代替实际完成出队,也不要通过向映射写花纹来“验证”一个还在被设备使用的缓冲区。

14. 本篇结束时,哪些关系已经建立?

REQBUFS 建立的管理对象和像素存储仍然存在。QUERYBUF 把对象描述送回应用,mmap 依据描述选择平面,为它建立应用地址,并增加相应持有关系。

这里没有新建第二套图像池,也没有通过一个系统调用就完成采集。真正建立的是:

缓冲区索引
    → 查询到的平面描述
    → 用 offset 选中既有存储
    → 在本进程中建立映射
    → 保存可用于后续处理的地址和容量

下一篇进入 QBUF 时,问题才变成:这些已经存在的缓冲区,怎样从应用的准备阶段交给 VB2,又在什么条件下交给驱动处理。

实现与接口索引

本篇的 VB2 主体按固定 bufs[]、num_buffers 及所展示函数体分析。源码片段中的检查和返回顺序是讨论依据;相邻版本若修改字段或内存管理接口,需要重新核对相应实现。

实现文件
本篇使用的函数或对象
drivers/media/test-drivers/vimc/vimc-capture.cvimc_capture_fops
、vimc_capture_ioctl_ops、队列与后端连接
drivers/media/common/videobuf2/videobuf2-v4l2.c
查询包装、类型与数组验证、__fill_v4l2_buffer()、映射包装
drivers/media/common/videobuf2/videobuf2-core.c
偏移安排、描述填充入口、平面查找、映射和存储持有判断
drivers/media/common/videobuf2/videobuf2-vmalloc.c
后端分配对象、映射、引用与释放
drivers/media/common/videobuf2/videobuf2-memops.cvb2_common_vm_open()
、vb2_common_vm_close()
drivers/media/v4l2-core/v4l2-dev.cv4l2_mmap()
 与文件入口
drivers/media/v4l2-core/v4l2-ioctl.c
QUERYBUF 适配、队列选锁、参数编组与回写

接口语义与通用内存管理补充对照 Linux v6.1。下面的接口文档用于确认应用约定,通用 MM 实现用于解释 VMA、页映射及引用,不替代本文逐段分析的 VB2 函数体。

相关学习资料