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-capvideo2: 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=0FAIL: captured expected non-black YUYV frameDQBUF memory_in=1 rc=0 errno=0 (OK) memory_out=1 bytesused=153600PASS: 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,xmm02995c3: movups XMMWORD PTR [rbx+0x40],xmm02995c7: movups XMMWORD PTR [rbx+0x30],xmm02995cb: movups XMMWORD PTR [rbx+0x20],xmm02995cf: movups XMMWORD PTR [rbx+0x10],xmm02995d3: movups XMMWORD PTR [rbx],xmm02995d6: mov QWORD PTR [rbx+0x50],0x02995de: mov DWORD PTR [rbx+0x4],0x12995e5: mov r15d,DWORD PTR [r14+0x40]2995f0: mov rsi,0xffffffffc05856112995f7: xor eax,eax2995f9: mov edi,r15d2995fc: mov rdx,rbx2995ff: 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=153600PASS: 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,并启用软件渲染:
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 代替。
git clone --branch v0.15.4.tencent1 https://github.com/lushangkan/v4l2loopback.gitcd v4l2loopback/packaging/archmakepkg -ssudo 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 看设备是否还被占用;不要强制卸载仍在使用的模块。
fuser -v /dev/video0下面是本机单个虚拟摄像头的加载参数。已有多个设备或自定义编号时,应保留自己的参数:
sudo modprobe -r v4l2loopbacksudo modprobe v4l2loopback devices=1 video_nr=0 exclusive_caps=1 card_label="OBS Virtual Camera"sudo udevadm settlecat /sys/module/v4l2loopback/versionmodinfo -F version v4l2loopbackdkms status前两个版本查询应当都显示 0.15.4.tencent1。modinfo 查询磁盘里的模块,/sys/module/ 显示当前已加载的模块;安装成功以后,内存里仍可能运行旧版本,所以两处都要看。
这些 modprobe 参数只影响本次加载。已有的 /etc/modprobe.d/ 配置继续保留,不需要为了补丁改动开机设备布局。
exclusive_caps=1 会让设备在没有生产者时只报告 OUTPUT 能力,OBS 开始输出后才报告 CAPTURE 能力。腾讯会议可能在启动时缓存设备列表,使用顺序应当是先启动 OBS 虚拟摄像头,再打开腾讯会议。
仓库里的真实设备测试也可以自己运行。测试会改变设备格式和缓冲区状态,需要使用空闲的 loopback 设备:
cd v4l2loopbackmake -C tests test_dqbuf_compat./tests/test_dqbuf_compat /dev/video0这里从克隆目录的上一级执行;如果刚才还在 packaging/arch,先回到仓库根目录。
有 EGL 报错时再设置 Mesa
驱动补丁处理的是取帧参数。已经正常显示画面,就不需要额外切换渲染器。
如果取帧成功但日志仍有 EGL_BAD_CONFIG,可以先用前面的 Mesa 命令临时启动。确认本机有效后,再复制桌面入口到用户目录:
mkdir -p ~/.local/share/applicationscp /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,后者用于传递会议链接。然后更新桌面入口缓存:
update-desktop-database ~/.local/share/applications这个路径和 wemeet-x11 启动器来自本次使用的 Arch wemeet-bin 软件包,其他发行版需要先确认文件是否存在。不要把这些环境变量设置成整个桌面的全局变量。
回退
驱动和渲染设置可以分别回退。关闭摄像头应用后,安装发行版原来的模块包:
sudo pacman -S v4l2loopback-dkms确认替换冲突包,再按原来的参数重新加载模块,或重启系统。软件包换回去了,已经加载进内核的模块不会自动跟着替换。
要恢复腾讯会议原来的启动方式,删除此次新建的用户入口即可:
rm ~/.local/share/applications/wemeetapp.desktopupdate-desktop-database ~/.local/share/applications如果修改前就有自己的入口,则恢复备份,不要直接删除。
此次保留了 v0.15.4 的驱动实现,只在 DQBUF 入口兼容腾讯会议漏填的字段。以后客户端正确填写 memory,这项兼容就不再必要;EGL 的设置是否还需要保留,要单独看预览和渲染日志。