ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

VFIO 与 mdev 实战:从物理设备直通到多虚拟机共享

VFIO 与 mdev 实战:从物理设备直通到多虚拟机共享

搞虚拟化的人,早晚会撞上设备直通这道坎。刚开始我遇到的问题是:业务方要求虚拟机里必须跑硬件加速,纯软件模拟怎么压都顶不住CPU开销;于是我把一张物理卡直接塞给某台虚拟机,性能确实上来了,可一台宿主机只有一两张卡,虚拟机却有十几台,最后又把卡撤了回来。后来我把 Linux 内核的 VFIO 和 mdev 这套组合彻底啃透,才找到平衡办法:既保证接近原生的性能,又让一个物理设备能被多台虚拟机安全共享。这篇就把我对 VFIO mdev 的完整理解,从设计思路到实操命令,再到排障记录,一次性讲明白。适合正在折腾虚拟化资源池、GPU虚拟化、云平台底层设备的同学参考。

1. 为什么绕不开 mdev:设备共享的三条技术路线

1.1 软件模拟:通用但性能天花板太低

先看最原始的做法。虚拟机里要有一块网卡、显卡或者磁盘控制器,传统虚拟化靠的是软件模拟。QEMU这类虚拟化程序会模拟出设备寄存器、DMA和中断处理,guest里的驱动每访问一次硬件,都要经过一次“虚拟机退陷入模拟器 → 模拟器翻译成真实硬件操作 → 再进虚拟机”的路径。这条路径非常费CPU,IO频率一高,宿主机负载跟着暴涨。你可以把它理解成一个小卖部只开了一个窗口,所有顾客都挤在同一个柜台前,业务员再熟练也快不起来。

并非说软件模拟没用。对于管理网络、低速磁盘这类IO不密集的场景,它完全够用,而且兼容性最好,任何设备都可以模拟出来,不需要硬件支持。但到了GPU、高速网卡、NVMe这类追求带宽和低延迟的设备,软件模拟基本顶不住。高帧率图形渲染、大规模并行计算场景里,虚拟机的IO模型一旦被模拟层卡住,性能损耗可以达到百分之几十甚至更多。所以才有后两条路线。

1.2 VFIO 全量直通:性能漂亮,分配成本太高

第二条路线是把真实物理设备整个直通给一台虚拟机。这套机制的内核基础就是 VFIO(Virtual Function I/O)。做法是把物理设备从宿主机驱动里解绑,改绑到 vfio-pci 驱动,通过 /dev/vfio/N 把设备暴露给用户态;QEMU 拿到这个文件描述符后,guest 驱动可以直接编程真实设备寄存器,可以发起 DMA,可以安装自己的中断处理,经过IOMMU的地址翻译和访问隔离后,几乎等于直接用裸机硬件。

关键原因是IOMMU。没有IOMMU的话,guest 驱动一旦能在宿主机地址空间里随意做 DMA,等于握着真实钥匙却连防盗门都不设,guest 可以读写宿主机任意物理内存。IOMMU 做的就是给每个设备限定一张“地址翻译表”,guest 产生的 DMA 地址被映射到指定物理页面之外,其他内存它看不到。这也是为什么开直通必须在 BIOS 开启 IOMMU 功能,并且内核里要有 CONFIG_VFIO_IOMMU_TYPE1。

VFIO直通的性能非常接近原生,但缺点也明显:一个物理设备一次只能给一个虚拟机独享。假如你有10台虚拟机想共用一块GPU,直通路线最多服务1台;想加卡成本立刻上去。而且直通时,设备所在IOMMU group里的设备最好都交给同一个虚拟机,否则组内别的设备可能会带来DMA越界风险,group不干净时就会碰见各种磨人的问题。

1.3 mdev 的定位:让驱动来“切分”物理设备

mdev(mediated device,中介设备)就是想解决直通“独占”问题的框架。它不把整个物理设备交给一台虚拟机,而是让物理设备对应的厂商驱动先把资源切成几份,每一份封装成一个虚拟设备。从 guest 视角看,这个虚拟设备依然是一块完整的PCI设备:有自己的PCI配置空间、BAR、中断,驱动照常加载。但从宿主机视角看,它只是一个mdev实例,底层共享同一个父设备的显存、计算单元、队列等资源,并由父驱动负责仲裁。

