当前位置: 首页 > 科技观察

CPU虚拟化:虚拟机的切换进出

时间:2023-03-12 00:55:17 科技观察

本文重点介绍虚拟机CPU如何在Host模式和Guest模式之间切换,以及KVM和物理CPU在Host模式和Guest模式切换时如何保存虚拟CPUcontextual。1.GCC内联汇编KVM模块中切入Guest模式的代码是使用GCC的内联汇编编写的。为了理解这段代码,我们需要简单介绍一下这段内联汇编涉及的语法。基本语法模板如下:asmvolatile(assemblertemplate:outputoperands/*optional*/:inputoperands/*optional*/:listofclobberedregisters/*optional*/);1、关键字asm和volatilasm是GCC的关键字,意思是接下来会嵌入汇编代码,如果asm和程序中的其他命名冲突,可以使用__asm__。volatile是一个可选的关键字,这意味着GCC不需要优化后面的汇编代码。同样,GCC也支持__volatile__。2、汇编指令(assemblertemplate)就是要嵌入的汇编指令。由于汇编代码在C语言中是内联的,所以命令必须用双引号括起来。如果嵌入多行汇编指令,则每条指令占1行,每行指令用双引号括起来,并以后缀\n\t结束,其中\n是换行符的缩写,\t是换行符的缩写选项卡。由于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");使用Extended模式时,即包含output、input和clobberlist部分时,需要两个“%”来指代汇编指令中的寄存器,如%%rax;用一个“%”来指代输入输出操作数,比如%1,是为了帮助GCC区分寄存器和C语言提供的操作数。3、输出操作数(outputoperands)内联汇编有零个或多个输出操作数,用来表示内联汇编指令修改了C代码中的变量。如果有多个输出参数,每个输出参数需要分开。每个输出操作数的格式为:[[asmSymbolicName]]constraint(cvariablename)我们可以为输出操作数指定一个名称asmSymbolicName,它可以在汇编指令中用来引用输出操作数。除了使用名称来指代操作数之外,还可以使用序号来指代操作数。例如,如果有两个输出操作数,您可以使用%0来引用第一个输出操作数,%1来引用第二个操作数,依此类推。输出操作数的约束部分必须以“=”或“+”为前缀,“=”表示只写,“+”表示可读写。在前缀之后,可以有各种约束。例如“=a”表示先将结果输出到rax/eax寄存器,再由rax/eax寄存器更新对应的输出变量。cvariablename是代码中C变量的名称,需要用括号括起来。4.输入操作数(inputoperands)内联汇编可以有零个或多个输入操作数。输入操作数来自C代码中的变量或表达式,用作汇编指令的输入。每个输入操作数的格式如下:[[asmSymbolicName]]constraint(cexpression)与输出操作数相同,也可以为每个输入操作数指定名称asmSymbolicName,可以用来指代输入操作数在装配说明中。除了使用名称来指代输入操作数外,还可以使用序号来指代输入操作数。输入操作数的序号以最后一个输出操作数的序号加1开始。例如,如果有两个输出操作数和三个输入操作数,则需要使用%2来引用第一个输入操作数,并且%3引用第二个输入操作数,依此类推。输入操作数的前缀与输出操作数基本相同,只是它们不必以“=”或“+”前缀开头。除了寄存器约束,我们还会在下面的代码中看到“i”约束,表示输入操作数是一个立即整数(immediateinteger)。cexpression是代码中的C变量或表达式,需要用括号括起来。5.clobberlist有些汇编指令在执行后会产生一些副作用,可能会隐式地影响某些寄存器或内存的值。如果受影响的寄存器或内存未在输入和输出操作数中列出,那么您需要在破坏列表中列出这些寄存器或内存。这样,内联汇编告诉GCC,GCC需要“照顾”这些受影响的寄存器或内存,比如在执行内联汇编指令前保存寄存器,在执行内联汇编指令后恢复寄存器。价值。接下来我们看一个具体的例子。本例为加法运算,一个加数为val,值为100,另一个加数为立即数400,计算结果保存在变量sum中:intval=100,sum=0;asm("movl%1,%%rax;\n\t""movl%c[加数],%%rbx;\n\t""addl%%rbx,%%rax;\n\t""movl%%rax,%0;\n\t":"="(sum):(c)(val),[加数]"i"(400):"rbx");我们先看第3行的汇编指令,因为有寄存器引用和操作数是按序号引用的,所以寄存器是用两个“%”来引用的。%1指的是输入操作数val,其中c的意思是用rcx寄存器来保存val,也就是说在执行这条汇编指令之前,先将val的值赋值给rcx寄存器,然后汇编指令赋值将rcx寄存器的值分配给rax寄存器。第4行汇编指令引用的加数是第二个输入操作数的符号名称。因为这是一个立即值,所以在这个变量前面使用了c修饰符。这是GCC的一种语法,意思是后面跟着一个立即数。第五条指令将rbx寄存器和rax寄存器相加,并将结果保存在rax寄存器中。第六条指令中的%0指的是输出操作数和,在C代码中是一个变量。因为sum是只写输出操作数,所以使用约束“=”。所以第6行的汇编指令是将计算的结果存放在变量sum中。从这段代码可以看出,汇编代码中使用了rbx寄存器,而rbx寄存器并没有出现在输出和输入操作数中,所以内联汇编需要将rbx寄存器包含在clobber列表中,见第10条代码行,告诉GCC汇编指令污染了rbx寄存器。如果需要,需要在执行内联汇编指令之前先保存rbx寄存器,执行完内联汇编指令后再自行恢复rbx寄存器。2.虚拟机切入退出及相关上下文保存了解了内联汇编的语法后,我们开始讨论虚拟机切入退出部分的内联汇编指令: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[失败](%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","内存",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将这些保存的host的通用寄存器从栈中恢复到CPU的物理寄存器中。这里宏R的值在64位是r,32位是e,所以通过定义这个宏,从编码层面上更简洁地支持64位和32位。但是读者可能会有疑问,为什么这里只保存这两个寄存器呢?事实上,KVM的原始实现将所有通用寄存器压入堆栈。后来利用GCC内联汇编的clobberlist特性,将所有可能被内联汇编代码影响的寄存器写入clobberlist,GCC自己负责保存和恢复这些寄存器的内容。代码的第57到61行是clobber列表。有两个特殊寄存器:rdx/edx和rbp/ebp。rdx/edx寄存器是GCC保留的regparm特性,不能放在clobber列表中。另一个rbp/ebp寄存器没有生效,所以KVM手动保存了这两个寄存器。另外,KVM在第8行代码中保存了rcx/ecx寄存器,其中rcx/ecx寄存器有着特殊的使命。从Guest退出到Host时,CPU不会自动保存Guest的一些寄存器,比如通用寄存器,KVM手动保存到结构体vcpu_vmx中的子结构体中。所以在Guest退出的那一刻,首先要获取结构体vcpu_vmx的实例,也就是第三行代码中的变量vmx,将CPU寄存器中的状态保存到这个vmx中,也就是之后保存Guest其他操作只能在Guest的状态之后进行,以免破坏Guest的状态。因此,在从Host切换到Guest之前的最后时刻,KVM将vmx的地址压入栈顶,然后Guest一退出就将vmx从栈顶移除。那么如何将vmx压入栈顶呢?见第47行,这里使用了GCC内联汇编的输入约束,即在执行汇编代码之前,告诉编译器将变量vmx加载到rcx/ecx寄存器中,然后执行第八行代码,当pushrcx/ecx寄存器的内容入栈,实际上是将变量vmx压入栈顶。当Guest退出时,CPU会自动将VMCS中Host的rsp/esp寄存器恢复到物理CPU的rsp/esp寄存器中,所以此时可以访问Host状态的VCPU线程的栈。在Guest退出后的第一行代码,即第36行代码中,调用了xchg指令将栈顶的值与序号为%0的变量进行交换。根据第47行代码,%0指的是变量vmx,对应的寄存器是rcx/ecx,也就是说这行代码恢复了切入前保存到栈顶的变量vmx的地址intoGuest到rcx/ecx寄存器,%0指的是这个地址,所以可以用%0指这个地址保存Guest的寄存器。读者可能会问,Guest不使用vmx这个变量,也没有销毁它,那么Host可以直接使用这个变量吗?实际上,从底层来说,对于存放在栈中的变量vmx,GCC通常使用栈帧基址指针rbp/ebp或者寄存器引用。但是,在Guest第一次退出的时候,除了专用寄存器之外,这些通用寄存器保存的是Guest的状态,所以自然不可能通过rbp/ebp加offset来引用vmx。因为CPU在退出Guest时会自动恢复宿主机的栈顶指针,所以KVM巧妙地利用了这一点,将vmx与栈顶保存在一起。然后,通过交换栈顶和rcx/ecx寄存器的变量,同时引用rcx/ecx寄存器中的vmx,Guest的rcx/ecx寄存器的状态被保存到栈中。获取到保存Guest状态的地址,然后保存Guest的状态,见代码37到43行。退出Guest后的第一行代码(即第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的值,以下面这段代码为例:commit1c696d0e1b7c10e1e8b34cb6c797329e3c33f262KVM:VMX:Simplifysavingguestrcxinvmx_vcpu_runlinux.git/arch/x86/kvm/x86.cvoidkvm_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检测到的cr2物理CPU的寄存器是否与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栈上的保留位置:commit40712faeb84dacfcb3925a88231daa08b3624d34KVM:VMX:Avoidatomicoperationinvmx_vcpu_runlinux。git/arch/x86/kvm/vmx.cstaticvoidvmx_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))...}line7代码是KVM在栈上为Guest的rcx/ecx寄存器预留的空间,第八行代码是将变量vmx压入栈中,在Guest退出的瞬间,CPU的rcx/ecx寄存器存放的是Guest的状态,所以在使用rcx/ecx寄存器之前,状态来宾的需要被保存。保存的位置是进入Guest前KVM在栈上预留的位置,即栈顶的下一个位置,见第12行代码,即栈顶加一个字(word)抵消。保存好Guest的值后,就可以使用rcx/ecx寄存器了,第13行代码将栈顶的值即vmx弹出到rcx/ecx寄存器中。弹出栈顶的vmx后,下面是Guest的rcx/ecx寄存器,所以第16行代码将Guest的rcx/ecx寄存器保存到结构体VCPU中的相关寄存器数组中。