0%

riscv kvm qemu 虚拟化调研

KVM

KVM(Kernel-based Virtual Machine,基于内核的虚拟机)是一种用于 Linux 内核中的虚拟化基础设施。本质是一个嵌入到 Linux 内核中的虚拟化功能模块 kvm.ko,该模块在利用 Linux 内核所提供的部分操作系统能力(e.g. 任务调度、内存管理、硬件设备交互)的基础上,再加入了处理器和内存虚拟化的能力,使得 Linux 内核具备了成为 VMM 的条件。

KVM 内核模块本身只能提供 CPU 和内存的虚拟化
KVM 需要硬件虚拟化技术支持(Intel VT-x, AMD svm, ARM hypervisor, RISCV H-extension),所以 KVM 也被称之为硬件辅助的虚拟化实现。

严格来说 kvm 属于硬件辅助的全虚拟化 + type2 类虚拟化技术.

KVM 的功能清单:

  • 支持 CPU 和 Memory 超分(Overcommit)
  • 支持半虚拟化 I/O(virtio)
  • 支持热插拔 (CPU、块设备、网络设备等)
  • 支持 SMP(Symmetric Multi-Processing,对称多处理)处理器架构
  • 支持 NUMA (Non-Uniform Memory Access,非一致存储访问)处理器架构
  • 支持实时迁移(Live Migration)
  • 支持 PCI 设备直接分配(Pass-through)和单根 I/O 虚拟化 (SR-IOV)
  • 支持合并相同内存页 (KSM )

KVM 内核模块加载流程

当启动 Linux 操作系统并加载 KVM 内核模块时:

  • 初始化 KVM 模块内部的数据结构
  • KVM 模块检测当前的 CPU 体系结构, 打开虚拟化模式开关
  • KVM 模块创建特殊的接口设备文件 /dev/kvm 并等待来自用户空间(QEMU)的指令。

KVM 是运行在内核态的且本身不能进行任何io设备的模拟。所以,KVM 还必须借助于一个运行在用户态的应用程序来模拟出虚拟机所需要的虚拟设备(e.g. 网卡、显卡、存储控制器和硬盘)同时为用户提供操作入口。目前这个应用程序的最佳选择就是 QEMU。

QEMU

纯软QEMU 本身作为一套完整的 VMM 实现,包括了处理器虚拟化内存虚拟化,以及模拟各类虚拟设备的功能。QEMU 4.0.0 版本甚至几乎可以模拟任何硬件设备,但由于这些模拟都是纯软件实现的,所以其性能低下

在 KVM 开发者在对 QEMU 进行稍加改造后,QEMU 可以通过 KVM 对外暴露的 /dev/kvm 接口来对其进行调用。从 QEMU 角度来看,也可以说是 QEMU 使用了 KVM 的处理器和内存虚拟化功能,为自己的虚拟机提供了硬件辅助虚拟化加速。

image-20240416112227420

虚拟机的配置和创建、虚拟机运行所依赖的虚拟设备、虚拟机运行时的用户环境和用户交互,以及一些虚拟机的特定技术,比如:动态迁移,都是交由 QEMU 来实现的。

QEMU 的使用方式

  1. 纯软件(二进制翻译)实现的全虚拟化虚拟机
  2. 基于硬件辅助虚拟化(KVM)的全虚拟化虚拟机
  3. 基于硬件辅助虚拟化(KVM)的半虚拟化虚拟机 (借助软件virtio 实现io 模拟)
  4. 仿真器:为用户空间的进程提供 CPU 仿真(指令翻译),让在不同处理器结构体系上编译的程序得以跨平台运行。例如:让 RISCV 架构上编译的程序在 x86 架构上运行(借由 VMM 的形式) 。

QEMU-KVM

KVM 官方提供的软件包下载包含了 KVM 内核模块、QEMU、qemu-kvm 以及 virtio, qemu-kvm 本质是专门针对 KVM 的 QEMU 分支代码包

QEMU-KVM 相比原生 QEMU 的改动:

  • 原生的 QEMU 通过指令翻译实现 CPU 的完全虚拟化,但是修改后的 QEMU-KVM 会调用 ICOTL 命令来调用 KVM 模块。
  • 原生的 QEMU 是单线程实现,QEMU-KVM 是多线程实现。

在 QEMU-KVM 中,KVM 运行在内核空间,提供 CPU 和内存的虚级化,以及 Guest OS 的 I/O 拦截。QEMU 运行在用户空间,提供硬件 I/O 虚拟化,并通过 ioctl 调用 /dev/kvm 接口将 KVM 模块相关的 CPU 指令传递到内核中执行。当 Guest OS 的 I/O 被 KVM 拦截后,就会将 I/O 请求交由 QEMU 处理

KVM 提供的ioctl 命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
open("/dev/kvm", O_RDWR|O_LARGEFILE)    = 3
ioctl(3, KVM_GET_API_VERSION, 0) = 12
ioctl(3, KVM_CHECK_EXTENSION, 0x19) = 0
ioctl(3, KVM_CREATE_VM, 0) = 4
ioctl(3, KVM_CHECK_EXTENSION, 0x4) = 1
ioctl(3, KVM_CHECK_EXTENSION, 0x4) = 1
ioctl(4, KVM_SET_TSS_ADDR, 0xfffbd000) = 0
ioctl(3, KVM_CHECK_EXTENSION, 0x25) = 0
ioctl(3, KVM_CHECK_EXTENSION, 0xb) = 1
ioctl(4, KVM_CREATE_PIT, 0xb) = 0
ioctl(3, KVM_CHECK_EXTENSION, 0xf) = 2
ioctl(3, KVM_CHECK_EXTENSION, 0x3) = 1
ioctl(3, KVM_CHECK_EXTENSION, 0) = 1
ioctl(4, KVM_CREATE_IRQCHIP, 0) = 0
ioctl(3, KVM_CHECK_EXTENSION, 0x1a) = 0

