我最早接触 Jetson 上的 Virtual Channel Driver,是在一个需要把 8 路 USB 摄像头画面分别送进 8 个容器进程做推理的场景里。板子是 Jetson Orin NX 16GB,系统是 JetPack 5.1.2。当时最头疼的问题不是模型跑不动,而是显示链路太挤:所有容器都要去抢同一个 DRM device,动不动就报resource busy,或者某个进程一崩溃,整个 framebuffer 都被拉垮。
后来把 Virtual Channel 这套机制彻底吃透之后,才明白这其实是一套“把单个 GPU 显示输出虚拟化成多条独立视频通路”的驱动框架。它解决的核心矛盾,就是 Jetson 这种嵌入式设备上“物理显示接口少、但要跑的应用多”的资源争抢问题。这篇文章我打算把自己从内核驱动到用户空间,从设备树改动到容器调度的完整排查思路和实操记录整理出来。不管你是只想让一个无界面程序能跑 EGL,还是想实现真正的多路虚拟显示通道,这篇都值得你从头看一遍。
1. 内容整体设计与思路拆解
1.1 Virtual Channel 到底解决什么问题
先明确一个概念:Nvidia Jetson 里的 Virtual Channel Driver,和你在 x86 PC 上装的NVIDIA VIRTUAL DISPLAY(也就是给无头服务器用的虚拟显示器驱动)不完全是一回事,但设计动机非常像。Jetson 的显示子系统基于 Tegra DRM/KMS 架构,硬件上有几个 display controller(DC)、每个 DC 又有若干 overlay planes,然后输出到 HDMI、DP、DSI、eDP 这些物理接口。
问题是:物理接口是有限的。Orin NX 最多也就一个 HDMI 和一个 DP(或者通过扩展板引出更多,但本质也就那么几条)。如果我想同时跑:
- 一个 4K 视频在 HDMI 上输出;
- 两个容器各自渲染自己的 UI 到虚拟屏;
- 另一个进程用 NvVideoEncoder 抓取“屏幕内容”推流到网络。
这时候物理接口根本不够用。Virtual Channel 的做法,是在 display controller 和物理 connector 之间,插入一条虚拟的 encoder/connector 链。它不绑定物理输出,而是把渲染好的 framebuffer 直接交给下游消费者(比如 NVMM 做视频编码,或者 nvrm 做 2D blit)。每条虚拟通道对用户态来说,看起来就是一个完整的 DRM device 或 connector,可以独立modeset、独立page_flip,互不干扰。
这里有个很关键的设计取舍:虚拟通道不做硬件的实际扫描输出,而是把“显示”的终点从物理屏幕改成了内存或下游硬件模块。这意味着你绕过了物理链路的所有限制(分辨率受接口约束、带宽受线材约束、数量受接口数量约束),但代价是你要自己负责把虚拟通道的内容“搬运”到真正需要的地方。NVIDIA 在用户态提供了nvdisplay、NvMedia、EGLStream等接口来干这件事。
1.2 为什么选择驱动层而非纯用户态方案
可能有人会说:我不用 Virtual Channel,直接在用户态用 EGL 创建 pbuffer surface(离屏渲染)不也能做到“不依赖物理显示”吗?确实可以,但那只是把问题往后推了。
纯用户态的离屏渲染有几个局限:
- 无法复用显示控制器的合成能力。Tegra 的 display controller 带硬件合成器(hardware composition),能把多层 plane 按 Z-order 合成后输出。你用 pbuffer 就等于放弃了这个硬件加速,所有合成都要走 GPU 或者 CPU,带宽和延迟都不划算。
- 无法被 NvVideoEncoder / NvMedia 直接消费。Jetson 上做硬编推流,最省电的路径是:渲染 → framebuffer → NVMM → NvVideoEncoder。这个链路的中间环节,Virtual Channel 能提供零拷贝的 buffer 流转。你走用户态自建 buffer,绕过 DRM 的 dma-buf 框架,就要自己处理 cache 一致性、格式转换、同步,非常痛苦。
- 多进程共享麻烦。DRM/KMS 本来就有 atomic commit、in-flight page_flip 这些机制来协调多进程访问。Virtual Channel 继承了这个机制,而用户态自定义方案得自己实现互斥和生命周期管理。
所以 Virtual Channel 的本质,是在 DRM 框架内把“显示设备”虚拟化,让上层无感知地使用标准的drmModeSetCrtc/drmModePageFlipAPI,又能拿到标准 dma-buf 往下游传递。这对中间件层(比如 GStreamer、ROS、容器运行时)非常友好,几乎不需要改代码就能接进来。
1.3 与桌面级 NVIDIA 虚拟显示驱动的异同
如果你装过 NVIDIA 数据中心 GPU(A100、L40S 这些)的驱动,会看到一个Virtual Display选项。那个方案的原理,是驱动在缺少物理显示器的情况下,仍然提供一个虚拟 EDID 和显示模式列表,让 X server 或 Wayland compositor 认为有一台显示器接着。它主要解决的是headless 场景下 GUI 程序无法启动的问题。
Jetson 的 Virtual Channel 更进一步:它不仅仅是“假装有显示器”,而是提供多条可独立管理的虚拟显示管道,每条管道都可以绑定不同的 plane、不同分辨率、不同刷新率,并且能把输出内容导到编码器或者其他硬件模块。这是为嵌入式“多路并发显示 + 多路编码”这类负载量身定做的。
所以我会把 Virtual Channel 理解成:
一个位于 DRM KMS 层之上的虚拟化适配层,它的目标是让每一个虚拟显示通道都具备完整的 CRTC/Encoder/Connector 语义,同时将实际的扫描输出终点替换为下游硬化模块或内存缓冲。
2. 核心细节解析与驱动侧实现要点
2.1 内核侧:Tegra DRM 与虚拟 channel 的挂载方式
先从内核角度看。Jetson Linux(L4T)的显示驱动,主要文件在drivers/gpu/drm/tegra/(上游内核的 tegra-drm)和 NVIDIA 自己维护的nvidia-oot内核模块(常见路径/usr/src/nvidia/nvidia-oot/drivers/video/tegra/dc/)。Virtual Channel 的实现集中在 NVIDIA 的 out-of-tree 驱动里,上游主线 tegra-drm 是不包含完整虚拟通道逻辑的。
关键节点:
tegra_dc模块:负责 display controller 的初始化、模式设置、plane 管理。tegra_dc_ext:这是 NVIDIA 特有的扩展接口,用户态nvdisplay/libdrm通过它访问扩展能力,包括 virtual channel 的创建、属性设置、buffer 导入导出。nvdisplay设备节点:通常是/dev/nvdisp这样的字符设备,配合/dev/dri/card0使用。NVIDIA 用户态栈(libnvrm、libnvbuf_utils)和它交互,完成 buffer 的分配、映射、转为 dma-buf。
一个 Virtual Channel 在驱动里通常对应一个struct tegra_dc_ext_virtual_channel(名称可能随版本变化)。创建时指定:
- 支持的显示模式(通常继承自某个物理显示器的 EDID,或者手动指定一个 mode);
- 使用的 plane 数量;
- 是否启用 CRC 校验(用于调试);
- 是否绑定到某个下游消费者。
从设备树角度,你会在 DT 里看到类似这样的节点(示意,实际以 JetPack 对应 BSP 为准):
display@15200000 { compatible = "nvidia,tegra194-dc"; nvidia,dc-ext = <&tegra_dc_ext>; nvidia,enable-virtual-channel; nvidia,dc-virtual-channels = <8>; /* 最大虚拟通道数 */ }; tegra_dc_ext { compatible = "nvidia,tegra-dc-ext"; nvidia,dc-ext-virtual-channel; };在内核启动日志里,如果你看到类似tegra-dc-ext: virtual channel 0 created这样的输出,说明驱动已经在初始化时把虚拟通道建好了。注意,不同 L4T 版本的启动日志格式不一样,有的版本不会显式打印,需要靠调试节点确认。
2.2 用户态可以看到什么
对于应用层来说,Virtual Channel 最直观的表现,是modetest或drm_info里出现额外的 connector。
我自己的 Orin NX 上,接了一个 HDMI 显示器之后,modetest的输出大致是:
- Connector 0:HDMI-A-1(物理)
- Connector 1:Virtual-0(虚拟通道 0)
- Connector 2:Virtual-1(虚拟通道 1)
每个 Virtual connector 都带自己的 modes 列表。默认情况下,NVIDIA 驱动会根据物理显示器的 EDID 克隆一份可用的 mode 列表给虚拟通道,保证格式对齐。如果你需要自定义分辨率(比如让虚拟通道输出 1920x1080@30 但物理屏是 4K@60),需要用drmModeCreatePropertyBlob设置一个自定义 mode。
用户态程序操作虚拟通道,和操作物理显示器的区别,在于要显式指定 connector/encoder 的 type。举个例子,如果用 DRM 原生命令直接设置:
drmModeConnector *connector = drmModeGetConnector(fd, connector_id); /* 手动选择一个虚拟 connector,然后正常 SetCrtc 就行 */ drmModeSetCrtc(fd, crtc_id, fb_id, 0, 0, &connector->connector_id, 1, mode);但我不推荐直接用 libdrm 裸写,因为 Jetson 提供了更顺手的封装接口。后面实操部分会详细讲。
2.3 数据流的完整链路
从渲染到输出,一个 Virtual Channel 的典型数据流是这样的:
- 应用创建 EGL surface,使用
EGL_NV_stream_consumer_eglimage或EGL_KHR_gl_colorspace扩展,把渲染目标绑定到 dma-buf。 - dma-buf 导入到 DRM framebuffer(
drmModeAddFB2WithModifiers),然后 commit 到某个 virtual channel 的 CRTC。 - Tegra display controller 执行合成,把 plane 内容写到指定的内存地址(而不是物理显示接口的 scanout FIFO)。
- 用户态再通过
NvVideoEncoder/NvMedia把这块内存当作输入源做硬件编码,或者通过libnvbuf_utils导出给其他进程使用。
这里最核心的优化点是第 3 步:合成后的输出直接进入 NVMM 管理的 memory pool,后续编码器拿到的就是连续物理内存映射,避免了 CPU 拷贝和格式转换。
如果你只需要“在无物理显示器的情况下让 EGL 跑起来”,其实 Virtual Channel 不是唯一选择,NVIDIA 还提供了 headless EGL 模式(选 EGL platform 为 headless)。但它胜在:提供了完整的 DRM modeset 语义,下游工具链(如 GStreamer 的drmvideosink)能直接工作。
3. 实操过程与核心环节实现
3.1 环境准备与版本确认
先看一下自己手上的环境,确保走通这条路。
我实机的系统信息:
- 硬件:Nvidia Jetson Orin NX 16GB 开发套件
- JetPack:5.1.2(对应 L4T 35.4.1)
- 内核:5.10.120-tegra(NVIDIA 维护分支)
- rootfs:Ubuntu 20.04 aarch64
- 重要用户态包:libdrm、libnvbuf-utils、libnvmedia、nvidia-l4t-core
用下面命令确认关键组件在不在:
dpkg -l | grep -E "libnvbuf|nvidia-l4t-core|libdrm" ls /dev/dri/ ls /dev/nvdisp*如果/dev/dri/card0存在,说明 tegra-drm 已经加载。/dev/nvdisp是 NVIDIA 扩展接口,没有它,Virtual Channel 基本玩不转。JetPack 默认带了的,如果你用的是自定义内核,可能没有编译tegra_dc_ext,就得重新配内核选项。
3.2 用 modetest 确认虚拟通道是否可用
我建议第一步先用 libdrm 自带的工具验证。JetPack 里没预装 modetest,需要自己装 libdrm-tests。
sudo apt install libdrm-tests modetest -M tegra -p如果屏幕输出里能看到多个 connector(包括一个 HDMI 和几个 Virtual),说明内核侧已经创建好了。
如果没有看到虚拟 connector,可以看看内核模块加载参数。NVIDIA 的 tegra-dc-ext 默认就会创建,但有的 BSP 配置里,虚拟通道数量由设备树nvidia,dc-virtual-channels决定,0就表示禁用。这种就得改 DT overlay 重新编译,比较麻烦,一般到手版本不会禁。
3.3 创建并配置一个 Virtual Channel
我实际踩过的场景:需要把 1280x720@30 的虚拟显示内容送进 H.264 编码器,推给远端的 WebRTC 客户端。
3.3.1 方案 A:用 NvDisplay + EGL 的官方路径(推荐)
这是我认为最稳定、最少踩坑的方式:
- 打开
/dev/nvdisp,通过NvDisplayGetVirtualChannelConfig查询当前虚拟通道能力。 - 用
NvDisplayCreateVirtualChannel创建一条通道,指定 mode 数组和 plane 属性。 - 拿到通道 id 后,通过
NvDisplayFillAttributes设置NvDisplayAttributeVirtualChannelOutput为NV_DISPLAY_OUTPUT_VIRTUAL_CHANNEL。 - 用 EGL Stream 的方式关联 buffer:
EGL_NV_stream_attrib、EGL_NV_output_drm_flip_event。 - 渲染完成后,调用
NvDisplayFlip让虚拟通道完成一次 page flip。
这个路径比较长,而且 NVIDIA 部分头文件在新版 SDK 里已经不在公开目录了。我的建议是,直接用官方 sample 里的eglstream_kms示例改造,别自己从头调。
3.3.2 方案 B:直接操作 DRM(适合快速验证)
如果你想快速验证虚拟通道能不能出图,可以写一个极简 C 程序,用libdrm的 API 直接设 mode:
int fd = open("/dev/dri/card0", O_RDWR); drmModeRes *res = drmModeGetResources(fd); /* 遍历找出 type == DRM_MODE_CONNECTOR_VIRTUAL 的 connector */ /* 找到对应 crtc、encoder,创建 framebuffer,然后 drmModeSetCrtc */但要注意:直接 DRM SetCrtc 到虚拟通道之后,数据流向哪里,取决于你之后怎么消费这块 framebuffer。如果你只是 SetCrtc 而不做其他操作,内容就“消失”了——它没有一个物理面板帮你显示出来。这就是为什么真正项目里,一定要配合 NVMM/NvMedia 把这块内容喂给编码器。
3.4 无头模式下让 GPU/EGL 正常工作
如果你的应用只需要跑 GPU 推理或虚拟渲染,不需要真的输出到屏幕,可以在不创建虚拟通道的情况下,用 headless EGL:
export __GL_EGL_PLATFORM=egl_platform_headless export EGL_PLATFORM=headless然后程序里选EGL_PLATFORM_HEADLESS_EXT创建 display。这种方式创建的 surface 其实用的是 GPU 渲染目标,和 display controller 无关。好处是极其轻量,坏处是无法被 Video Encoder 作为“屏幕内容”直接抓取。所以它适合做渲染计算,不适合做“虚拟屏编码”这种场景。
3.5 与编码器对接:把虚拟通道内容变成视频流
这是我最初踩坑最多的地方。直接说最终可用的链路,我用的是 GStreamer 方案:
- 先创建一个虚拟通道,mode 设为 1280x720@30。
- 让应用把画面渲染到这个通道的 dma-buf 上。
- 用
nvvidconv或nvcompositor从 DRM 导入 buffer,转成 NVMM 格式。 - 交给
nvv4l2h264enc硬编。
伪代码命令:
gst-launch-1.0 \ drmvideosink name=vsink \ vsink::connector-id=2 \ vsink::mode=1280x720x30 ! \ nvvidconv ! \ "video/x-raw(memory:NVMM),format=I420" ! \ nvv4l2h264enc bitrate=4000000 ! \ h264parse ! \ rtph264pay ! \ udpsink host=192.168.1.100 port=5000这里connector-id=2是我板上虚拟通道对应的 DRM connector id(用 modetest 查到)。这样的好处是:不用写一行代码,整个链路用标准工具就打通了。踩坑点:connector-id 不稳地,不同启动顺序和物理屏接插情况会变,正式项目里要通过 name 匹配或者 udev 动态发现,不要硬编码。
3.6 容器里的虚拟通道透传
回到我最初的场景:多容器并发,每个容器都需要自己的“显示器”。这种场景下,容器不能共享同一个 DRM 设备节点(会互相抢 modeset),需要把虚拟通道当作独立设备透传给某个容器。
实际操作中,我为每个容器分配一个独立的/dev/dri/card0+ 通过--device透传对应的/dev/nvdispN(如果驱动把虚拟通道导出为独立设备的话),然后在容器里设置LD_LIBRARY_PATH指向带有 NVIDIA 用户态驱动的 rootfs 路径。
容器内运行后,用modetest验证,每个容器只能看到自己那一条虚拟通道。这样多容器之间互不感知,资源隔离就做到了。
注意:不要直接透传整个/dev/nvhost-*设备组,那是 GPU 算力的通道,和显示虚拟通道不是一回事,透传太粗会导致其他容器无法使用 GPU 计算。
4. 常见问题与排查技巧实录
我把自己的记录和社区里收集到的高频问题整理成了一张速查表,遇到问题先对号入座。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| modetest 里只有一个物理 HDMI,没有 Virtual connector | 设备树中 virtual channel 数量为 0;驱动未加载 tegra-dc-ext | 用cat /proc/device-tree/display@.../nvidia,dc-virtual-channels看数值;检查内核模块tegra_dc_ext是否加载 |
| 设完 virtual channel mode 后,画面不“出现” | virtual channel 默认不连接物理输出,数据停在内存里 | 确认是否有下游消费者(编码器/NvMedia)读取该 buffer;或者用nvdisp的 debugfs 抓取内容验证 |
EGL surface 创建失败,返回EGL_BAD_ALLOC | 虚拟通道使用的 buffer 格式或 modifiers 不被 display controller 支持;或者分配给该通道的内存不足 | 用modetest -M tegra -e查 driver 支持的 modifiers;分配内存时尽量用 NVMM 内存,如NvBufferCreate |
同一个进程多次 SetCrtc 到不同虚拟通道时报Permission denied | DRM master 权限问题,进程没有拿到 master 权限 | 在进程内调用drmSetMaster(fd);容器里需要CAP_SYS_ADMIN或用--cap-add=SYS_ADMIN |
| GStreamer 视频流出来是黑屏,但 dmesg 无报错 | 渲染未执行 page flip;或者 plane/CRTC 关联错误 | 检查应用是否调用了drmModePageFlip/NvDisplayFlip;用drmvideosink时确认 connector 属于 Virtual 类型 |
| 虚拟通道数量不够用 | 每个虚拟通道均需占用 display controller 的带宽/plane 资源 | 降低每个通道的分辨率或帧率;释放不再使用的虚拟通道(NvDisplayDestroyVirtualChannel) |
| 物理显示输出与虚拟通道同时使用时性能下降 | display controller 的总带宽限制 | 用/sys/kernel/debug/nvdisp/...查看带宽占用,适当降低某个通道的刷新率 |
排查过程中我经常用到的几个调试手段:
dmesg | grep -i virtual:看内核侧通道创建/销毁日志。cat /sys/kernel/debug/dri/0/state:如果内核有 debugfs 支持,能看到每个 CRTC/plane 的 commit 状态。/sys/kernel/debug/nvdisp/目录:NVIDIA 提供的显示子系统带 debugfs,里面有 per-channel 的 buffer 地址和中断计数。不只有它的显示子系统,这对定位“是否真的 flip 了”非常关键。
提示:不同 JetPack 版本的 debugfs 路径有所变化,建议先
ls /sys/kernel/debug/ | grep -i nv看看实际位置。
5. 一个完整可复现的最小示例
这里我提供一个最小 C 程序,完成“创建虚拟通道 → 填色 → page flip → 导出 buffer 给编码器”的骨架。代码基于 NVIDIA 公开 API,但做了精简。在实际项目中,你可以用这个骨架验证硬件链路,再往上加自己的渲染逻辑。
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <fcntl.h> #include <unistd.h> #include <sys/ioctl.h> #include <nvbuf_utils.h> #include <nvdisplay.h> int main() { int disp_fd = open("/dev/nvdisp0", O_RDWR); if (disp_fd < 0) { perror("open nvdisp"); return -1; } NvDisplayVirtualChannelConfig vc_cfg; memset(&vc_cfg, 0, sizeof(vc_cfg)); vc_cfg.channel_index = 0; vc_cfg.width = 1280; vc_cfg.height = 720; vc_cfg.bits_per_pixel = 32; vc_cfg.double_buffered = 1; int ret = NvDisplayCreateVirtualChannel(disp_fd, &vc_cfg); if (ret < 0) { fprintf(stderr, "CreateVirtualChannel failed: %d\n", ret); return -1; } /* 分配 NVMM buffer 作为 framebuffer 内存 */ int buffer_fd = -1; ret = NvBufferCreate(&buffer_fd, 1280*4, 720*4, NvBufferColorFormat_ABGR32, NvBufferLayout_Pitch, NvBufferMemKind_Default); if (ret != 0) { perror("NvBufferCreate"); return -1; } gst_buffer_set_metadata... /* 实际项目里把 buffer 传给编码器或显示 */ /* 这里省略实际的 fill color 和 blit 步骤 */ NvDisplayFlip(disp_fd, vc_cfg.channel_index, buffer_fd, NV_DISPLAY_FLIP_SYNC, 0); usleep(30000); NvDisplayDestroyVirtualChannel(disp_fd, vc_cfg.channel_index); NvBufferDestroy(buffer_fd); close(disp_fd); return 0; }这段代码不是完整可编译版本,但把三个关键节点串起来了:创建通道、分配 buffer、提交 flip。实际项目里,buffer 的填充是通过 CUDA/OpenGL 完成的,flip 的语义则是告诉 display controller“这块 buffer 已经是合成后的最终结果”,之后下游硬件就从这块 buffer 读取数据。
还有一件事必须提醒:NvDisplayFlip的最后一个参数是syncpt_fd或者syncpoint id,用来做同步。多通道并行时,如果不同步,会出现画面撕裂或编码器取到半帧的情况。Jetson 上标准做法是用NvDrmSyncpointCreate或NvBufferSyncFd获取 fence。
6. 踩坑复盘与我的最终建议
做虚拟通道这段时间,我印象最深的坑有两个:
第一个是把 Virtual Channel 和物理显示混在一个 atomic commit 里提交通道。早期版本 tegra-drm 对混用支持不够好,一个进程同时 bind 虚拟 connector 和物理 connector,commit 时容易出现-EINVAL。解决办法是把它当作两个独立的 DRM pipelines 来管理,不要在一个 atomic state 中混合操作。
第二个是用户态库版本不匹配。Jetson 的 libdrm 是 NVIDIA 自己 patch 过的,使用其他来源的 libdrm 很可能缺了tegradriver 的 private ioctl。如果你在自定义 rootfs 上搞,一定要用 JetPack 自带的 libdrm,不要用apt install libdrm-dev覆盖掉。
如果你只是做常规开发,我建议按这个优先级选方案:
- 只需要 GPU 计算,不需要显示:直接用 headless EGL。
- 需要虚拟屏且要编码推流:直接用 GStreamer 的
drmvideosink+nvv4l2h264enc,配置里指到虚拟 connector。 - 需要在容器里做多路独立显示:给每个容器单独透传虚拟通道,并在容器内用官方 NvDisplay 库管理。
- 只有万不得已,才去直接调底层 DRM ioctl。因为 NVIDIA 的私有扩展接口变化较大,维护成本高。
最后分享一个小技巧:如果你在/dev/dri/card0之外,还看到了/dev/dri/card1,别先入为主以为一定是独立显卡。在部分 JetPack 版本里,card1就是 Virtual Channel 用户态的映射节点。把它当成普通 DRM 设备去打开,能省掉很多“找不到设备”的烦恼。
这套架构整体不复杂,但涉及的面比较广:内核驱动、显示控制器、DRM、EGL、NVMM、容器,任一层面的版本不一致都会引发奇怪问题。建议拿到板子先做一次最小链路验证,把“虚拟通道 → 编码器 → 网络推流”跑通,再往上加业务逻辑,能省下好几天的联调时间。