kavin

CPU虚拟化:虚拟机切入和退出

kavin 运维技术 2022-11-07 459浏览 0

本文重点讨论了虚拟机CPU如何在Host模式和Guest模式之间切换,以及在Host模式和Guest模式切换时,KVM及物理CPU是如何保存虚拟CPU的上下文的。

CPU虚拟化:虚拟机切入和退出

一、GCC内联汇编

KVM模块中切入Guest模式的代码使用GCC的内联汇编编写,为了理解这段代码,我们需要简要地介绍一下这段内联汇编涉及的语法,其基本语法模板如下:

asmvolatile(assemblertemplate
:outputoperands/*optional*/
:inputoperands/*optional*/
:listofclobberedregisters/*optional*/
);

1. 关键字asm和volatile

asm为GCC关键字,表示接下来要嵌入汇编代码,如果asm与程序中其他命名冲突,可以使用__asm__。

volatile为可选关键字,表示不需要GCC对下面的汇编代码做任何优化,类似的,GCC也支持__volatile__。

2. 汇编指令(assembler template)

这部分即要嵌入的汇编指令,由于是在C语言中内联汇编代码,因此须用双引号将命令括起来。如果内嵌多行汇编指令,则每条指令占用1行,每行指令使用双引号括起来,以后缀\n\t结尾,其中\n为newline的缩写,\t为tab的缩写。由于GCC将每条指令以字符串的形式传递给汇编器AS,所以我们使用\n\t分隔符来分隔每一条指令,示例代码如下:

__asm__("movl%eax,%ebx\n\t"
"movl$56,%esi\n\t"
"movl%ecx,$label(%edx,%ebx,$4)\n\t"
"movb%ah,(%ebx)\n\t");

当使用扩展模式,即包含output、input和clobber list部分时,汇编指令中需要使用两个“%”来引用寄存器,比如%%rax;使用一个“%”来引用输入、输出操作数,比如%1,以便帮助GCC区分寄存器和由C语言提供的操作数。

3. 输出操作数(output operands)

内联汇编有零个或多个输出操作数,用来指示内联汇编指令修改了C代码中的变量。如果有多个输出参数,则需要对每个输出参数进行分隔。每个输出操作数的格式为:

[[asmSymbolicName]]constraint(cvariablename)

我们可以为输出操作数指定一个名字asmSymbolicName,汇编指令中可以使用这个名字引用输出操作数。

除了使用名字引用操作数外,还可以使用序号引用操作数。比如输出操作数有两个,那么可以用%0引用第1个输出操作数,%1引用第2个操作数,以此类推。

输出操作数的约束部分必须以“=”或者“+”作为前缀,“=”表示只写,“+”表示读写。在前缀之后,就可以是各种约束了,比如“=a”表示先将结果输出至rax/eax寄存器,然后再由rax/eax寄存器更新相应的输出变量。

cvariablename为代码中的C变量名字,需要使用括号括起来。

4. 输入操作数(input operands)

内联汇编可以有零个或多个输入操作数,输入操作数来自C代码中的变量或者表达式,作为汇编指令的输入,每个输入操作数的格式如下:

[[asmSymbolicName]]constraint(cexpression)

同输出操作数相同,也可以为每个输入操作数指定名字asmSymbolicName,汇编指令中可以使用这个名字引用输入操作数。

除了使用名字引用输入操作数外,还可以使用序号引用输入操作数。输入操作数的序号以最后一个输出操作数的序号加1开始,比如输出操作数有两个,输入操作数有3个,那么需要使用%2引用第1个输入操作数,%3引用第2个输入操作数,以此类推。

除了不必以“=”或者“+”前缀开头外,输入操作数的前缀与输出操作数基本相同。除了寄存器约束外,在后面的代码中我们还会看到“i”这个约束,表示这个输入操作数是个立即数(immediate integer)。

cexpression为代码中的C变量或者表达式,需要使用括号括起来。

5. clobber list

某些汇编指令执行后会有一些副作用,可能会隐性地影响某些寄存器或者内存的值,如果被影响的寄存器或者内存并没有在输入、输出操作数中列出来,那么需要将这些寄存器或者内存列入clobber list。通过这种方式,内联汇编告知GCC,需要GCC“照顾”好这些被影响的寄存器或者内存,比如必要时需要在执行内联汇编指令前保存好寄存器,而在执行内联汇编指令后恢复寄存器的值。

接下来我们来看一个具体的例子。这个例子是一个加法运算,一个加数是val,值为100,另外一个加数是一个立即数400,计算结果保存到变量sum中:

intval=100,sum=0;