QEMU-KVM 调用 KVM 内核模块启动虚拟机的流程概要

  • 获取 /dev/kvm fd(文件描述符)

  • 创建虚拟机,获取虚拟机的句柄

    KVM_CREATE_VM 可以理解成 KVM 为虚拟机创建了对应的数据结构,然后,KVM 会返回一个文件句柄来代表该虚拟机。针对这个句柄执行 ioctl 调用即可完成对虚拟机执行相应的管理,比如:

    • 创建用户空间虚拟地址(Virtual Address)
    • 客户机物理地址GPA(Guest Physical Address)以及主机物理地址HPA(Host Physical Address)之间的映射关系
  • 为虚拟机映射内存和其他的 PCI 设备,以及信号处理的初始化。

    1
    ioctl(kvmfd, KVM_SET_USER_MEMORY_REGION, &mem);
  • 将虚拟机镜像数据映射到内存,相当于物理机的 boot 过程,把操作系统内核映射到内存。

  • 创建 vCPU,并为 vCPU 分配内存空间。KVM_CREATE_VCPU 时,KVM 为每一个 vCPU 生成对应的文件句柄,对其执行相应的 ioctl 调用,就可以对 vCPU 进行管理

    1
    2
    ioctl(kvmfd, KVM_CREATE_VCPU, vcpuid);
    vcpu->kvm_run_mmap_size = ioctl(kvm->dev_fd, KVM_GET_VCPU_MMAP_SIZE, 0);
  • 创建 vCPU 个数的线程并运行虚拟机。

    1
    ioctl(kvm->vcpus->vcpu_fd, KVM_RUN, 0);
  • 线程进入循环,监听并捕获虚拟机退出原因,做相应的处理。这里的退出并不一定指的是虚拟机关机,虚拟机如果遇到 I/O 操作,访问硬件设备,缺页中断等都会执行退出。执行退出可以理解为将 CPU 执行上下文返回到 QEMU。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    open("/dev/kvm")
    ioctl(KVM_CREATE_VM)
    ioctl(KVM_CREATE_VCPU)
    for (;;) {
    ioctl(KVM_RUN)
    switch (exit_reason) { /* 分析退出原因,并执行相应操作 */
    case KVM_EXIT_IO: /* ... */
    case KVM_EXIT_HLT: /* ... */
    }
    }

虚拟化 kvm-qemu io模拟框架

image-20240416112232080

virtio

传统的设备模拟中,虚拟机内部设备驱动完全不知道自己处在虚拟化环境中。对于网络和存储等,I/O操作会完整地走完虚拟机内核栈->QEMU->宿主机内核栈,产生很多的VM Exit和VM Entry,所以性能很差。virtio方案则是旨在提高性能的一种优化方案,在该方案中,虚拟机能够感知到自己处于虚拟化环境,并且会加载相应的virtio总线驱动和virtio设备驱动。

半虚拟化包含两个部分:

  • VMM 创建出模拟的设备 (后端)
  • guest os 安装好该模拟设备的驱动 (前端)

传统的模拟手段, 如模拟网卡收发包过程中, 会配置众多的寄存器和io端口, 每一次都需要陷入陷出VMM, 使得网卡性能较差. 而半虚拟化通过虚拟的设备封装设备请求, 大幅减少陷入陷出的次数.

virtio是一种前后端架构,包括前端驱动(Front-End Driver)和后端设备(Back-End Device)以及自身定义的传输协议。通过传输协议,virtio不仅可以用于QEMU/KVM方案,也可以用于其他的虚拟化方案

前端驱动为虚拟机内部的virtio模拟设备对应的驱动,每一种前端设备都需要有对应的驱动才能正常运行。前端驱动的主要作用是接收用户态的请求,然后按照传输协议将这些请求进行封装,再写I/O端口,发送一个通知到QEMU的后端设备。

后端设备则是在QEMU中,用来接收前端驱动发过来的I/O请求,然后从接收的数据中按照传输协议的格式进行解析,对于网卡等需要实际物理设备交互的请求,后端驱动会对物理设备进行操作,从而完成请求,并且会通过中断机制通知前端驱动

virtio能够支持各种不同的设备,如基于virtio实现的网络架构通常被称为virtio-net

后端设备位于用户态QEMU进程,VCPU 需要暂停执行

image-20240416112239957

virtio设备的初始化

virtio设备首先需要创建一个PCI设备,叫作virtio PCI代理设备,这个代理设备挂到PCI总线上,接着virtio代理设备再创建一条virtio总线,这样virtio设备就可以挂到这条总线上了。

virtio PCI代理的父设备是一个PCI设备,类型为VirtioPCIClass,实例为VirtIOPCIProxy,注意这是一个抽象设备,所以并不能创建其实例,只能由其子类去创建。QEMU中定义了所有virtio设备的PCI代理设备,如virtio balloon PCI设备、virtio scsi PCI设备、virito crypto PCI设备

image-20240416112244530

所有的virtio设备都有一个共同的父类TYPE_VIRTIO_DEVICE

virtio ballon 设备示例

这里以virtio balloon设备为例分析virtio设备的初始化过程。创建virtio balloon时只需要创建其PCI代理设备(即TYPE_VIRTIO_BALLOON_PCI)即可,在命令行指定-device virtio-balloon-pci

