ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

VirtIO GPU虚拟化显示全解析:从协议到内核驱动实践

VirtIO GPU虚拟化显示全解析:从协议到内核驱动实践

我第一次认真啃 VIRTIO_GPU 内核驱动,起因是真被虚拟机显示折腾麻了。那时候开一台图形桌面虚机,要么分辨率卡在 1024×768,要么 3D 加速永远是个摆设,换个显示设备就得重新调一堆参数。后来把 VirtIO GPU 这套链路从内核驱动摸到宿主侧实现,才发现虚拟机显示这件事并没有想象中那么玄乎——它只是把"显卡"重新设计成了一个靠队列通信的协议设备。

这篇文章适合三类人看:一类是搞桌面虚拟化、云桌面或者自动化测试环境的人,想给虚机配一个能用的显示方案;一类是想读内核 DRM 代码但找不到下手的点,想通过 virtio_gpu 这个不算太大的驱动入门;还有一类是纯踩坑型选手,虚机黑屏、分辨率不对、渲染慢,想快速定位问题。我不会只给结论,会把协议、内核侧代码、宿主侧配置和调试手段整条链路都拆开来讲。

1. 虚拟机显示为什么这么难搞:VirtIO GPU 的定位与出场逻辑

1.1 全模拟显卡的三个老毛病

先回到最原始的问题:虚拟机的显卡到底难在哪?

传统思路是全模拟。宿主侧用软件实现一个老式 VGA 设备,客户机里的旧兼容驱动直接操作 MMIO 寄存器、读显存端口。好处是任何系统的 BIOS 阶段都能亮起来,但坏处非常明显。

第一是带宽低。老式 VGA 的显存访问路径在虚拟化环境下要走一圈内存模拟,一帧 1080p 画面倒腾到宿主显示后端,CPU 要参与大量位拷贝和像素格式转换,体验就是"鼠标都飘"。第二是分辨率差。传统 VGA 能跑到 1280×1024 就算不错,很多场景连宽屏都开不了。第三是功能割裂。没有 KMS、没有硬件光标、没有多屏概念,现代桌面系统根本不愿意配合,往往只能跑在兼容 framebuffer 模式。

市面上出现过不少改良方案,比如基于 spice 协议的显卡设备、增强型 VGA。它们比老 VGA 强,但本质上还是"宿主软件渲染 + 位块传输"那套路子,3D 无从谈起。

1.2 半虚拟化:让客户机自己承认"我在虚拟机里"

VirtIO 的思路完全不同。它默认客户机里的驱动知道自己在虚拟环境里,于是双方定好一套显式协议,通过共享内存和队列来传输数据,而不是去模拟一个真实硬件的行为。

VirtIO GPU 就是这块虚拟显卡的名字。它不是一个逼真假显卡模拟器,更像一个"显卡业务接口":客户机驱动把图像资源、绘制命令、光标动作包装成标准消息,丢进 virtqueue;宿主侧设备把它翻译成实际显示输出。

这种设计的价值是双重的。对客户机来说,驱动不需要处理一堆奇奇怪怪的寄存器组合,代码路径笔直,出错容易排查;对宿主侧来说,不需要维护复杂的 MMIO 模拟逻辑,接收协议消息后可以直接走现代图形栈。

1.3 显示设备选型一览

我整理了一张常用虚拟显示设备的对照,方便你根据场景选择:

设备方案数据路径3D 能力适合场景
老式 VGAMMIO 模拟无UEFI/BIOS 阶段兜底
增强型 VGABorch 风格 MMIO无老系统兼容
spice 协议显卡spice 通道软件渲染无古老远程桌面场景
VirtIO GPUvirtqueue 协议命令可通过 VirGL/Venus 开启现代桌面虚机、CI 测试、轻量云桌面
物理 GPU 直通硬件直接映射完整原生高性能图形、专业渲染

强调一下:我的经验是,新项目直接用 VirtIO GPU 作为主力显示设备,保留一个老式 VGA 端口做 BIOS 兜底即可。别再把增强型 VGA 当默认选项了,它省下的那点配置时间会在后续驱动兼容性上全部赔回去。

2. 一块虚拟显卡的自我修养:从资源到扫描输出的协议链

很多人上来就翻 virtio_gpu 内核驱动的 C 代码,结果一头雾水。我建议先理解协议层的工作方式,驱动代码只是协议的一个客户端实现。

2.1 最核心的对象:资源

VirtIO GPU 协议里最基础的对象叫"资源"。可以把它理解成一张宿主侧显存区域,客户机往里灌像素,宿主往出送画面。

