# 腾讯会议 OBS 虚拟摄像头黑屏:从 DQBUF 参数查到 EGL 渲染

Table of Contents

去年写过一篇 v4l2loopback 在腾讯会议黑屏的问题。当时 OBS 能输出画面,ffplay 也能读取,只有腾讯会议黑屏。换回旧版驱动就好了,但具体为什么,一直没有查清。

那篇文章里猜测新版加强了参数校验。这次重新排查,找到了对应的检查和引入它的提交,也确认了腾讯会议漏填参数的位置。原文把版本写成了 1.13.1,应为 0.13.1。

修完驱动后还遇到了一次黑屏。取帧已经成功,腾讯会议却无法创建 EGL 显示窗口。这两个问题在界面上看起来一样,需要分开处理。

从旧日志里的 memory=(null) 开始

本次使用 Arch Linux / CachyOS,桌面是 Niri,OBS 为 33.0.0,腾讯会议为 3.26.10.401。出问题时安装的是 v4l2loopback-dkms 0.15.4-2,NVIDIA 驱动为 580.178.04。

旧文的日志里,前面的格式设置、申请缓冲区、内存映射都已经成功。直到这里才连续报错:

video2: VIDIOC_STREAMON: type=vid-cap
video2: VIDIOC_DQBUF: error -22: ... type=vid-cap, ... memory=(null), bytesused=0 ...
video2: VIDIOC_QBUF: error -22: ... type=vid-cap, ... memory=(null), bytesused=0 ...

这三行摘自旧日志,中间不相关的字段用省略号缩短。-22 对应 EINVAL,含义是参数无效。更可疑的是 memory=(null):申请缓冲区时明明用了 memory=mmap,到了取帧时却变成了零。

V4L2(Video4Linux2)是 Linux 的视频设备接口。OBS 把图像写入 v4l2loopback,腾讯会议把同一个设备当作摄像头读取。这里的 OUTPUT 和 CAPTURE 都是从驱动的接口方向命名的:OBS 使用 OUTPUT,腾讯会议使用 CAPTURE。

使用 MMAP,也就是内存映射方式读取视频时,常见的操作顺序如下:

操作用途
VIDIOC_S_FMT协商分辨率、像素格式
VIDIOC_REQBUFS申请缓冲区,指定 memory=V4L2_MEMORY_MMAP
VIDIOC_QUERYBUF、mmap()获取缓冲区信息,将它映射到应用的地址空间
VIDIOC_QBUF、VIDIOC_STREAMON将缓冲区入队,开始采集
VIDIOC_DQBUF,处理图像后再 VIDIOC_QBUF取出已经填好的缓冲区,使用后归还

memory 表示缓冲区使用哪一种 I/O 方法,既不表示剩余内存,也不表示图像地址。V4L2_MEMORY_MMAP 的值是 1,零不是合法的内存方法。

内核文档明确要求应用在调用 DQBUF 前填写 type、memory 等字段。即使前面已经用 MMAP 申请过缓冲区,取帧时也要填写。

写个小程序替掉腾讯会议

我先写了一个只操作 /dev/video0 的小程序。写入端设置 YUYV 格式,写入彩色帧;捕获端申请、映射、入队缓冲区,最后调用 DQBUF。

两次运行只改变取帧请求里的 memory。当前原版驱动的输出是:

DQBUF memory_in=0 rc=-1 errno=22 (Invalid argument) memory_out=0 bytesused=0
FAIL: captured expected non-black YUYV frame
DQBUF memory_in=1 rc=0 errno=0 (OK) memory_out=1 bytesused=153600
PASS: captured expected non-black YUYV frame

取回的数据跟写入的内容逐字节比过,第二次确实读到了同一帧。设备、分辨率、像素格式都没换,只把 memory 从零改成 MMAP,就能取帧。

随后打开当前版本的腾讯会议,在本地设置页选择 OBS 虚拟摄像头,同时用 strace 记录 ioctl。实际调用顺序与旧日志一致:MMAP 缓冲区申请成功,最初的 QBUF 成功,STREAMON 成功;进入循环以后,DQBUF 和后续 QBUF 反复返回 EINVAL。

旧日志的猜测已经可以复现。问题出在取帧请求。

为什么 0.13.1 可以,0.15.4 不行