asm("movl%1,%%rax;\n\t"
"movl%c[addend],%%rbx;\n\t"
"addl%%rbx,%%rax;\n\t"
“movl%%rax,%0;\n\t”


:“=”(sum)
:(c)(val),[addend]”i”(400)
:“rbx”
);

我们先来看第3行的汇编指令。因为存在寄存器引用和通过序号引用的操作数,所以使用两个“%”引用寄存器。%1引用的是输入操作数val,其中c表示使用rcx寄存器保存val,也就是说在执行这条汇编指令前,首先将val的值赋值到rcx寄存器中,然后汇编指令再将rcx寄存器的值赋值到rax寄存器中。

第4行的汇编指令引用的addend是第2个输入操作数的符号名字,因为这是一个立即数,所以这个变量前面使用了c修饰符。这是GCC的一个语法,表示后面是个立即数。

第5条指令求rbx寄存器和rax寄存器的和,并将结果保存到rax寄存器中。

第6条指令中的%0引用的是输出操作数sum,这是C代码中的变量,因为sum是只写的输出操作数,所以使用约束“=”。所以第6行的汇编指令是将计算的结果存储到变量sum中。

从这段代码中我们看到,在汇编代码中使用了rbx寄存器,而rbx寄存器没有出现在输出、输入操作数中,所以内联汇编需要把rbx寄存器列入clobber list中,见第10行代码,告诉GCC汇编指令污染了rbx寄存器,如果有必要,则需要在执行内联汇编指令前自行保存rbx寄存器,执行内联汇编指令后再自行恢复rbx寄存器。

二、虚拟机切入和退出及相关的上下文保存

了解了内联汇编的语法后,接下来我们开始探讨虚拟机切入和退出部分的内联汇编指令:

staticvoidvmx_vcpu_run(structkvm_vcpu*vcpu)
{
structvcpu_vmx*vmx=to_vmx(vcpu);
…
asm(
/*Storehostregisters*/
"push%%"R"dx;push%%"R"bp;"
"push%%"R"cx\n\t"
"cmp%%"R"sp,%c[host_rsp](%0)\n\t"
"je1f\n\t"
"mov%%"R"sp,%c[host_rsp](%0)\n\t"
__ex(ASM_VMX_VMWRITE_RSP_RDX)"\n\t"
"1:\n\t"
/*Reloadcr2ifchanged*/
"mov%c[cr2](%0),%%"R"ax\n\t"
"mov%%cr2,%%"R"dx\n\t"
"cmp%%"R"ax,%%"R"dx\n\t"
"je2f\n\t"
"mov%%"R"ax,%%cr2\n\t"
"2:\n\t"
/*Checkifvmlaunchofvmresumeisneeded*/
"cmpl$0,%c[launched](%0)\n\t"
/*Loadguestregisters.Don'tclobberflags.*/
"mov%c[rax](%0),%%"R"ax\n\t"
"mov%c[rbx](%0),%%"R"bx\n\t"
…
"mov%c[rcx](%0),%%"R"cx\n\t"/*kills%0(ecx)*/


/*Enterguestmode*/
"jne.Llaunched\n\t"
__ex(ASM_VMX_VMLAUNCH)"\n\t"
"jmp.Lkvm_vmx_return\n\t"
".Llaunched:"__ex(ASM_VMX_VMRESUME)"\n\t"
".Lkvm_vmx_return:"
/*Saveguestregisters,loadhostregisters,keep…*/
"xchg%0,(%%"R"sp)\n\t"
"mov%%"R"ax,%c[rax](%0)\n\t"
"mov%%"R"bx,%c[rbx](%0)\n\t"
"pop"Q"%c[rcx](%0)\n\t"
"mov%%"R"dx,%c[rdx](%0)\n\t"
…
"mov%%cr2,%%"R"ax\n\t"
"mov%%"R"ax,%c[cr2](%0)\n\t"


"pop%%"R"bp;pop%%"R"dx\n\t"
"setbe%c[fail](%0)\n\t"
::"c"(vmx),"d"((unsignedlong)HOST_RSP),
[launched]"i"(offsetof(structvcpu_vmx,launched)),
[fail]"i"(offsetof(structvcpu_vmx,fail)),
[host_rsp]"i"(offsetof(structvcpu_vmx,host_rsp)),
[rax]"i"(offsetof(structvcpu_vmx,
vcpu.arch.regs[VCPU_REGS_RAX])),
[rbx]"i"(offsetof(structvcpu_vmx,
vcpu.arch.regs[VCPU_REGS_RBX])),
…
[cr2]"i"(offsetof(structvcpu_vmx,vcpu.arch.cr2))
:"cc","memory"
,R"ax",R"bx",R"di",R"si"
#ifdefCONFIG_X86_64
,"r8","r9","r10","r11","r12","r13","r14","r15"
#endif
);
…
}

