KVM_Qemu

虚拟

说到虚拟化,经常与底层打交道的同学可能十分熟悉,并且咱们平时办公学习可能会接触到不同的操作系统环境,因此这里我们都会普遍用到一个名叫虚拟机的技术,接下来我们就将重点介绍一下这一方面的具体知识

0x00 虚拟化整体架构

首先从架构来说,系统虚拟化的核心思想就是将一台物理机系统虚拟化为多台虚拟机计算机系统,一般来说其中的虚拟环境分为以下三个部分:

  1. 硬件(处理器、内存、IO、网络接口等)
  2. VMM(Virtual Machine Monitor, 虚拟机监控器,别名Hypervisor):负责管理所有资源和虚拟环境支持
  3. 虚拟机

其中如果没有进行系统虚拟化,那么我们的计算机操作系统是直接运行在硬件之上的,在系统虚拟化之后,VMM就成了其中重要的一份子,他取代了传统操作系统的位置,用来调配真实物理硬件,成为了他的管理者,然后向上层软件提供虚拟的硬件平台,而上层的虚拟硬件平台我们又可以通过构建不同的操作系统来达成一套物理硬件环境下同时存在多个操作系统的效果,具体情况如下:

然后我们引入两个基本指令概念——特权指令和敏感指令

  • 特权指令:系统中用来操作和管理资源的指令,在现在的操作系统当中大部分只使用到了ring0和ring3,以此来区别内核和用户,该特权指令就是仅系统软件所能使用的指令
  • 敏感指令:虚拟化世界里面操作特权资源的指令

所以,他俩可以看作一个容纳的关系,也就是特权指令一定是敏感指令,但敏感指令不一定是特权指令,而我们VMM的实现功能中就有完全控制系统资源,敏感指令应该设置为必须在VMM的监控审查下进行,如果说一个系统上所有敏感指令都是特权指令,则我们称其可虚拟化。VMM运行在系统的最高特权级上,然后客户机操作系统运行在非最高特权级上,此时如果客户机操作系统执行敏感指令,他就会陷入到VMM,VMM再模拟执行引起异常的敏感指令,这种方法被称为“陷入再模拟”

但如果说我么的敏感指令并不都是特权指令,那也就是说有的敏感指令无法触发异常,这样就会存在虚拟化漏洞,这也就被定义为不可虚拟化。
接下来我们来介绍VMM的几个重要功能

0x01 处理器虚拟化

我们在x86下实现虚拟化,需要在客户机操作系统下加入虚拟化层,该虚拟化层必须处于ring0级别,而客户机操作系统必须是ring0以上的级别。而如果我们客户机中的特权指令若不是运行在ring0级别会导致虚拟化漏洞,而基于软件虚拟化技术弥补这一漏洞的手段有以下两种:

  1. 全虚拟化:采用二进制代码动态翻译技术(Dynamic Binary Translation),也就是碰到客户机操作系统无法触发异常的敏感指令,会进行一个转换过程,然后由宿主机进行执行,此时客户机不知道自己是虚拟的,以为自己就是正常运行再物理环境下;
  2. 半虚拟化:通过修改客户机操作系统,将所有的敏感指令替换为对底层虚拟化平台的超级调用。此时客户机知道自己处于虚拟环境。

几种架构对比如下,由左至右分别是未虚拟化、全虚拟化、半虚拟化:

1.vCPU

硬件虚拟化采用vCPU(virtual CPU)描述符来描述虚拟CPU,实际上就是一个结构体。在VMM创建客户机的时候,首先要为客户机创建一个vCPU,然后通过VMM进行调度,类似于进程调度。

2.Intel VT-x

虽然我们可以通过处理器软件虚拟化来实现VMM,但是增加了系统的复杂性和开销,因此如果我们在CPU中加入对虚拟化的支持,那么就可以使得系统软件更高效的实现虚拟化,其中该类硬件辅助虚拟化技术就有标题的Intel VT-x和即将讲到的AMD SVM.这俩分别是Intel和AMD两大CPU公司提供的技术。
我们上文讲到一般我们指令的虚拟化是采用陷入再模拟的方式实现的,而IA32架构有19条敏感指令不能通过该方法处理,因此导致了虚拟化漏洞。因此Intel VT中VT-x技术为处理器增加了一套名为Virtual Machine Extensions,VMX,也就是虚拟机扩展的指令集,其中包含十条左右用来支持虚拟化相关的操作,且其中也引入了两种操作模式,统称为VMX操作模式:

  1. 根操作模式(VMX Root Operation):VMM运行所处模式
  2. 非根操作模式(VMX Non-Root Operation):客户机运行所处模式