通常来说,要改变客户机占用的宿主机内存,是要先关闭客户机,修改启动时的内存配置,然后重启客户机才能实现。而内存的ballooning(气球)技术可以在客户机运行时动态地调整它所占用的宿主机内存资源,而不需要关闭客户机。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
static void virtio_balloon_pci_register(void)
{
virtio_pci_types_register(&virtio_balloon_pci_info);
}
static const VirtioPCIDeviceTypeInfo virtio_balloon_pci_info = {
.base_name = TYPE_VIRTIO_BALLOON_PCI,
.generic_name = "virtio-balloon-pci",
.transitional_name = "virtio-balloon-pci-transitional",
.non_transitional_name = "virtio-balloon-pci-non-transitional",
.instance_size = sizeof(VirtIOBalloonPCI),
.instance_init = virtio_balloon_pci_instance_init,
.class_init = virtio_balloon_pci_class_init,
};
static void virtio_balloon_pci_class_init(ObjectClass *klass, void *data)
{
DeviceClass *dc = DEVICE_CLASS(klass);
VirtioPCIClass *k = VIRTIO_PCI_CLASS(klass);
PCIDeviceClass *pcidev_k = PCI_DEVICE_CLASS(klass);
k->realize = virtio_balloon_pci_realize; // 具现
set_bit(DEVICE_CATEGORY_MISC, dc->categories);
device_class_set_props(dc, virtio_balloon_pci_properties);
pcidev_k->vendor_id = PCI_VENDOR_ID_REDHAT_QUMRANET;
pcidev_k->device_id = PCI_DEVICE_ID_VIRTIO_BALLOON;
pcidev_k->revision = VIRTIO_PCI_ABI_VERSION;
pcidev_k->class_id = PCI_CLASS_OTHERS;
}

QEMU在main函数中会对所有-device的参数进行具现化,设备的具现化函数都会调用device_set_realized函数,在该函数中会调用设备类的realize函数

virtio设备类的继承链关系为DeviceClass->PCIDeviceClass->VirtioPCIClass

image-20240416112248800

image-20240416112252260

virtio驱动的加载

由于virtioPCI代理设备的存在,PCI进行扫描的时候会扫描到这个设备,并且会调用相应驱动的probe函数,virtio_pci_driver及其probe回调函数定义如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
static struct pci_driver virtio_pci_driver = {
.name = "virtio-pci",
.id_table = virtio_pci_id_table,
.probe = virtio_pci_probe, // probe 实现
.remove = virtio_pci_remove,
.sriov_configure = virtio_pci_sriov_configure,
};
static int virtio_pci_probe(struct pci_dev *pci_dev,
const struct pci_device_id *id)
{
struct virtio_pci_device *vp_dev, *reg_dev = NULL;
int rc;
vp_dev = kzalloc(sizeof(struct virtio_pci_device), GFP_KERNEL);
pci_set_drvdata(pci_dev, vp_dev);
vp_dev->vdev.dev.parent = &pci_dev->dev;
vp_dev->vdev.dev.release = virtio_pci_release_dev;
vp_dev->pci_dev = pci_dev;
INIT_LIST_HEAD(&vp_dev->virtqueues);
spin_lock_init(&vp_dev->lock);

/* enable the device */
rc = pci_enable_device(pci_dev); // pci_enable_device使能该PCI设备
if (rc)
goto err_enable_device;
...
rc = virtio_pci_modern_probe(vp_dev); // 初始化该PCI设备对应的virtio设备
if (rc == -ENODEV)
rc = virtio_pci_legacy_probe(vp_dev); // 初始化该PCI设备对应的virtio设备
if (rc)
goto err_probe;
...
pci_set_master(pci_dev);

vp_dev->is_legacy = vp_dev->ldev.ioaddr ? true : false;

rc = register_virtio_device(&vp_dev->vdev);
reg_dev = vp_dev;
if (rc)
goto err_register;

return 0;
err_register:
... // err
return rc;
}

