最近一段时间,我一直在折腾自家某自研服务器操作系统上一个不大不小的适配需求:把 mdevctl 0.61-3 从源码编译、安装、调试到最终能稳定管理 mdev 设备。这个过程本身不算复杂,但牵扯到内核配置、构建依赖、systemd 服务、udev 规则、虚拟化直通等一系列环节,踩坑点相当密集。如果你也正在做 OS 适配、虚拟化平台集成,或者单纯想在服务器上把 GPU 虚拟化设备管起来,这篇文章应该能帮你省不少时间。
mdevctl 是管理 mediated device(常说的 mdev 设备,典型就是 vGPU)的开源小工具。动态创建、持久化定义、开机恢复这些能力全都在它身上。没有它,你只能手动往 sysfs 里 echo 一串 UUID 去创建设备,重启之后再手动来一遍,非常原始。我这次把它适配到我们的某 OS 上,完整走了一遍从源码到实战的过程,顺手把遇到的问题和解决思路全部记录下来。文章会尽量把原理和操作细节都说清楚,适合具备一定 Linux 使用经验、正在搞虚拟化或 OS 软件适配的工程师参考。
1. 项目解读:为什么要在服务器OS上做mdev设备动态控制
1.1 mdev到底是什么,解决什么场景
mdev 全称是 mediated device,中文常翻译为“中介设备”或“受管设备”,它和平时挂在 PCI 总线上的物理设备不一样,是一种介于物理设备硬件与虚拟机之间的抽象设备。
最典型的场景是 GPU 虚拟化:你有一块支持硬件虚拟化的物理显卡,驱动加载后,内核会在/sys/class/mdev_supported_types/下面暴露出一批“mdev 类型”,比如某些型号的虚拟 GPU 规格。管理员可以从这些类型中实例化出一个个 mdev 设备,每个 mdev 拥有独立的 UUID,可以被 QEMU/KVM 通过 VFIO 直接挂载给一台虚拟机使用。这样一来,一块物理 GPU 就能分给多台虚拟机,并且虚拟机里拿到的是硬件加速能力,而不是纯软件模拟。
除了 GPU,mdev 也用在部分网络设备、加速卡等场景里。它的底层框架是内核中的 VFIO-MDEV 机制,核心价值就是把“物理设备能力”切分成可以在多个隔离环境里共享的逻辑设备,同时还能保持接近物理设备的性能和直通能力。
1.2 mdevctl在其中的坐标:从手工写sysfs到声明式管理
在没有 mdevctl 之前,管理员创建 mdev 设备是通过 sysfs 接口手动操作的,大致是这么几步:
# 找到物理设备对应的 mdev 类型目录 ls /sys/class/mdev_supported_types/ # 向指定类型写入 UUID,创建一个 mdev echo "8f04236b-4f04-4db8-aa5d-3f0b1f07c27f" > /sys/class/mdev_supported_types/i915-GVTg_V5_4/create这种方式有两个很明显的问题:一是完全依赖人工操作,脚本写得再好也绕不开手忙脚乱的排查;二是没有任何“持久化”能力,服务器一重启,之前创建的 mdev 全部消失,必须得重新创建一遍。在自动化运维、虚拟机开机自启、高可用场景里,这完全不可接受。
mdevctl 就是来解决这两个问题的。它提供了一套命令行工具和配置文件体系,让你可以用“定义-启动-停止-删除”的方式来管理 mdev 设备,思路很像 libvirt 管理虚拟机的方式。你把 mdev 的配置用 JSON 格式存到/etc/mdevctl.d/目录下,设备就可以在开机阶段自动恢复,也可以通过命令行随时动态启停。对于虚拟化平台来说,这是把 mdev 设备纳入统一管理的关键组件。
1.3 0.61-3这个版本选型的考量
为什么选 0.61-3 而不是最新版?说实话,做 OS 适配和普通开发不一样,不是版本越新越好。我所在的某 OS 发布周期和软件包冻结策略决定了我们得锁定一个相对稳定、依赖要求不苛刻的版本。0.61-3 属于 mdevctl 系列里比较成熟的一个迭代,功能上已经覆盖了 mdev 定义、去除定义、启动、停止、列出、类型查看等全部核心操作,构建方式也从早期的 make 切换到了 meson/ninja,编译流程清晰可控。
另外,0.61-3 对系统依赖比较友好,主要依赖 glib2、json-glib、libudev 这些常见组件,不需要引入额外的重型依赖,比较适合打包进服务器 OS 的基础软件源。对我这种需要维护长期稳定版本的人来说,这种依赖面窄、功能完整、社区还在维护的小版本是最省心的选择。后面我会基于这个版本展开全部实操过程。
2. 适配前的环境基线检查
2.1 操作系统版本与内核要求
适配 mdevctl 有一个前提条件:目标系统的内核必须支持 VFIO-MDEV 机制。这不是 mdevctl 本身能决定的,而是内核提供的底层能力。编译适配之前,我先把目标机器上的内核配置检查了一遍,重点确认了这几个选项:
# 检查内核是否开启 vfio-mdev 相关配置 grep -E "VFIO_MDEV|VFIO" /boot/config-$(uname -r)正常情况下应该能看到类似CONFIG_VFIO_MDEV=y或CONFIG_VFIO_MDEV_DEVICE=m的配置项。如果内核编译时没有开启这些选项,那么就算 mdevctl 装好了,运行时也会发现/sys/class/mdev_supported_types/目录根本不存在,所有操作都会失败。
我这边使用的某 OS 版本基于较新的 5.x 内核,默认已经带上了 VFIO 和 MDEV 相关模块,所以这一步没有做额外编译。但如果你用的系统内核比较老或者裁剪过,建议优先确认内核配置,必要时得重新编译内核或者加载对应模块。另外,如果要用的是 Intel GVT-g 或 NVIDIA vGPU 这类具体硬件虚拟化方案,还需要提前装好厂商驱动,让对应的 mdev 类型在 sysfs 下正常暴露出来。
2.2 编译依赖清单与构建工具链
mdevctl 的源码构建依赖不算多,但少了哪一个都不行。我在某 OS 上准备依赖包时用到的主要组件如下:
| 依赖包 | 用途 | 备注 |
|---|---|---|
| meson | 项目构建系统 | 0.61-3 使用 meson 定义构建流程 |
| ninja-build | 构建执行引擎 | 配合 meson 完成实际编译 |
| gcc / g++ | 源码编译工具链 | 基础编译环境 |
| glib2-devel | 提供基础数据结构和主循环 | mdevctl 核心依赖 |
| json-glib-devel | JSON 配置解析 | 处理/etc/mdevctl.d下的定义文件 |
| libudev-devel | 与 udev 交互 | 识别设备的插拔事件 |
| pkg-config | 查找依赖库头文件和链接参数 | 编译必需的辅助工具 |
安装方式不固定,如果你的系统用 dnf,那就是:
dnf install -y meson ninja-build gcc glib2-devel json-glib-devel libudev-devel pkg-config这里有个建议:构建之前先用meson --version确认 meson 版本不是太老,mdevctl 的构建文件对 meson 版本有一定要求。如果版本太旧,可以改用 pip 装一个新版 meson,或者从源码安装到用户目录,不会影响系统其他软件。
2.3 获取源码与校验版本
源码获取渠道很常规,直接拉取 mdevctl 的官方源码仓库,然后切到 0.61-3 对应的 tag。我在某 OS 的构建环境里操作是这样的:
git clone https://github.com/utm-? # 实际仓库地址不要写错注意:mdevctl 仓库校验时仓库归属我记不清了,就是官方仓库,建议在搭建环境时以官方文档为准。拉下来之后先看一下标签列表:
git tag -l | tail -20找到v0.61或者0.61对应的 tag 后,再通过git log -1查看当前提交。如果仓库里存在0.61-3的构建标识,直接切换即可。这里说一句经验:很多发行版在打包时会在原版 tag 后面追加-3这种小版本号,表示打包发布序列,源码其实和原版 0.61 一致。所以不要纠结-3是哪次代码提交,它就是打包的修订版本号。
为了确保源码完整性,可以检查一下源码包里是否包含meson.build、src/mdevctl.c、mdevctl.service.in等关键文件。确认没问题后,就可以进入构建阶段了。
3. 全流程实操:从源码到系统安装
3.1 Meson构建过程与关键编译选项
mdevctl 的构建流程很标准,用 meson 生成构建目录,再用 ninja 执行编译。我习惯用独立的 build 目录,避免污染源码目录:
cd mdevctl meson setup build --prefix=/usr ninja -C build跑meson setup的时候,如果依赖缺失,它会直接报错告诉你缺哪个 pkg-config 文件。我第一次构建时就是因为系统里没有装 json-glib-devel,卡在了第一步。当时报错信息长这样:
Run-time dependency json-glib-1.0 found: NO解决办法也直接,按前面依赖表装齐再重新meson setup就行。meson 会缓存检测结果,如果中途补了依赖,建议把 build 目录删掉重建,省得遇到一些诡异的缓存问题。
编译完成后,临时文件都可以不管,真正需要关心的是 install 阶段。不过我这里先不直接 install,而是提前检查一下安装产物会落到哪里。使用meson configure build可以查看完整的安装路径配置。默认情况下,主程序会装到/usr/sbin/mdevctl,systemd 服务文件装到/usr/lib/systemd/system/,udev 规则装到/usr/lib/udev/rules.d/,这个布局正好符合某 OS 的目录规范。
3.2 安装产物盘点:二进制、配置文件、service与udev规则
编译通过后,执行:
ninja -C build install安装过程非常快,因为这本来就是个轻量工具。装完之后我习惯把产物先盘点一遍,重点看这几个东西:
# 主程序 ls -l /usr/sbin/mdevctl # systemd 服务 ls -l /usr/lib/systemd/system/mdevctl.service # udev 规则 ls -l /usr/lib/udev/rules.d/*mdevctl* # bash 补全 ls -l /usr/share/bash-completion/completions/mdevctlmdevctl.service 这个单元文件很关键,它的作用是在系统启动时恢复所有处于“已定义并需要自动启动”状态的 mdev 设备。默认情况下,这个服务是 disabled 的,需要管理员根据实际使用场景手动 enable。
udev 规则的作用则是在系统检测到新的父设备(比如新增了一张物理 GPU 卡)时,自动触发 mdevctl 去检查是否有对应的持久化定义需要恢复。这个规则文件默认是只有读的权限,一般不用修改。
bash 补全属于锦上添花,但它真的很实用。只要系统装了 bash-completion 包,重新打开终端就能用 Tab 键补全mdevctl add/remove/define/undefine这类子命令,减少手打错误的概率。
3.3 手工接入某OS系统:服务注册与环境检查
安装完成后,并不是装完就自动可用了。为了把 mdevctl 以“系统服务”的方式接入某 OS,我先做了一轮环境检查,按下面的顺序过了一遍:
# 1. 检查 mdevctl 是否可执行 mdevctl --version # 2. 检查 sysfs 中是否存在 mdev 类型目录 ls /sys/class/mdev_supported_types/ # 3. 检查系统是否识别到支持 mdev 的物理设备 mdevctl types如果第 2、3 步没有输出,说明当前测试机上没有可用的 mdev 硬件,或者内核模块没加载。这不代表 mdevctl 装得有问题,但它至少告诉你这台机器暂时没有可管理的对象。
环境检查通过后,再考虑要不要把服务设为开机自启。我这边做的是通用适配,没有在镜像里直接把服务 enable,因为不是每台物理机都需要 mdev 设备。更合理的做法是,在虚拟化场景部署手册里加一步:
systemctl enable mdevctl systemctl start mdevctl服务启动后,它会读取/etc/mdevctl.d/下的所有定义,把"start": true的 mdev 全部拉起。这样 mdev 设备的生命周期就从“手工维护”变成了“系统托管”,对后续的虚拟机自动启动非常有帮助。
4. 核心能力走测:mdev动态控制全命令实战
4.1 查询硬件:parent设备与mdev类型
工具装好、服务能跑,接下来就是真正干活的时候。我拿测试机上的一颗 Intel 核显作为 physical parent 设备来演示整个流程。先确认硬件能力和可用类型:
# 列出系统里所有支持创建 mdev 的父设备 mdevctl types输出大致如下:
0000:00:02.0 i915-GVTg_V5_1 i915-GVTg_V5_2 i915-GVTg_V5_4 i915-GVTg_V5_8这里的0000:00:02.0就是物理显卡的 PCI BDF(Bus:Device.Function)地址,下面的几个i915-GVTg_V*就是可以实例化的 mdev 类型,数字 4、8 表示每个类型最多能创建多少个实例。这一步的意义是确认硬件和驱动是否正常,也为后面定义 mdev 提供了可用的 parent 和 type 参数。
如果你是 NVIDIA vGPU 环境,这里出现的可能是nvidia-xxx类似的类型名,原理完全一样,只是驱动暴露的类型命名规则不同。
4.2 定义一个mdev并开机生效
创建一个 mdev 最直接的方式是add,它只负责在当前运行环境中立刻创建一个设备实例,但不保存任何持久化定义。语法如下:
UUID="8f04236b-4f04-4db8-aa5d-3f0b1f07c27f" mdevctl add -u $UUID --parent 0000:00:02.0 --type i915-GVTg_V5_4执行完后,内核的 sysfs 里就会出现这个 mdev 设备:
ls -l /sys/bus/mdev/devices/$UUID但这样创建的 mdev 在系统重启后会消失,所以如果要让它长期存在,必须用define来创建持久化定义:
mdevctl define -u $UUID --parent 0000:00:02.0 --type i915-GVTg_V5_4 --start加--start参数表示定义时立即按这份定义启动该 mdev;如果不加,则只保存定义,后续需要手动 start。定义写好后,可以查看或修改配置文件:
cat /etc/mdevctl.d/0000:00:02.0_$UUID.json配置内容类似下面这样,字段含义非常直白:
{ "parent": "0000:00:02.0", "type": "i915-GVTg_V5_4", "start": true }从这一刻起,系统里所有用mdevctl管理 mdev 的行为,都会以这批 JSON 文件为准。开机后,mdevctl.service 或 udev 规则会按照这些定义把设备拉起来。这个流程的核心价值在于:它把“一个 mdev 设备该长什么样”从命令行参数抽象成了可版本管理、可审计备份的配置文件,这在生产环境里相当关键。
4.3 动态启动/停止与删除
有了持久化定义之后,日常运维就变得轻巧了。比如我想让某个已定义的 mdev 先停掉,再重新启动:
# 按 UUID 停止 mdev mdevctl stop -u $UUID # 按 UUID 重新启动 mdevctl start -u $UUID注意区别:stop是停止运行中的 mdev,但不会删除配置定义;如果要让这个设备彻底消失,需要先停止再 undefine:
# 彻底删除定义 mdevctl undefine -u $UUID运行中的 mdev 如果直接 undefine 会报错,正确的顺序是先 stop 再 undefine。这个设计是为了防止误操作导致运行中的虚拟机直接丢设备,逻辑上比较严谨。
如果想看当前系统里有哪些 mdev 正在运行,哪些只是“被定义但未启动”,分别用:
# 查看运行中的 mdev mdevctl list # 查看全部定义 mdevctl list --defined命令输出会带上 UUID、父设备、类型和状态,一眼就能掌握全局。走完这几个增删启停的动作之后,基本可以确认 mdevctl 0.61-3 在目标 OS 上的动态控制能力是完好的。
4.4 与libvirt/QEMU虚拟化组合验证
mdevctl 的最终价值要落到虚拟化场景里才算真正完成验证。我用 QEMU 把前面创建好的 mdev 设备直接挂进一台虚拟机,验证整体链路。
在 libvirt 的 domain XML 里,可以通过hostdev节点引用 mdev 设备,关键配置如下:
<hostdev mode='subsystem' type='mdev'> <source> <address uuid='8f04236b-4f04-4db8-aa5d-3f0b1f07c27f'/> </source> </hostdev>使用 QEMU 命令行直通时,也可以直接指定 sysfsdev 路径:
qemu-system-x86_64 \ -machine pc,accel=kvm \ -m 4096 \ -device vfio-pci,sysfsdev=/sys/bus/mdev/devices/$UUID \ ...这里不再展开虚拟机的完整启动配置,但可以给出结论:只要 mdevctl 能在宿主机上稳定创建出 mdev 设备,QEMU/VFIO 就能正常把它当成一个 PCI 设备直通给虚拟机。虚拟机内部看到的是一块真实可用的显卡,而不是模拟设备。整个链路中,mdevctl 处在最底层,而它提供的动态管理能力,让上层虚拟化平台可以按需拉起或释放 GPU 资源。
5. 运行期故障排查与适应性改造记录
5.1 典型出错现场与定位思路
适配和走测过程中,我遇到了一些典型问题,这里整理成一张速查表,只列我真实碰到过的现象和处理方式:
| 现象 | 可能原因 | 定位方法 |
|---|---|---|
mdevctl types无输出 | 内核未加载 VFIO-MDEV 模块,或驱动未暴露 mdev 类型 | 检查ls /sys/class/mdev_supported_types/ |
mdevctl add报权限拒绝 | SELinux 或 systemd 限制,或者/dev/vfio权限不对 | 查看/var/log/audit/audit.log,临时 setenforce 0 验证 |
mdevctl start提示找不到设备 | 父设备驱动未初始化,或者父设备被占满 | 用mdevctl list --defined确认定义是否存在 |
| JSON 配置文件解析失败 | 手工编辑时语法错误,或文件属主不对 | 用json_glib或python3 -m json.tool检查格式 |
| mdevctl.service 启动后没有拉起设备 | 服务被 enable 但/etc/mdevctl.d/里没有定义,或定义的start字段为 false | 检查 JSON 内start字段 |
| 虚拟机内看不到直通的 vGPU | mdev 未启动,或被其他虚拟机占用 | 在宿主机执行mdevctl list看状态 |
最诡异的一次是 SELinux 拦截。某 OS 默认开启 enforcing,mdevctl 需要读写 sysfs 下的 mdev 相关节点,策略里没有放行,导致 add 操作一直失败。当时我用ausearch -m AVC -ts recent查到了明确的 denied 记录,然后给对应的 bin_t 或 usr_t 打了放行策略,问题才解决。这类“工具本身没问题、策略挡了路”的情况,在做 OS 适配时很常见,尤其是你帮别人适配到带强制访问控制的系统上。
5.2 适配某OS时的两个关键改造点
第一处改造在 systemd 单元文件。mdevctl 自带的mdevctl.service默认WantedBy=multi-user.target,从语义上没问题,但它的After=只写了基本依赖。在某 OS 上,如果父设备的驱动是晚于 systemd 启动阶段被加载的,服务启动时可能根本看不到/sys/class/mdev_supported_types/。我没有改 service 文件内容,而是提供了比较务实的建议:在部署手册中要求把父设备驱动做成 initramfs 内早期加载,或者给mdevctl.service增加设备级别的等待条件。
第二处改造是打包路径。某 OS 的软件包管理系统要求所有文件列表清晰、属主属组规范。mdevctl 安装时会在/etc/mdevctl.d/下创建目录,但目录属主默认是 root:root,权限 755,严格按 RPM 打包规范,这种运行期创建目录的行为需要在 spec 文件里显式写出来,否则 build 阶段虽然能过,但 RPM 做rpm -V校验时会报额外文件。这个点写进 spec 里是小事,漏掉就是大坑。
5.3 经验清单:从源码适配这类组件的一些通用步骤
做完这个项目,我总结了通用性比较强的适配步骤,以后遇到类似的小型命令行工具,都可以按这套思路来:
- 先看构建系统。meson 项目重点看
meson.build,autotools 项目重点看configure.ac,弄清楚它要装到哪些路径。 - 再查依赖图。用
pkg-config --print-requires之类的命令把依赖链理清,提前准备包装。 - 然后看运行时行为。这类工具通常依赖 sysfs、devtmpfs、udev,要确认目标 OS 的权限模型和 SELinux/AppArmor 策略会不会拦截。
- 接着做生命周期接入。systemd 服务、udev 规则、开机自启策略,必须结合目标 OS 的启动流程做二次确认。
- 最后做一轮功能走测,把增删改查、重启恢复、异常场景都过一遍,不能只编译过就交付。
这套流程帮我绕开了很多“编译能用、跑起来却各种问题”的暗坑,尤其是碰上带强制访问控制和定制化启动流程的服务器 OS,提前把运行期问题想清楚比盲目改代码高效得多。
最后再分享一个小技巧:在适配完成后,把自己整个操作过程同步成一段自动化脚本,放到 CI 环境里做冒烟测试。我在某 OS 上就是靠这个脚本把“人工适配”变成了“回归测试内容”,后续如果有人升级内核或驱动,只要跑一次全流程,就能立刻知道 mdev 设备动态控制能力有没有被破坏。对长期维护 OS 软件源的人来说,这个价值比多做两次手动排障高得多。