在非根模式下,所有敏感指令(包括那19条不能被虚拟化的敏感指令)的行为都被重新定义,使得他们可以不通过虚拟化就直接运行或通过陷入再模拟的方式来处理;再根模式下,他所有指令就如传统IA32一样没有改变。
这两种模式均具有0和3特权级,因此描述程序运行在某个特权级应该具体指明处于何种模式。

而该VMX模式在默认情况下是关闭的,当VMM需要使用这个功能的时候,就可以使用VT-x提供的指令来打开此操作模式,大致过程如下:

  1. VMM执行VMXON进入VMX操作模式,此时CPU处于VMX根操作模式,VMM软件开始执行
  2. VMM执行VMLAUNCH或VMRESUME产生VM-Entry,客户机软件开始执行,此时CPU从根模式转换成非根模式
  3. 当客户机执行特权指令,或者客户机发送中断或异常,VM-Exit被触发然后陷入VMM,CPU自动从非根模式切换到根模式,VMM根据其原因做相应处理,然后转至步骤2继续执行
  4. 如果VMM决定退出,则执行VMXOFF关闭VMX操作模式

此外还有VMCS来支持处理器虚拟化,他是一个保存在内存当中的数据结构,一般包含如下几个重要字段:

  1. vCPU标识信息:标识vCPU的一些属性
  2. 虚拟寄存器信息:虚拟的寄存器资源
  3. vCPU状态信息:标识vCPU当前状态
  4. 额外寄存器/部件信息:存储VMCS中没有保存的一些寄存器或CPU部件
  5. 其他信息:存储VMM进行优化或者额外信息的字段

每一个VMCS对应一个虚拟CPU需要的相关信息,CPU在发生VM-Exit和VM-Entry时都会自动查询和更新VMCS

总结以下,整个VT-x架构可以用下图表示

3.AMD SVM

Intel说完了,他的老对手AMD当然也得讲一下,在其中的SVM当中,有很多同Intel VT-x类似的地方,例如他也有根模式和非根模式等,只不过技术上略有不同,这里涉猎较少就掠过了

0x02 内存虚拟化

VMM提供一个虚拟的物理地址空间给客户机操作系统,因此客户机会认为其中的物理地址是连续的,但其实他在真实的物理地址上是随意分布的,因此客户机的物理地址不能直接被发送到系统容总线上去,VMM需要先将客户机物理地址转换为实际的物理地址再交给处理器执行,因此需要解决下面两个问题:

  1. 维护宿主机物理地址和客户机物理地址之间的映射关系
  2. 截获宿主机对客户机物理地址的访问,并根据所记录的映射关系转换成宿主机物理地址

第一个问题中,有两轮地址转换,分别是客户机虚拟地址(GVA,Guest Virtual Address)->客户机物理地址(GPA,Guest Virtual Address)->宿主机虚拟地址(HPA,Host Physical Address),其中第一轮转换是由客户机操作系统通过VMCS(AMD SVM 中的VMCB)中客户机状态域的CR3,也就是pdbr指向的页表来进行转换,也就是客户机自己的一个页目录表,然后第二轮转换则是由VMM决定。

而传统IA32架构只支持以此地址转换,这和虚拟化需要两次转换相矛盾,因此存在一种解决方式,就是直接建立GVA到HPA的映射,其中存放映射关系的表叫做影子页表,也就是Shadow Page Table,但是缺点也十分明显,即映射机制十分复杂。

因此为了优化这一点,Intel公司提供EPT技术,AMD公司提供AMD NPT技术,直接在硬件上支持GVA->GPA->HPA的两次地址转换。

而第二个问题的解决方法是让客户机对宿主机物理地址的访问每一次都触发异常,由VMM查询地址转换表再模仿其访问,但是性能较差

1.Intel EPT

EPT页表存在于VMM内核空间,由VMM来维护,其EPT页表基地址由VMCS的字段来指定,包含了EPT页表的宿主机系统物理地址,通过该页表能够将客户机物理地址最直接翻译成宿主机物理地址,我们这里通过一个流程图来了解此时地址转换的一个过程

假设为二级页表。此时地址翻译过程如下:

  1. 首先通过CR3找到客户机的页表,此时指向的是客户机物理地址GPA,然后CPU通过EPT TLB来进行一个缓存搜索,找到则直接返回HPA,否则CPU再通过EPT MMU,从EPT页表中找到对应值返回HPA
  2. 此时通过GVA在页目录中的对应表项来获取L1页表项的GPA,然后重复上述操作获取HPA,最后找到我们想要访问的GPA,然后再次进行对应转换即可得到最终的HPA

