likes
comments
collection
share

虚拟机十日谈: 第二日-虚拟机基础知识

作者站长头像
站长
· 阅读数 22

1 优化漫谈

问:今天从什么讲起?

答:在讲一般方法之前,先讲优化。

问:什么叫优化?

答:记一个系统S的性能 为P(S) ,对S施加 一 个操作f后产 生 的 新系统 为f(S),其性能为P(f(S))。 如果P(f(S)) > P(S),则称操作f对于系统进行了优化。

问:本来我还挺喜欢优化的.....

答:你就说装不装吧。

问:看不太懂,接着说吧,这个叫优化,然后呢?

答:优化定律:如果对系统进行一项操作的性能期望大于不进行这项操作性能期望,那这项操作就应该进行。

问:这个好复杂。

答:其实很简单,就是如果在期望上有收益,就应该进行某个操作,否则就不应该。

问:学会这个就能优化了?

答:法无定法然后知非法法也,了犹未了何妨以不了了之。

问:写这两句的人还活着嘛?

答:这个定律就是个判断标准,问题关键是上面的那个期望其实不好求。它需要在度量后才知道,比如每个事情发生的几率;有时候性能本身都是不太好测量的东西。

问:说了白说呀。

答:并不是:

  • 我们在度量的基础上,确定热点来优化,就是基于度量的优化方法

  • 有几个轻松容易的推论可以方便的用:

    • 阿姆达尔定律:Make the common case fast

    • 积极优化定律:如果一个东西很大可能发生,那么当它已经发生

    • 消极优化定律:如果一个东西很小可能发生,那么当它不会发生

问:第一个定律听过,后两个没听过。

答:那是你没早认识我。先说第一个,为什么要Make the common case fast,因为the common case的几率较大,所以在最终的期望中的权值也较大。

问:不对吧。万一the uncommon case占用的时间非常多,那权值也不一定大吧。

答:抬杠了吧,我们说的是the common casethe uncommon case相差没这么大的,所以是个可以方便使用的推论。

问:明白了,那后两个呢?也是类似的前提吧?

答:是的。

问:那我明白了,积极优化定律说的是,一个事情发生概率趋近1,那么就当它是1。消极优化定律说的是,一个事情发生概率趋近0,那么就当它是0。可是,有什么用呢?

答:积极优化定律说的是,如果一个东西很大可能发生,那么我们就当它已经发生,而做各种工作。这个最常见的是Cache系统的运用,而运用这个原则要用到局部性原理。

问:这个我学过,不过只能记得模模糊糊的。

答:局部性原理讲的是这样的事情,如果一个事情已经发生,那么它附近的事情也很大几率会发生。它可以具体的拓展为两点:

  • 时间局部性

  • 空间局部性

问:不对啊。我查了百度百科,还有一种叫“顺序局部性”。我看有人写的,都有五个局部性。

答:少看点乱七八糟的东西,当心被噶腰子。空间和时间已经概括了四个维度,它是怎么能折腾其他维度的,是进入了高维世界了嘛?

问:那么什么是时间局部性?

答:我要拿你举个例子。从现在开始,您就不是您了。

问:那我是?

答:您是一位德高望重的老程序员,年近90,依然奋战在编码的第一线。

问:就这么热爱技术。

答:更加令人羡慕的还是您的婚姻生活,虽然您已经快90了,但是新婚妻子才刚刚20,正值妙龄,而且性格温顺、色艺俱佳。

问:给我这么安排的吗?

答:当您晚上3点多愉快的忙完工作回到家,回到家让媳妇一检查手机,发现一件事情,嘿!

问:怎么着?

答:您嘞,出轨了!

问:不是,合着我3 点多没在公司加班啊。

答:你以为呢。

问:我都90了,都快出土了,还出轨?

答:于是啊,您媳妇就用了时间局部性原理。

问:怎么着了?

答:您出轨既不是第一次,也不是最后一次。它发生了一次并被观察到了,那么它将在不久的未来反复发生

问:你这破桥段一股学生社团的赶脚,接梗也生硬无比。那空间局部性呢?