沿着 vidioc_dqbuf() 的提交历史查下去,找到上游提交 1b3067f3d9c0189ec365afca70e10231452dfdee,标题是 QBUF/DQBUF ensure buffer type is MMAP。

它在 DQBUF 入口加了这个检查:

if (buf->memory != V4L2_MEMORY_MMAP)
return -EINVAL;

这个改动首先进入 v0.14.0。v0.13.1 和 v0.13.2 没有这项输入字段检查,v0.15.4 仍保留它。

降级有效的原因也就清楚了。腾讯会议传了零,旧版没有检查这个字段,仍然返回图像;新版按 API 要求拒绝了请求。上游加强校验有依据,客户端的调用需要修正。

旧版也检查缓冲区是否已经映射。此次变更额外要求调用者填写内存方法,修复时只需处理这个字段。

在腾讯会议的二进制里确认漏填位置

腾讯会议没有公开这部分源码,我检查了本机 libxcast.so 的反汇编。当前 x86-64 版本中,取帧函数从 0x2995b0 开始。

下面摘出与参数初始化和 ioctl 有关的指令:

2995c0: xorps xmm0,xmm0
2995c3: movups XMMWORD PTR [rbx+0x40],xmm0
2995c7: movups XMMWORD PTR [rbx+0x30],xmm0
2995cb: movups XMMWORD PTR [rbx+0x20],xmm0
2995cf: movups XMMWORD PTR [rbx+0x10],xmm0
2995d3: movups XMMWORD PTR [rbx],xmm0
2995d6: mov QWORD PTR [rbx+0x50],0x0
2995de: mov DWORD PTR [rbx+0x4],0x1
2995e5: mov r15d,DWORD PTR [r14+0x40]
2995f0: mov rsi,0xffffffffc0585611
2995f7: xor eax,eax
2995f9: mov edi,r15d
2995fc: mov rdx,rbx
2995ff: call ce230 <ioctl@plt>

前面五次 16 字节写入,加上末尾一次 8 字节写入,清空了整个 88 字节的 v4l2_buffer。随后只在偏移 0x4 写入 1,也就是设置 type=V4L2_BUF_TYPE_VIDEO_CAPTURE。

memory 位于偏移 0x3c,清零后没有再写入。调用的请求号 0xc0585611 正是这个 ABI 下的 VIDIOC_DQBUF。

重新入队的函数位于 0x299670,它直接把传入的描述符交给 QBUF,没有补写 memory。这样,第一次取帧失败以后,零值继续传给入队请求,形成了日志里的连续报错。

这些偏移和地址对应本次检查的 3.26.10.401 x86-64 二进制,换版本或架构后需要重新定位。

如果腾讯会议修改自己的调用,取帧前应当像这样初始化参数:

struct v4l2_buffer buf = { 0 };
buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
buf.memory = V4L2_MEMORY_MMAP;

给新版驱动加一个有限的兼容处理

这次在 lushangkan/v4l2loopback 建了 fork,以 v0.15.4 为基础修改,版本标记为 v0.15.4.tencent1。

补丁放在 vidioc_dqbuf() 的入口,保留原来的 MMAP 检查,只增加一个例外:

if (buf->memory != V4L2_MEMORY_MMAP &&
!(buf->memory == 0 && type == V4L2_BUF_TYPE_VIDEO_CAPTURE &&
opener->io_method == V4L2L_IO_MMAP && opener->buffer_count > 0 &&
(opener->format_token & V4L2L_TOKEN_CAPTURE)))
return -EINVAL;

这个例外要求当前文件描述符已经选择 MMAP,持有非空的捕获缓冲区,并且请求类型恰好是普通的单平面 CAPTURE。此时 I/O 方法已经确定,漏填的零值不会改变后面读取哪一类缓冲区。

通过检查后,原有捕获代码会把驱动保存的描述符复制回应用,其中包含 memory=V4L2_MEMORY_MMAP。腾讯会议拿这个结果重新入队,QBUF 就能通过原来的检查。

QBUF 没改。OUTPUT、USERPTR、DMABUF 以及未分配或已经释放的缓冲区,也不在兼容范围内。

opener->io_method 这个条件也要保留。驱动的 read() 路径内部同样会申请 MMAP 缓冲区,但随后把 I/O 方法标记成文件读写。只检查缓冲区数量和捕获类型,会把这条路径也放进来。补丁把例外限制为 V4L2L_IO_MMAP。

完整修改在补丁提交 ac04f79。

