Blog

影子页表(Shadow page table)

影子页表(Shadow page table) #

运行在实模式的 Guest 只需要一个页表就可以完成 GPA 到 HPA 的映射。但在保护模式下,我们知道每个进程都有自己的页表,维护着 GVA 到 GPA 的映射。所以保护模式下的内存转换方式要更加复杂。

在保护模式下的进程,当 Guest 准备访存时,cr3 寄存器此时存放的是 Guest 的页表。如果将这个页表交给 MMU 去查询,得到的将是 GPA 的地址,而不是真正的 HPA 地址。这是因为从 GVA 到 HPA 之间存在三层映射关系,即:

  1. GVA 到 GPA 的映射;
  2. GPA 到 HVA 的映射;
  3. HVA 到 HPA 的映射。

但 MMU 却只有一个。因此,要解决这个问题,我们需要将 cr3 寄存器中指向的 Guest 的页表,替换成为一张从 GVA 到 HPA 映射的页表。当 Guest 再进行访存时,则可以通过这个页表完成完成从 GVA 到 HPA 的转换过程。因为在这个过程中,新建的这张页表实际上会把 Guest 本身的页表给遮挡起来,所以我们称这个页表为影子页表 (Shadow page table)。

我们说过,保护模式下每个进程都需要有自己的页表,同样的,VMM 也需要为 Guest 的每个进程维护一个影子页表。在 Guest 的进程切换过程,要更新 cr3 寄存器指向的页表地址,VMM 要把这个操作拦截下来,将 Guest 页表换成影子页表。影子页表的示意图如下所示:

...

虚拟化技术的三个核心角色

虚拟化技术的三个核心角色 #

在虚拟化技术中涉及的有三个核心角色,分别是宿主机,客户机和虚拟机监控器。宿主机,也被称为 Host,一般指代物理主机。客户机,也被称为 Guest,是指运行在宿主机上的虚拟机。而负责为客户机准备虚拟 CPU,虚拟内存等虚拟资源,并同时对客户机进行管理的模块,就是虚拟机监控器 (Virtual Machine Monitor , VMM)。

虚拟化技术可以让用户相互隔离开。在不同的虚拟机实例中运行的用户,虽然运行在同一个物理主机上,但是相互无法看到对方,这样就很好的保证了虚拟机用户的隐私与安全。在所有的虚拟化实现方案中,内置于 Linux 内核的虚拟化技术,也就是基于内核的虚拟机 (Kernel based Virtual Machine, KVM) 是影响力比较大的一个。

第三个比较重要的组件是 VMM,有些资料中也把它叫做 Hypervisor。正如它的名字的含义,它是负责管理和调度虚拟机的,虚拟机在执行特权指令、处理中断和管理内存等特殊操作时,都需要通过 VMM 来完成相应功能。

Viewpoint #

From #

12 | 内存虚拟化:云原生时代的奠基者

实模式Guest的访存

实模式Guest的访存 #

正常情况下,当一个 Host 系统中启动运行 Guest 系统时,此时的 Host 是处于保护模式的,而 Guest 则因为刚启动,所以需要运行在实模式下。此时又碰到一个问题,Guest 里实模式的代码又如何运行在 Host 处于保护模式下的 CPU 上呢?

这个问题同样需要硬件来支持。在 x86 体系的 CPU 中,可以支持一种虚拟 8086 的模式,这个模式又被称为虚拟 - 实模式,意思是可以让 CPU 在保护模式下来运行实模式的程序。当然这里虚拟 8086 模式下访问的地址,并不意味着程序跟实模式一样,就可以直接访问 Host 的真实物理地址了,只是说在该模式下,程序可以采用同实模式下一样的寻址方式,但访问的地址还是 Host 的虚拟地址,但在 Guest 自己看来,它认为自己访问的是 Guest 的物理地址 (Guest Physical Address,GPA)。

这种情况下,Guest 代码中的逻辑地址到 Host 的物理地址(Host Physical Address, HPA)的转换主要分为三个步骤:

  1. 我们知道实模式下程序访存时是通过段式寻址的方式,也就是说,Guest 程序的逻辑地址可以通过分段机制转换为 GPA,这个过程是由 Guest 模式下 CPU 自发地进行,需要 CPU 运行在上边提到的虚拟 8086 模式下;
  2. Guest 的物理内存可以由 VMM 转换到 Host 的虚拟内存地址(Host Virtual Address,HVA)。这一步的转换过程可以由 VMM 内部维护的数据结构进行查表得出;
  3. 最后一步的转换也是由 VMM 直接调用缺页中断服务函数 (get_user_pages) 将 Host 的虚拟内存映射到物理页。你要注意的是,这一步是 VMM 主动调用的,而不是由中断触发的。

我们知道,在物理机上进行虚拟地址与物理地址转换的话,需要 cr3 寄存器来存放页表。因此,在 Guest 的实模式下,为了能够获取到实际运行的物理地址,我们需要在 VM Enter 的过程中将 cr3 寄存器设置成 VMM 为 Guest 准备的页表。

...

VMX指令支持虚拟化

VMX指令支持虚拟化 #

现代的 X86 芯片提供了 VMX 指令来支持虚拟化,并且在 CPU 的执行模式上提供了两种模式:root mode 和 non-root mode,这两种模式都支持 ring 0 ~ ring 3 三种特权级别。VMM 会运行在 root mode 下,而 Guest 操作系统则运行在 non-root mode 下。所以,对于 Guest 的系统来讲,它也和物理机一样,可以让 kernel 运行在 ring 0 的内核态,让用户程序运行在 ring 3 的用户态,只不过整个 Guest 都是运行在 non-root 模式下。