答:我还要拿你举个例子...

问:甭举了,就着上面说吧。

答:您媳妇发现您出轨了之后,就打电话给您同事的媳妇,让她看看老公出轨没。

问:我同事怎么也得80好几了。

答:根据空间局部性原理,由于您出了轨,您的朋友圈大概率也有问题。如果一块空间被访问,那么它临近空间很可能也要被访问。

问:好像这个和时间局部性不太一样,那个不太强调”内存访问“。

答:没错。时间局部性不局限在内存系统,在其他系统一样可以使用。空间局部性,大部分情况下,我们指的是内存访问。不过,这里的内存访问你也不要做狭隘的理解,不单单是访存指令。哈佛结构里面的I-Cache也是用了这个原理。

问:那么cache系统是怎么把局部性原理和积极优化定律结合的呢?

答:cache系统发现,大部分的访问都是符合局部性原理的,所以:

  • 访问一个数据时,同时把它取入cache,以便后面再次访问,这是时间局部性。

  • 取到cache里面时候,是以cache line为单位的,这是空间局部性

    前者叫旧情难忘、后者叫爱屋及乌。无论哪一种,都是为了让大概率要发生的事情先做。在前者,是大概率会被再次使用;在后者,是大概率会被使用。

问:好像是那么回事。那么什么是消极优化定律呢?

答:这个用的最多的是惰性策略,也就是拖延症。

问:比如说。

答:比如说各种惰性求值。当然,更常见的一个例子是我们内存系统里面的写时复制CoW。当我们fork时候,子进程和父进程实际上是使用同一块内存的,如果只是做读操作,那么一切安好。一旦出现了写操作,会重新申请内存来写。

问:这是把写操作推迟了是吗?

答:对的。类似的,惰性策略很多,不等到用的时候不计算是一个很有效的东西,不要为了所谓的代码整齐和软件工程就提前算一些没用的东西。

问:明白了,耽误了大半天,我们接着讲下面的东西吧。

答:妥。

2 如何虚拟ISA

问:有哪些手段来仿真一个机器的指令系统呢,比如说第一步,怎么转成主机能用的指令序列呢?

答:常见的,是一种编译器思维。将guest ISA转成host ISA可以看成两个语言的转换,实际上这种虚拟部件就是一个编译器,不过有时候这种编译并非离线的,而是即时的,就是随用随生成。

问:所以虚拟ISA实际上是一种编译器?

答:是的。不管是采用解释还是采用翻译,都可以归结为编译行为。不过基于解释的模拟器,那就很简单了,直接将每个guest指令对应一个例程,写个switch...case引导解释器跳转过去就行了。只是对于翻译方式的模拟器,它就需要正儿八经的编译了。

2.1 真实的处理器如何译码

问:这个我还能理解。那么真实的处理器也是这样做的吗?

答:你指的是?

问:比如真实的Intelx86处理器,它内部译码怎么实现?

答:对于x86/64来说,译码过程是一个二进制翻译过程。作为界面的CISC是一个guest op,而作为内部真正使用的host op是一种RISC-like的指令。

问:为什么是RISC-like,而不是RISC的呢?

答:这是因为它实际上是译码后的微指令,而不是RISC用作界面的那种指令。以P6为例,内部这种host op是一种长度为118位的指令。

问:好长。

答:因为其中还额外附加了内部执行需要的信息,只是像RISC那样,因为RISC确实是吞吐友好的。

问:这样译码工作也复杂了吧?

答:是的。以Nehalem架构为例,它的译码分成两个阶段:

  • 前一个阶段被称为ILD(instruction length decoder,指令长度解码器),它的结果放到一个buffer里面,这个bufferinstruction queue。这部分的主要工作是,把指令长度计算出来,从而把指令(此处还是作为界面的x86 CISC指令)切分到instruction queue

  • 第二个阶段是一个二进制翻译器,它负责从instruction queue取出来指令,然后翻译成微指令,并放到另一个buffer里面,这个buffer叫做instruction decoder queue

