第 10 课:采集第一帧画面
# 第 10 课:采集第一帧画面
嘿,我是小方。 模块 2 我们把 OBS 的"世界观"摸透了。从这一课起,进入模块 3:画面是怎么来的(视频链路),顺着流水线追一帧画面走完全程。 第一站是采集。这一课我答应你:把一个真实采集源的每一步都摊开,代码、用到的库、每行在干嘛,全讲清楚 —— 你照着看,就能明白一个摄像头插件到底是怎么写出来的。我们拿 Linux 的
v4l2摄像头插件开刀(纯 C,最干净;Windows 的win-dshow把 DirectShow 包一层,六步一一对应)。
📖 先补:几个采集要用到的系统词 —— 文件描述符(fd):Linux 里"打开的设备/文件"的一个编号,像门牌号;ioctl:对设备下命令的通用函数("设个分辨率""开始采集"都靠它);mmap(内存映射):把设备的缓冲区直接映射到你的内存,读它就等于读设备,省掉一次拷贝(零拷贝),快;plane/linesize:一帧图像按平面存,每行实际字节数叫 linesize(可能比"宽×像素字节"大,有对齐填充)。下面看采集循环时,这些词就不陌生了。
# 10.1 先回到地图:采集是第一站
📍我们在哪:模块 3「视频链路」开篇——顺着流水线追一帧画面,第一站就是采集:把现实世界的光变成一个
obs_source_frame。

采集这一站干的事,一句话:把现实世界的光,变成一个个 struct obs_source_frame。 第 2 课那个结构体 —— width/height(分辨率)、data(像素字节)、format(像素格式)、timestamp(时间戳)—— 采集源就是把镜头前的画面填进它,再交给引擎。
怎么交?第 6 课讲过两条路:异步推帧(obs_source_output_video)和 GPU 渲染(video_render)。这一课走第一条(更普适,直接续上第 2 课);GPU 那条留到第 11 课。
# 10.2 采集源,就是个 INPUT 源
📍接上一节:知道了采集要产出
obs_source_frame,这一节先给采集源定位——它就是第 6 课那四类里的INPUT源,走异步喂帧那条路。
翻到 plugins/linux-v4l2/v4l2-input.c 那张"登记表":

// plugins/linux-v4l2/v4l2-input.c:1099
struct obs_source_info v4l2_input = {
.id = "v4l2_input",
.type = OBS_SOURCE_TYPE_INPUT, // 它是个「源头」
.output_flags = OBS_SOURCE_ASYNC_VIDEO // 走「异步喂帧」那条路
| OBS_SOURCE_DO_NOT_DUPLICATE,
.create = v4l2_create, // 创建实例时调
.destroy = v4l2_destroy,
.get_width = v4l2_getwidth,
.get_height = v4l2_getheight,
...
};
2
3
4
5
6
7
8
9
10
11
12
OBS_SOURCE_ASYNC_VIDEO 这一个标志,就告诉引擎"我会自己抓帧、主动推过来,别每帧来问我要"。那它在哪抓帧?.create(v4l2_create)里会起一个采集线程,真正干活的都在那个线程里。下面我们把那个线程从头到尾拆开。
# 10.3 用到哪些库,以及完整的六步
📍承上启下:知道了它是个 INPUT 源、会起采集线程,这一节先摊开用到哪些库,再列出把摄像头跑起来的完整六步。
先看这个插件 #include 了什么(v4l2-input.c:17),"用了哪些库"一目了然:
#include <fcntl.h> // open() 的标志位 O_RDWR
#include <sys/ioctl.h> // ioctl()
#include <sys/select.h> // select() —— 等设备「有帧了」
#include <linux/videodev2.h> // ★ V4L2:Linux 内核的摄像头接口(所有 VIDIOC_* 命令码、结构体)
#include <libv4l2.h> // ★ libv4l2:用户态封装库(v4l2_open / v4l2_ioctl / v4l2_mmap …)
#include <obs-module.h> // OBS 插件 API
#include <util/threading.h> // OBS 的线程封装
2
3
4
5
6
7
8
两个关键的库:
<linux/videodev2.h>—— V4L2(Video for Linux 2),内核暴露的摄像头标准接口。你跟摄像头的所有对话,都是对设备文件/dev/video0发ioctl(...),命令码就是VIDIOC_*那一串。<libv4l2.h>—— libv4l2,一个用户态封装库。它最大的好处是自动补格式:有些摄像头只会输出冷门像素格式,libv4l2 在用户态偷偷帮你转成常见格式,你的应用就不用对每种奇葩格式都写一遍处理。所以你看代码里是v4l2_open/v4l2_ioctl/v4l2_mmap(带v4l2_前缀),而不是裸的open/ioctl/mmap—— 就是在走 libv4l2 这层。
把一个摄像头采集源跑起来,完整是这六步,前五步是"一次性准备",第六步才是不停转的循环:

下面逐步上代码。
# 步骤 ①②:打开设备、设置格式
data->dev = v4l2_open(data->device_id, O_RDWR | O_NONBLOCK); // ① 打开 /dev/video0
v4l2_open 就像普通的 open(),把摄像头这个"设备文件"打开,拿到一个文件描述符 data->dev,之后所有 ioctl 都对它发。
然后用 VIDIOC_S_FMT 这个 ioctl 跟设备协商画面格式 —— 你想要多大分辨率、什么像素格式(YUYV?MJPEG?),设备同意了就定下来,确定后的宽、高、像素格式被记进 data->width / data->height / data->pixfmt。这一步是"谈好规格"。
# 步骤 ③:申请并映射缓冲(mmap)
这步是 V4L2 流式采集的精髓。摄像头不会把每帧"拷"给你,而是和你共享一块内存:内核分配几个缓冲,通过 mmap 映射到你的进程地址空间,之后摄像头往里填、你从里读,零拷贝。看真实代码(v4l2-helpers.c:99):
int_fast32_t v4l2_create_mmap(int_fast32_t dev, struct v4l2_buffer_data *buf)
{
struct v4l2_requestbuffers req;
memset(&req, 0, sizeof(req));
req.count = 4; // 要 4 个缓冲(够流水起来,又不浪费)
req.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
req.memory = V4L2_MEMORY_MMAP; // 用 mmap 方式
if (v4l2_ioctl(dev, VIDIOC_REQBUFS, &req) < 0) // 向内核「申请」这些缓冲
return -1;
buf->count = req.count;
buf->info = bzalloc(req.count * sizeof(struct v4l2_mmap_info));
struct v4l2_buffer map;
memset(&map, 0, sizeof(map));
map.type = req.type;
map.memory = req.memory;
for (map.index = 0; map.index < req.count; ++map.index) {
if (v4l2_ioctl(dev, VIDIOC_QUERYBUF, &map) < 0) // 问出第 index 个缓冲的大小/偏移
return -1;
buf->info[map.index].length = map.length;
buf->info[map.index].start = // ★ 把它 mmap 进自己的地址空间
v4l2_mmap(NULL, map.length, PROT_READ | PROT_WRITE,
MAP_SHARED, dev, map.m.offset);
if (buf->info[map.index].start == MAP_FAILED)
return -1;
}
return 0;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
逐行讲:
req.count = 4+VIDIOC_REQBUFS—— 跟内核说"给我 4 个采集缓冲,用 mmap 方式"。4 个是经验值:足够让"摄像头在填一个、你在读一个"流水起来,又不至于占太多内存。- 循环里
VIDIOC_QUERYBUF—— 逐个问出"第 index 个缓冲有多大(map.length)、在设备内存的什么偏移(map.m.offset)"。 v4l2_mmap(...)—— 把这个内核缓冲映射进你的进程,返回一个能直接读的指针start,存进buf->info[index].start。以后摄像头往这块填一帧,你这边的start立刻就能看到,不用拷贝。
这就是为什么 10.5 你会看到引擎那边"复制一份"是必要的 —— 因为这块内存是和内核共享的,你读完得赶紧还回去给摄像头填下一帧,不能一直占着。
# 步骤 ④:把帧的"固定信息"准备好(只做一次)
分辨率、像素格式这些每帧都一样的东西,没必要循环里反复填。v4l2_prep_obs_frame(v4l2-input.c:117)在开抓之前一次性填好那个 obs_source_frame:
static void v4l2_prep_obs_frame(struct v4l2_data *data, struct obs_source_frame *frame,
size_t *plane_offsets)
{
memset(frame, 0, sizeof(struct obs_source_frame));
const enum video_format format = v4l2_to_obs_video_format(data->pixfmt); // V4L2 格式 → OBS 格式
frame->width = data->width; // 宽(步骤②谈好的)
frame->height = data->height; // 高
frame->format = format; // 像素格式(第 3 课的 video_format)
// 按格式算每个平面的「每行字节数」linesize 和「平面偏移」plane_offsets
switch (data->pixfmt) {
case V4L2_PIX_FMT_NV12: // 两平面 4:2:0
frame->linesize[0] = data->linesize; // Y 平面每行字节
frame->linesize[1] = data->linesize; // UV 平面每行字节
plane_offsets[1] = data->linesize * data->height; // UV 平面从这里开始
break;
...
default:
frame->linesize[0] = data->linesize; // 打包格式,就一个平面
break;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
这里出现的 linesize、plane_offsets,正是第 3 课讲 YUV 时埋的伏笔:像 NV12 这种格式,亮度(Y)和色度(UV)分开放在不同"平面",每个平面"每行多少字节(linesize)"、"从整块内存的哪个偏移开始(plane_offset)",都得告诉引擎,它才知道怎么把这堆字节解读成一幅画面。v4l2_to_obs_video_format 则负责把 V4L2 的格式码翻译成第 3 课那个 video_format 枚举。
# 步骤 ⑤:开始推流
最后,把 4 个缓冲全部 VIDIOC_QBUF(入队,交给摄像头去填),再发一个 VIDIOC_STREAMON,命令摄像头开始往缓冲里填画面。这步由 v4l2_start_capture(v4l2-helpers.c:26)完成。从此,数据就源源不断地来了。
# 10.4 源码深读:第六步,不停转的抓帧循环
📍接上一节:前五步是一次性准备,第六步才是不停转的主循环——这一节逐行拆开采集线程的抓帧循环。
准备就绪,采集线程 v4l2_thread(v4l2-input.c:160)进入主循环。我把它最核心的部分摊开,逐行注:

struct obs_source_frame out;
v4l2_prep_obs_frame(data, &out, plane_offsets); // ④ 先把固定信息填好(上一节)
while (os_event_try(data->event) == EAGAIN) { // 没收到「停止」信号就一直转
FD_ZERO(&fds); FD_SET(data->dev, &fds);
tv.tv_usec = timeout_usec;
int r = select(data->dev + 1, &fds, NULL, NULL, &tv); // 等设备「有帧了」(带超时)
if (r == 0) { /* 超时,可能掉线,记录并重试 */ continue; }
struct v4l2_buffer buf = {0};
buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
buf.memory = V4L2_MEMORY_MMAP;
v4l2_ioctl(data->dev, VIDIOC_DQBUF, &buf); // ① 取走一个「已填好」的缓冲
out.timestamp = timeval2ns(buf.timestamp); // ② 这帧的采集时刻
if (!frames) first_ts = out.timestamp;
out.timestamp -= first_ts; // 首帧归零
start = data->buffers.info[buf.index].start; // 这个缓冲映射后的地址(步骤③拿到的)
for (i = 0; i < MAX_AV_PLANES; ++i)
out.data[i] = start + plane_offsets[i]; // ③ 让 out.data 指向各平面的字节
obs_source_output_video(data->source, &out); // ④ ★ 把这一帧交给引擎
v4l2_ioctl(data->dev, VIDIOC_QBUF, &buf); // ⑤ 把缓冲还给摄像头,去填下一帧
frames++;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
逐句过一遍:
select(...)—— 这是个 POSIX 系统调用,作用是"阻塞等待,直到这个设备有数据可读(或超时)"。没帧的时候,采集线程就睡在这,不空转烧 CPU;摄像头填好一帧,select立刻返回。timeout_usec是超时(代码里设成约 5 个帧周期),超时多半意味着设备掉线,会去记录/重置。VIDIOC_DQBUF(dequeue buffer)—— 从摄像头手里"取走"一个已经填好画面的缓冲。buf.index告诉你是 4 个里的哪一个。- 时间戳 ——
out.timestamp记下采集时刻,-= first_ts做首帧归零(第一帧时间戳从 0 算起)。这个时间戳是音画同步的命根子(模块 4)。 out.data[i] = start + plane_offsets[i]—— 关键的一步:让out的数据指针指向那个缓冲里各平面的起始字节(步骤④算好的plane_offsets在这用上了)。注意是"指过去",没有拷贝。obs_source_output_video—— 把填好的整帧交给引擎。第 6 课那条"异步出帧路",源头就在这一行。VIDIOC_QBUF(queue buffer)—— 把这个缓冲"还给"摄像头,让它去填下一帧。然后while转回去select等下一帧。
整个循环就是 等帧 → 取缓冲 → 填 obs_source_frame → 交引擎 → 还缓冲,周而复始,一秒钟转几十圈。这,就是"采集"在做的全部事情。
# 10.5 源码深读:引擎"收下"一帧时做了什么
📍承上启下:采集线程把帧「交」出去之后呢?这一节切到引擎一侧,看
obs_source_output_video收下一帧时做的两件事。
out.data 只是指向那个马上要被 QBUF 还回去的共享缓冲 —— 引擎要是不赶紧处理,缓冲一被摄像头覆盖,画面就花了。引擎怎么接住?看 obs_source_output_video_internal(obs-source.c:3563):

static void obs_source_output_video_internal(obs_source_t *source,
const struct obs_source_frame *frame)
{
...
struct obs_source_frame *output = cache_video(source, frame); // ① 复制进自己的缓存
pthread_mutex_lock(&source->async_mutex);
...
da_push_back(source->async_frames, &output); // ② 推进「待显示队列」
source->async_active = true;
pthread_mutex_unlock(&source->async_mutex);
}
2
3
4
5
6
7
8
9
10
11
12
就两件事:
- ①
cache_video:复制一份。 这就解开了上面的矛盾 —— 引擎把这帧像素拷进 source 自己的缓存,于是采集线程拷完立刻就能QBUF把共享缓冲还给摄像头,根本不用干等。一次拷贝,换来采集线程和引擎彻底松绑。 - ②
da_push_back(source->async_frames, ...):入队。 把缓存帧推进async_frames数组 —— 正是第 6 课"实例剖面图"里那个async_frames[],采集线程和渲染线程的"交接台"。
注意:output_video 只管"收下并入队",并不在这里把画面画出来。 画,是渲染线程的活儿。两条线程靠 async_frames 队列加一把 async_mutex 锁,安全地隔着传帧。
# 10.6 第一帧的完整旅程:跨线程
📍收束:生产端(采集线程)和消费端(渲染线程)都看过了,这一节把它俩连起来,走一遍第一帧的完整跨线程旅程。
把生产端和消费端连起来:

- 采集线程(生产者):摄像头填好一帧 →
DQBUF取出、填进obs_source_frame→obs_source_output_video(引擎cache_video复制)。 - 中间:帧进
async_frames[]队列,按时间戳排着。 - 渲染线程(消费者):按"当前该显示哪一帧"的时间戳取一帧 → 上传成 GPU 纹理(第 11 课)→ 交给"合成"叠进画面 → 显示。
这个队列把"设备来帧的节奏"和"屏幕刷新的节奏"解耦了:摄像头 30fps 不太稳、屏幕 60Hz 刷,两边对不上没关系,队列在中间缓冲,渲染线程到点了按时间戳取最合适那帧。音画同步也在这一层做(模块 4)。
# 10.7 回到第 6 课:采集的两条路
📍收束:采集讲完,回扣第 6 课那「两条出帧路」——本课走的是异步 RAM 帧,另一条 GPU 纹理正好引出下一课。

- ① 异步 RAM 帧(本课) —— 摄像头(
v4l2/win-dshow)、视频文件(obs-ffmpeg)。帧在内存里产生,obs_source_output_video推给引擎,进async_frames队列。 - ② GPU 纹理(第 11 课) —— 显示器采集、游戏采集。画面本就在显存里,走
video_render回调,引擎每帧主动调它直接在显卡上画,不走内存搬运,更快。
下一课,我们就钻进 GPU 那条。
# 10.8 本课小结
- 采集 = 把现实画面填进
struct obs_source_frame,交给引擎;采集源是INPUT+OBS_SOURCE_ASYNC_VIDEO(v4l2-input.c:1099)。 - 用到的库:
<linux/videodev2.h>(V4L2 内核接口,VIDIOC_*ioctl)+<libv4l2.h>(用户态封装,自动补格式);POSIX 的open/ioctl/select/mmap。Windows 上是win-dshow+ DirectShow,六步一一对应。 - 六步配方:①
v4l2_open开设备 → ②VIDIOC_S_FMT设格式 → ③VIDIOC_REQBUFS+mmap申请映射缓冲(零拷贝共享内存)→ ④v4l2_prep_obs_frame填好 width/height/format/linesize(一次)→ ⑤QBUF×N +STREAMON开推流 → ⑥ 抓帧循环。 - 抓帧循环:
select等帧 →VIDIOC_DQBUF取缓冲 → 填时间戳(首帧归零)+out.data指向各平面 →obs_source_output_video交帧 →VIDIOC_QBUF还缓冲。 - 引擎收帧(
obs-source.c:3563):cache_video复制(让采集线程能立刻还缓冲)+da_push_back(async_frames)入队;不负责画。 - 跨线程:采集线程产 →
async_frames队列 → 渲染线程按时间戳取 → GPU 纹理 → 合成。队列解耦"设备节奏/屏幕节奏",也是音画同步层。
# 10.9 动手 / 观察(本课作业)
- 加个摄像头源:OBS → 添加来源 →「视频采集设备」。你加的就是本课这个
ASYNC_VIDEO的 INPUT 源。 - 对照另一个异步源:在
plugins/obs-ffmpeg/obs-ffmpeg-source.c搜obs_source_output_video,看视频文件(也是异步源)在哪、怎么把解码出的一帧交给引擎 —— 跟摄像头是不是同一个套路? - 想清楚那次拷贝:用自己的话讲明白,为什么
obs_source_output_video_internal里那句cache_video是必须的。(提示:步骤③那块内存是和谁共享的?第六步最后那句QBUF之后,这块内存还归你吗?) - (进阶)数一数 ioctl:本课的六步里,一共用到了哪几个
VIDIOC_*命令?各自在哪一步、干什么?
# 下一课预告
这一课的帧,最后"上传成 GPU 纹理"就交给了下一站。可 GPU 到底怎么回事?纹理是什么?屏幕/游戏采集为什么能不走内存、直接在显卡上画?以及 OBS 怎么用同一套代码,在 Windows(D3D11)、Linux(OpenGL)、macOS(Metal)三种显卡上干活?
第 11 课:GPU 与图形子系统 —— 钻进 libobs/graphics/,看 OBS 怎么把"在显卡上画个东西"抽象成统一接口,以及一帧画面在 GPU 上合成的真实过程。下节课见。
📁 本课配图:
imgs/10-01~imgs/10-07📌 源码锚点:plugins/linux-v4l2/v4l2-input.c:17(头文件/库)、:117(prep_obs_frame)、:160(v4l2_thread)、:235(抓帧循环)、:1099(obs_source_info)、plugins/linux-v4l2/v4l2-helpers.c:26(start_capture/STREAMON)、:99(create_mmap)、libobs/obs-source.c:3563(output_video_internal)、:3595(obs_source_output_video)、libobs/obs.h:283(obs_source_frame)
本留言区仅对应当前文章,欢迎补充观点、提出问题或帮助修正文中疏漏。 留言由 GitHub/Gitalk 提供,需要使用 GitHub 登录。
社区交流
讨论与留言