有了 VMX 硬件的支持,Trap-and-Emulate 就很好实现了。Guest 可以在 non-root 模式下正常执行指令,就如同在执行物理机的指令一样。当遇到“不安全”指令时,例如 I/O 或者中断等操作,就会触发 CPU 的 trap 动作,使得 CPU 从 non-root 模式退出到 root 模式,之后便交由 VMM 进行接管,负责对 Guest 请求的敏感指令进行模拟执行。这个过程称为 VM Exit。

而处于 root 模式下的 VMM,在一开始准备好 Guest 的相关环境,准备进入 Guest 时,或者在 VM Exit 之后执行完 Trap 指令的模拟准备,再次进入 Guest 的时候,可以继续通过 VMX 提供的相关指令 VMLAUNCH 以及 VMResume,来切换到 non-root 模式中由 Guest 继续执行。 这个过程也被称为 VM Enter。

...

Trap-and-Emulate模型

Trap-and-Emulate模型 #

陷入模型的核心思想是:将 Guest 运行的指令进行分类,一类是安全的指令,也就是说这些指令可以让 Host 的 CPU 正常执行而不会产生任何副作用,例如普通的数学运算或者逻辑运算,或者普通的控制流跳转指令等;另一类则是一些“不安全”的指令,又称为“Trap”指令,也就是说,这些指令需要经过 VMM 进行模拟执行,例如中断、IO 等特权指令等。

接下来,我们来看一下它的具体实现过程:对于“安全”的指令,Guest 在执行时可以交由 Host 的 CPU 正常运行,这样可以保证大部分场景的性能。不过,当 Guest 执行一些特权指令时就需要发出 Trap,通知 VMM 来接管 Guest 的控制流。VMM 会对特权指令进行模拟 (Emulate),从而达到资源控制的效果。当然在进行模拟的过程中需要保证执行结果的等价性。

经过这样一个 Trap-and-Emulate 的过程,Guest 就可以在保障等价性以及资源限制的前提下,尽可能地满足虚拟化的高效性的条件。

Viewpoint #

From #

12 | 内存虚拟化:云原生时代的奠基者

虚拟化的三个条件

虚拟化的三个条件 #

1974 年,两位计算机科学家 Gerald Popek 和 Robert Goldberg 发表了一篇重要的论文 《虚拟化第三代体系结构的正式要求》,在这篇论文中提出了虚拟化的三个基本条件:

  1. 等价性即要求在虚拟机环境中运行的程序,应当与在物理机上运行的程序行为一致,且所有能在物理机上运行的程序都应该能够在虚拟机中运行;
  2. 资源限制即要求虚拟机使用的资源需要被进行监督和限制,虚拟机不能越界使用到不属于它的资源;
  3. 高效性即要求在虚拟机中运行的程序与在物理机中运行的程序相比,性能应该无明显的损耗。

这三个条件便为后续的虚拟化技术的发展提供了有效的指导原则,设计良好的虚拟化技术需要同时满足以上三个条件。

Viewpoint #

From #

12 | 内存虚拟化:云原生时代的奠基者

常量折叠

常量折叠 #

这个例子的代码如下:

public static int test() {
    int b = 3;
    int c = 4;
    return b + c;
}

在这块代码中,由于第 2 行对 b 赋值一个常量后,后面的语句没有再改过 b 的值,我们就可以把后面所有出现 b 的地方都改为 3,同理所有出现 c 的地方都改为 4。经过这种优化,代码的第 4 行就可以改写成:

return 3 + 4;

接着,编译器再做一轮分析,将运算符两边都是常量的情况,直接进行计算,也就是把上面的代码再优化成:

return 7;

这种优化被称为常量折叠。(这里也请你思考一下,如果这行代码是 C 语言的,在最优化的情况下,gcc 会生成什么样的机器码?)

Viewpoint #

From #

11 | 即时编译:高性能JVM的核心秘密

退优化Deoptimization

退优化Deoptimization #

这是一个 C 语言编译器没有办法优化,但是 JIT 编译却能进一步优化的例子。在这个例子中,你会发现常量传播不再起作用了:

public static int test(boolean flag) {
    int b = 0;
    if (flag) {
        b = 3;
    }
    else {
        b = 2;
    }
    return b + 4;
}

和第一个例子相比较,这个例子增加了第 3 行到第 8 行的条件判断。所以编译器无法知道在第 9 行 b 的真实取值是什么。只能严格按照这个函数的逻辑去生成比较,跳转,赋值等等,那么这个例子就比可以常量折叠那个例子复杂多了。

一般情况下,虽然这个例子中的 test 函数没有优化空间了,但是 JVM 的 JIT 技术还是在这里找到了最优化的机会。假如存在一种情况,每一次 test 方法被调用的时候,传的参数 flag 都是 true 或者都是 false,也就是说,flag 的取值固定。那么 JIT 编译器就可以认为另外一个分支是不存在的,可以不编译。

JIT 编译器在开始之前,test 方法是由解释器执行的。解释器一边执行,一边会统计 flag 的取值,这种统计就叫做性能采样(Profiling)。当 JIT 编译器发现,test 方法被调用了 500 次(这个阈值可以以 JVM 参数指定),每一次 flag 的值都是 true,那它就可以合理地猜测,下一次可能还是 true,它就会把 test 方法优化成这个样子:

...