调用pci_enable_device使能该PCI设备,接下来调用virtio_pci_legacy_probe或者virtio_pci_modern_probe来初始化该PCI设备对应的virtio设备,只考虑modern设备,virtio_pci_modern_probe代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
int virtio_pci_modern_probe(struct virtio_pci_device *vp_dev)
{
struct virtio_pci_modern_device *mdev = &vp_dev->mdev;
struct pci_dev *pci_dev = vp_dev->pci_dev;
int err;

mdev->pci_dev = pci_dev;

err = vp_modern_probe(mdev); // 进入 vp_modern_probe
if (err)
return err;

if (mdev->device)
vp_dev->vdev.config = &virtio_pci_config_ops;
else
vp_dev->vdev.config = &virtio_pci_config_nodev_ops;

vp_dev->config_vector = vp_config_vector;
vp_dev->setup_vq = setup_vq;
vp_dev->del_vq = del_vq;
vp_dev->isr = mdev->isr;
vp_dev->vdev.id = mdev->id;

return 0;
}
int vp_modern_probe(struct virtio_pci_modern_device *mdev)
{
struct pci_dev *pci_dev = mdev->pci_dev;
int err, common, isr, notify, device;
u32 notify_length;
u32 notify_offset;

check_offsets();
// 设置device id
if (pci_dev->device < 0x1040) {
mdev->id.device = pci_dev->subsystem_device;
} else {
mdev->id.device = pci_dev->device - 0x1040;
}
// 设置vendor id
mdev->id.vendor = pci_dev->subsystem_vendor;

// 接下来调用多次virtio_pci_find_capability来发现virtio PCI代理设备的pci capability,这也是在virtio_pci_device_plugged写入到virtio PCI代理设备的配置空间中的

/* check for a common config: if not, use legacy mode (bar 0). */
common = virtio_pci_find_capability(pci_dev, VIRTIO_PCI_CAP_COMMON_CFG,
IORESOURCE_IO | IORESOURCE_MEM,
&mdev->modern_bars);
...

/* If common is there, these should be too... */
isr = virtio_pci_find_capability(pci_dev, VIRTIO_PCI_CAP_ISR_CFG,
IORESOURCE_IO | IORESOURCE_MEM,
&mdev->modern_bars);
notify = virtio_pci_find_capability(pci_dev, VIRTIO_PCI_CAP_NOTIFY_CFG,
IORESOURCE_IO | IORESOURCE_MEM,
&mdev->modern_bars);
...
err = dma_set_mask_and_coherent(&pci_dev->dev, DMA_BIT_MASK(64));
device = virtio_pci_find_capability(pci_dev, VIRTIO_PCI_CAP_DEVICE_CFG,
IORESOURCE_IO | IORESOURCE_MEM,
&mdev->modern_bars);

err = pci_request_selected_regions(pci_dev, mdev->modern_bars,
"virtio-pci-modern");
...
err = -EINVAL;
// 调用map_capability将对应的capability在PCI代理设备中的BAR空间映射到内核地址空间,如virtio_pci_device的common成员就映射了virtio_pci_common_cfg的数据到内核中,这样,后续就可以直接通过这个内存地址空间来访问common这个capability了,其他的capability类似。这样实际上就将virtio PCI代理设备的BAR映射到虚拟机内核地址空间了,后续直接访问这些地址即可实现对virtio PCI代理设备的配置和控制
mdev->common = vp_modern_map_capability(mdev, common,
sizeof(struct virtio_pci_common_cfg), 4,
0, sizeof(struct virtio_pci_common_cfg),
NULL, NULL);
...
mdev->isr = vp_modern_map_capability(mdev, isr, sizeof(u8), 1,
0, 1,
NULL, NULL);
...

/* Read notify_off_multiplier from config space. */
pci_read_config_dword(pci_dev,
notify + offsetof(struct virtio_pci_notify_cap,
notify_off_multiplier),
&mdev->notify_offset_multiplier);
/* Read notify length and offset from config space. */
pci_read_config_dword(pci_dev,
notify + offsetof(struct virtio_pci_notify_cap,
cap.length),
&notify_length);

pci_read_config_dword(pci_dev,
notify + offsetof(struct virtio_pci_notify_cap,
cap.offset),
&notify_offset);


if ((u64)notify_length + (notify_offset % PAGE_SIZE) <= PAGE_SIZE) {
mdev->notify_base = vp_modern_map_capability(mdev, notify,
2, 2,
0, notify_length,
&mdev->notify_len,
&mdev->notify_pa);
...
} else {
mdev->notify_map_cap = notify;
}

// virtio_pci_modern_probe函数接着设置virtio_pci_device中virtio_device的成员vdev的config成员。如果有device这个capability,则设置为virtio_pci_config_ops,设置virtio_pci_device的几个回调函数
if (device) {
mdev->device = vp_modern_map_capability(mdev, device, 0, 4,
0, PAGE_SIZE,
&mdev->device_len,
NULL);
...
}

return 0;

err_map_device:
// ... err
}
EXPORT_SYMBOL_GPL(vp_modern_probe);

首先设置了virtio设备的vendor ID和device ID,值得注意的是,virtio PCI代理设备的device ID就是上一节中在virtio_pci_device_plugged函数中设置的0x1040+5,所以这里virtio设备的device ID为5。virtio_pci_modern_probe函数接下来调用多次virtio_pci_find_capability来发现virtio PCI代理设备的pci capability,这也是在virtio_pci_device_plugged写入到virtio PCI代理设备的配置空间中的,virtio_pci_find_capability找到所属的PCI BAR,写入到virtio_pci_device的modern_bars成员中,从QEMU的virtio_pci_realize函数中可以知道这个modern_bars是1<<4。接着pci_request_selected_regions就将virtio PCI代理设备的BAR地址空间保留起来了

virtio_pci_modern_probe函数调用map_capability将对应的capability在PCI代理设备中的BAR空间映射到内核地址空间,如virtio_pci_device的common成员就映射了virtio_pci_common_cfg的数据到内核中,这样,后续就可以直接通过这个内存地址空间来访问common这个capability了,其他的capability类似。这样实际上就将virtio PCI代理设备的BAR映射到虚拟机内核地址空间了,后续直接访问这些地址即可实现对virtio PCI代理设备的配置和控制。virtio_pci_modern_probe函数接着设置virtio_pci_device中virtio_device的成员vdev的config成员。如果有device这个capability,则设置为virtio_pci_config_ops,设置virtio_pci_device的几个回调函数,config_vector与MSI中断有关,setup_vq用来配置virtio设备virt queue,del_vq用来删除virt queue

执行完 virtio_pci_modern_probe 后, 注册的函数指针:

image-20240416112259350

virtio_pci_modern_probe返回之后会调用register_virtio_device,这个函数将一个virtio device注册到系统中

register_virtio_device函数设置virtio设备的Bus为virtio_bus,virtio_bus在系统初始化的时候会注册到系统中。设置virtio设备的名字为类似virtio0virtio1的字符串,然后调用dev->config->reset回调函数重置设备,最后调用device_register将设备注册到到系统中。device_register函数跟设备驱动相关比较大,这里简单介绍一下其作用。该函数会调用device_add将设备加到系统中,并且会发送一个uevent消息到用户空间,这个uevent消息中包含了virtio设备的vendor iddevice id,udev接收到这个消息之后会加载virtio设备的对应驱动(动态驱动的情况下, 如果是打包到kernel的驱动, 这一步则不需要, 直接跳过即可)。device_add会调用bus_probe_device,最终调用到Bus的probe函数和设备的probe函数,也就是virtio_dev_probe和virtballoon_probe函数

