V4l2loopback实现笔记(六):基本视频回环
本模块实现核心的视频回环功能,是 v4l2loopback 的核心机制演示。
实现的功能
- 生产者-消费者模式
- 双 VB2 队列 (OUTPUT + CAPTURE)
- 内部帧缓冲区传递数据
- 等待队列同步
- 第一个打开者为生产者,后续为消费者
学习要点
- 理解生产者-消费者模式在内核中的实现
- 理解等待队列 (
wait_queue_head_t) 的使用 - 理解双队列系统的设计
- 理解缓冲区共享和同步的挑战
- 理解打开者角色分配的策略
核心架构设计
这个模块采用了 Shared Memory via Kernel Copy(内核拷贝共享内存) 的模式。
核心组件:
全局设备对象 (struct loop_device):
这是“中央枢纽”,所有的状态都保存在这里。
关键成员: frame_buffer (中间缓冲区)。这是生产者和消费者交换数据的“中转站”。
双向 VB2 队列:
OUTPUT 队列 (生产者): 负责接收用户空间的视频帧,拷贝到内核的 frame_buffer。
CAPTURE 队列 (消费者): 负责从内核的 frame_buffer 读取数据,拷贝回用户空间。
角色分配机制:
- 代码逻辑非常简单粗暴:第一个打开设备的是生产者 (OUTPUT),后续打开的是消费者 (CAPTURE)。
架构图解:
[ App A: ffmpeg ] [ Kernel Space: basic_loopback_v4l2 ] [ App B: ffplay ]
(生产者/Producer) (消费者/Consumer)
| ^
v |
[ User Buffer ] ---> [ loop_out_buf_queue ] ---> [ frame_buffer ] ---> [ loop_cap_buf_queue ] ---> [ User Buffer ]
(用户空间) (VB2 Output) (vmalloc) (VB2 Capture) (用户空间)
|
wake_up (唤醒)关键代码解析
一、数据结构定义
1. 缓冲区封装
/* 缓冲区封装 */
struct loop_buffer {
struct vb2_v4l2_buffer vb;
struct list_head list;
};2. 主设备结构体
所有打开这个设备文件的进程,都共享这一个结构体实例。它起到了“共享内存”和“状态同步”的作用。
/* 主设备结构体 */
struct loop_device {
/* V4L2 核心 */
struct v4l2_device v4l2_dev;
struct video_device vdev;
/* 同步 */
struct mutex lock;
spinlock_t qlock;
/* 等待队列 - 用于消费者等待数据 */
wait_queue_head_t wait_queue;
/* 格式 */
struct v4l2_pix_format pix_format;
/*
* 内部缓冲区 - 存储最新的帧数据
* 这是回环的核心:生产者写入,消费者读取
*/
void *frame_buffer;
size_t frame_size;
bool frame_ready; /* 是否有新帧可用 */
unsigned int write_seq; /* 写入序号 */
unsigned int read_seq; /* 读取序号 */
/* 流状态 */
bool output_streaming; /* 输出流是否活动 */
bool capture_streaming; /* 捕获流是否活动 */
int output_opener; /* 输出打开者计数 */
int capture_opener; /* 捕获打开者计数 */
};void *frame_buffer: 这是内核中的中间缓冲区,生产者将数据写入这里,消费者从这里读取数据。
3. 打开者私有数据
/* 每个打开者的私有数据 */
struct loop_opener {
struct v4l2_fh fh;
struct loop_device *dev;
enum v4l2_buf_type type; /* OUTPUT 或 CAPTURE */
struct vb2_queue vb2_queue;
struct list_head buf_list;
unsigned int buf_count;
unsigned int sequence;
};每当应用程序调用一次 open("/dev/videoX"),内核就会 kmalloc 一个 struct loop_opener。它代表了当前进程与驱动的一次会话。
二、V4L2 文件操作
1. 打开设备
当用户空间的应用程序(如 ffmpeg 或 ffplay)调用 open("/dev/videoX", …) 时,内核就会执行这个 loop_open 函数。它的核心任务是:建立会话(Session),分配角色(生产者 vs 消费者),并初始化缓冲区队列(VB2 Queue)
static int loop_open(struct file *file)
{
struct loop_opener *opener;
struct vb2_queue *q;
int ret;
printk("loop open\n");
opener = kzalloc(sizeof(*opener), GFP_KERNEL);
if (!opener)
return -ENOMEM;
opener->dev = loop_dev;
INIT_LIST_HEAD(&opener->buf_list);
/* 初始化 V4L2 文件句柄 */
v4l2_fh_init(&opener->fh, &loop_dev->vdev);
file->private_data = opener;
v4l2_fh_add(&opener->fh);
/*
* 根据打开顺序决定是输出还是捕获
* 第一个打开者是输出(生产者),后续是捕获(消费者)
*/
if (loop_dev->output_opener == 0) {
opener->type = V4L2_BUF_TYPE_VIDEO_OUTPUT;
loop_dev->output_opener++;
printk("opened as OUTPUT (producer)\n");
} else {
opener->type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
loop_dev->capture_opener++;
printk("opened as CAPTURE (consumer)\n");
}
/* 初始化 VB2 队列 */
q = &opener->vb2_queue;
q->type = opener->type;
q->io_modes = VB2_MMAP | VB2_USERPTR | VB2_READ | VB2_WRITE;
q->drv_priv = opener;
q->buf_struct_size = sizeof(struct loop_buffer);
q->mem_ops = &vb2_vmalloc_memops;
q->timestamp_flags = V4L2_BUF_FLAG_TIMESTAMP_MONOTONIC;
q->lock = &loop_dev->lock;
q->min_queued_buffers = LOOP_MIN_BUFFERS;
if (opener->type == V4L2_BUF_TYPE_VIDEO_OUTPUT) {
q->ops = &loop_out_qops;
} else {
q->ops = &loop_cap_qops;
}
ret = vb2_queue_init(q);
if (ret) {
printk(KERN_ERR LOOP_MODULE_NAME ": failed to init vb2 queue\n");
v4l2_fh_del(&opener->fh);
v4l2_fh_exit(&opener->fh);
kfree(opener);
return ret;
}
return 0;
}分配会话私有数据并挂载v4l2文件句柄:
kzalloc分配struct loop_opener,用于保存当前打开者的状态。v4l2_fh_init和v4l2_fh_add初始化并注册 V4L2 文件句柄以支持事件通知(Events)和优先级控制等标准功能。
角色分配:
- 通过检查
loop_dev->output_opener计数器,决定当前打开者是生产者(OUTPUT)还是消费者(CAPTURE)。 - 第一个打开者被标记为 OUTPUT,后续的都被标记为 CAPTURE。
- 通过检查
初始化 VB2 队列
V4L2 子系统使用 videobuf2 (VB2) 框架来管理视频内存,所以每个打开者都需要初始化一个 VB2 队列。队列类型必须与 open 的类型一致。队列的IO模式设置为支持内存映射(MMAP)、用户指针(USERPTR)、读写操作。mem_ops = &vb2_vmalloc_memops指定使用 vmalloc 分配内存。这意味着视频帧在内核中是使用虚拟连续内存存储的。
- 挂载队列操作函数
根据打开者的类型,挂载不同的队列操作函数(loop_out_qops 或 loop_cap_qops)。这些操作函数定义了如何处理缓冲区的请求、队列启动/停止等操作。
2. 释放设备
当用户空间的应用程序调用 close(fd) 时,内核会执行这个 loop_release 函数。它的核心任务是:清理会话资源,更新设备状态。
三、生产者 VB2 队列操作
四、内存空间分布
这张图展示了内存空间的物理/逻辑分布。
在这个驱动中,内存被分为了三个明确的独立区域。它们都在 内核空间(Kernel Space) 中分配(因为使用了 vb2_vmalloc_memops),但归属权不同。
内存架构图解
+-----------------------------------------------------------------------+
| 用户空间 (User Space) |
+-----------------------------------------------------------------------+
| [ 生产者 App (ffmpeg) ] [ 消费者 App (ffplay) ] |
| | (mmap) ^ (mmap) |
| v | |
+-----------------------------------------------------------------------+
| 内核空间 (Kernel Space) |
+--------------------------+-----------------+--------------------------+
| 区域 A: 生产者队列内存 | 区域 B: 中转 | 区域 C: 消费者队列内存 |
| (Output Queue Buffers) | (Global Buf) | (Capture Queue Buffers) |
+--------------------------+-----------------+--------------------------+
| | | |
| +------------------+ | memcpy 1 | +------------------+ |
| | Buffer [0] |---|-------------\ | | Buffer [0] | |
| | (vb2_vmalloc) | | | | | (vb2_vmalloc) | |
| +------------------+ | v | +------------------+ |
| | | |
| +------------------+ | +-------------+ | +------------------+ |
| | Buffer [1] | | | | | | Buffer [1] | |
| | (当前正在处理) |-->| | frame_buffer| |-->| (当前正在填充) | |
| +------------------+ | | (vzalloc) | | +------------------+ |
| | +-------------+ | |
| +------------------+ | | +------------------+ |
| | Buffer [2] | | memcpy 2 | | Buffer [2] | |
| | (vb2_vmalloc) | | ^ | | (vb2_vmalloc) | |
| +------------------+ | | | +------------------+ |
| |-------------/ | |
| | | |
+--------------------------+-----------------+--------------------------+三大内存区域详解
1. 区域 A:生产者私有队列 (Output Queue)
- 归属:属于生产者的
struct loop_opener->vb2_queue。 - 分配方式:当 ffmpeg 调用
REQBUFS时,由 VB2 框架调用vmalloc分配一组 buffer(通常是 3-4 个,构成一个环形队列)。 - 内容:这里存放的是源视频数据。
- 操作:
- 用户空间通过
mmap直接往这里写数据。 - 驱动程序的
loop_out_buf_queue函数读取这里的地址作为memcpy的源地址 (src)。
2. 区域 B:全局共享中转站 (Internal Frame Buffer)
- 归属:属于全局唯一的
struct loop_device。 - 分配方式:在驱动初始化或
S_FMT(设置格式)时,通过vzalloc仅仅分配 1块 内存。 - 内容:这里存放的是当前最新的一帧画面。
- 作用:它是连接左右两边的桥梁,也是数据的“持久化”地点。
- 即使生产者把 Buffer [1] 收回去了,这一帧画面依然保存在这里,等待消费者来取。
3. 区域 C:消费者私有队列 (Capture Queue)
- 归属:属于消费者的
struct loop_opener->vb2_queue。 - 分配方式:当 ffplay 调用
REQBUFS时分配一组 buffer。 - 内容:这里存放的是即将给用户显示的视频数据。
- 操作:
- 驱动程序的
loop_cap_buf_queue函数将中转站的数据memcpy到这里(作为目标地址 dst)。 - 用户空间通过
mmap直接读取这里的数据进行渲染。
数据的生命周期 (Data Lifecycle)
- 产生:数据首先出现在左边的 区域 A (Buffer 1) 中。
- 备份:驱动执行
memcpy,数据被复制到中间的 区域 B (frame_buffer)。此时,左边的 Buffer 1 任务完成,可以被覆盖写下一帧了。 - 分发:当消费者请求数据时,驱动执行
memcpy,数据从 区域 B 复制到右边的 区域 C (Buffer 1)。 - 消费:右边的 Buffer 1 交给用户空间,此时数据完成了穿越。
这种内存设计的优缺点
优点(解耦):
生产者和消费者不需要拥有相同数量的 buffer。
异步性:如果消费者处理得慢,生产者可以继续写(只要不断更新中间的
frame_buffer即可)。消费者读到的永远是“最新存入的那一帧”,虽然会发生丢帧(旧的被覆盖了),但不会阻塞生产者。缺点(性能):
这就是所谓的 Double Copy(双重拷贝)。
数据路径:RAM -> CPU寄存器 -> RAM (中间) -> CPU寄存器 -> RAM (目标)。
对于高分辨率视频,这会消耗大量的内存带宽和 CPU 缓存。
用户空间交互流程
1. 生产者 (ffmpeg) 流程
ffmpeg -re -f lavfi -i testsrc=size=640x480:rate=30 -vcodec rawvideo -pix_fmt yuv420p -f v4l2 /dev/videoX关键步骤:
打开设备:调用
fd = open("/dev/video0", O_RDWR),虚拟文件系统查找设备,调用loop_fops->open,内核执行loop_open(),分配为 OUTPUT 角色。初始化 vb2_queue,挂载 loop_out_qops设置格式:调用
ioctl(fd, VIDIOC_S_FMT, &fmt),内核配置pix_format和分配frame_buffer。请求缓冲区:调用
ioctl(fd, VIDIOC_REQBUFS, &req),内核通过 VB2 框架分配 OUTPUT 队列的缓冲区(区域 A)。核心循环:填数据并入队:用户空间可以通过不同的方式将数据写入缓冲区(mmap/userptr/read/write)。通过
ioctl(fd, VIDIOC_QBUF, &buf)或者直接调用write(fd, buf, size)都可以把缓冲区入队。当使用ioctl时,其内部逻辑为loop_qbuf -> vb2_qbuf -> vb2_core_qbuf;当使用write时,其内部逻辑为loop_write -> vb2_write -> (内部模拟逻辑) -> vb2_core_qbuf。最终,都通过vb2_core_qbuf调用驱动的loop_out_buf_queue函数,执行memcpy将数据从区域 A 复制到区域 B(中转站),然后标记frame_ready = true并唤醒等待队列。
User Space
[ 路径 A: 标准推流 (MMAP) ] [ 路径 B: 传统写入 (Write) ]
| |
ioctl(fd, VIDIOC_QBUF, ...) write(fd, data, len)
| |
v v
Kernel VFS | |
loop_fops.unlocked_ioctl loop_fops.write
(即 video_ioctl2) |
| |
v v
V4L2 / Driver | Driver: loop_write
loop_ioctl_ops.vidioc_qbuf |
| |
Driver: loop_qbuf 调用 vb2_write(...)
| |
调用 vb2_qbuf(...) |
| |
| +----------------------------------+
| | VB2 Core (vb2_write 内部逻辑) |
| | 1. 若未启动,自动 REQBUFS/STREAMON|
| | 2. 找一个空闲 Buffer |
| | 3. copy_from_user() [数据拷贝#1] |
| | 4. 自动发起入队操作 |
| +----------------------------------+
| |
+-------------------+-------------------+
|
v
VB2 Core (vb2_core_qbuf) <--- 【汇流点】
|
1. 校验 buffer 状态
2. 调用 q->ops->buf_prepare ----> [loop_out_buf_prepare]
3. 将 buffer 状态设为 QUEUED
4. 调用 q->ops->buf_queue ----> [loop_out_buf_queue]
|
v
Driver (loop_out_buf_queue) <--- 【最终干活的地方】
|
memcpy() [数据拷贝#2] (QueueBuf -> Global)
wake_up() 唤醒消费者
vb2_buffer_done() 标记完成传统方式(write/read)生产者-消费者数据流转全景图
在这个场景中,我们将核心关注点放在 Buffer 的选择逻辑 和 数据的四次搬运 上。
核心概念图例
- User Buffer: 用户自己 malloc 的临时内存(ffmpeg/ffplay 各 1 个)。
- Kernel Array:
vb2_queue中的bufs[]指针数组(仓库货架)。 - Kernel Buffer: 实际的内核内存(vmalloc 分配)。
- Global Buffer: 驱动的中间暂存区。
第一阶段:初始化 (REQBUFS) —— “建立仓库”
在推流和拉流开始前,内核会先建立好“仓库”和“货架”。
[1] ffmpeg 调用 ioctl(REQBUFS, count=3)
|
v
内核 VB2 (Output Queue):
1. 申请指针数组: bufs[3]
2. 申请 3 块内核内存 (vmalloc)
3. 填充数组:
bufs[0] -> 指向 Kernel_Buf_A (状态: FREE)
bufs[1] -> 指向 Kernel_Buf_B (状态: FREE)
bufs[2] -> 指向 Kernel_Buf_C (状态: FREE)
---------------------------------------------------
[2] ffplay 调用 ioctl(REQBUFS, count=3)
|
v
内核 VB2 (Capture Queue):
1. 申请指针数组: bufs[3]
2. 申请 3 块内核内存 (vmalloc)
3. 填充数组:
bufs[0] -> 指向 Kernel_Buf_X (状态: FREE)
bufs[1] -> 指向 Kernel_Buf_Y (状态: FREE)
bufs[2] -> 指向 Kernel_Buf_Z (状态: FREE)第二阶段:完整数据流转图 (write / read 模式)
这是一张合并了推流和拉流的动态流程图。
文字版详细流程解析
1. 推流侧 (ffmpeg write)
- 发起: ffmpeg 用户程序的临时 Buffer 填满数据,调用
write()。 - 选择 (Selection):
- 内核
vb2_write函数查看vb2_queue->bufs[]数组。 - 发现
bufs[0](Buf A) 的状态是DEQUEUED(空闲)。 - 决定使用 Buf A。
- 拷贝 #1 (User->Kernel):
- 内核调用
copy_from_user,将 ffmpeg Buffer 的内容复制到 Kernel Buf A。
- 入队 (Driver Queue):
- VB2 调用驱动函数
loop_out_buf_queue(Buf A)。
- 拷贝 #2 (Kernel->Global):
- 驱动执行
memcpy(Global_Buf, Buf A)。 - 此时,中间仓库有了最新数据。
- 归还:
- 驱动调用
vb2_buffer_done(Buf A)。 - VB2 将 Buf A 的状态重置为
DEQUEUED。 write()函数返回,ffmpeg 可以复用它的临时 Buffer 填下一帧了。
2. 拉流侧 (ffplay read)
- 发起: ffplay 用户程序准备一个空的临时 Buffer,调用
read()。 - 选择 (Selection):
- 内核
vb2_read函数查看vb2_queue->bufs[]数组。 - 发现
bufs[0](Buf X) 的状态是DEQUEUED(空闲)。 - 决定使用 Buf X。
- 入队 (Driver Queue):
- VB2 调用驱动函数
loop_cap_buf_queue(Buf X)。
- 拷贝 #3 (Global->Kernel):
- 驱动执行
memcpy(Buf X, Global_Buf)。 - Buf X 拿到了最新的画面。
- 完成信号:
- 驱动调用
vb2_buffer_done(Buf X)。
- 拷贝 #4 (Kernel->User):
- 内核调用
copy_to_user,将 Kernel Buf X 的内容复制到 ffplay Buffer。
- 归还:
- VB2 将 Buf X 的状态重置为
DEQUEUED。 read()函数返回,ffplay 拿到数据开始渲染。
总结关键点
- 选择逻辑:非常简单粗暴,“谁闲着就用谁”(通常是顺序轮询)。
- 解耦:ffmpeg 的 Buffer 和 Kernel Buf A 没有永久绑定关系,只是在那一次
write调用中临时发生了数据搬运。 - 瓶颈:你可以清楚地看到图中的 4 条虚线箭头(数据搬运),这就是为什么
read/write模式慢的原因。如果换成 MMAP,ffmpeg 直接往K_Buf_A写,ffplay 直接读K_Buf_X,中间只剩 Global 那一次中转。
MMAP数据流转图解
这是一个关于 MMAP 机制 核心逻辑的问题。
在 MMAP 模式下,缓冲区的管理方式与 Read/Write 模式完全不同。控制权发生了反转:在 Read/Write 模式下,内核帮你选 Buffer;但在 MMAP 模式下,FFmpeg 必须自己管理 Buffer 的索引,并且通过“借用”和“归还”的方式与驱动交互。
1. FFmpeg 应该用几个 Buffer?
答案:这是“协商”出来的,通常是 2 到 4 个。
FFmpeg 不会凭空决定用几个,它会走以下流程:
- 默认期望:FFmpeg 的 V4L2 输出模块通常默认请求 4 个 Buffer(为了保证足够的平滑度,防止丢帧)。
- 驱动限制:FFmpeg 调用
ioctl(VIDIOC_REQBUFS),传入它想要的数量(例如 4)。 - 最终决定:
- 你的驱动(
basic_loopback_v4l2.c)在queue_setup函数里有话语权:
if (*nbuffers < LOOP_MIN_BUFFERS)
*nbuffers = LOOP_MIN_BUFFERS; // 你的代码强行限制最少 2 个
- 如果 FFmpeg 请求 4 个,驱动允许,那就用 4 个。
- 如果 FFmpeg 请求 1 个,驱动强行改为 2 个,FFmpeg 必须接受用 2 个。
最佳实践: 通常推荐 3 个 (Triple Buffering)。
- Buffer A: 驱动正在读取(显示)。
- Buffer B: FFmpeg 正在填充(准备)。
- Buffer C: 备用(缓冲抖动)。
2. 填充时,FFmpeg 怎么选择 Free 的 Buffer?
在 MMAP 模式下,FFmpeg 不能随意选择往哪个 Buffer 里写数据。它必须严格遵循 “出列 (DQBUF) -> 填充 -> 入列 (QBUF)” 的循环机制。
我们可以把它想象成 “借盘子” 的游戏。
阶段一:启动阶段 (Priming) —— 所有的盘子都在 FFmpeg 手里
刚开始 (REQBUFS 之后),所有的 Buffer (比如索引 0, 1, 2) 都在用户空间手里,都是 Free 的。
- FFmpeg 只有本地记录,它知道 0, 1, 2 都是空的。
- FFmpeg 逻辑:
- 选中 Index 0 -> 填满数据 ->
ioctl(QBUF, index=0)(把盘子递给内核)。 - 选中 Index 1 -> 填满数据 ->
ioctl(QBUF, index=1)。 - 选中 Index 2 -> 填满数据 ->
ioctl(QBUF, index=2)。
- 此时:FFmpeg 手里没有盘子了!所有 Buffer 都在内核队列里排队。
阶段二:循环阶段 (Steady State) —— 找内核要空盘子
FFmpeg 继续要推流,但手里没 Buffer 了。它必须问内核要:
- 索取 (DQBUF):
FFmpeg 调用
ioctl(VIDIOC_DQBUF)。
- 如果驱动还没处理完:FFmpeg 阻塞 (Sleep),等待。
- 如果驱动处理完了(比如 Buffer 0 的数据被拷走了):内核唤醒 FFmpeg,并告诉它:“Index 0 现在空闲了,还给你”。
- 填充 (Fill):
FFmpeg 拿到返回的 Index 0。它根据之前
mmap得到的地址,往 Index 0 的内存区域写入新的一帧画面。 - 归还 (QBUF):
FFmpeg 调用
ioctl(VIDIOC_QBUF, index=0),告诉内核:“Index 0 我又填好了,你拿去用吧”。
3. MMAP 完整 Buffer 流转图
假设协商使用了 3 个 Buffer (Index 0, 1, 2)。
4. 关键区别总结
| 特性 | Read/Write 模式 | MMAP 模式 |
|---|---|---|
| 谁分配内存? | 用户自己 malloc 一个临时的 | 内核分配,用户映射 (map) 过来用 |
| 谁决定用哪个? | 内核决定。vb2_write 内部自己找空闲的。 | 协商决定。用户通过 DQBUF 询问内核哪个能用。 |
| 数据怎么进去? | copy_from_user (拷贝) | 直接写内存指针 (零拷贝到内核区) |
| Buffer 状态 | 用户不可见,黑盒。 | 用户通过 QBUF/DQBUF 显式切换所有权。 |
5. 你的驱动需要改什么吗?
不需要。
- 你的驱动代码(
vb2_ops和ioctl接口)已经完全支持这套逻辑了。 - 只要你按照之前的修复(去掉 M2M 标志,或者禁用 RW 强制 MMAP),FFmpeg 就会自动按照上面的流程图工作:
REQBUFS(申请 2-4 个)。MMAP(拿到地址)。- 循环
DQBUF-> 写内存 ->QBUF。
这就是为什么 MMAP 高效的原因:FFmpeg 知道 Buffer 在哪(Index),直接往那个地址写,写完通知驱动。少了一次 write() 系统调用带来的内存拷贝。
问题
用户空间->内核空间的数据拷贝有哪些方式?read/write vs mmap vs userptr vs ioctl(QBUF) 有什么区别?分别怎么实现?
两个队列的内存分布分别是怎样的?推流和拉流的内存路径是怎样的?
双队列设计的优缺点?如果只用单队列会怎么样?有哪些挑战?