验证时不能只看返回值

最初的小程序能验证 memory 的差异,之后又把检查扩展成了仓库里的 tests/test_dqbuf_compat.c。

测试交替发送标准 MMAP 请求和零值请求,每次写入不同的 YUYV 帧。取帧后检查实际内容、返回的 memory 和缓冲区索引,再把同一描述符重新入队。这样才能发现“接口返回成功,但取到旧数据或错误缓冲区”的情况。

写测试时也踩了一个坑:为了让设备进入可捕获状态,提前写入了一帧。第一次取帧会读到这帧,不能直接跟后面刚写入的新帧比较。我先用标准 MMAP 请求读完初始帧,再开始交替测试。

边界检查覆盖了另一个文件描述符借用分配状态、释放缓冲区后取帧、OUTPUT 请求、非零的非法内存方法,以及 read() 建立的文件 I/O 状态。

原版 0.15.4 在第一次零值取帧时失败。加载补丁后,测试输出:

PASS: legacy and MMAP capture/requeue preserve frame contents; invalid memory, output, unallocated, released and file-I/O requests remain rejected

最初的小程序也通过了,零值输入得到的输出描述符已经是 MMAP:

DQBUF memory_in=0 rc=0 errno=0 (OK) memory_out=1 bytesused=153600
PASS: captured expected non-black YUYV frame

再打开腾讯会议,strace 中的 DQBUF、QBUF 已经连续返回 0,取到的是 1920×1080 的 YUYV 帧,每帧 4147200 字节。

但预览还是黑的。

第二次黑屏:取到了图像,显示不出来

先退出腾讯会议,让它释放捕获队列,再用 FFmpeg 从 /dev/video0 保存一帧。文件中能看到 OBS 的真实画面,图像已经从 OBS 经过驱动送出来了。

腾讯会议的 xcast 日志里出现了另一种错误:

eglCreateWindowSurface returned EGL_NO_SURFACE error:3005

这里的 3005 对应十六进制 0x3005,即 EGL_BAD_CONFIG。EGL 负责连接图形渲染接口与窗口系统;这次失败发生在创建用于显示的窗口表面时,跟前面的 V4L2 取帧错误是两回事。

本机环境里有 EGL_PLATFORM=wayland,而 Arch 的腾讯会议启动脚本会把 Qt 切到 X11。我先怀疑是平台混用,改用软件包提供的 /usr/bin/wemeet-x11。它会设置 X11 所需的环境,结果仍然报同样的错误。

再单独设置 QT_XCB_GL_INTEGRATION=xcb_egl,预览也没有恢复。只修正平台选择还不够。

接着把腾讯会议使用的 EGL 实现限定为 Mesa,并启用软件渲染:

Terminal window
env __EGL_VENDOR_LIBRARY_FILENAMES=/usr/share/glvnd/egl_vendor.d/50_mesa.json \
LIBGL_ALWAYS_SOFTWARE=1 /usr/bin/wemeet-x11

这次设置页显示出了 OBS 的画面。退出后从持久化启动入口重新打开,预览仍然正常。

至此,V4L2 的问题已经定位到具体漏填字段,EGL 的问题则定位到本机腾讯会议的窗口创建失败。腾讯闭源渲染器与 NVIDIA EGL 配置之间的具体缺陷还没查清;Mesa 这个办法在当前环境下管用。

软件渲染只对腾讯会议生效。它可能增加 CPU 占用,本次验证没有加入实际会议,也没有做长时间会议或性能测试。

安装修补版

使用 Arch 的 DKMS 软件包

仓库提供 packaging/arch/PKGBUILD。DKMS(Dynamic Kernel Module Support)会在安装新内核时重新构建外部模块,不必每次手动复制 .ko 文件。

先装好构建工具、DKMS 和对应内核的头文件。CachyOS 内核需要匹配的 CachyOS headers,不能用普通 linux-headers 代替。

Terminal window
git clone --branch v0.15.4.tencent1 https://github.com/lushangkan/v4l2loopback.git
cd v4l2loopback/packaging/arch
makepkg -s
sudo pacman -U v4l2loopback-tencent-dkms-0.15.4.tencent1-1-any.pkg.tar.zst

安装时会提示与 v4l2loopback-dkms 冲突,确认替换。软件包名是 v4l2loopback-tencent-dkms,内核模块仍然叫 v4l2loopback,原来的工具包可以继续使用。