一般来讲,virtio驱动初始化一个设备的过程如下:

1)重置设备,这是在上述register_virtio_device函数中通过dev->config->reset调用完成的。

2)设置ACKNOWLEDGE状态位,表示virtio驱动已经知道了该设备,这同样是在register_virtio_device函数中由add_status(dev,VIRTIO_CONFIG_S_ACKNOWLEDGE语句完成的。

3)设置DRIVER状态位,表示virtio驱动知道怎么样驱动该设备,这是在virtio总线的probe函数virtio_dev_probe中通过add_status(dev,VIRTIO_CONFIG_S_DRIVER)完成的。

4)读取virtio设备的feature位,求出驱动设置的feature,将两者计算子集,然后向设备写入这个子集特性,这是在virtio_dev_probe函数中完成的,计算driver_features和device_features,然后调用virtio_finalize_features

5)设置FEATURES_OK特性位,这之后virtio驱动就不会再接收新的特性了,这一步是在函数virtio_finalize_features中通过调用add_status(dev,VIRTIO_CONFIG_S_FEATURES_OK)完成的

6)重新读取设备的feature位,确保设置了FEATURES_OK,否则设备不支持virtio驱动设置的一些状态,表示设备不可用,这同样是在virtio_finalize_features函数中完成的。

7)执行设备相关的初始化操作,包括发现设备的virtqueue、读写virtio设备的配置空间等,这是在virtio_dev_probe函数中通过调用驱动的probe函数完成的,即drv->probe(dev)。

8)设置DRIVER_OK状态位,这通常是在具体设备驱动的probe函数中通过调用virtio_device_ready完成的,对于virtio balloon来说是virtballoon_probe,如果设备驱动没有设置DRIVER_OK位,则会由总线的probe函数virtio_dev_probe来设置

virtio 设备驱动加载过程分析

  1. 启动虚拟机

    image-20240416112303889

  2. 在虚拟机中使用auditd对驱动访问进行监控

    image-20240416112307138

  3. 在虚拟机中调用udevadm monitor对uevent事件进行监控

    image-20240416112309964

  4. 添加virtio-rng-pci设备

    image-20240416112313355

udev可以看到有设备添加的消息

image-20240416112316272

再从audit的日志看,udev确实加载了virtio-rng.ko驱动

image-20240416112319969

使用lspci-v可以看到,所有virtio设备的驱动均为virtio_pci,并没有virtio-rng、virtio-net等驱动

image-20240416112324004

因为virtio设备是由一个PCI的控制器添加的,其本质是一个virtio设备,会挂到virtio总线上,所以PCI总线上只会显示其驱动为virtio-pci

virtio 驱动初始化

先说一下驱动加载过程中的virtio_pci_config_ops, 被赋值给了 virtio_device 结构体的config 成员

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static const struct virtio_config_ops virtio_pci_config_ops = {
.get = vp_get,
.set = vp_set,
.generation = vp_generation,
.get_status = vp_get_status,
.set_status = vp_set_status,
.reset = vp_reset,
.find_vqs = vp_modern_find_vqs,
.del_vqs = vp_del_vqs,
.synchronize_cbs = vp_synchronize_vectors,
.get_features = vp_get_features,
.finalize_features = vp_finalize_features,
.bus_name = vp_bus_name,
.set_vq_affinity = vp_set_vq_affinity,
.get_vq_affinity = vp_get_vq_affinity,
.get_shm_region = vp_get_shm_region,
.disable_vq_and_reset = vp_modern_disable_vq_and_reset,
.enable_vq_after_reset = vp_modern_enable_vq_after_reset,
};

virtio_pci_config_ops结构体中的成员函数通常是代理virtioPCI代理设备的I/O操作,包括读写virtio PCI代理设备的PIO和MMIO,如get_status和set_status成员对应的vp_get_status和vp_set_status函数

1
2
3
4
+ vp_set_status(vdev, status)
\ -+ vp_modern_set_status(&vp_dev->mdev, status);
\ - *cfg = mdev->common
| - vp_iowrite8(status, &cfg->device_status);

vp_dev->mdev->common对应的是virtio PCI代理设备第四个BAR表示的地址中的一段空间,其指向的数据表示如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
struct virtio_pci_common_cfg {
/* About the whole device. */
__le32 device_feature_select; /* read-write */
__le32 device_feature; /* read-only */
__le32 guest_feature_select; /* read-write */
__le32 guest_feature; /* read-write */
__le16 msix_config; /* read-write */
__le16 num_queues; /* read-only */
__u8 device_status; /* read-write */
__u8 config_generation; /* read-only */

/* About a specific virtqueue. */
__le16 queue_select; /* read-write */
__le16 queue_size; /* read-write, power of 2. */
__le16 queue_msix_vector; /* read-write */
__le16 queue_enable; /* read-write */
__le16 queue_notify_off; /* read-only */
__le32 queue_desc_lo; /* read-write */
__le32 queue_desc_hi; /* read-write */
__le32 queue_avail_lo; /* read-write */
__le32 queue_avail_hi; /* read-write */
__le32 queue_used_lo; /* read-write */
__le32 queue_used_hi; /* read-write */
};