创建资源时用RESOURCE_CREATE_2D,带上宽度、高度、像素格式;如果要走 3D 路线,还有RESOURCE_CREATE_3D和更现代的 blob 资源。blob 资源有点像一个可以直接被宿主和客户机共享的缓冲区对象,客户机上拿到 fd,宿主上对应一段真实显存或系统内存,省去了多次拷贝。现代内核驱动里,这部分已经和 TTM 内存管理整合得比较深。

2.2 命令是怎么传的:控制队列与光标队列

设备默认有两个 virtqueue:一个是控制队列,一个是光标队列。控制队列处理所有重活,比如资源创建、数据搬运、扫描输出设置;光标队列只处理两件事——移动光标、更新光标图形。之所以拆开,是因为光标更新频率极高,给它一条专用快车道可以避免和其他命令互相排队。

每条命令就是一个固定头部加可变负载的结构,客户机往里填参数后,通过 virtqueue 通知宿主。宿主在另一端解包、执行,如果有结果,还会通过队列把响应送回去。整个过程没有任何 MMIO 模拟层面的像素搬运。

2.3 一帧画面的完整旅程

我经常用"寄画"来类比这个过程。

客户机里的应用程序渲染出画面后,会把它放进一块客户机内存。内核驱动把这个内存区域附加成资源的后端缓冲,然后发一个TRANSFER_TO_HOST_2D消息,相当于"我把画寄到展厅"。宿主收到后,把资源里的像素同步到自己的显示后端。接着驱动发FLUSH让宿主触发重绘,再通过SET_SCANOUT把资源绑定到某个扫描输出端口上,展厅就把这幅画摆上展架了。

如果只改了一小块区域,驱动可以只传输脏矩形,而不是整帧重新搬运。这个细节对远程桌面场景的带宽影响非常大。

2.4 上下文与功能集协商

3D 场景下还有一个关键概念叫上下文。客户机创建一个渲染上下文,指定使用哪套功能集。常见的几个功能集 ID 是老牌的 VirGL、较新的 VirGL2,以及 Vulkan 路线的 Venus。

功能集协商有点像上车前先看清这是什么车型:客户机声明自己支持哪个版本的协议能力,宿主侧同意后,双方才开始传输对应的命令流。协商失败的表现通常是 3D 功能静默降级,或者干脆报上下文创建失败,这是后期排查 3D 问题时首先要确认的点。

3. 内核驱动文件里的门道:virtio_gpu 如何长在 DRM 上

看完协议,再回来看内核代码就会轻松很多。virtio_gpu 驱动挂在 Linux 的 DRM 子系统下,这意味着它对外暴露的是标准 DRM 接口:/dev/dri/card0给显示管理,/dev/dri/renderD128给渲染。上层无论跑 X 还是 Wayland,认识的都是一套统一 API。

3.1 驱动骨架与加载条件

驱动在drivers/gpu/drm/virtio/目录下,核心文件包括了设备管理、命令队列、显示逻辑、GEM 对象等模块。内核配置项是CONFIG_DRM_VIRTIO_GPU,可以编成模块或编进内核。

驱动的匹配依赖 VirtIO 设备 ID。VirtIO 标准里显卡设备的 ID 是 16,也就是VIRTIO_ID_GPU=16,id_table 里声明这个 ID 后,probe 函数会在虚拟总线发现对应设备时被调用。加载后可以用lsmod | grep virtio_gpu确认,也可以在dmesg里看到类似 virtio_gpu 设备初始化的日志。

3.2 内存管理:GEM 与共享页

我不展开讲 DRM 内存管理的全部,只讲 virtio_gpu 最特别的地方:客户机里的显存对象本质上就是一段由共享内存构成的 GEM 对象。

普通真实显卡的显存需要 DMA 映射、可能要走 IOMMU;而 virtio_gpu 的 GEM 对象创建后,驱动拿到对象的散列表,通过RESOURCE_ATTACH_BACKING把这段内存告诉宿主。宿主直接把它当作资源的内容来读取。翻译一句就是:客户机分配普通内存,宿主直接读这块内存当显存,两边通过协议协调一致性。

这对理解性能很有帮助。你看到的"显存带宽"瓶颈,往往不在内核驱动本身,而在宿主侧如何把这段共享内存储存到实际显示表面,以及显示后端走的是软件合成还是硬件合成。

3.3 KMS 显示部分

显示部分由 DRM 的 KMS 接口包着。驱动注册一个或多个 connector,客户机通过标准 DRM ioctl 查询支持的模式。