问:这个二进制翻译器做点什么呢?

答:按照指令类型:

  • 对于简单的指令,它parse指令,然后转成1条内部的微指令

  • 对于复杂的指令,它parse指令,然后转成4条内部的微指令

  • 对于更复杂的指令,它parse指令,然后调用microsequencer里面已经准备好的微指令序列

问:前两个我还能理解,最后一个是做什么?

答:实际上,在这里它snapshot了一些复杂指令(用超过4条微指令实现的)实现方式的子例程,并把它们拷贝输出到instruction decoder queue

问:我发现这个过程就是虚拟CPU 仿真的过程。

答:是很像虚拟CPU的解码部分,假作真时真亦假嘛。

2.2 如何执行

问:转成host ISA后,还要进行执行。那一般的基于翻译的模拟器怎么执行呢?

答:转成host ISA序列后,将其写到一块缓冲区,然后变更此缓冲区的页面属性为可执行,将执行指针pc指过去就可以了。

问:这样的话,感觉需要一点权限。

答:其实这样利用了冯诺依曼机的特点:存储程序

问:那是什么?冯诺依曼我知道,它造了第一台计算机。

答:那肯定是你看了盗版书。第一台通用计算机ENIAC的发明人是莫克利和艾克特。冯诺依曼是在使用了这台计算机后,意识到了存储程序架构的。

问:我记混了。

答:冯诺依曼是一位匈牙利裔的美国科学家。他是个神童,全名应该叫约翰冯诺依曼。他本来姓Neumann,后来他跑路美国,为了冒充德国贵族,自己加了一个von,变成了von Neumann

问:冒充?von是个什么意思?

答:是的。在德语国家,如果你祖上是贵族,就可以在姓前面加个von,这个词是德语的介词 ,中文音译过来就是个“冯”。不过不局限在德语国家,其他国家也有类似的玩意。比如咱们熟悉的堂吉诃德那个“堂”也是类似的贵族称号。不过,不一样的是von表示“从何处来”,后来虚化为一个贵族称谓。而Don的西班牙原意是先生。

问:中文也有类似的东西吗?

答:都是中国玩剩下的了。在中国有贵族的魏晋时期,也会提自己从何处来,表示自己的形式不同凡响。这种东西魏晋叫门阀,到唐叫郡望,比如陇西李氏、太原王氏这种。比如刘备走到哪儿挂到哪儿的中山靖王之后。

问:那也是真的,别管隔了多少代。

答:问题是中山靖王刘胜很可能也不是刘邦的后代,有基因学证据来怀疑这一点。除了这一点外,还有很多人为了抬高自己会冒充别人姓,比方说当初的匈奴君主、抓了晋朝皇帝的刘渊就是冒姓刘,他证据是匈奴是汉朝的女婿,倒插门上来了。冯诺伊曼的“冯”也是冒充的。他爸爸的确当了奥匈帝国的贵族,获得Margittai称号,按照道理他可以叫Neumann de Margittai,不过匈牙利和中国一样都是姓前名后,他当时叫Margittai Neumann Janos。他自己给自己加von,是当时的一种常见贴金手段。类似的,还有著名的美国空气动力学家冯卡门,钱学森和郭永怀两位老先生的导师(钱伟长老先生也是跟他读的博后 ),也是假“冯”。这些匈牙利科学家到了美国后,要把名姓倒过来,然后顺手加了一个“冯”。冯卡门当然是伟大的,当然论起来姓氏,他可能还不如两位钱老的“钱”呢。

问:哦?

答:对的。他们都是吴越钱氏,现在在杭州有多处东西纪念他们家。比如那个钱王射潮、钱王祠,都是纪念这一支的开支之祖钱镠的;西湖旁宝石山上的保俶塔,就是纪念这一支最后一位地方割据统治者钱弘俶的。他被宋太祖诚挚友好请到了开封,他舅舅建了这个塔,这一次他正常归来。看来封建迷信有时候还真能撞大运。

问:扯远了不是?先说冯诺依曼吧。