从上面过程中我们可以得知,CPU需要进行大量的内存访问才可以实现最终的地址转换,因此一般我们将EPT TLB设置大一点来尽量减少访问次数。
可能有同学觉得这个根影子页表差不多,但是需要注意一点的是,影子页表是我们基于软件层面实现的,因此十分缓慢且复杂,而这个EPT则是直接由硬件支持,速度快且实现简单。

0x03 I/O虚拟化

0x04 构建KVM环境

KVM是一种用于Linux内核中的虚拟化基础设置,可以将Linux内核转化为一个Hypervisor(VMM),其具体实现方式就是在Linux内核上通过加载一个新的模块使得Linux内核变成一个Hypervisor,在主流Linux内核的v2.6.20后,KVM已经成为了主流Linux内核的一个模块嵌入其中,他不仅支持Linux客户操作系统的虚拟化,同时也支持其他硬件对虚拟化敏感的Windows系统的虚拟化

1.KVM运行过程概述

首先我们知道,KVM模块让Linux主机成为了一个虚拟机监视器,并且在原有的Linux两种执行模式-用户模式和内核模式,增加了一种新的模式,那就是客户模式,他执行非IO的客户代码,虚拟机运行在这个模式之下。

在KVM的模型当中,每一个虚拟机都是一个由Linux调度程序管理的标准进程,都可以使用Linux进程管理命令进行管理,这样就使得我们Linux内核也成为了一个Hypervisor了。
当KVM内核模块被内核加载的时候,KVM模块会首先初始化内部的数据结构,然后检测系统当前的CPU,打开CR4控制器的虚拟化模式开关,通过执行VMXON指令将宿主操作系统(包括KVM模块本身)置于虚拟化模式中的根模式,然后创建特殊设备文件/dev/kvm并且等待来自用户空间的命令,接下来就是用户程序(qemu)和KVM模块的相互配合,主要是通过ioctl调用来进行管理。

2.KVM与QEMU的关系

事实上,qemu自身就有一套完整的虚拟机实现,包括处理器虚拟化、内存虚拟化以及等等外设的模拟,但是他是纯软件实现,因此效率极低,此时有人就提议将KVM和QEMU结合使用,于是他们就修改了qemu的部分代码,使得qemu可以控制KVM内核模块。KVM和QEMU相互配合,QEMU可以通过KVM达到硬件虚拟化的速度,而KVM通过QEMU来模拟设备。他俩的关系简单来讲,就是KVM只模拟CPU和内存,因此一个客户机操作系统可以在宿主机上面运行,但是你看不到他,无法通过外设与他沟通,而我们就可以通过修改QEMU代码,把qemu中模拟CPU,内存的部分换成KVM,而网卡、显示器等保留,因此QEMU+KVM就构成了一个完整的虚拟化平台

3.宿主机Linux环境

我们本次的宿主环境是Ubuntu 20.04,我们可以通过使用命令cat /etc/issue查看,如下:

dawn@dawn-virtual-machine:~$ cat /etc/issue
Ubuntu 20.04.7 LTS \n \l

这里有个点需要注意,如果你的宿主机是安装在vmware workstation上面(我就是这样,虚拟中的虚拟机中的虚拟机),需要勾选这里才可以,注意我这里灰着是因为虚拟机还开着,他需要关闭虚拟机的时候才可以设置

然后我们可以查看CPU是否支持KVM,也就是是否支持虚拟化,可以使用如下命令grep -E -o 'vmx|svm'/proc/cpuinfo

dawn@dawn-virtual-machine:~$ grep -E '(svm|vmx)' /proc/cpuinfo
flags		: fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ss syscall nx pdpe1gb rdtscp lm constant_tsc arch_perfmon rep_good nopl xtopology tsc_reliable nonstop_tsc cpuid pni pclmulqdq vmx ssse3 fma cx16 pcid sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand hypervisor lahf_lm abm 3dnowprefetch cpuid_fault invpcid_single ssbd ibrs ibpb stibp ibrs_enhanced tpr_shadow vnmi ept vpid fsgsbase tsc_adjust bmi1 avx2 smep bmi2 erms invpcid avx512f avx512dq rdseed adx smap avx512ifma clflushopt clwb avx512cd sha_ni avx512bw avx512vl xsaveopt xsavec xgetbv1 xsaves arat avx512vbmi umip pku ospke avx512_vbmi2 gfni vaes vpclmulqdq avx512_vnni avx512_bitalg avx512_vpopcntdq rdpid md_clear flush_l1d arch_capabilities
flags		: fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ss syscall nx pdpe1gb rdtscp lm constant_tsc arch_perfmon rep_good nopl xtopology tsc_reliable nonstop_tsc cpuid pni pclmulqdq vmx ssse3 fma cx16 pcid sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand hypervisor lahf_lm abm 3dnowprefetch cpuid_fault invpcid_single ssbd ibrs ibpb stibp ibrs_enhanced tpr_shadow vnmi ept vpid fsgsbase tsc_adjust bmi1 avx2 smep bmi2 erms invpcid avx512f avx512dq rdseed adx smap avx512ifma clflushopt clwb avx512cd sha_ni avx512bw avx512vl xsaveopt xsavec xgetbv1 xsaves arat avx512vbmi umip pku ospke avx512_vbmi2 gfni vaes vpclmulqdq avx512_vnni avx512_bitalg avx512_vpopcntdq rdpid md_clear flush_l1d arch_capabilities