典型例子就是GPU虚拟化。某个GPU厂商在驱动里实现了mdev parent ops,把一块真实GPU按不同模板划分出多个虚拟GPU,每个虚拟GPU有独立显存空间或受限算力,guest里加载对应驱动后就能工作。多个虚拟机可以同时访问同一块真实GPU,但不能互相看到对方的显存,安全性由厂商驱动和VFIO共同保证。这种“切分”能力是软件模拟和全量直通都没有的。

1.4 三种方案对比与选型思路

下面这张表我整理项目时经常拿来当决策参考:

方案性能共享能力配置复杂度依赖条件
软件模拟中低可以任意共享低无需特殊硬件
VFIO全量直通接近原生不共享中需要IOMMU,设备组要干净
mdev接近原生可多VM共享中高需要厂商驱动实现mdev接口

选型上没有绝对答案。如果只是网卡多队列,SR-IOV(硬件级的单根I/O虚拟化)往往更贴合,因为网卡天然支持硬件VF;但如果对象是GPU、FPGA这类SR-IOV支持弱、且需要软件划分资源的设备,mdev基本是最优解。有些智能网卡和存储控制器也会用mdev做细粒度虚拟化。我在实际项目里的默认判断是:硬件有VF就优先试VF,硬件没有VF、但厂商驱动提供了mdev支持,就用mdev;两者都不满足才考虑软件模拟或者干脆不虚拟化。

2. mdev 内核框架拆解:从父设备到虚拟设备

2.1 四个概念先对齐

写内核代码的人看mdev,其实就是一套“基于物理父设备的虚拟设备管理框架”。里面四个概念必须分清:父设备(parent)、mdev类型(type)、mdev实例(instance)、device_api。

父设备是真实存在的硬件,比如PCI总线上的一块GPU,它有自己的BDF地址,挂在PCI子系统的设备树里。mdev类型是一组资源模板,由厂商驱动定义,比如“类型A”表示分配4GB显存、最多4个实例,“类型B”表示分配8GB显存、最多2个实例。每种类型在sysfs里都有对应目录,目录下能看到设备接口、剩余可创建实例数等属性。mdev实例就是根据某个类型模板创建的虚拟设备,每个实例有唯一UUID,内核会给它在mdev总线上建立一个设备节点。device_api则是这个实例对外宣称的“访问方式”,最常见是 vfio-pci,意思是用VFIO的PCI语义来访问它;有些新框架里的vDPA类型会走别的总线语义,比如 vdpa 这类。

2.2 厂商驱动如何注册自己

mdev框架不是凭空感知父设备的,需要厂商驱动主动接入。驱动初始化时调用注册函数,把自己实现的 parent ops 交给内核;退出时再注销。parent ops 里最关键的就是create和remove回调:创建mdev时,内核调用create,厂商驱动核对剩余实例数、分配硬件资源、初始化虚拟设备上下文;销毁时调用remove,回收资源。另外还要提供设备接口描述、剩余实例数展示这些钩子,让sysfs里的属性有数据可读。

代码示意如下,字段名会随内核版本调整,核心逻辑不变:

// 逻辑示意,不是某个具体内核版本的精确结构 struct mdev_parent_ops { int (*create)(struct mdev_device *mdev); int (*remove)(struct mdev_device *mdev); ssize_t (*available_instances)(struct mdev_type *type); ssize_t (*show_device_api)(struct mdev_type *type); };

如果你去看新版内核,会发现接口形态换成了 mdev_driver、mdev_class 这一套,但你要抓的本质还是:厂商驱动在父设备上声明了“我能创建哪些虚拟设备、每个长什么样、怎么创建销毁”。至于新版里 mdev 设备创建后是走 vfio-mdev 这类VFIO子驱动,还是走独立mdev总线,都由 device_api / class 决定,这个抽象是这套机制后来越用越广的原因。

注意:编译内核时除了保留基础的 VFIO 支持,还要确认 CONFIG_VFIO_MDEV 和 CONFIG_VFIO_MDEV_DEVICE 已经打开,否则即便厂商驱动写了parent ops,系统里也没有 vfio_mdev 驱动来承接。

2.3 一次mdev创建,内核发生了哪些事