答:冯诺依曼在使用ENIAC时候产生了一系列想法,在EDVAC讨论后,写了一个报告。这个机器和ENIAC一样,都是由莫克利和艾克特发明的。不过由于这个报告,导致老莫和老艾没法申请专利。这个报告里面核心讲了影响至今的冯诺依曼结构。

问:所以尽管冯诺依曼不是ENIAC的发明者,却是冯诺依曼结构的创始人?

答:未必,实际上冯诺依曼架构也是来自莫克利和艾克特的工作。洛必达法则不是洛必达发现的,冯诺依曼结构其实和冯诺依曼关系也没有那么紧密。不过,冯诺依曼在论文中细致描述了这一点,这是他的功绩。实际上,图灵在通用图灵机那篇论文里面就阐述了数据和指令混合存储的思想,当然他没造出来。

问:忙着吃苹果呢。

答:少玩梗,当心有人玻璃心举报你。在规划EDVAC时候,莫克利和艾克特就提出来存储程序的概念。由于在EDVAC的讨论时候,冯诺依曼也在场,加上他记忆力惊人,很可能当时就记住这个东西了。在此前,冯诺依曼没有发表过关于“存储程序”的其他论文,反而是莫克利和艾克特在194312月写过存储程序相关的东西。然后冯诺依曼就在EDVAC建造过程中发表了那个报告(First Draft of a Report on the EDVAC),并且只写了他自己的名字。

问:感觉好像有抄袭的嫌疑啊。

答:没错。不过有些时候,就是抄了你又能如何。冯诺依曼终其一生也没真正解释过这个问题,并且作为计算机奠基人被敬仰。

问:窃钩者诛,窃国者侯?

答:别感慨了,这种事太多了,你还年轻。回到冯诺依曼结构。他将指令和数据混在一起,这使得JIT成为可能。如果指令不能和数据转换,那么就没法在主存动态生成。

问:不是还有那个哈佛结构吗?现在不都是哈佛结构了嘛?

答:都是哈弗结构?你听魏建军说的?至今没有可以实用的哈佛结构的通用计算机,充其量在cache层次,将指令和数据分开。在主存这个层次,仍然是一致的。如果真的要跳出冯诺依曼结构,那么数据就是数据,无法成为指令。核心是:它们用一套编址。这对于目前计算机思维方式都是毁灭性的打击。因为想要动态运行一段代码的话,就先要从外存读取一段程序,那么它是作为数据进来的,还要费劲从数据转成指令,这不是闲的嘛。充其量一些DSP用哈佛结构来做一点小事情,干不出来什么太大的动静。

问:我大致理解一下,JIT就是先把指令当成数据生成,然后再运行?

答:是的。

问:明白了,扯了这么多。

答:只是每种虚拟机会处理特定的情形,我们用到时候再结合代码谈吧。

2.3 翻译单位

问:我有一个问题,翻译一般是按照什么为单位组织的?

答:这个要分情况。在二进制翻译里面,一般是以基本块来组织的;一个guest的基本块对应一块host代码。但是对于高级语言虚拟机,就不用这么麻烦,它们有两种组织方式:

  • 基于trace

  • 基于method

问:这样就是三种翻译单元了是吗?

答:对的:

  • 对于二进制翻译,它没有method概念,在缓存里面用的key只能是pc,所以它以基本块来作为翻译单元。

  • 对于大部分高级语言虚拟机,method是一个组织有效的单位,以method作为切分,也比较利于链接和与解释器的配合。这是主流的高级语言虚拟机使用的手段,包括JSC\Hotspot\v8

  • 对于一些函数很小的脚本类语言,method太小了,反而是一个运行踪迹比较合适,而且执行踪迹相对固定,就以踪迹作为翻译单元。这一类的代表就是luajit,此外还有pypy\tracemonkey,另外Dalvik的早期版本也是用的trace

问:它们有什么利弊呢?

