Nested virtualization

Nested Virtualization

Intel processor已经支持来了VMX 特性,用以运行虚拟机。但是在guest中如果想嵌套地再运行一层hypervisor 是不被允许的,因为VMRunVMResume 是两个特权指令,不能在guest mode下被运行,由此提出了嵌套虚拟化技术。

CPU perspective

image-20210812100834355

define

  • 如图所示,当只有两层的时候,为了说明方便,把硬件之上的hypervisor定义为L0,把L0运行的虚拟机定义为L1,把L1guest套娃运行的虚拟机成为L2。同时相应的,在x86架构下(Intel AMD),从L0到L1有VMCS0->1 ,从L1到L2有VMCS1->2 。但是为了更好地管理L1与L2,L0的hypervisor中还有VMCS0->2 .

exception, trap and emulate

  • CPU在正常情况会由于运行特权指令或者异常指令而导致exception和trap。

  • 相应的,在VMX硬件扩展下,CPU支持root和non-root两种模式。当CPU在non-root模式下执行一些root模式的指令(VMRun,VMResume)等等,就会发生一次VMexit从non-root模式退出到root模式运行。而从软件视角来看就是当虚拟机正在跑自己的代码的时候(指令),突然运行了一条异常或者特权指令就需要切换到root模式去执行,而guest运行时候的状态都会被保存的VMCS结构中。然后加载VMCS结构中的host状态,从而发生一次切换,让CPU运行host的hypervisor处理异常。

嵌套情况下的CPU虚拟化

  • L0为了运行L1需要为L1准备VMCS0->1结构,从硬件角度来说运行L0时CPU处于root-mode,运行L1时,CPU处于non-root-mode,L1并不能感知自己处于guest-mode。
  • 在L1中为了运行L2,需要为L2准备VMCS1->2 ,该结构从L1的视角保存了L2的上下文环境。
  • 而VMCS0->1和VMCS1->2 结合成VMCS0->2,具体结合规则如下:
    • host state区域:VMCS0->2的host region=VMCS0->1的host region,VMCS1->2的host region=VMCS0->1 的guest region
    • guest state区域:VMCS0->2的guest region=VMCS1->2的guest region,VMCS0->1的guest region=VMCS1->2 的host region
    • control data区域:分几种情况,具体怎么合并的看源码。(情况2还没仔细去看源码)
      • 1:VMCS1->2会退出而VMCS0->1不会退出,即L1定义了某个特定事件会发生VMexit,但是L0定义该事件不会退出。由于不论是L1还是L2发生VMexit都是到L0,所以这种情况下,L2因为执行了特殊代码,必须要导致退出,所以在VMCS0->2的control data中必须记录所有这种情况的事件。
      • 2:VMCS1->2不会退出而VMCS0->1会退出。(猜测就不退出了呗,反正1->2不退出,那接着运行不就好了,运行的毕竟是L2的代码,管L1啥事)
      • 3:都会退出的情况。(参考1)

嵌套情况下的VMExit

因为VMrun,VMresume是特权指令,所以在嵌套虚拟化的情况下,L1和L2运行这些指令都会导致VMexit到L0 ,而L0需要对这些指令进行模拟。这里只讲L2情况下的VMExit。

L2的VMExit有两种可能的原因,

  • 一种是外部中断,非屏蔽中断(NMI),还有在VMCS0->2中记录的,但是在VMCS1->2中没有记录的会导致VMExit的事件,这些事件只需要由L0来处理完成之后直接恢复L2即可。
  • 另一种是由于在VMCS1->2中记录的会导致VMExit的事件。这种情况下退出到L0,L0再将退出的原因写入到VMCS1->2中,然后恢复运行L1,这样L1恢复之后就会认为是L2退出导致的事件,然后在L1中处理完了之后要恢复运行L2,会执行VMrun或者VMresume指令,这是特权指令,所以又会导致VMexit进入到L0,L0利用VMCS0->2帮忙模拟,直接运行L2。

嵌套情况下的内存虚拟化

但是对于嵌套情况下的内存虚拟化,由于硬件最多只支持两段page walk,即stage1和stage2,嵌套的情况下可能有三段,甚至更多段的page walk,所以需要对除了stage1以外的page walk压缩成一段。