上面代码中的每一个成员都表示一个virtio PCI代理设备modern MMIO地址空间中对应的值,读写这些成员都会陷入到QEMU中, 比如设置或者获取设备状态的device_status成员, 该MemoryRegion对应的回调操作结构是common_ops.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
--> hw/virtio/virtio-pci.c
static void virtio_pci_modern_regions_init(VirtIOPCIProxy *proxy)
{
static const MemoryRegionOps common_ops = {
.read = virtio_pci_common_read,
.write = virtio_pci_common_write,
.impl = {
.min_access_size = 1,
.max_access_size = 4,
},
.endianness = DEVICE_LITTLE_ENDIAN,
};
...
}

virtio_pci_config_ops的各个函数封装了这些I/O操作, virtio 设备可以通过这个结构体中的各个回调函数来驱动设备.

virtio balloon 设备驱动初始化示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
+ virtballoon_probe(virtio_device *vdev)
\ - virtio_balloon *vb = kzalloc(sizeof(*vb), GFP_KERNEL)
| - INIT_WORK(&vb->update_balloon_stats_work, update_balloon_stats_func);
| - balloon_devinfo_init(&vb->vb_dev_info);
| -+ init_vqs(vb);
\ - callbacks[VIRTIO_BALLOON_VQ_INFLATE] = balloon_ack;
| - callbacks[VIRTIO_BALLOON_VQ_DEFLATE] = balloon_ack;
| -+ virtio_find_vqs(vb->vdev, VIRTIO_BALLOON_VQ_MAX, vqs, callbacks, names, NULL)
\ -+ vdev->config->find_vqs(vdev, nvqs, vqs, callbacks, names, NULL, desc)
\ -+ vp_modern_find_vqs(vdev, nvqs, vqs, callbacks, names, NULL, desc)
\ -+ vp_find_vqs(vdev, nvqs, vqs, callbacks, names, ctx, desc)
\ -| vp_find_vqs_msix(vdev, nvqs, vqs, callbacks, names, true, ctx, desc);
| -| vp_find_vqs_msix(vdev, nvqs, vqs, callbacks, names, false, ctx, desc);
| -+ vp_find_vqs_intx(vdev, nvqs, vqs, callbacks, names, ctx)
\ - vp_dev->vqs = kcalloc(nvqs, sizeof(*vp_dev->vqs), GFP_KERNEL)
| - request_irq(vp_dev->pci_dev->irq, vp_interrupt, IRQF_SHARED,
dev_name(&vdev->dev), vp_dev) "申请中断"
| -+ qs[i] = vp_setup_vq(vdev, queue_idx++, callbacks[i], names[i],
ctx ? ctx[i] : false, VIRTIO_MSI_NO_VECTOR);
\ -+ vq = vp_dev->setup_vq(vp_dev, info, index, callback, name, ctx,
msix_vec);
\ - vp_modern_get_num_queues(mdev)
| - num = vp_modern_get_queue_size(mdev, index)
| - !num | vp_modern_get_queue_enable(mdev, index)
| - vq = vring_create_virtqueue(index, num,
SMP_CACHE_BYTES, &vp_dev->vdev, true, true, ctx,
vp_notify, callback, name);
| - vp_modern_set_queue_enable(&vp_dev->mdev, [for vq in vqs]->index, true);
| - vb->inflate_vq = vqs[VIRTIO_BALLOON_VQ_INFLATE];
| - vb->deflate_vq = vqs[VIRTIO_BALLOON_VQ_DEFLATE];
| - virtio_device_ready(vdev); "设置driver ok 状态位"

vp_find_vqs函数本质上只调用了一个函数vp_try_to_find_vqs,但是3次调用的参数不同。3次调用的区别主要是virtio设备使用中断的方式,vp_try_to_find_vqs函数的最后两个参数一个是是否使用MSIx的中断方式,另一个是如果使用MSIx中断方式,最后一种是否是每个virtqueue一个vector。virtio设备是否使用MSIx,是由QEMU中virtio PCI代理设备结构VirtIOPCIProxy中的nvectors决定的,而这个值是作为属性添加的. 如virtio PCI代理设备的属性virtio_crypto_pci_properties定义中有DEFINE_PROP_UINT32("vectors",VirtIOPCIProxy,nvectors,2),这句代码表示virtio crypto有两个MSIx的vector。virtio pci balloon设备没有定义这个属性,所以还是使用传统的INTx中断方式,也就是所有的中断都使用一个中断

vp_request_intx申请了一个中断资源,中断处理函数为vp_interrupt, 中断申请之后会对每一个virtqueue调用vp_setup_vq来初始化virtqueue, 这个回调函数同样是在virtio_pci_modern_probe中设置的,为setup_vq

首先得到virtio_pci_device的common成员,这是virtio PCI代理设备中用来配置的一段MMIO,直接读写这些地址会导致陷入到QEMU中的virtio_pci_common_read/write函数