CPU从Host模式切换到Guest模式时,并不会自动保存部分寄存器,典型的比如通用寄存器。因此,第7行代码KVM将宿主机的通用寄存器保存到栈中。当发生VM退出时,KVM从栈中将这些保存的宿主机的通用寄存器恢复到CPU的物理寄存器中。这里,宏R在64位下值为r,32位下为e,所以通过定义这个宏,从编码层面更简洁地支持64位和32位。但是读者可能有疑问,为什么这里只保存这两个寄存器?事实上,KVM最初的实现是将所有的通用寄存器都压入栈中了。后来使用了GCC内联汇编的clobber list特性,将所有可能会被内联汇编代码影响的寄存器都写入clobber list中,GCC自己负责保存和恢复操作这些寄存器的内容。代码第57~61行就是clobber list。这里面有两个特殊的寄存器:rdx/edx和rbp/ebp,其中rdx/edx寄存器是GCC保留的regparm特性,不能放在clobber list中,另外一个rbp/ebp寄存器也不生效,所以KVM手动保存了这两个寄存器。

此外,KVM在第8行代码保存了rcx/ecx寄存器,这里的rcx/ecx寄存器有着特殊的使命。当从Guest退出到Host时,CPU不会自动保存Guest的一些寄存器,典型的如通用寄存器,KVM手动将其保存到了结构体vcpu_vmx中的子结构体中。因此,在Guest退出的那一刻,首先必须要获取结构体vcpu_vmx的实例,也就是第3行代码中的变量vmx,将CPU寄存器中的状态保存到这个vmx中,也就是说,在保存完Guest的状态后,才能进行其他操作,避免破坏Guest的状态。于是,每次从Host切入Guest前的最后一刻,KVM将vmx的地址压入栈顶,然后在Guest退出时从栈顶第一时间取出vmx。那么如何将vmx压入栈顶呢?参见第47行代码,这里使用了GCC内联汇编的input约束,即在执行汇编代码前,告诉编译器将变量vmx加载到rcx/ecx寄存器,那么在执行第8行代码,即将rcx/ecx寄存器的内容压入栈时,实际上是将变量vmx压入栈顶了。

在Guest退出时,CPU会自动将VMCS中Host的rsp/esp寄存器恢复到物理CPU的rsp/esp寄存器中,所以此时可以访问VCPU线程在Host态下的栈。在Guest退出后的第1行代码,即第36行代码,调用xchg指令将栈顶的值和序号%0指代的变量进行交换,根据第47行代码可见,%0指代变量vmx,对应的寄存器是rcx/ecx,也就是说,这行代码将切入Guest之前保存到栈顶的变量vmx的地址恢复到了rcx/ecx寄存器中,%0引用的也是这个地址,那么就可以使用%0引用这个地址保存Guest的寄存器了。

读者可能会问,Guest没有使用变量vmx,也没有破坏它,那么Host是否可以直接使用这个变量呢?事实上,从底层来看,对于存放在栈中的变量vmx,GCC通常使用栈帧基址指针rbp/ebp或寄存器引用。但是,在Guest退出的第一时间,除了专用寄存器,这些通用寄存器中保存的都是Guest的状态,所以自然也无法通过rbp/ebp加偏移的方式来引用vmx。因为退出Guest时CPU自动恢复Host的栈顶指针,所以KVM巧妙地利用了这一点,借助栈顶保存vmx。然后,通过交换栈顶的变量和rcx/ecx寄存器,实现了在rcx/ecx寄存器中引用vmx的同时,又将Guest的rcx/ecx寄存器的状态保存到了栈中。

获取到了保存Guest状态的地址,接下来保存Guest的状态,见代码第37~43行。

退出Guest后的第1行代码(即第36行)将Guest的rcx/ecx寄存器的值保存到了栈中,所以第39行代码从栈顶弹出Guest的rcx/ecx的值到保存Guest状态的内存中rcx/ecx相应的位置。

并不是每次Guest退出到切入,Host的栈都会发生变化,因此Host的rsp/esp也无须每次都更新。只有rsp/esp变化了,才需要更新VMCS中Host的rsp/esp字段,以减少不必要的写VMCS操作。所以KVM在VCPU中记录了host_rsp的值,用来比较rsp/esp是否发生了变化,见代码第9~13行。