影子页表:当处理器不支持虚拟化硬件特性的时候,最早是用影子页表。我们都知道,intel是用cr3寄存器来记录地址翻译的时候的页表的物理地址的,但是在虚拟化的环境下,如果只用原本的页表去做地址翻译,只能讲GVA翻译成GPA,但是这并不是真实的物理地址,所以影子页表就此诞生了,它旨在把所有虚拟机内部对于页表的操作(包括缺页中断,INVLPG指令和上下文切换等等),都被VMM拦截,并在VMM生成一个相应的影子页表,对影子页表进行修改,然后把影子页表的基址替换CR3寄存器的页表基址,这样虚拟机恢复运行的时候,就能正确把GVA翻译成HPA了。

在讲压缩过程之前,需要了解intel EPT的特性可以在L0和L1的hypervisor中选择开启或者不开启,所以就有四种选择,这主要是由于L1的vcpu是由L0虚拟化出来的,他可以在里面决定vcpu的特性是否支持VMX.

情况1 情况2 情况3 情况4
L1 影子页表 影子页表 EPT EPT
L0 影子页表 EPT EPT 影子页表

第四种情况比较笨比,有EPT了非要整个影子页表增加overhead,正常情况下肯定没人用啊,就不讲了,讲前面三种,分别对应下图中的1,2,3.

image-20210818191252056

如图所示,SPT(shadow page table),GPT(guest page table),EPT(extend page table)。

  • 1.影子页表+影子页表的形式:L0为了运行L1创建了SPT01,L1为了运行L2创建了SPT12,但是L0同时要为L2的运行,需要为L2准备SPT02。这样就能将L2的GVA通过SPT02的基址替换CR3,就能直接将GVA翻译成HVA了。
  • 2.影子页表+EPT:L1为运行L2准备了SPT12,通过CR3将L2的GVA直接翻译成L1的GPA,然后L1的GPA经过EPT翻译成L0的HPA。
  • 3.EPT+EPT:L0把两个EPT压缩成一个EPT,第一阶段为用L2自身的页表将L2的GVA转换成L2的GPA,然后第二阶段将L2的GPA直接转换成L0的HPA。

缺页中断:当L2发生缺页中断之后,根据如上三种情况形成三种不同的解决路径。

  • 影子页表+影子页表:所有的L2中修改页表的操作都需要被L0拦截,然后生成和修改相应的SPT02,再恢复运行。
  • 影子页表+EPT:所有L2的修改页表操作,都要发生VMexit到L0,但是由于该事件需要由L1处理,所以L0需要把修复页表这个事件注入到L1,L1处理完成之后再恢复运行L2(此时又会发生VMexit,到L0,然后L0运行L2)。所以它甚至比影子页表+影子页表的形式更慢。
  • EPT+EPT:L2缺页首先由自己kernel修复,当GPT修复完成之后再执行访存,会导致EPT缺页,发生VMexit到L0,由于EPT需要由EPT12和EPT01合并形成,所以首先检查EPT12是否有缺失,如果有则L0将EPT缺失事件注入到L1,然后运行L1处理(这里有一个问题,就是所有的EPT12修改,都要导致EPT02的修改,所以L0为了拦截EPT12的修改,将L0中的存储EPT12的内存区域的EPT01设置为只读(设置的是EPT01,设置的区域存储了EPT12),这样L1在运行的时候就会导致内存访问错误,然后L0来帮忙修改),L1当然处理不了,因为是只读权限,所以又发生写内存时间,陷入到L0,L0处理帮忙写了之后恢复到L1,L1处理完了运行vmresume,又退出到L0,然后L0直接运行L2,L2又运行之前出错指令,发现又EPT出错退出到L0,L0继续检查发现是EPT01没有配置,所以他又配置EPT01,然后恢复L2运行。(很绕,但是没得办法。。。)

TLB问题:硬件上有VPID机制,但是在嵌套虚拟化的情况下,需要把L1使用的VPID,映射进L0使用的VPID中,保证没有冲突(paper就简单提了一下,具体需要看源码,回头再来改这里,先这么写着。)

嵌套情况下的IO虚拟化

comming soon