结合setup_vq的代码,可以总结初始化一个virtqueue的步骤:

  1. 如果判断需要初始化的virtqueue的索引大于读取出来的队列,那就返回错误,对应到QEMU中,通过判断VirtQueue中vring成员num(也就是virtqueue)大小不为零来判断队列个数
  2. 读取队列大小,队列大小不能为0并且该队列不处于enable状态
  3. 读取queue_notify_off寄存器的值,这个值表示virtio驱动在通知virtio设备后端时应该写的地址在notify_base中的偏移,QEMU只是简单以队列的索引返回,所以进行通知时,只需要队列索引号*notify_offset_multiplier即可。
  4. 调用alloc_virtqueue_pages分配virtqueue的页面,本质上就是分配vring的descriptor table、available ring和used ring 3个部分,这个3个部分是在连续的物理地址空间中,“info->queue”保存了分配空间的虚拟地址。
  5. 调用vring_new_virtqueue创建一个vring_virtqueue结构,参数中的vp_notify表示virtio驱动用来通知virtio设备的函数,callback表示virtio设备使用了descriptor table之后virtio驱动会调用的函数。vring_virtqueue的第一个成员是virtqueue结构,vring_virtqueue包含了所有virtqueue的信息,vring_new_virtqueue即是用来分配vring_virtqueue的,值得注意的是还多分配了num个void*指针,这是用来在调用使用通知时传递的所谓token。vring_new_virtqueue中还会调用vring_init,这个函数初始化vring,设置vring中队列大小(vring->num)、descriptor table(vring->desc)、avail ring(vring->avail)和used ring(vring->used)的地址。值得注意的是,vring_new_virtqueue会把每个vring_desc的next成员设置为下一个vring_desc的索引
  6. 激活队列,这个步骤会把队列大小,队列的descriptor table、avail ring和used ring的物理地址写入到相应的寄存器中。
  7. 设置virtqueue的priv成员为notify地址。对于virtio balloon来说,notify_offset_multiplier为4个字节, 当virtio驱动调用vp_notify通知virtio设备时,会直接写vq->priv地址, QEMU这边只需要将地址除以notify_offset_multiplier即可找到对应的队列

virtio_balloon设备的init_vqs函数调用之后,相关的数据结构

image-20240416112329901

virtqueue机制

  • 处理批量的异步I/O请求
  • 减少上下文切换次数
  • 基于共享内存机制

Virtqueue机制具体实现—vring

  • 描述符表:保存一系列描述符,每一个描述符都被用来描述一块客户机内的内存区域
  • avail ring:保存后端设备可以使用的描述符
  • used ring:后端驱动已经处理过并且尚未反馈给前端驱动的描述符

image-20240416112333572

virtqueue 机制虽然一定程度上改善了性能, 但是由于io 数据发送接收过程仍然涉及到多次上下文切换, qemu 用户态的virtio的后端需要集中处理来自guest的io 请求, 最后再从host 用户态发给内核态处理这些io请求.

例如 guest 发包给外部网络,首先,guest 需要切换到 host kernel,然后 host kernel 会切换到 qemu 用户态来处理 guest 的请求, Hypervisor 通过系统调用将数据包发送到外部网络后,会切换回 host kernel , 最后再切换回 guest。这样漫长的路径无疑会带来性能上的损失。vhost 正是在这样的背景下提出的一种改善方案

image-20240416112336510

vhost

vhost 是位于 host kernel 的一个模块,用于和 guest 直接通信,数据交换直接在 guest 和 host kernel 之间通过 virtqueue 来进行,qemu 不参与通信,但也没有完全退出舞台,它还要负责一些控制层面的事情,比如和 KVM 之间的控制指令的下发等。

以vhost-net 内核模块举例, 它以一个独立的模块完成 guest 和 host kernel 的数据交换过程。

初始化过程:

  • 通过ioctl初始化vhost-net

image-20240416112340081

guest 通知 host kernel 中vhost-net 模块的事件要借助 host kernel的 kvm.ko 模块来完成

vhost-net 初始化期间,会启动一个工作线程 work 来监听 eventfd,一旦 guest 对vhost-net 发出event,kvm.ko 触发 ioeventfd 通知到 vhost-netvhost-net 通过 virtqueue 的 avail ring 获取数据,并设置 used ring。同样,从 vhost 工作线程向 guest 通信时,也采用同样的机制,只不过这种情况发的是一个回调的 call envent,kvm.ko 触发 irqfd 通知 guest。

可以看到这种架构下, 从guest 中虚拟io设备发送数据到 host的 真实io 设备

  • 通信过程不需要进入到host的用户态处理
  • 异步处理, 多核cpu上不需要终止vcpu的执行, 避免了上下文开销
  • 不影响前端驱动的设计, guest os 不需要额外的修改

Vhost-user架构

Vhost从host 内核迁移到host用户态,一般集成在DPDK等用户态驱动中

采用 UNIX 域套接字来建立QEMU进程与vhost-user之间的联系,进而初始化vhost-user

事件通知机制与Vhost-net相同

数据在用户态传递

image-20240416112343837

VFIO

设备直通就是将物理设备直接挂到虚拟机,虚拟机通过直接与设备交互来获得较好的性能。传统的透传设备到QEMU/KVM虚拟机的方法为PCI passthrough,这种老的设备直通方式需要KVM完成大量的工作,如与IOMMU交互、注册中断处理函数等。显然这种方法会让KVM过多地与设备打交道,扮演一个设备驱动的角色,这种方案不够通用灵活,所以后来有了VFIO(Virtual Function I/O)

VFIO是一个用户态驱动框架,它利用硬件层面的I/O虚拟化技术,如Intel的VT-d和AMD的AMD-Vi,将设备直通给虚拟机。传统上,设备驱动与设备进行交互需要访问设备的很多资源,如PCI设备的配置空间BAR地址空间设备中断等,所有这些资源都是在内核态进行分配和访问的。虚拟化环境下,把设备直通给虚拟机之后,QEMU需要接管所有虚拟机对设备资源的访问。

VFIO的基本思想包括两个部分

  • 将物理设备的各种资源分解,并将获取这些资源的接口向上导出到用户空间
    • QEMU等应用层软件可以利用这些接口获取硬件的所有资源,包括设备的配置空间、BAR空间和中断
  • 聚合,也就是将从硬件设备得到的各种资源聚合起来,对虚拟化展示一个完整的设备接口,这种聚合是在用户空间完成
    • 它从硬件设备分解各种资源之后,会重新聚合成一个虚拟机设备挂到虚拟机上,QEMU还会调用KVM的接口将这些资源与虚拟机联系起来,使得虚拟机内部完全对VFIO的存在无感知,虚拟机内部的操作系统能够透明地与直通设备进行交互,也能够正常处理直通设备的中断请求

