最近抽空翻看了《虚拟机-系统与进程的通用平台》一书,又在网上翻了一些关于虚拟机的文章,受益非浅,略记一些自己的理解。
计算机系统由上自下主要可以分为三个层次:应用程序-操作系统-硬件平台。由此,就出现了两种主要的虚拟机:进程虚拟机和系统虚拟机。进程虚拟机为一个应用程序提供虚拟的运行环境,它需要对操作系统和硬件平台(或二者之一)作虚拟;而系统虚拟机则为操作系统提供虚拟环境,主要是对硬件平台的虚拟。
进程虚拟机
进程虚拟机对操作系统和硬件平台进行虚拟,以满足应用程序的运行要求。进程虚拟机一般作为主机上的一个进程来运行,这个进程装载客户软件,然后仿真运行之。举例来说,进程虚拟机可以让windows下的软件在linux上运行(wine就是这样一个虚拟机,不过它只是对操作系统的虚拟,并不涉及指令集的仿真)。
进程虚拟机大体上基于如下功能模块来实现:(摘自《虚拟机》)
进程虚拟机启动以后,加载器将客户软件加载到内存,并完成相关的初始化工作,包括对各种信号处理函数的设置等。
初始化完成后,客户软件的代码被当作数据,由仿真引擎读取,并进行解释或翻译。
仿真执行
仿真引擎读取到的客户软件是一系列的指令集合,这些指令可能需要两个层面的仿真:
一、指令集的仿真。如果客户软件的指令集与主机不相同,则需要将其翻译成主机的指令集。
比如X86下的指令add %eax,4可能需要替换成PPC下的addi r4,r4,4。除了指令的变换,寄存器、内存地址都需要做相应的变换。比如X86下的eax替换成PPC下的r4。因为PPC的寄存器比X86要多很多,这种寄存器的直接替换是可行的。但是如果反过来呢?用X86来仿真PPC的时候就不能这样做了,实在不行只能将寄存器映射到内存上面(可怜的是内存比寄存器要慢很多)。内存地址也是需要变换的,客户软件在运行的时候总是认为整个内存地址空间都是属于自己的(尽管并不是所有地址空间都会使用),但是当它在进程虚拟机上面运行时,有一部分内存空间却是属于虚拟机本身的。进程虚拟机在仿真时需要对客户软件所使用的内存地址进行映射,使其不与虚拟机自己使用的内存相冲突。简单的做法是给内存地址加一个基值,而虚拟机使用的内存都小于这个基值。
另外,像add这样的指令还会改变条件码EFLAGS寄存器。于是,在仿真完“增加”的功能之后,还需要进行一系列的判断来决定每一个条件码是否应该被置位(比如溢出标记、零结果标记、等)。这样做会非常的累,所以虚拟机往往会采用懒惰的做法:对于每一个条件码,记录下最后一条会影响到它的指令。而当有后续指令需要关心某个条件码时,再找到这条影响条件码的指令,判断这个条件码是否应该被置位。
二、系统调用的模拟。如果客户软件与主机使用的不是同一操作系统,则需要对系统调用进行模拟。
客户软件中的系统调用代码将被替换成主机上的相关调用(也可能不是系统调用)。但是有些时候,系统调用却是无法模拟的。比如linux下的fork,在 windows下就难以模拟。
异常也是操作系统模拟的一个部分,比如进程虚拟机收到一个信号,可能需要报告给客户软件。要实现这一点,进程虚拟机需要为所有(至少是客户软件需要关心的)信号注册handler。然后截获客户软件注册信号handler的系统调用,为其设置相应的异常索引表项(信号=>函数地址)。那么当进程虚拟机收到一个信号时,虚拟机设置的相应handler被调用。这个handler可以通过异常索引表发现这个信号是被客户软件所关心的,于是跳转到客户软件上对应的的函数地址去。
翻译和优化
以上就是进程虚拟机的一个基本方法。然而,要想让客户软件尽量快地运行,仅对客户软件进行解释执行肯定是不够的。客户软件中的每一条指令都需要被虚拟机一次一次地从内存读出来,然后运行解释程序,将其解释成一系列主机上的指令,然后再去执行它们。如果对客户软件进行翻译,将客户软件指令的解释结果生成代码块,然后缓存起来,就可以免去每一次都进行解释的过程。
以怎样的粒度来翻译代码块呢?是基本块。一个基本块开始于分支或跳转后立即执行的指令、结束于下一条分支或跳转指令。也就是说,一个基本块会被顺序执行,中间不存在分支或跳转指令。而所有的分支或跳转指令只会跳转到一个基本块的第一条指令。
翻译好以后的基本块被放入代码cache中,当执行到一条跳转指令时,通过跳转的目标地址索引到代码cache中对应的基本块,则可以直接跳转到该基本块去执行。当然代码cache中也可能找不到相应的基本块(它可能还未被翻译,或已从cache中清除),这就需要“跳转”到客户软件的相应代码,继续解释执行或进行一次新的翻译。
而执行完一个被翻译的基本块后,程序流程该何去何从呢?按上面的定义,基本块的最后一条指令是跳转指令。但是它们是被翻译以后的,是直接执行的,不由虚拟机干预的。所以最后一条跳转指令应该跳转到虚拟机的处理代码中,由虚拟机程序继续选择下一个已翻译的基本块、或是解释新的基本块。(这种方式是可以进一步优化的,比如通过分支预测技术预测一组顺序运行的基本块,使它们尽量能够顺序运行。而只在预测失败的情况下才跳转回虚拟机的代码中。)
进程虚拟机对于基本块所能做的,除了翻译之外,还可以优化,就像编译器优化目标代码那样。但是编译器只能做静态的优化,而虚拟机却是动态的,可以在实际执行过程中收集到更多有利于优化的信息。(比如像通过函数指针调用函数这样的间接跳转,很难通过静态优化去预测跳转目标,而动态优化则是有可能的。)
剖析
虽然执行翻译后的基本块比重新解释这个基本块要更快一些(省略了重新解释的过程),但是如果这个基本块总共只会执行一次呢?显然翻译基本块的开销更大一些(除了需要解释之外,还需要分配和管理代码cache等)。所以一味进行代码翻译,并不是上策。况且系统的内存有限,也未必能容得下客户软件的所有代码翻译。对翻译后的基本块进行优化也是这样,优化本身也是有开销的,一味进行优化也不是上策。
为了解决解释与翻译(和优化)的矛盾,需要靠剖析客户软件来提供指导数据。简单的说,剖析统计了各个基本块在最近一段时间内的执行次数,如果大于某个值,则应该翻译,再大于某个值,则应该优化,并且随着执行次数的增加可能需要更高级别的优化。
为了对客户软件进行剖析,在解释执行基本块的时候,可以很自然地增加统计剖析数据的代码;而在翻译后的代码中也可以插入这样的统计代码(翻译并不一定严格按照客户软件,这些统计代码就是客户软件里面没有的)。
高级语言虚拟机
进程虚拟机里面有一类特殊的虚拟机,叫做高级语言虚拟机,最出名的莫过于java。高级语言虚拟机和上面描述的进程虚拟机拥有几乎相同的功能模块,但是其客户软件并不是基于某种实际的操作系统和硬件平台的,其操作系统和硬件平台本身就是虚拟的,这种虚拟可以解决前面提到的解释过程中遇到的很多问题,使解释更容易,解释过程也就更快。比如主机和客户机寄存器不匹配的问题,虚拟指令集可以定义尽可能少的寄存器,以便实际的体系结构都能满足它。再比如虚拟机可以定义一套系统API,通过API可以对具体的操作系统接口进行抽象,避免出现系统调用难以仿真的情况。
进程虚拟机一般用于将其他平台上的应用程序快速迁移到主机上来运行。相比之下,程序移植需要付出更大的代价,并且很多程序并不开放源代码,不太可能随意移植。在快速迁移的另一面,根据上面的描述可以看出,应用程序在进程虚拟机下执行的性能比起在实际机器上执行应该是会打很大折扣的。
而像java这样的高级语言虚拟机,定义了更利于仿真的指令集和API,可以把更多的时间用于动态优化,更利于性能的提升。极端情况下,可能比用C写的运行在实际机器上的程序更高效。比如这个程序反复执行的代码非常集中(这些代码显然会被翻译并优化,它们几乎都是直接执行的了)、并且这些代码在执行过程中可能得到更有突破性的动态优化。
系统虚拟机
系统虚拟机对硬件平台进行虚拟,以满足客户操作系统的运行需要。操作系统是对性能要求很高的软件,如果虚拟化使其动辄损失70%~80%的性能,这是让人很难接受的。所以系统虚拟机多用于同一硬件平台下的虚拟,避免指令集的仿真。
那么既然是同一硬件平台,为什么还要虚拟呢?其目的是在同一台物理机器上虚拟出多个机器来。这样做的好处,比如方便服务器部署、安全性考虑、等等。 windows下的vmware、linux下的xen、kvm+qemu都是这样的虚拟机。
系统虚拟机的架构:(摘自《虚拟机》)
VMM管理硬件资源,提供客户操作系统的运行环境,让它们都以为自己是在独占整个硬件资源的。VMM一般就是一个运行在实际机器上的操作系统,然后加入一些VMM的功能。比如kvm作为linux内核的一个模块,加载之后,linux内核就变成了一个VMM。而客户操作系统一般是作为VMM上的一个进程来实现的。
现在的操作系统一般都支持多进程,操作系统本身就给进程提供了一个虚拟机环境:通过分时复用,让进程以为自己独占了一个CPU;通过虚拟内存技术,让进程以为自己独占了整个内存空间;通过系统调用,让进程能够操控硬件设备。可见,将客户操作系统作为VMM上的一个进程来实现,本身就具备了基本的虚拟环境。
但是客户操作系统毕竟是一个操作系统,它是需要跟硬件亲密接触的,进程的虚拟环境并不能满足操作系统运行的需求:进程,一般就是指用户进程,是不能执行CPU特权指令的。而操作系统有时候则必须要能够执行。所以VMM需要给客户操作系统提供执行特权指令的途径;操作系统不仅要关心实际的内存地址空间,还需要为运行于其上的进程提供虚拟地址空间,这一般是通过跟硬件协作来完成的(管理页表,然后由mmu来执行内存地址映射)。所以VMM需要给客户操作系统提供页表和mmu的虚拟;操作系统是通过设备驱动程序直接操作硬件设备的,而在系统虚拟机中,硬件设备主要被VMM控制了(虽然也可能被VMM直接提供给客户操作系统使用)。所以VMM需要给客户操作系统提供设备的虚拟。
CPU的虚拟化
前面说到,系统虚拟机是在同一硬件平台下的虚拟,客户操作系统的指令是可以在主机CPU上直接执行的。但是由于特权指令的存在,在客户操作系统上执行指令时,可能因为遇到特权指令而触发CPU异常。这种情况倒还比较好办,VMM捕捉到这些异常,然后判断:如果客户操作系统正运行在它的用户态,这种情况属于客户操作系统上的进程越权访问,则触发客户操作系统的异常处理过程;而如果客户操作系统正运行在它的内核态,则VMM会替它去执行这条特权指令。
然而,可能存在一些比较讨厌的指令,它们在用户态和内核态下面执行的效果不同,它们是非特权的敏感指令。这样一来,客户操作系统在它的内核态下执行这样的指令时,实际上却是在VMM的用户态下去执行的,达不到预期的效果,可能造成程序错误。然而这样的指令又不属于特权指令,VMM根本无法通过CPU异常来捕捉。于是VMM只好在执行客户操作系统的代码之前先扫描一下将要执行的指令,如果遇到这样的非特权的敏感指令,VMM就将其替换成一条陷阱指令,并且记录下“XX地址原本是YY指令”(显然这个过程还是比较耗时的)。当客户操作系统执行到这里时,就会硬铛铛地触发一次CPU异常,然后再由VMM来处理。
据说当前大部分的硬件平台都具有这样的非特权的敏感指令,在它们之上建立的系统虚拟机很难避免上面说到的指令扫描与替换(vmware就是这样做的)。而一些CPU为了更好地支持虚拟化,也可能通过一些扩展来规避非特权的敏感指令。比如intel的VT-x技术,明确地为虚拟化提供了一种名为VMX的操作模式。在这种模式下,原有的非特权的敏感指令都变成了特权指令(此外还支持很多虚拟化的特征)(kvm就是利用这种模式来实现的)。
内存的虚拟化
客户操作系统本身有自己的虚拟内存管理,它维护了一套页表来实现地址映射。而客户操作系统认为的物理地址实际上是VMM提供的虚拟地址,这个地址还需要通过由VMM维护的页表来进行二次映射,才能得到真正的物理地址。硬件mmu只支持一次映射,而另一次映射如果要通过软件来完成的话,内存访问的效率将会大打折扣。
客户操作系统的页表(记为A=>B)和VMM的页表(记为B=>C)对于VMM来说都是可见的,于是VMM可以将这两个页表综合起来,生成一个A=>C的页表,谓之影子页表。真正被mmu使用的就是这个影子页表。这样一来,客户操作系统的虚拟地址就只需要一次映射便能得到物理地址了。当然,页表A=>B和页表B=>C也必须都保存在内存中的,它们才是真正逻辑上的页表。程序读写页表需要关心的就是它们,而当它们被更新时,VMM必须立刻生成新的影子页表。
但是,如果客户操作系统修改了自己的页表,VMM又怎么知道呢?这还得靠CPU异常。一般来说可以利用CPU特权来保护这些页表所在的内存,当客户操作系统需要更新它们时将触发CPU访存异常。然后再由VMM捕捉异常,替它完成页表的更新,并且顺便更新影子页表。
设备的虚拟化
客户操作系统里面的驱动程序是直接操作硬件设备的,对于设备的虚拟化,一般有两种办法,一是把真实的设备暴露给客户操作系统,让它独享这个设备。而如果想让设备在多个虚拟机之间共享,则VMM需要对设备做虚拟,以便协调多个客户操作系统对设备的使用。VMM需要模仿硬件接口实现虚拟设备(这一点我觉得是最复杂的),然后让客户操作系统看到这些虚拟设备。而客户操作系统对设备的操作都被提交到VMM上,再由VMM转换成实际对设备的操作。比如,VMM给某个客户操作系统提供的一个虚拟磁盘,实际上可能是真实磁盘的一个分区,或者是其中的一个文件。又比如,VMM给某个客户操作系统提供的一个虚拟显示器,实际上可能是VMM所拥有的桌面系统中的一个窗口。再比如,VMM给各客户操作系统提供的网卡,可能是复用实际的网卡来实现的。或者它们就是完全虚拟的网卡,因为在VMM所管理的各个虚拟机之间的网络通信,实际上是不需要借助真实网卡的,只需要VMM做一些报文转发(进程间通信)即可。
中断的虚拟化
除了上述三个虚拟化方面外,中断的虚拟化也是必不可少的。这方面看到的资料比较少,按我的理解,如果设备是由VMM虚拟的,那么VMM可能要通过信号来告知客户操作系统中断的到来,信号处理函数就是客户操作系统的中断处理函数。
而像时钟中断这样的东西,一般每1ms会触发一次。我不知道这样的中断是否会让客户操作系统来处理?如果是,假设主机上运行了100个客户操作系统,那么在这1ms之间,将有100个时钟中断被处理、要经历100次进程切换、中断处理函数里面可能还有VMM需要通过CPU异常来捕捉的特权指令。并且随着客户操作系统的个数增加,情况将会进一步恶化。不知道主机能撑得住多少?而如果时钟中断不会由客户操作系统来处理呢?那么,客户操作系统读写本地时间的操作、定时器相关的操作、等等都必须被VMM捕获并处理。不知道这样是否可行?
准虚拟化方案
如上面说到的,要想为客户操作系统提供一套完整的虚拟机环境,VMM要做的事情还是非常多的,并且有一些事情还是很碍于效率的。于是就出现了准虚拟化的方案(相对于上面说的全虚拟化),这种方案最大的特点是需要修改客户操作系统,使其知道自己是运行在虚拟机环境中的(linux下的xen就是这样一种方案)。于是客户操作系统会主动调用VMM的接口来请求需要的操作(就像进程使用系统调用那样),而不是冒失地去直接操作,再被VMM捕获异常。这样下来,虚拟机与客户操作系统将达成协作,性能会非常之高,几乎能达到实际机器的运行效果。但是方案的缺点是:修改操作系统比较麻烦,并且随着操作系统主干版本的升级,修改版本可能也需要随时同步升级。有些操作系统又并不是开源的,其拥有者不一定愿意为支持你的方案而提供代码修改的支持。
看了几天的虚拟机,感觉里面的水还是非常之深,并且还找不到一本合适的引人深入的书。路漫漫其修远兮……