virtio_gpu 的取模式逻辑是通过GET_DISPLAY_INFO向宿主要显示参数。如果宿主侧没配好,客户机得到的显示模式就会非常朴素,通常只有一个固定分辨率。另外,光标的显示走独立的 cursor plane,内核驱动把光标位置和图像通过 cursorq 发送,这样宿主可以快速更新光标,不需要整帧重传。

3.4 与传输层的关系

virtio_gpu 本身不关心自己跑在哪种 virtio 传输上。同一个驱动代码既可以在 PCI 形态下工作,也可以跑在其他 virtio 运输实现上。PCI 形态下现代设备走的是 virtio 1.0 之后的标准,具备 MSI-X 中断和多队列特性;老式实现则会退回兼容模式。

这个抽象对排查问题的影响是:如果遇到中断丢失或队列通知异常,先确认传输层是否正常,别一股脑钻进显卡协议里找原因。我见过有人查了几天花屏,最后发现是宿主侧中断路由配置不对。

4. 动手配置一台带 VirtIO GPU 的虚拟机

原理说够了,实际操作一下。

4.1 客户机内核要求

首先客户机内核必须开启CONFIG_DRM_VIRTIO_GPU。发行版的内核普遍默认开启,但精简内核或嵌入式内核要手动确认。如果没有,可以在启动命令里临时加载:

modprobe virtio_gpu

加载后应该能看到/dev/dri/card0和随机的 render 节点。如果只有 card0 没有 render 节点,多半是内核选项里把渲染节点关了,或者宿主设备创建时没启用 3D 能力。

4.2 宿主侧设备配置

启动虚拟机时,宿主侧有很多种方式把 VirtIO GPU 挂上去。命令行里最直接的写法是:

qemu-system-x86_64 \ -m 4096 \ -smp 4 \ -device virtio-vga,xres=1920,yres=1080,max_outputs=2 \ -display gtk

这里用virtio-vga的原因是它自带 VGA 兼容逻辑,可以在早期引导阶段也提供显示;如果直接用virtio-gpu,有些发行版的引导期会短暂黑屏,直到内核加载 DRM 驱动后才亮起来。

xres和yres是宿主要求的分辨率上限,max_outputs是虚拟显示器的数量。多显示器用户把max_outputs设成 2 或 3,客户机桌面系统就能识别出多个输出。

如果要开 3D,宿主侧需要换用带 GL 能力的设备变体,类似virtio-vga-gl这种。这类设备内部接到宿主自己的图形栈,用真实 OpenGL 上下文去执行客户机发来的渲染指令。

4.3 验证配置是否生效

启动进入系统后,用下面几招快速判断:

lspci -nnk | grep -i -A3 vga

看到 VGA 设备关联的驱动是 virtio-gpu,说明设备识别正常。再看 dmesg:

dmesg | grep -i virtio

正常能看到 virtio_gpu 设备初始化、队列创建、DRM 设备注册的日志。想确认客户机能识别多少分辨率,可以查:

cat /sys/class/drm/card0/modes

如果列出的模式和你宿主要求的分辨率对不上,优先排查宿主侧 xres/yres 参数以及显示后端的能力。

5. 3D 加速的两种路线:VirGL 与 Vulkan-over-VirtIO

2D 显示只是 VirtIO GPU 的一半价值,另一半是 3D 加速。这里有两套主流玩法,很多人没搞清楚区别。

5.1 VirGL:让客户机的 OpenGL 命令在宿主演"翻译"

VirGL 的设计思路很直接:客户机里的 Mesa 图形栈不再自己执行 GL 操作,而是把 GL 函数调用翻译为一条序列化的命令流,通过 virtio-gpu 的 3D 资源提交给宿主。宿主侧有一个渲染器库,把命令流还原成真正的 OpenGL 调用,在宿主 GPU 上执行。

这样做的好处是客户机不需要真正的 GPU 驱动,普通集成显卡的宿主也能支撑,本质上宿主的 GPU 成了共享渲染资源。客户机里跑glxinfo如果看到渲染器字符串是 VirGL 相关字样,就说明路径通了。

需要注意一个容易被忽略的点:宿主的 OpenGL 版本直接影响 VirGL 能暴露给客户机的能力。宿主只有 GL 3.3,客户机里就别指望能把 OpenGL 4.5 的应用跑起来。升级宿主图形栈往往比调客户机参数更立竿见影。

5.2 Venus:把 Vulkan 也搬上 virtqueue