image-20240416112347325

  • VFIO Interface作为接口层,用来向应用层导出一系列接口,QEMU等用户程序可以通过相应的ioctl对VFIO进行交互
  • iommu driver是物理硬件IOMMU的驱动实现,如Intel和AMD的IOMMU
  • pci_bus driver是物理PCI设备的驱动程序
  • vfio_iommu是对底层iommu driver的封装,用来向上提供IOMMU的功能,如DMA Remapping以及Interrupt Remapping
  • vfio_pci是对设备驱动的封装,用来向用户进程提供访问设备驱动的功能,如配置空间和模拟BAR

VFIO的重要功能之一是对各个设备进行分区,但是即使有IOMMU的存在,想要以单个设备作为隔离粒度有时也做不到。所以,VFIO设备直通中有3个重要的概念,即containergroupdevice,其关系如图所示

image-20240416112350504

group是IOMMU能够进行DMA隔离的最小单元,一个group内可能只有一个device,也可能有多个device,这取决于物理平台上硬件的IOMMU拓扑结构。设备直通的时候一个group里面的设备必须都直通给一个虚拟机。不能让一个group里的多个device分别从属于2个不同的VM,也不允许部分device在宿主机上而另一部分被分配到虚拟机里,因为这样一个虚拟机中的device可以利用DMA攻击获取另外一个虚拟机里的数据,无法做到物理上的DMA隔离。

device指的是要操作的硬件设备,不过这里的“设备”需要从IOMMU拓扑的角度去理解。如果该设备是一个硬件拓扑上独立的设备,那么它自己就构成一个IOMMU group。如果这里是一个multi-function设备,那么它和其他的function一起组成一个IOMMU group,因为多个function设备在物理硬件上是互联的,它们可以互相访问数据,所以必须放到一个group里隔离起来

container是由多个group组成的,虽然group是VFIO的最小隔离单元,但是有的时候并不是最好的分割粒度。如多个group可能会共享一组页表,通过将多个group组成一个container可以提高系统的性能,也能够方便用户。一般来讲,每个进程/虚拟机可以作为一个container。

image-20240416112353598

VFIO使用方法

  1. 假设需要直通的设备如下所示

    image-20240416112358776

  2. 找到这个设备的VFIO group,这是由内核生成的

    image-20240416112402860

  3. 查看group里面的设备,这个group只有一个设备

    image-20240416112405858

  4. 将设备与驱动程序解绑

    image-20240416112409452

  5. 找到设备的生产商&设备ID

    image-20240416112412904

  6. 将设备绑定到vfio-pci驱动,这会导致一个新的设备节点“/dev/vfio/15”被创建,这个节点表示直通设备所属的group文件,用户态程序可以通过该节点操作直通设备的group

    image-20240416112417061

  7. 修改这个设备节点的属性

    image-20240416112420366

  8. 设置能够锁定的内存为虚拟机内存+一些IO空间

    image-20240416112423822

  9. 向QEMU传递相关参数

    image-20240416112427019

KVM上层管理工具

一个成熟的虚拟化解决方案离不开良好的管理和运维工具,部署、运维、管理的复杂度与灵活性是企业实施虚拟化时重点考虑的问题。KVM目前已经有从libvirt API、virsh命令行工具到OpenStack云管理平台等一整套管理工具,尽管与老牌虚拟化巨头VMware提供的商业化虚拟化管理工具相比在功能和易用性上有所差距,但KVM这一整套管理工具都是API化的、开源的,在使用的灵活性以及对其做二次开发的定制化方面仍有一定优势。

libvirt

libvirt是使用最广泛的对KVM虚拟化进行管理的工具和应用程序接口,已经是事实上的虚拟化接口标准,作为通用的虚拟化API,libvirt不但能管理KVM,还能管理VMware、Hyper-V、Xen、VirtualBox等其他虚拟化方案。

virsh

virsh是一个常用的管理KVM虚拟化的命令行工具,对于系统管理员在单个宿主机上进行运维操作,virsh命令行可能是最佳选择。virsh是用C语言编写的一个使用libvirt API的虚拟化管理工具,其源代码也是在libvirt这个开源项目中的。

virt-manager

virt-manager是专门针对虚拟机的图形化管理软件,底层与虚拟化交互的部分仍然是调用libvirt API来操作的。virt-manager除了提供虚拟机生命周期(包括:创建、启动、停止、打快照、动态迁移等)管理的基本功能,还提供性能和资源使用率的监控,同时内置了VNC和SPICE客户端,方便图形化连接到虚拟客户机中。virt-manager在RHEL、CentOS、Fedora等操作系统上是非常流行的虚拟化管理软件,在管理的机器数量规模较小时,virt-manager是很好的选择。因其图形化操作的易用性,成为新手入门学习虚拟化操作的首选管理软件。

OpenStack

OpenStack是一个开源的基础架构即服务(IaaS)云计算管理平台,可用于构建共有云和私有云服务的基础设施。OpenStack是目前业界使用最广泛的功能最强大的云管理平台,它不仅提供了管理虚拟机的丰富功能,还有非常多其他重要管理功能,如:对象存储、块存储、网络、镜像、身份验证、编排服务、控制面板等。OpenStack仍然使用libvirt API来完成对底层虚拟化的管理。