本机的 7.3.0-4-cachyos 和 6.18.55-2-cachyos-lts 都完成了 DKMS 构建、签名和安装。实际加载与画面验证在前一个内核上完成,LTS 内核没有重启进去测试。

手动编译时我踩过工具链的坑。当前 CachyOS 内核用 Clang 构建,直接执行 make 却选中了 GCC,报出不支持的编译选项。手动构建需要传 LLVM=1;本机的 DKMS 构建流程已经会传入这个选项。

重新加载并检查实际版本

先关闭 OBS 虚拟摄像头,并退出所有占用设备的应用。 停止输出后可以用 fuser 看设备是否还被占用;不要强制卸载仍在使用的模块。

Terminal window
fuser -v /dev/video0

下面是本机单个虚拟摄像头的加载参数。已有多个设备或自定义编号时,应保留自己的参数:

Terminal window
sudo modprobe -r v4l2loopback
sudo modprobe v4l2loopback devices=1 video_nr=0 exclusive_caps=1 card_label="OBS Virtual Camera"
sudo udevadm settle
cat /sys/module/v4l2loopback/version
modinfo -F version v4l2loopback
dkms status

前两个版本查询应当都显示 0.15.4.tencent1。modinfo 查询磁盘里的模块,/sys/module/ 显示当前已加载的模块;安装成功以后,内存里仍可能运行旧版本,所以两处都要看。

这些 modprobe 参数只影响本次加载。已有的 /etc/modprobe.d/ 配置继续保留,不需要为了补丁改动开机设备布局。

exclusive_caps=1 会让设备在没有生产者时只报告 OUTPUT 能力,OBS 开始输出后才报告 CAPTURE 能力。腾讯会议可能在启动时缓存设备列表,使用顺序应当是先启动 OBS 虚拟摄像头,再打开腾讯会议。

仓库里的真实设备测试也可以自己运行。测试会改变设备格式和缓冲区状态,需要使用空闲的 loopback 设备:

Terminal window
cd v4l2loopback
make -C tests test_dqbuf_compat
./tests/test_dqbuf_compat /dev/video0

这里从克隆目录的上一级执行;如果刚才还在 packaging/arch,先回到仓库根目录。

有 EGL 报错时再设置 Mesa

驱动补丁处理的是取帧参数。已经正常显示画面,就不需要额外切换渲染器。

如果取帧成功但日志仍有 EGL_BAD_CONFIG,可以先用前面的 Mesa 命令临时启动。确认本机有效后,再复制桌面入口到用户目录:

Terminal window
mkdir -p ~/.local/share/applications
cp /usr/share/applications/wemeetapp.desktop ~/.local/share/applications/wemeetapp.desktop

已有同名自定义入口时,先备份再修改。把其中的 Exec= 改成:

Exec=env __EGL_VENDOR_LIBRARY_FILENAMES=/usr/share/glvnd/egl_vendor.d/50_mesa.json LIBGL_ALWAYS_SOFTWARE=1 /usr/bin/wemeet-x11 %u

保留其余字段和末尾的 %u,后者用于传递会议链接。然后更新桌面入口缓存:

Terminal window
update-desktop-database ~/.local/share/applications

这个路径和 wemeet-x11 启动器来自本次使用的 Arch wemeet-bin 软件包,其他发行版需要先确认文件是否存在。不要把这些环境变量设置成整个桌面的全局变量。

回退

驱动和渲染设置可以分别回退。关闭摄像头应用后,安装发行版原来的模块包:

Terminal window
sudo pacman -S v4l2loopback-dkms

确认替换冲突包,再按原来的参数重新加载模块,或重启系统。软件包换回去了,已经加载进内核的模块不会自动跟着替换。

要恢复腾讯会议原来的启动方式,删除此次新建的用户入口即可:

Terminal window
rm ~/.local/share/applications/wemeetapp.desktop
update-desktop-database ~/.local/share/applications

如果修改前就有自己的入口,则恢复备份,不要直接删除。

此次保留了 v0.15.4 的驱动实现,只在 DQBUF 入口兼容腾讯会议漏填的字段。以后客户端正确填写 memory,这项兼容就不再必要;EGL 的设置是否还需要保留,要单独看预览和渲染日志。

My avatar

应该没什么好看的了

不过可以的话, 请我喝杯咖啡吧


More Posts

Comments