答: trace相当来说复杂一点,在去优化的时候也比较难受。luajit选择trace是因为其场景是一种脚本,执行路径相对固定。更重要是,lua函数相对来说比较小,如果搞成method-based上下文切换开销太大,反而是基于热路径仍好。一般来说,热路径会比method大一些,因为包含分支的选择。另外,trace对于benchmark也很好,因为你总可以调整你的代码,让它适合benchmark

问:还有没有其他的翻译单元。

答:还有一个重要的翻译单元,就是以循环体作为翻译单元。

问:这是因为什么?

答:因为在高级语言虚拟机中,一个函数是否要被JIT需要考察它的热度,这种玩意是一种PGO,还记得吗?我们昨天讲的那个 FX!32就是以PGO闻名的。所谓热度,基本上就是函数被调用的次数。现在就面临一个问题,某个函数包含了一个循环,这个循环进行了很多次,但是函数本身只被执行一次。前述的PGO算法导致此函数不能被JIT,这不合理,更重要的,对benckmark不友好。所以我们除了函数计数器,还要加上循环计数器(或称“回边计数器”)来记录循环次数。

问:但是这样困难吗?

答:应该说难度不太大。我们首先转换一个思维,将循环看成一个特殊的子例程。我们将循环使用的变量,加上循环体自己用的变量(初始值、循环次数、结束条件),一起作为这个循环的参数,这样就转成基于method的了。

2.4 翻译答疑

问:为什么不把所有的代码都JIT呢?

答:那不成了AOT了吗?不选择AOT有很多不同的原因:

  • 在二进制翻译器中,由于翻译后代码实在太大,而且有些东西实际上根本不会走到,所以基于JIT是一种很好的选择。并且SMC等情况也不好由SBT处理。

  • 在高级语言虚拟机里面,又分两种情况:

    • Java这种语言中,一方面PGO可以提供一些优化信息;更重要的是,和runtime结合更紧密,比如反射这种东西,JIT更容易处理。

    • 对于JavaScript这种语言,由于没有类型,不能高效AOT。反而是PGO能够猜出来一些类型常用情况,从而进一步优化。

问:你的意思是其实都能AOT?

答:当然都可以:

  • 二进制翻译器的AOT就是SBT,遇到SMC 这种要走runtime

  • Java这种语言的AOT也要带一个runtime

  • JavaScript语言要带一个非常大的runtime来处理各种类型。

问:就算是C这种语言,也不能不带runtime吧,glibc不就是吗?

答:没错。但是C语言的runtime和我们此处说的Javaruntime是两个概念。C语言的runtime主要是库代码,而真正的运行时(linkerloader)基本上是操作系统+AOT后的代码一起实现,不用放到glibc。如果要类比的话,和glibc对应的应该是Javart库。Java如果要AOT 的话,它的runtime需要包含一些特殊的操作,比如处理前述反射等情况,还要处理GC。所以JavaAOT难点在于搞一个高效的runtime来处理各种复杂情况。

问:也就是说JavaAOT实际上是没什么硬伤的吧?

答:是没什么硬伤,可是也不代表一定是高效率。

问:如果是这样的话,OpenJDK官方为什么不带AOT

答:实际上,已经有了啊,从JDK9JEP295)开始有AOT,就是基于Graaljaotc,不过在JDK17 里面通过JEP410把它给删除了。删除原因语焉不详。

问:所以之后就没有了吗?

答:并非如此。Graal vmAOT仍然还在,Spring的云原生用的就是Graal VM。关于这些,我想聊到Java虚拟机的时候再展开吧。

问:这样我明白了,JavaAOT应该只是选择而已,那么JavaScript呢?

答:对于JS来说,基本上高效的AOT很难做。因为它缺乏类型信息,而大部分的MIR都是基于类型语言的(不然非常多优化没法做),要想继续下去,只能使用PGO+AOT,这个就有所依赖。所以JSAOT基本上挂一个体量和VM类似的runtime,效率并不高。

3 如何虚拟内存

问:如何高效的虚拟内存呢?

答:实际上这个问题牵涉两个方面:

  • 如何模拟内存

  • 如何翻译内存模型

3.1 系统模拟器如何做