手动创建一个mdev实例,本质是在sysfs里触发内核走一遍设备生命周期。假设父设备类型目录下有 create 这个文件,向它写入一个UUID,接下来发生几步:

  1. 内核根据UUID在mdev总线上创建 mdev_device 结构,并挂到 /sys/bus/mdev/devices/ 。
  2. 调用厂商驱动里的create回调,厂商驱动检查该类型实例余量,按模板分配显存等资源。
  3. mdev设备把 device_api 暴露出来,内核依据它决定后续绑定到哪个驱动。
  4. 绑定vfio_mdev后,VFIO框架看到该设备,把它加入一个VFIO group,最终通过 /dev/vfio/N 暴露给用户态。
  5. guest通过QEMU拿到这个VFIO设备后,就可以像访问真实PCI设备一样访问它。

销毁时,往mdev设备目录里的remove属性写下1,内核触发remove回调,回收资源并移除设备节点。这个生命周期和PCI设备本身的hotplug模型有点像,只不过mdev不是真实的物理hotplug,而是完全由软件和厂商驱动协同管理。

上面这段写清楚后,你在排障时心里就有一幅地图:是父设备驱动没起来,还是类型资源不够,还是vfio_mdev没承接,还是VFIO group出了问题。接下来第三章就照这个地图做一次真实操作。

3. 实操记录:把 mdev 设备串给虚拟机

3.1 前置检查与内核参数

动手之前先把地基打好。BIOS里确认IOMMU开关打开,不同平台叫法不同,常见是 VT-d 或 AMD-Vi。然后在内核引导参数里开启IOMMU和直通模式。我通常在grub里这么配(以你熟悉的发行版配置方式为准):

intel_iommu=on iommu=pt

如果是AMD平台,把 intel_iommu=on 替换成 amd_iommu=on 这类参数。iommu=pt 的意思是“直通模式”,让直通设备尽量不做额外地址转换,把IOMMU对性能的损耗压到最低。生产环境我基本都加这个参数。

进入系统后先确认三件事:

# 1. 内核是否识别IOMMU dmesg | grep -i -e DMAR -e IOMMU # 2. IOMMU group是否已经创建 ls /sys/kernel/iommu_groups/ | head # 3. VFIO用户态接口是否存在 ls -l /dev/vfio/vfio

如果 /sys/kernel/iommu_groups 下面没有内容,或者 /dev/vfio/vfio 不存在,多半是BIOS没开、内核参数没生效,或者模块没加载。先补这块再看mdev,省得一步错步步错。

3.2 找到父设备并确认可用类型

用 lspci 找到目标设备地址。以一块GPU为例:

lspci -nnk | grep -i -A3 "VGA"

记下类似 0000:00:02.0 的BDF地址,接着查看它是否带有 mdev_supported_types 目录:

ls /sys/devices/pci0000:00/0000:00:02.0/mdev_supported_types/

这个目录下每一个子目录就是一种mdev类型。进入类型目录,重点看三个属性:

cat /sys/devices/pci0000:00/0000:00:02.0/mdev_supported_types/某类型/device_api cat /sys/devices/pci0000:00/0000:00:02.0/mdev_supported_types/某类型/available_instances cat /sys/devices/pci0000:00/0000:00:02.0/mdev_supported_types/某类型/description

device_api 会告诉你这个类型是走 vfio-pci 还是别的总线;available_instances 是还能创建几个实例;description 通常描述这个类型的资源规格。假设某GPU上注册了两种类型,我用一个示意表格展示它们的差异:

类型device_apiavailable_instances资源规格(示意)
类型Avfio-pci44GB显存,适合普通桌面
类型Bvfio-pci28GB显存,适合图形渲染/训练

如果 available_instances 是0,说明父设备资源已经被占满了,需要先释放其他实例,或者换个资源小的类型。这里也提醒一句:实际设备上类型目录的名字可能很长,别手打,用TAB补全或复制粘贴最稳。

3.3 创建实例并确认绑定

类型选好后,用uuidgen生成一个UUID,写入对应create文件:

UUID=$(uuidgen) echo "$UUID" > /sys/devices/pci0000:00/0000:00:02.0/mdev_supported_types/某类型/create

创建完确认设备节点和device_api:

ls -l /sys/bus/mdev/devices/$UUID cat /sys/bus/mdev/devices/$UUID/device_api

这个阶段,新版内核通常会把带 vfio-pci 语义的mdev自动绑定到 vfio_mdev 驱动,不需要你手动操作。如果查看 /sys/bus/mdev/devices/$UUID/driver 发现是空的,就手动补一刀:

echo "vfio_mdev" > /sys/bus/mdev/devices/$UUID/driver_override echo "$UUID" > /sys/bus/mdev/drivers/vfio_mdev/bind

如果你不确定驱动路径,可以用这个命令搜索:

ls /sys/bus/mdev/drivers/

正常会出现 vfio_mdev 或其他厂商提供的mdev驱动。如果内核版本比较老,sysfs路径可能会出现在 /sys/class/mdev_bus/devices/ 下面,原理是一样的。绑定完成后,设备就已经在VFIO的管辖范围里了,接下来交给QEMU/libvirt即可。

3.4 在 libvirt/QEMU 里透传 mdev

用libvirt管理虚拟机的同学,直接编辑虚拟机XML,加一段 hostdev:

<hostdev mode='subsystem' type='mdev' model='vfio-pci'> <source> <address uuid='12345678-1234-1234-1234-123456789abc'/> </source> </hostdev>

保存后 virsh define 或直接从图形管理界面添加这张“中介设备”。libvirt会翻译成QEMU命令行,本质上就是下面这行:

qemu-system-x86_64 \ -device vfio-pci,sysfsdev=/sys/bus/mdev/devices/12345678-1234-1234-1234-123456789abc \ ...

注意这里用的是 sysfsdev 参数,不是普通直通里的 host。mdev设备没有真实的PCI BDF,只能用sysfs路径或mdev uuid来定位。有些QEMU版本也支持用 /dev/vfio/N 直接传,但用 sysfsdev 是最稳妥、文档最齐全的方式。

虚拟机启动后,在guest里 lspci 应该能看到对应设备,并且能加载驱动。如果是GPU,guest里跑一次图形测试或者对应厂商的监控工具,确认设备正常计数、显存正确。我按这个流程在一台带GPU的测试机上操作,从写UUID到虚拟机里看到设备,前后不到三分钟,前提是前置检查和厂商驱动都已经就位。

3.5 用 mdevctl 把设备管理起来

手动echo创建有一个大坑:重启失效。设备不在任何配置库里,宿主机一重启,mdev就消失了,虚拟机再开机也找不到设备。mdevctl 这个工具就是干这个的:把mdev设备定义为持久化配置,系统启动时可以自动恢复。基本用法:

# 列出当前运行中的设备 mdevctl list # 把设备保存为定义,指定父设备、类型、uuid mdevctl define --parent 0000:00:02.0 --type 某类型 --uuid 12345678-1234-1234-1234-123456789abc # 手动启动一个已定义的设备 mdevctl start --uuid 12345678-1234-1234-1234-123456789abc # 查看已定义列表 mdevctl list --defined

把 define 做完后,这台设备在重启后就能自动恢复。如果你所在的环境没有 mdevctl,可以自己写一个 systemd 单元来在开机时 echo UUID,但我强烈建议优先用发行版自带的 mdevctl,因为它还会处理依赖顺序和配置冲突。

4. 常见问题排查与避坑指南

4.1 /dev/vfio 不存在或IOMMU group为空

现象:进系统后没有 /dev/vfio/vfio,ls /sys/kernel/iommu_groups/ 为空。绝大多数情况是BIOS里IOMMU开关没开,或者内核参数没生效。先重查 BIOS 设置,再检查 grub 引导参数是否真的应用到当前内核。有时候多内核引导,参数只加到旧内核上了,用 uname -r 和 /proc/cmdline 对一下就知道。

另外别忘了加载 vfio 模块:

modprobe vfio modprobe vfio_iommu_type1 modprobe vfio_pci

部分发行版把这些模块做进 initramfs,改完内核参数需要刷新 initramfs,不然重启后模块照样加载不上。这个坑我踩过不止一次。

4.2 类型目录不存在或创建返回失败

现象:父设备 sysfs 里根本没有 mdev_supported_types,或者 available_instances 是0,echo UUID 到 create 时报错。先说没有目录:八成是厂商驱动没加载,或者该硬件不支持mdev。确认驱动模块是否正常加载,dmesg里有没有对应的mdev注册信息。如果是GPU,还要确认硬件已经被厂商虚拟化驱动接管,而不只是普通显示驱动。