VirGL 只覆盖 OpenGL,Vulkan 应用需要另一条路。Venus 方案把 Vulkan 命令流通过 virtio-gpu 的上下文能力传给宿主,宿主翻译成真实 Vulkan 提交。

它对宿主的要求比 VirGL 更高,需要宿主具备可用的 Vulkan 驱动和对应渲染器支持,同时客户机里 Mesa 版本也不能太老。配置方法以客户机 Mesa 的加载器驱动选择为主,不同发行版有差异,核心是让 Vulkan ICD 指向 virtio 相关的加载库。

5.3 性能预期管理

我见过的最大问题不是跑不起来,而是期望错位。给一张"性能分层"的参考:

模式典型效果说明
VirGL 2D 桌面流畅日常桌面完全够用
VirGL 简单 3D可接受适合简单 GL 应用、轻量 UI
Venus/Vulkan视宿主 GPU 而定能跑一些图形 demo,但不是打大型游戏的方案
物理 GPU 直通接近原生高性能场景的唯一选择

虚拟化图形永远有开销,VirtIO GPU 的目标是把开销控制在合理范围,而不是替代物理显卡。选型时想清楚自己的应用究竟需要哪一层能力。

6. 我踩过的坑和调试套路

最后分享几个真实踩过、也在社区里反复见到的问题,以及对应的排查链路。

6.1 黑屏:先分清是引导期还是驱动期

宿主导入后客户机屏幕一片黑,很多人第一反应是显卡配置错了。我的排查顺序是:先看 dmesg 里有没有 virtio_gpu 初始化的记录。完全没有,说明设备都没被内核认出来,检查宿主设备参数和内核选项;有初始化记录但屏幕还是黑,基本是客户机显示栈的问题,比如 X/Wayland 起了但帧缓冲没对上。

临时应急可以给内核命令行加video=参数强制指定输出模式,先把系统拉起来再逐步分析。

6.2 分辨率只有 1024×768

客户机拿不到 EDID 时,DRM 会退回到保守分辨率。VirtIO GPU 路径下,最常见原因是宿主侧没有配置 xres/yres,或者显示后端本身能力受限。确认方向是宿主侧参数 > 显示后端 > 客户机模式列表。

如果开了多屏,一定检查max_outputs。默认是 1,客户机只会看到一个 output,折腾半天多屏是没用的。

6.3 3D 静默降级成软件渲染

客户机里 glxinfo 显示渲染器是软件渲染器,而不是 VirGL,这说明 virtio-gpu 的 3D 上下文没协商成功。可能的环节包括:宿主侧设备没有启用 GL 能力、宿主渲染器库缺失、客户机 Mesa 驱动选择不对。

排查链路是三段式:先确认/dev/dri/renderD128存在且权限正确,再确认宿主侧设备确实是带 GL 的变体,最后看客户机 Mesa 环境变量 GALLIUM_DRIVER 是否被错误指定。很多人卡在第三段,因为发行版会默认选软件渲染器兜底。

6.4 多显卡时设备节点乱序

同时有虚拟显卡和物理显卡,或者挂了多张虚拟显卡时,/dev/dri/card0 和 card1 的顺序可能不稳定。客户机桌面起不来或者起来后渲染到错误的卡上。

最稳的办法是通过 sysfs 里的设备信息来区分节点,比如:

cat /sys/class/drm/card*/device/vendor

看到 vendor 是 virtio 对应的厂商 ID,就认得出哪张卡是虚拟的。然后写 udev 规则固定命名,别靠枚举顺序猜。

6.5 调试工具组合

日常调试我比较依赖三个层次的东西。第一层是 DRM 子系统的动态调试参数drm.debug=0x1f,能输出模式设置、帧缓冲和 ioctl 相关的细粒度日志,比瞎猜快得多。第二层是宿主侧的命令行跟踪,观察 virtqueue 上消息的提交和处理情况,重点看有没有命令半途失败。第三层是 dmesg 里出现区段错误或资源访问异常时,先检查客户机内存大小和宿主侧显存配置,这类问题往往不是驱动代码的 bug,而是资源边界没满足。

我在实际使用中最深刻的体会是:VirtIO GPU 这套东西,代码层面已经相当成熟,绝大多数问题不是"驱动坏了",而是"链路上某一段没配对"。宿主设备参数、客户机内核、显示栈、渲染后端,四段各查一遍,比反复重新编译内核驱动高效得多。上手之后,你会觉得虚拟机的显示器,终于有点像一块真的显卡了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进