对于前面一个问题,在系统级模拟器里面是一个平凡(naive)的问题。我们只需要用一块内存来模拟guest的一块物理内存即可。当然,如果你愿意,也可以把这个问题搞复杂一些,也就是用一些不连续的内存来模拟guest的一块连续物理内存,不过这个只是让你的细节处理更多,带不来理论上的难度。

对于后者,实际上也包含两个问题,第一是如何确定guest的地址和host地址对应关系,更准确的说,这是一个MMU模拟的问题;第二,如何在host建立guestmemory model。后一个问题,比较复杂,我们会在多线程模拟的时候来讲。

问:MMU的模拟好像也比较简单,直接给一个对应表格就行。而且基本上都是线性模型对线性模型,所以其实知道起始地址对应关系就可以。

答:实际上却未必,我们来描述一个场景,因为在CPU模拟中我们拦截到的地址都是guest的虚拟地址。

问:这是什么意思?难道guest OS kernel里面的代码也是虚拟地址吗?

答:对的。凡是写在代码里面的都是virtual address。我们的模拟器拦截到了这个虚拟地址,比如load regA, [Address]。按照常理:

  • 首先要去查询guest页表,去看看virtual address对应的guestphysic address

  • 找到这个,再去查找guestphysic address对应的hostvirtual address

  • 然后去取hostvirtual address存放值

不过,你有没有发现这玩意可以优化?

问:在哪儿?

答:这种多级查表通用优化是建立一个快表,直接拉通对齐端到端。例如这个例子,就是建立一个快表来映射从guestvirtual address对应到hostvirtual address,万一失手了(dirty),我们再走慢路径刷一遍各个表格。

问:好像页表里面的快表就是这样。

答:对的,多级查找常用这种优化。不过它建立在一个前提上,就是这种对应关系很大程度上是不变的。考考你,这是那种形式的优化?

问:积极的优化吧。因为对应关系是很大程度不变的。

3.2 进程模拟器如何做

答:对的。我们前面说的是系统级模拟器,在进程模拟器又稍有不同。

问:区别是什么?

答:进程模拟器大致分为以下几类:

  • 进程

  • 进程级二进制翻译器

  • 高级语言虚拟机

  • 容器

下面我们分别讨论。

问:进程也需要讨论吗?

答:进程也需要内存管理,这个过程实际上是操作系统完成的。实际上就是操作系统帮助进程虚拟地使用内存,这个体制就是前述的页表制度,当然其中一部分需要CPU来协助。

问:那么进程级二进制翻译器呢?

答:如果不涉及多线程问题,大部分其实连上面的快表都不需要。因为直接把guestvirtual address对应到hostvirtual address就可以了。

问:容器也是以虚拟内存作为基础的,想必容器也没有什么特殊东西吧?

答:容器的内存虚拟实际上就是进程的内存虚拟。不过我们前面聊过,容器的重要地方,在于它对于资源的管理。其中包含对于内存的管理,这个已经距离内存模拟有点远我们到时候再说。

问:就剩高级语言虚拟机了?

答:是的。高级语言虚拟机的内存模拟难点是要满足“高级语言”这个约束,很多高级语言对于内存管理有自己的想法。

问:指的是垃圾回收吗?

答:确切地说,垃圾回收并非语言的要求,而是一种虚拟机对于语言的实现。语言只是对语言的使用者承诺无需手动垃圾回收,即自动内存管理。而负责实施的就是虚拟机。从道理上讲,虚拟机无需垃圾回收,只要保证有无尽的内存即可。不过,如果选择使用垃圾回收实现自动内存管理,那么它就成为内存模拟的一部分了。

问:那么相比较传统的内存模拟,垃圾回收有什么特殊的地方吗?

答:没有太多东西。不过垃圾回收系统肯定是和虚拟机其他部分,尤其是ISA模拟部分耦合的,这个也符合我们的认识:CPU和内存不分家。它们行为上的界限很模糊。

问:我听说有很多GC算法...

答:后续我们会介绍这些算法,不过,值得注意的是这些算法其实只是一种优化,而不是一种实现的必要。