然后里面可以发现确实存在vmx,说明我们的处理器是支持虚拟化了已经。

我们此时可以查看一下我们的内核版本,如下:

dawn@dawn-virtual-machine:~$ uname -r
4.15.0-142-generic

在linux内核2.6.20版本后KVM已经正式加入到内核发行代码当中,因此咱们没必要下载KVM源码进行编译,我们再次来确认模块当中是否含有KVM,如下:

dawn@dawn-virtual-machine:~$ lsmod|grep kvm
kvm_intel             217088  0
kvm                   614400  1 kvm_intel
irqbypass              16384  1 kvm

4.qemu安装

首先咱们安装依赖环境,如下(注意l和1的区别):

sudo apt-get install gcc libsdl1.2-dev zlib1g-dev libasound2-dev linux-kernel-headers pkg-config libgnutls-dev libpci-dev

依赖装完,我们来下载qemu的源码,直接clone下来

dawn@dawn-virtual-machine:~/qemu/qemu-2.2.1$ git clone https://gitlab.com/qemu-project/qemu.git
Cloning into 'qemu'...
remote: Enumerating objects: 670896, done.
remote: Counting objects: 100% (62/62), done.
remote: Compressing objects: 100% (61/61), done.
remote: Total 670896 (delta 23), reused 0 (delta 0), pack-reused 670834
Receiving objects: 100% (670896/670896), 261.02 MiB | 10.61 MiB/s, done.
Resolving deltas: 100% (558496/558496), done.
Checking connectivity... done.

然后我们cd到qemu目录下,执行

./configure 

然后安装即可:make make install,此处可以采取多线程编译,如果说嫌自己编译太麻烦,可以直接使用
sudo apt-get install qemu-system qemu-user来进行下载

5.客户机安装步骤

首先创建一个镜像文件来做我们的虚拟硬盘,有两种方式:

  1. dd if=/dev/zero of=ubuntu.img bs=1M count=8192,其中/dev/zero这个设备会产生无限的0,也就是说这个操作使得我们生成了一个大小为8192字节的虚拟硬盘,且内容全0;
  2. qemu-img create -f qcow2 win7.img 10G,其中qcow2是文件格式

然后我们需要准备安装系统的ISO文件,这里我们直接到Ubuntu官网下载一个14.04的历史镜像作为我们的客户机
,然后我们直接开启虚拟机,注意这里的参数搭配

dawn@dawn-virtual-machine:~/KVMlearning$ sudo qemu-system-x86_64 -enable-kvm -m 1024 -smp 4 -boot order=cd -hda ./ubuntu.img -cdrom ./ubuntu-14.04.6-desktop-amd64.iso

我们来讲解下面命令的几个基本参数

  • -enable-kvm:表示使用kvm内核开启虚拟机加速,而不是qemu自己的内核。
  • -m 1024:表示给客户机分配1024MB内存
  • -smp 4:表示分配给客户机4个CPU
  • -boot order=cd:表示指定系统的启动顺序为光驱CD-ROM而不是硬盘hard Disk
  • -hda:我们刚刚创建的镜像文件用来作为客户机的硬盘
  • -cdrom:表示分配给客户机的光驱,并在光驱中使用我们上面准备的ISO文件作为系统的启动文件

可以看到,由于我是在vmware workstation上面运行的我的ubuntu 20.04,此时再在里面使用qemu开启另一个14.04虚拟机,
然后我们按照步骤进行安装即可

上面的启动参数类似于咱们重装系统从usb中启动,当我们安装成功之后就不需要这么多参数了,我们只需要从我们建立的虚拟硬盘启动即可

qemu-system-x86_64 -enable-kvm -m 1024 -smp 4 -hda ubuntu.img

0x05 KVM核心模块解析