将Host的rsp/esp写入VMCS中的指令是:

ASM_VMX_VMWRITE_RSP_RDX

写VMCS的指令有两个参数,一个指明写VMCS中哪个字段,另外一个是写入的值。rsp/esp很好理解,指明写入的值在rsp/esp寄存器里。那么rdx是什么呢?见第47行代码对寄存器rdx/edx的约束:

"d"((unsignedlong)HOST_RSP)

结合宏HOST_RSP的定义:

/*VMCSEncodings*/
enumvmcs_field{
…
HOST_RSP=0x00006c14,
…
};

可见,ASM_VMX_VMWRITE_RSP_RDX就是将rsp/esp的值写入VMCS中Host的rsp字段。

VMX没有定义CPU自动保存cr2寄存器,但是事实上,Host可能更改cr2的值,以下面这段代码为例:

commit1c696d0e1b7c10e1e8b34cb6c797329e3c33f262
KVM:VMX:Simplifysavingguestrcxinvmx_vcpu_run
linux.git/arch/x86/kvm/x86.c


voidkvm_inject_page_fault(structkvm_vcpu*vcpu,…)
{
++vcpu->stat.pf_guest;
vcpu->arch.cr2=fault->address;
kvm_queue_exception_e(vcpu,PF_VECTOR,fault->error_code);
}

所以,在切入Guest前,KVM检测物理CPU的cr2寄存器与VCPU中保存的Guest的cr2寄存器是否相同,如果不同,则需要使用Guest的cr2寄存器更新物理CPU的cr2寄存器,见第14~20行代码。但是绝大数情况下,从Guest退出到下一次切入Guest,cr2寄存器的值不会发生变化,另一方面,加载cr2寄存器的开销很大,所以只有在cr2寄存器发生变化时才需要重新加载cr2寄存器。

有些Guest的退出是由页面异常引起的,比如通过MMIO方式访问外设的I/O,而页面异常的地址会记录在cr2寄存器中,因此在Guest退出时,KVM需要保存Guest的cr2,见代码第42~43行。由于指令格式的限制,mov指令不支持控制寄存器到内存地址的复制,因此需要通过rax/eax寄存器中转一下。

在切入Guest前,除了加载cr2寄存器外,还需要加载那些物理CPU不会自动加载的通用寄存器,见代码第24~27行。

考虑到xchg是个原子操作,会锁住地址总线,因此为了提高效率,后来KVM摒弃了这条指令,设计了一种新的方案。KVM在VCPU的栈中为Guest的rcx/ecx寄存器分配了一个位置。这样,当Guest退出时,在使用rcx/ecx寄存器引用变量vmx前,可以将Guest的rcx/ecx寄存器临时保存到VCPU的栈中为其预留的位置:

commit40712faeb84dacfcb3925a88231daa08b3624d34
KVM:VMX:Avoidatomicoperationinvmx_vcpu_run
linux.git/arch/x86/kvm/vmx.c


staticvoidvmx_vcpu_run(structkvm_vcpu*vcpu)
{
…
asm(
/*Storehostregisters*/
"push%%"R"dx;push%%"R"bp;"
"push%%"R"cx\n\t"/*placeholderforguestrcx*/
"push%%"R"cx\n\t"
…
".Lkvm_vmx_return:"
/*Saveguestregisters,loadhostregisters,…*/
"mov%0,%c[wordsize](%%"R"sp)\n\t"
"pop%0\n\t"
"mov%%"R"ax,%c[rax](%0)\n\t"
"mov%%"R"bx,%c[rbx](%0)\n\t"
"pop"Q"%c[rcx](%0)\n\t"
…
[wordsize]"i"(sizeof(ulong))
…
}

第7行代码就是KVM为Guest的rcx/ecx寄存器在栈上预留的空间,第8行代码是将变量vmx压入栈中。

在Guest退出的那一刻,CPU的rcx/ecx寄存器中存储的是Guest的状态,所以使用rcx/ecx寄存器前,需要将Guest的状态保存起来。保存的位置就是进入Guest前,KVM为其在栈上预留的位置,即栈顶的下一个位置,见第12行代码,即栈顶加上一个字(word)的偏移。

保存好Guest的值后,rcx/ecx寄存器就可以使用了,第13行代码将栈顶的值即vmx弹出到rcx/ecx寄存器中。弹出栈顶的vmx后,下面就是Guest的rcx/ecx寄存器了,所以第16行代码将Guest的rcx/ecx寄存器保存到结构体VCPU中的相关寄存器数组中。

继续浏览有关 系统运维 的文章
发表评论