4 如何虚拟外围设备

4.1 外围设备分类

问:那么如何虚拟外围设备呢?

答:外围设备更加难以处理,因为它范围较广,很难给一个统一的节奏如何进行虚拟。在这里我们先根据前述的系统级模拟器和进程级模拟器的分类来看看,有哪些外围设备。

  • 系统级虚拟机

    • 硬盘等块设备

    • 串口等字符设备

    • 网络设备

    • VGA

    • 键盘、鼠标等输入设备

  • 进程级虚拟机

    • 进程

      • 操作系统提供的一致性的接口
    • 进程级二进制翻译器

      • 由二进制翻译器提供的库

      • 由二进制翻译器模拟的文件系统、信号系统

    • 高级语言虚拟机

      • 提供的runtime

      • 虚拟的一致性的文件系统

    • 容器

      • 对上提供的隔离的文件系统

      • 对上提供的隔离的网络资源

问:分类很多啊。

答:大部分外围设备模拟比较复杂,我们讲到具体的虚拟机时候再说吧。

问:我很好奇,比如说系统级的虚拟机,是如何模拟设备的?

4.2 如何模拟硬盘

答:我知道你很急,我劝你不要急。我们分类看看每一类设备,块设备最简单。硬盘模拟非常简单,你可以用一个文件来模拟一块物理硬盘,对于硬盘的写,也就映射成对于这个文件的写。

问:不对吧。我们都知道,在操作系统中,对于硬盘的写是通过相关的驱动来进行的。这些驱动识别硬盘是要写到扇区上的,写好之后,也是从扇区上读出来的。你用文件模拟怎么找扇区?

答:你这个是只考虑了一点,掉到了仿真的泥坑。实际上硬盘模拟没必要真的要模拟硬盘的物理结构,只需要保证对上的接口正确就行了。也就是说,上层给了对应的地址(CHS编址或者LBA编址),我能够把数据存入或者读出即可。至于对上的接口,不是硬盘模拟要关心的问题。

问:两个问题:

第一,什么是对上,虚拟硬盘的上面是什么?

第二,是谁要关心的?

答:这两个问题实际上是一个问题。统一回复:硬盘控制器,例如IDE控制器、SATA控制器。

问:为什么是这些控制器?

答:因为实际上和驱动打交道的不是硬盘,而是硬盘控制器。要明确这一点,我们去看看操作系统驱动中,是如何让硬盘来读写的:

/*
 * spinup disk - called only in sd_revalidate_disk()
 */