首先讲解qemu的一些标准选项

  • -name name:设定客户机名称
  • -M machine:设定要模拟的主机类型,例如Ubuntu 14.04PC等
  • -m megs:设定客户机的RAM大小
  • -cpu model:设定CPU模型,例如qemu32、qemu64等
  • -smp:设定模拟的SMP架构中CPU的个数
  • -numa opts:指定模拟多节点的numa设备
  • -fda file:指定file作为软盘镜像
  • -hda/b/c/d file:使用指定file作为硬盘镜像
  • -cdrom file:使用指定file作为CD-ROM镜像,需要注意-cdrom和-hdc不可同时使用

1.内核模块组成

Linux内核2.6.20版本后就将KVM收入到内核当中,主要位于/virt,/arch/x86/kvm这两个目录当中,要想分析Linux KernelMakefileKconfig是理解源代码的最好的地图。Kconfig中包含主要的有三个选项:
KVMKVM-INTEL,KVM-AMD,其中KVM选项是KVM的开关,后面两个就是对应不同厂商。下面我们来看看makefile

# SPDX-License-Identifier: GPL-2.0

ccflags-y += -I $(srctree)/arch/x86/kvm
ccflags-$(CONFIG_KVM_WERROR) += -Werror

ifeq ($(CONFIG_FRAME_POINTER),y)
OBJECT_FILES_NON_STANDARD_vmenter.o := y
endif

KVM := ../../../virt/kv

kvm-y			+= $(KVM)/kvm_main.o $(KVM)/coalesced_mmio.o \
                $(KVM)/eventfd.o $(KVM)/irqchip.o $(KVM)/vfio.o \
                $(KVM)/dirty_ring.o $(KVM)/binary_stats.o
kvm-$(CONFIG_KVM_ASYNC_PF)	+= $(KVM)/async_pf.o

kvm-y			+= x86.o emulate.o i8259.o irq.o lapic.o \
               i8254.o ioapic.o irq_comm.o cpuid.o pmu.o mtrr.o \
               hyperv.o debugfs.o mmu/mmu.o mmu/page_track.o \
               mmu/spte.o

ifdef CONFIG_HYPERV
kvm-y			+= kvm_onhyperv.o
endif

kvm-$(CONFIG_X86_64) += mmu/tdp_iter.o mmu/tdp_mmu.o
kvm-$(CONFIG_KVM_XEN)	+= xen.o

kvm-intel-y		+= vmx/vmx.o vmx/vmenter.o vmx/pmu_intel.o vmx/vmcs12.o \
               vmx/evmcs.o vmx/nested.o vmx/posted_intr.o
kvm-intel-$(CONFIG_X86_SGX_KVM)	+= vmx/sgx.o

kvm-amd-y		+= svm/svm.o svm/vmenter.o svm/pmu.o svm/nested.o svm/avic.o svm/sev.o

ifdef CONFIG_HYPERV
kvm-amd-y		+= svm/svm_onhyperv.o
endif

obj-$(CONFIG_KVM)	+= kvm.o
obj-$(CONFIG_KVM_INTEL)	+= kvm-intel.o
obj-$(CONFIG_KVM_AMD)	+= kvm-amd.o

我们可以看到主要的就是最后三行是主要的点,其中第一项即为KVM核心模块,后面两项就是厂商独立模块了。

2.KVM内核源码结构

先简单介绍下KVM的基本工作原理:
用户模式的qemu通过接口libkvm通过ioctl系统调用进入内核模式,KVM Driver为虚拟机创建虚拟内存和虚拟CPU后执行VMLAUNCH指令进入客户模式,装在客户机且执行。如果客户机发生外部中断或者影子页表却也之类的情况,那就暂停客户机的执行,退出客户模式进行一些必要的处理。处理完毕后重新进入客户模式,执行客户代码。如果发生I/O事件或者信号队列中有信号到达,就会进入用户模式处理。KVM采用全虚拟化技术,客户机不用修改就可以运行。如下图:

而KVM内核模块的实现当中主要包括三大部分:虚拟机的调度执行,内存管理,设备管理。

虚拟机调度执行

直接上图

KVM中的内存管理

KVM使用影子页表实现客户机物理地址到主机物理地址的转换。在KVM中存在一个哈希列表和哈希函数,以客户机页表项中的虚拟页号和该页表所在页表的级别作为键值,如果不为空则说明影子页表已经形成,为空则需要新生成一张表,KVM将获取指向该影子页表的主机物理页号填充到相应的影子页表项的内容中。如果客户机os当中出现进程切换,我们的影子页表就需要全部删除重建。

KVM中的设备管理