available_instances 为0,说明类型资源配额满了。把现网里占用资源的实例列出来,能删的先删掉,腾出配额再创建。还要注意一种隐蔽情况:多个类型共用父设备的总资源,某个大类型占用的空间可能影响到其他类型的剩余实例数。在 dmesg 里能看到具体资源不足的报错,逐条读。

dmesg | grep -i -e mdev -e vfio -e mediated

4.3 虚拟机启动报 “VFIO group is not viable”

这个报错在直通和mdev场景都可能出现。意思是IOMMU group里有设备没有正确进入VFIO管控,导致整组不可安全使用。排查顺序一般是:先确认除了要透传的设备外,组里其他设备也被绑到 vfio_pci,或者让它们保持在驱动正常状态但组要干净;再检查是不是有几个设备必须作为一个整体一起给guest,不能只给其中一部分。

有个测试环境常用的内核参数 pcie_acs_override=downstream,multifunction 可以强行拆分IOMMU group。但要提醒:这个参数会改变PCI ACS隔离语义,等于削弱了硬件对DMA隔离的保证,我只建议在测试环境用,生产环境不要碰。真到生产,宁可去调整设备插槽,让目标设备和它所在的组变干净。

4.4 mdev 设备重启后消失

这个我在上一章讲了 mdevctl 的持久化做法,这里再补充一个实操里的坑:如果 mdevctl define 报父设备类型不对,先确认父设备BDF在重启后有没有变化。PCI地址在某些拓扑下会变,最稳妥的办法是在define时用完整的父设备路径,并确认和 mdevctl list 里的一致。另外,libvirt里如果已经定义了虚拟机引用这个mdev,mdevctl启动失败会让虚拟机整体无法启动,日志通常会停在设备构建阶段,顺着报错去查父设备资源和驱动状态。

4.5 guest 里设备无法驱动或初始化失败

这种情况常见原因有三类:device_api 不匹配、guest需要额外的厂商驱动、mdev类型资源过小导致初始化失败。先用 cat 确认 device_api 是不是 vfio-pci,不是的话说明这个类型本来就不该用普通guest驱动访问;再进 guest 看 dmesg 报错,判断是注册BAR失败、显存访问异常还是中断分配失败;最后可以尝试切到资源更大的类型,排除资源不足的干扰。

厂商驱动的兼容性也要注意。mdev 的实现高度依赖厂商驱动和内核版本的配对,宿主机内核升级后,原有mdev类型可能失效或可用实例数变化。我项目里会先把内核和厂商驱动的版本固定下来,升级前单独做回归测试,不在生产环境直接升。

4.6 性能达不到预期的几个检查点

mdev性能通常已经接近原生,但如果和裸机对比差距明显,按这个顺序检查:首先是 iommu=pt 有没有加,没加的话IOMMU做完整地址翻译会有额外开销;其次是guest里中断是否用了MSI/MSI-X,传统INTx中断在高IO场景下损耗不小;还有CPU是否绑定正确,特别是大流量网卡和GPU场景,vCPU不绑核会导致跨NUMA访问,延迟情况差别很大。最后别忽视:mdev共享父设备意味着资源总分有限,多个guest同时跑高负载,单guest看到的性能自然会下降,这是共享方案的物理边界,不是配置错误。

5. 一些实践体会与避坑心得

如果让我总结mdev这套机制最值得记住的一点,那就是“资源如何切分”永远比“如何透传”更重要。你要先花时间弄清父设备能提供什么类型、每种类型消耗什么资源、余量怎么变化,再考虑QEMU和libvirt的配置。我在几次生产交付里发现,真正出问题的并不是命令敲错,而是资源规划和持久化管理没做好。启动一个mdev只要一条echo,但忘记 mdevctl define,或者类型资源没预留好,第二天就能让整个资源池出现连锁故障。

我个人的习惯是把所有mdev设备清单纳入版本管理。每台宿主机上定义了哪些UUID、对应哪个类型、服务哪台虚拟机,都写下来留档。排查问题时不至于对着一个UUID发呆。如果你刚接触这套东西,建议先在一台测试机里故意制造几次错误:关掉IOMMU再创建一次、把available_instances占满再创建一次、拔掉父设备再启动虚拟机,把这些错误日志都看一眼。这样落地生产时,你再看到同样的报错,基本不用翻文档就能定位到问题。

RELATED READING

延伸阅读

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