static void
sd_spinup_disk(struct scsi_disk *sdkp)
{
...
	/* Spin up drives, as required.  Only do this at boot time */
	/* Spinup needs to be done for module loads too. */
	do {
		retries = 0;

		do {
			bool media_was_present = sdkp->media_present;

			cmd[0] = TEST_UNIT_READY;
			memset((void *) &cmd[1], 0, 9); // 发送命令字,查询是否准备好

			the_result = scsi_execute_cmd(sdkp->device, cmd,
						      REQ_OP_DRV_IN, NULL, 0,
						      SD_TIMEOUT,
						      sdkp->max_retries,
						      &exec_args); // 执行命令
...
        
}

kernel对于SCSI接口的控制是通过发送响应的命令字来实现的。真实的控制器是如下工作:我们的硬盘上的SCSI控制器接收到一系列命令(比如Read32)后,分析其中包含的参数,它是设备块号(LBA寻址),由寻址机构(它负责把LBA转成CHS)定位到具体的扇区,然后从扇区中复制出来放到对应的内存位置(DMA)。我们可以看到,只需要模拟SCSI控制器就可以完成对上的接口,从而使得kernel可以借由驱动层使用我们的SCSI的硬盘。

问:那么如何模拟硬盘上的SCSI控制器呢?

答:这个也不太难,核心是理解它对上抽象了什么。它只需要支持SCSI协议,也就是说,它要能接收、发送这些命令字即可。

问:那么如何从文件上找到指定的扇区呢?

答:要能从模拟的硬盘(如上,是一个文件)中找到对应的扇区。我们只需要能够将扇区与文件内部指针对应即可。我们当然知道从文件的某个偏移到另一个偏移属于何种CHS编址下的扇区,所以这一点也是平凡的。值得注意的是,这其实是一个硬盘控制器应该做的,和具体的哪种硬盘协议控制器无关。

问:我大致明白了。SCSI控制器负责接收命令字,在寻址后,把对应文件偏移内容传送回去,是吗?

答:没错。所以想要正确模拟,不仅要模拟SCSI协议,还要模拟寻址。

4.3 设备直通

问:在虚拟化中,我们还听过设备直通这个说法,大致是什么?

答:设备直通是为了弭平在内外设备模拟时候的大量开销。为了说明这个问题,我们可以看一下。要模拟一个键盘接收主机键盘输入,我们需要干哪些事情:

硬件做的:

  • 主机键盘按下键盘

  • 键盘将扫描码送到特定端口

  • 发送中断

    驱动做的:

  • 接收中断,读取扫描码

  • 驱动将代码搬运到用户空间(ASCII码)

以下是虚拟机要做的

  • 读取ASCII

  • 反过来转成要模拟的键盘的扫描码

  • 填充端口值,发送中断

以下是虚拟机内的guest os驱动要做的

  • 接收中断,读取扫描码

  • 驱动将代码搬运到用户空间(ASCII码)

以下是虚拟机内的guest os用户程序动要做的

  • 读取ASCII

发现有什么问题没?

问:好像出现了重复?似乎可以直接让虚拟机接收到那个扫描码就行了。

答:是的。不过,有个现实性的难题,获取扫描码这种操作必须要在驱动侧、内核态做掉,因为权限的管制,虚拟机侧、用户态是没有权限来做这个的。

问:那么怎么解决这个问题呢?

答:解决这个问题可以有三个途径:

  • 将虚拟机全部放到内核态。

  • 在驱动侧挂钩子或者重写驱动

  • 用户态驱动

问:这三个有什么区别呢?

答:将虚拟机放到内核态,是希望借助内核态的直接和免跳环属性来完成虚拟设备直通,我曾经实现一个完全工作在内核态的薄化虚拟层,设备驱动直接到达虚拟层。同时,在guest os处也可以放置残桩驱动,这样就可以实现真正的直通。

问:这样似乎就不用关心跳环的问题。

答:对的。这个也可以避免用户态和内核态之间的无谓的数据复制,应该是最高效的手段。

问:那么挂钩子或者重写驱动呢?

答:这个也是在用户态驱动推广之前,主要的直通手段。我们替换原来的设备驱动或者在原来设备驱动处挂钩子,使其把关键代码委托给我们的设备驱动或者钩子。我们的设备驱动或者钩子会通过简单的途径直接把设备交互内容传递给虚拟机监视器,避免了一部分的开销。甚至,对于一些特别繁重的设备,我们可以双向钩子,修改guest os的驱动,可以实现“设备-钩子-钩子(guest os)-用户程序”的操作。

问:这个看起来也不错。

答:很多成熟的模拟器都可以做类似的工作,不过要想高效的进行,需要在guest os里面做一个残桩或者钩子,来和虚拟机监视器通信。

问:这个就类似Vmwarevmtool吧?

答:有点像。实际上它依赖的接口更加标准,也是这种方式的标准化virtio,同时它也是第三种方法的基础上的。也就是说,第三种方法是第二种的标准化。

问:用户态驱动吗?

答:对。用户态驱动这个东西有点标题党。因为它实际上包含两部分:驱动和用户态程序。它只是一个可以让驱动和用户态程序快速交互的框架。它向用户态程序提供一些接口,通过这些接口可以让用户态程序快速管理设备。

问:有了前面的解释,我好像明白为什么要搞这种技术了。

答:用户态驱动是一些程序运行的基础,比如DPDK。也是一些高效设备模拟虚拟机的基础,我们会在后面继续聊它。今天天也不早了,还是回去睡吧。