KVM通过移植QEMU的设备模型进行设备的管理和访问。在操作系统当中,软件使用可编程I/O(Programmable Input/Output,PIO)和内存映射(Memory Mapping Input/Output,MMIO)与硬件进行交互。硬件可以发出中断请求给可编程控制器,然后通过控制器来经过INTR线来向CPU发出中断请求。所以虚拟机需要捕获并且模拟PIO和MMIO的请求。

  • PIO的捕获:硬件直接提供。当VM发出PIO指令的时候,会发出VM Exit然后硬件会将其原因及对应的指令写入VMCS控制结构当中,这样KVM就会模拟PIO的指令
  • MMIO的捕获:对MMIO页的访问导致缺页异常,被KVM捕获,然后通过x86模拟器模拟执行MMIO指令。其中KVM中的I/O虚拟化都是通过QEMU实现的,而所有的PIO和MMIO指令都是转发到QEMU的

3.KVM API

KVM API实际上就是一组ioctl指令集合,主要功能是为了控制虚拟机的整个生命周期。其所提供的用户空间API从功能上划分,大致可以分为三种类型

API类型 功能说明
System指令 针对虚拟机全局性参数进行查询和设置以及用于虚拟机创建等操作控制
VM指令 影响具体VM虚拟机的属性进行查询和设置,比如内存大小设置、创建VCPU等。VM指令不是进程安全的
vCPU指令 针对具体vCPU进行参数设置,比如MRU寄存器读写、中断控制等

这些API指令都是围绕/dev/kvm来进行的,他是一个标准的字符型设备

dawn@dawn-virtual-machine:~/KVMlearning$ ls -l /dev/kvm
crw-rw----+ 1 root kvm 10, 232 Apr 30 07:02 /dev/kvm

一般来说,用户态程序通过对KVM API的操作是由打开kvm设备文件开始的,通过调用open来获取该文件的一个句柄指针也就是文件描述符,然后通过ioctl系统调用加上特定的指令字来执行我们的操作。

KVM API中的结构体们

首先那就当然是我们的file_operations,他在/linux/fs.h当中定义,用来存储驱动内核模块提供的对设备进行各种操作的函数指针。该结构体的每个域都对应着驱动内核模块用来处理某个被请求事务的函数地址,他定义在/include/linux/fs.h当中:

struct file_operations {
    struct module *owner;
    loff_t (*llseek) (struct file *, loff_t, int);
    ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
    ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
    ssize_t (*read_iter) (struct kiocb *, struct iov_iter *);
    ssize_t (*write_iter) (struct kiocb *, struct iov_iter *);
    int (*iopoll)(struct kiocb *kiocb, bool spin);
    int (*iterate) (struct file *, struct dir_context *);
    int (*iterate_shared) (struct file *, struct dir_context *);
    __poll_t (*poll) (struct file *, struct poll_table_struct *);
    long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
    long (*compat_ioctl) (struct file *, unsigned int, unsigned long);
    int (*mmap) (struct file *, struct vm_area_struct *);
    unsigned long mmap_supported_flags;
    int (*open) (struct inode *, struct file *);
    int (*flush) (struct file *, fl_owner_t id);
    int (*release) (struct inode *, struct file *);
    int (*fsync) (struct file *, loff_t, loff_t, int datasync);
    int (*fasync) (int, struct file *, int);
    int (*lock) (struct file *, int, struct file_lock *);
    ssize_t (*sendpage) (struct file *, struct page *, int, size_t, loff_t *, int);
    unsigned long (*get_unmapped_area)(struct file *, unsigned long, unsigned long, unsigned long, unsigned long);
    int (*check_flags)(int);
    int (*flock) (struct file *, int, struct file_lock *);
    ssize_t (*splice_write)(struct pipe_inode_info *, struct file *, loff_t *, size_t, unsigned int);
    ssize_t (*splice_read)(struct file *, loff_t *, struct pipe_inode_info *, size_t, unsigned int);
    int (*setlease)(struct file *, long, struct file_lock **, void **);
    long (*fallocate)(struct file *file, int mode, loff_t offset,
              loff_t len);
    void (*show_fdinfo)(struct seq_file *m, struct file *f);
#ifndef CONFIG_MMU
    unsigned (*mmap_capabilities)(struct file *);
#endif
    ssize_t (*copy_file_range)(struct file *, loff_t, struct file *,
            loff_t, size_t, unsigned int);
    loff_t (*remap_file_range)(struct file *file_in, loff_t pos_in,
                   struct file *file_out, loff_t pos_out,
                   loff_t len, unsigned int remap_flags);
    int (*fadvise)(struct file *, loff_t, loff_t, int);
} __randomize_layout;

我们可以通过源码看出来他的一些成员就是咱们的系统调用的一些指针。
而KVM提供的接口当中,总的接口是/dev/kvm设备文件,该接口提供了KVM最基本的功能,如查询API版本、创建虚拟机等,对应的设备文件fop结构为kvm_device_fops,他定义在/virt/kvm/kvm_main.c当中:

static const struct file_operations kvm_device_fops = {
    .unlocked_ioctl = kvm_device_ioctl,
    .release = kvm_device_release,
    KVM_COMPAT(kvm_device_ioctl),
    .mmap = kvm_device_mmap,
};

他是一个标准的file_operations结构体,但是中包含了ioctl函数,其他诸如readopen等常用系统调用均默认实现。所以我们就只能在用户态通过ioctl函数进行操作。

在KVM创建虚拟机的过程当中,我们首先通过上述接口创建一个VM,调用函数kvm_dev_ioctl_creat_vm,它实现在/virt/kvm/kvm_main.c当中,代码如下:

static int kvm_dev_ioctl_create_vm(unsigned long type)
{
    int r;
    struct kvm *kvm;
    struct file *file;

    kvm = kvm_create_vm(type);      //核心函数:真正创建vm函数,通过该函数创建一个匿名inode
    if (IS_ERR(kvm))
        return PTR_ERR(kvm);
#ifdef CONFIG_KVM_MMIO
    r = kvm_coalesced_mmio_init(kvm);
    if (r < 0)
        goto put_kvm;
#endif
    r = get_unused_fd_flags(O_CLOEXEC);
    if (r < 0)
        goto put_kvm;

    snprintf(kvm->stats_id, sizeof(kvm->stats_id),
            "kvm-%d", task_pid_nr(current));

    file = anon_inode_getfile("kvm-vm", &kvm_vm_fops, kvm, O_RDWR);
    if (IS_ERR(file)) {
        put_unused_fd(r);
        r = PTR_ERR(file);
        goto put_kvm;
    }

    /*
     * Don't call kvm_put_kvm anymore at this point; file->f_op is
     * already set, with ->release() being kvm_vm_release().  In error
     * cases it will be called by the final fput(file) and will take
     * care of doing kvm_put_kvm(kvm).
     */
    if (kvm_create_vm_debugfs(kvm, r) < 0) {
        put_unused_fd(r);
        fput(file);
        return -ENOMEM;
    }
    kvm_uevent_notify_change(KVM_EVENT_CREATE_VM, kvm);

    fd_install(r, file);
    return r;

put_kvm:
    kvm_put_kvm(kvm);
    return r;
}

在该函数当中调用了kvm_create_vm之后,创建一个匿名inode,对应fop为kvm_vm_fops的结构体。在QEMU当中则是通过ioctl调用/dev/kvm的接口,返回该inode的文件描述符,之后对该VM的操作全部都是通过该文件描述符进行,对应的fop结构定义在/virt/kvm/kvm_main.c当中,代码如下:

static struct file_operations kvm_vm_fops = {
    .release        = kvm_vm_release,
    .unlocked_ioctl = kvm_vm_ioctl,
    .llseek		= noop_llseek,
    KVM_COMPAT(kvm_vm_compat_ioctl),
};

创建完VM,QEMU还需要对每个虚拟机的vCPU创建一个线程,在其中调用kvm_vm_ioctl中的KVM_CREATE_VCPU,该操作通过kvm_vm_ioctl=>kvm_vm_ioctl_create_vcpu=>create_vcpu_fd创建一个名为kvm-vcpu的匿名inode并返回其描述符。之后对每个vCPU的操作都通过该文件描述符进行,该匿名inode的fop定义在/virt/kvm/kvm_main.c当中,如下:

static struct file_operations kvm_vcpu_fops = {
    .release        = kvm_vcpu_release,
    .unlocked_ioctl = kvm_vcpu_ioctl,
    .mmap           = kvm_vcpu_mmap,
    .llseek		= noop_llseek,
    KVM_COMPAT(kvm_vcpu_compat_ioctl),
};

System ioctl调用

这里给出我们system ioctl的一些指令字

  • KVM_CREATE_VM:创建KVM虚拟机,较为重要,通过该参数KVM将返回虚拟机对应的一个文件描述符,它指向内核空间中的一个新的虚拟机。全新的虚拟机没有vCPU,也没有内存,这需要通过后续的ioctl进行配置,使用mmap()系统调用,则会直接返回虚拟机对应的虚拟内存空间,并且内存偏移量为0.
  • KVM_GET_API_VERSION:查询当前KVM API 版本
  • KVM_GET_MSR_INDEX_LIST:获得MSR索引表
  • KVM_CHECK_EXTENSION:检查扩展支持情况
  • KVM_GET_VCPU_MMAP_SIZE:运行虚拟机以及获得用户态空间共享的一片内存区域大小,返回vCPU mmap区域的大小
  • KVM_RUN:ioctl通过共享的内存区域与用户空间进行通信

VM ioctl调用

我们这里的ioctl大多需要通过之前kvm_create_vm函数返回的fd来进行操作,其中具体包括:配置内存,配置vCPU,运行虚拟机等,主要ioctl指令如下:

  • KVM_CREATE_VCPU:为虚拟机创建vCPU,返回一个vCPU对应的fd描述符,然后后续调用下面的KVM_RUN来启动
  • KVM_RUN:运行VM虚拟机,通过mmap()系统调用函数映射vCPU的fd所在的内存空间来获得kvm_run结构体,该结构体位于内存偏移量0中,结束位置在KVM_GET_VCPU_MMAP_SIZE
  • KVM_CREATE_IRQCHIP:创建虚拟APIC,这里APIC应该是可编程中断控制器,然后将之后创建的vCPU关联到此APIC
  • KVM_IRQ_LINE:对某虚拟APIC发送中断信号
  • KVM_GET_IRQCHIP:读取APIC的中断标志信息
  • KVM_SET_IRQCHIP:写入APIC的中断标志信息
  • KVM_GET_DIRTY_LOG: 返回脏内存页的位图

其中kvm_run结构体位于/include/upai/linux/kvm.h当中,其中数据结构过长,因此用一下形式来表示他的字段

字段名 功能
request_interrupt_window 向vCPU当中发出一个中断插入请求,让vCPU做好相关的准备工作
ready_for_interrupt_injection 响应上面的中断请求,当此位有效,说明可以进行中断
if_flag 中断标识,如果使用了APIC,则不起作用
hardware_exit_reason 当vCPU因各种不明原因退出的时候,该字段保存了失败的描述信息
io 为一个结构体,当KVM产生硬件出错的原因是因为I/O输出时,该结构体将保存出错的I/O请求的数据
mmio 为一个结构体,当KVM产生出错的原因时时因为内存I/O映射,该结构体将保存出错的内存I/O映射请求的数据

vCPU ioctl调用

主要针对具体的每一个虚拟机的vCPU进行配置,包括寄存器读写,中断设置,内存设置,开关调试,时钟管理等功能,其中ioctl寄存器控制是最重要的环节,如下:

指令字 功能
KVM_GET_REGS 获取通用寄存器信息,返回kvm_regs
KVM_SET_REGS 设置通用寄存器信息
KVM_GET_SREGS 获取特殊寄存器信息,返回kvm_sregs
KVM_SET_SREGS 设置特殊寄存器信息
KVM_GET_MSRS 获取MSR寄存器信息,返回kvm_msrs
KVM_SET_MSRS 设置MSR寄存器信息
KVM_GET_FPU 获取浮点寄存器信息,返回kvm_fpu
KVM_SET_FPU 设置浮点寄存器信息
KVM_GET_XSAVE 获取vCPU的xsave寄存器信息,返回kvm_xsave
KVM_SET_XSAVE 设置vCPU的xsave寄存器信息
KVM_GET_XCRS 获取vCPU的xcr寄存器信息,返回kvm_xcrs
KVM_SET_XCRS 设置vCPU的xcr寄存器信息

4.kvm内核模块数据结构

在我们创建的虚拟机时,我们一般是通过/dev/kvm字符设备的System ioctl来创建虚拟机VM,其中kvm结构体是关键,一个虚拟机对应一个kvm结构体,虚拟机的创建过程实际上就是kvm结构体的创建和初始化过程,大致如下:

用户态iotcl(fd, KVM_CREATE_VM,...)--->内核态kvm_dev_ioctl()
    kvm_dev_ioctl_creat_vm()
        kvm_create_vm()//首先虚拟机创建的主要函数
            kvm_arch_alloc_vm()//为kvm结构体分配空间
            kvm_arch_init_vm()//初始化kvm结构中的架构相关部分,比如中断等
            hardware_enable_all()//开启硬件、架构的相关操作
                hardware_enable_nolock()
                    kvm_arch_hardware_enable()
                        kvm_x86_ops->hardware_enable()
            kzalloc()//分配memslots结构,并初始化为0
            kvm_init_memslots_id()//初始化内存槽位slot的id信息
            kvm_eventfd_init()//初始化时间通道
            kvm_init_mmu_notifier()//初始化mmu操作的通知链
            list_add(&kvm->vm_list, &vm_list)//将新创建的虚拟机的kvm结构加入到全局链表vm_list当中

KVM_Qemu
https://peiandhao.github.io/2023/06/22/KVM-Qemu/
作者
peiwithhao
发布于
2023年6月22日
许可协议