1.5.1 系统虚拟化
问:白天说到了英特尔的贡献是为x86/64加上了虚拟化,那么是什么样的背景呢?
答:小孩没娘,说来话长。这个主要是为了加速同指令集的CPU模拟。
问:那是什么?
答:我们先来定义一下,如果guest ISA和host ISA不兼容,或言guest ISA的指令无法在host ISA 的机器上直接运行,称为异构虚拟化;如果guest ISA的指令可以在host ISA 的机器上直接运行,称为同构虚拟化。前者又称为跨ISA虚拟化,后者又称为同ISA虚拟化。
问:我发现你们这些搞东西的人就喜欢故弄玄虚。所谓跨ISA虚拟化就是两个ISA不相同,所谓同ISA虚拟化就是两个ISA相同。
答:不对。因为就算是ISA不相同,也可能可以运行的。比如Intel64就可以运行x86的指令。
问:不行吧。不然为啥还分64的操作系统和32位的操作系统?
答:对于Intel64来说,它有32位模式和64位模式,也就是它天生支持32位模式,这个32模式几乎兼容x86-32,所以如果一个32位指令在Intel64的CPU上运行,那就是同ISA虚拟化。
问:明白了。那Arm32和Arm64的关系也是这样吧?
答:不是的。Arm32和AArch64是两套ISA,它们之间的区别很大。AArch64也没有特别规定32位模式来兼容Arm32,所以如果guest ISA 是Arm32,而host ISA是AArch64,那么它们应该属于跨ISA虚拟化。不过...
问:为什么我没有感觉到这一点?
答:那是因为你用的处理器是高通吧?高通目前的处理器都有两套东西,一套用来执行AArch64,另一套用来执行 Arm32。由于Armv8-A提供了32和64的架构的用户空间兼容性,允许32位程序在64位操作系统运行.....
问:这不是说AArch64必须要兼容Arm32吗?
答:没有啊。是允许32位程序在64位操作系统运行,这个是个残留问题,而且和虚拟化有点关系,稍后说以下这个问题。高通为了兼容性,就搞了兼容,而且是物理兼容。不过它将来也会放弃这种东西的,因为Arm官方已经放弃对于32位支持了。高通的骁龙8 Gen 1中超大核和中核都是纯AArch64,不再兼容32位。
问:那其他主流Arm处理器呢?
答:现在市面上的华为麒麟都是只支持AArch64的处理器。
问:看起来还是华为拥抱新时代。
答:是的。32位早该被淘汰了。回到苹果阵容,苹果A7之后的处理器都是AArch64,不支持32位。
问:刚才你说的和虚拟化有关系的问题,是什么?
答:长话短说,Arm只强调了AArch64和Arm32在user space兼容,并没有说在kernel space兼容,实际上仔细想想就知道不行。具体的,这句话意思是:如果kenel是64的,那么应用程序可以是32的或者64的;但是如果kernel是32的,那么应用程序只能是32的。这个实际上在EL里面可以看出端倪。在虚拟化的时候,64位可以虚拟化成32位的,而不能反过来。这个也是因为EL不能倒过来。知道这一点就够了。当然了,这些都只是理论。
问:所以如果guest ISA 是Arm32,而host ISA是AArch64,那么它们应该属于同ISA虚拟化?
答:不是的。Arm描述的那个东西不好实现,所以高通用来两套东西来兼容32和64,也不完全是在64位处理器里面兼容了32位指令。稍后我们会揭示为什么这种规定很无聊。事实上,也没有主流处理器这么干。
问:回到开始的地方吧,分为跨ISA虚拟化和同ISA虚拟化,然后呢?
答:如果是跨ISA虚拟化,我们只能采用二进制翻译来虚拟处理器。但是如果是同ISA虚拟化,看起来我们有一种高效的方法。
问:什么方法?
答:我们可以把指令分成两类,一类叫敏感指令,一类叫非敏感指令。什么叫敏感指令呢?它包括两类,一类是运行结果依赖系统资源配置的指令,称为行为敏感指令;另一类是运行结果影响系统资源配置的指令,称为控制敏感指令。
问:这么说有点太虚了,能不能举个例子?
答:比如说,在x86/64上,IN和OUT这种读取端口的指令,它们一个依赖系统资源(端口的值)、一个影响系统资源(端口的值)。它们都是敏感指令。
问:这种分类的意义何在?
答:如果是非敏感指令,那么它可以直接在host ISA上执行,而不需要经过管控(二进制翻译)。比如ADD EAX, EBX这种,根本不需要翻译,我们只要保证EAX、EBX是对的就行了。
问:没错。
答:但是对于敏感指令,它依赖或者改变系统资源,那么我们就要让它去依赖或者改变虚拟机系统资源,这样才能保证虚拟化实现和隔离。
问:这样我们只需要区分两类指令就可以做一个高效的虚拟机了。
答:这才是棘手的地方。由于x86设计缺陷,导致CPU无法做到这一点。
问:为什么?
答:一般来说,敏感指令都是特权指令,这个很好理解。因为敏感指令修改系统资源,常常需要一些权限。如果所有敏感指令都是特权指令,那么高效虚拟机就很好做了。我们只需要让虚拟机上的那个OS正常执行,执行非敏感指令我们不用管;但是执行到了敏感指令它一定需要跳环(从3环跳到0环),这时候我们虚拟机弄一个驱动获取跳环陷入内核的那条指令,再根据指令进行二进制翻译,就可以把这些敏感指令执行旁路到我们虚拟机监视器(monitor)里面了。这种方式称为”陷阱-模拟“方式。
问:好像是这么回事,那么问题呢?
答:问题在于那个前提,在x86里面,并非所有的敏感指令都是特权指令。这是虚拟化先行者Popek和Goldberg在1974年指出的。由于存在一些指令,它是敏感指令,但是不是特权指令,于是它执行时候不会跳环,于是不能被拦截住,导致虚拟机的监视器无法拦截,虚拟化就会失败。
问:我怎么都不知道有这种指令,举个例子?
答:比如POPFD这个指令,它从栈顶弹出一个值,修改掉Eflags(这是一种无法在虚拟机和真实机之间共享的资源),但是无法触发陷阱跳环。
问:我查了一下手册,还真是。想不到1974年就被他们看出来了。
答:虚拟化理论起步早的很(如前面说的,起自图灵),基本上上世纪80年代就把所有理论探索完了,所以你想发表这方面理论方面的论文是没戏了。
问:那么x86应该怎么做呢?
答:针对这种情况,有三种方法构建高效虚拟机,不过其中的半虚拟化已经废弃了,我们就讲两种。第一个是”扫描-修补“法。
问:这种方法是什么思路?
答:它是这样想,敏感指令分成两类,一类是特权指令,另一类不是特权指令。把后一类称为”关键指令“(临界指令),我只需要处理这类关键指令就行了。
问:那么怎么做呢?
答:它的算法是,首先我扫一下要执行的那些指令,如果包含了关键指令,就把它修改成特权指令A。这样虚拟机监视器就能拦截住了。
问:然后呢?
答:然后虚拟机监视器只需要在A的入口地方写二进制翻译器来处理具体的指令就可以了。因为这种算法核心是进行扫描然后修改,所以被称为”扫描-修补法“。早期的(在没有VT-X或者AMD-SVM的时候)VMWare就是用这个办法。
问:看起来也不错,这种方法有什么问题呢?
答:问题就在那个扫描上。因为实际上这种扫描是一种二进制翻译的parse,因为对于x86这种非定长指令集,你只有完全parse才能进行真正的扫描。
问:明白了。那么第二种解决办法是什么呢?
答:这种方法就是Intel提出的硬件虚拟化了。它通过添加了一种新的操作模式来将特权和敏感分离。具体的,它给处理器增加了一个mode,叫VMX。在VMX-mode下面处理器可以有两个状态,一个叫root,另一个叫non-root。无论是root还是non-root,都有ring0~ring34个特权级(换言之,它们都可以正常的表征“特权”这个概念)。在non-root状态下,资源访问被限制,敏感指令的执行处于监控中,在符合条件情况下,这些敏感指令会陷入root态(术语称为VM-exit)。这样的话,我们只需要把虚拟机监视器放在root态,就能拦截掉这些指令的执行,然后用修补方式实现监控。
问:我明白了。这样一折腾,特权还是特权,但是敏感就被root和non-root区分了。这两个概念不是混合而是正交了。
答:对的,所以这种解决方法还是很巧妙的。而且,由于是硬件实现这种区分,效率上只需要在敏感指令执行时(具体的,是解码后)额外查询一下状态位,对于性能也不会有太多负担。所以这种解决方案漂亮又高效。
问:哇,看起来Intel还是实力不容小觑。
答:不要忘了"敏感指令不是特权指令"这种破事情就是Intel搞出来的。先搞出bug,然后修正,不能叫功绩啊。
问:有了错误就改,人类就是这么螺旋上升,从必然王国到自由王国的嘛!如果原来没有漏洞,那么特权永远要和敏感绑定在一起了。
答:那样其实也没有特别多的害处。不过事情已经是这样的了,我们了解就可以了。由于硬件虚拟化的高效,"云"才得以迅速发展,也是整个云计算的基础架构,我们后面还会具体的谈硬件虚拟化技术。
1.5.2 从chroot到容器
问:除了前面的虚拟化,还有什么类型的虚拟机?
答:我们前面讲处理器仿真,讲系统虚拟机,更多的还是介绍在ISA层的花花绕绕。我们接下来讲另一类大的虚拟机,它们实际上玩转的不再是ISA,而是外围设备。
问:那是什么。
答:这是我们在日常中用到的另一类重要东西:容器。它是一种进程虚拟机。
问:什么叫进程虚拟机?
答:进程这个概念,就是一种虚拟机。它是同ISA的一种虚拟机。操作系统通过虚拟内存来让进程共享物理内存资源,使得每个进程都以为它拥有全部甚至多于实际的物理内存。
问:“虚拟内存”这个概念我早就知道,但是不知道它的“虚拟”原来和“虚拟机”是一个范畴。
答:回想我们白天说的,虚拟机就是对于黑盒子的抽象。拥有多道程序系统的操作系统提供了这样一种资源抽象,让每个进程都以为它拥有资源。
问:我看看录音笔哦,好像是的。当初谈这个概念,你罗里吧嗦,我直接没咋记得。
答:居然还带录音笔,毫无道德底线啊。说回去,进程虚拟机包括很多,除了进程本身外,还包括用户态的二进制翻译器、高级语言虚拟机(它是一种特殊的二进制翻译器)。我们先说多道程序系统,也就是进程。常规的进程概念不必多说,我们直接从chroot开始说起,chroot搞了一个轻量级的目录沙盒。
问:我用过,它可以改变当前进程的根目录,也就是把某个目录作为当前进程的根目录。
答:对。它这样相当于从这个目录开始新建了一套根的目录树。如果你想的话,它可以实现一套子系统。它的原理我们具体看到的时候再讲,不过chroot并没有完全隔离,它只是实现了目录隔离。
问:其他还有什么?
答:网络、进程信息本身啊。要想隔离这些,就需要其他东西。比如,利用namespace来对内核资源隔离,这样进程可以在单独的名称空间下运行,限制访问对应名称空间下的资源。它可以隔离pid/net/mnt/ipc/uts等。用namespace其实可以做一个小型的沙盒环境。
问:那么对比真正的沙盒,缺少什么呢?
答:它还缺少对于资源的控制和记录。
问:这个有什么用?
答:如果只是你自己跑跑玩,可能没有用。不过对于一个真正的沙盒,是需要有查看和资源控制的,不然没法掌握资源的使用情况。cgroup就是做这个事情的。
问:明白了,有了namespace和cgroup,我自己也可以搭建一个沙盒吧?
答:对的。lxc就是使用chroot、namespace和cgroup等东西实现的一种容器虚拟化。它只虚拟资源,不虚拟处理器,所以相对轻量级。
问:那Docker呢?
答:Docker最早也是基于lxc的简单包装,不过后来它重写了整个东西,不再依赖lxc。不过实质上和lxc原理是一致的,你可以看成重新实现了一遍。
问:不过我看那些介绍Docker的,都宣传的神乎其神。
答:有些技术人有两个趋势,一是宣传自己搞的这个多么多么难,以彰显自己水平高;二是宣传自己这个搞的这个东西多么多么有用,以彰显自己地位重要。Docker很好的思路是它模仿包管理的思想,搞了镜像和仓库的概念,使得你不用从头来搞一套环境,这是它漂亮和伟大的地方。至于技术,它那些技术都很一般。但是技术是最没用的东西,现在不比武功,比想法。
1.5.3 进程级二进制翻译器
问:其他的进程级二进制翻译器还有哪些?
答:就是一些二进制翻译器、二进制优化器。包括DynamoRIO/qemu-user,闭源的还有英特尔的Houdini、华为的Exagear等等。不过更重要的一类进程级二进制翻译器是高级语言虚拟机。
问:高级语言虚拟机看起来和前面讲的虚拟机没什么关系,他们能归于一类吗?
答:是的。而且高级语言虚拟机和系统虚拟机的亲缘关系更近。从架构上,高级语言虚拟机将一种语言视为guest ISA,将其转成host ISA,这和常规的系统虚拟机的处理器模拟没什么不同。
问:但是系统虚拟机不只模拟处理器,还有外围设备啊。
答:这部分实际可以分为两部分,一部分是内存子系统的模拟,这部分在高级语言虚拟机里面也有,典型代表是一些高级语言虚拟机有的垃圾回收系统;另一部分是其他外围设备和资源的模拟,这部分高级语言虚拟机里面以各种支持库的形式体现。
问:不过我怎么感觉垃圾回收系统更像操作系统的内存管理?这部分实际上是在裸机器之上的?
答:只是看起来如此。的确,垃圾回收系统更像优化而不像必备配件。但是它实际上是内存管理系统的另一个名字,即便不采用复杂的内存管理算法而是鸵鸟政策,这个系统仍然必须存在。它的主要作用是仿真内存,而不是去充分利用。充分利用只是优化而已。
1.5.4 用于安全领域的虚拟机
问:我常听说“虚拟机保护”,和我们讲的虚拟机有没有关系?
答:是一类的东西。虚拟机保护是伴随着软件安全的攻防进行的。经过数轮纠缠,最后防的一方总结一些有效的手段,其中重点是两条:
-
强壳。将指令和壳融合在一起,配合上壳的各种反逆功能来阻拦关键代码的分析。
-
指令本身的混淆。使用花指令来拖延分析者的时间。
问:什么是花指令?
答:就是把一条或者几条指令变成等价意义的另一个指令块的行为,变形后的汇编更不容易读和分析。
问:这样看起来很有用?
答:的确带来了一些麻烦:
-
强壳导致有时候脱壳很难,从而必须带壳调试。因为一部分代码已经通过偷代码融合进去,恢复起来很费劲。因为有时候关键代码是随用随生成的,所以需要花费功夫去看这些被偷的代码就很烦。加上强壳往往还有各种反调和暗桩,会扰乱分析。
-
混淆本身就是通过耗费分析者时间来进行对抗的,通过各种手段更改代码流程和具体指令,让分析者无法通过经验分析。
问:既然如此,那似乎攻的一方没有办法了?
答:并没有:
-
带壳调试实际上也不能叫麻烦。你既然打算
JIT生成代码,总要去写内存执行的吧?麻烦是麻烦点,又不是不能折腾。搞格式化?我这不是虚拟机吗? -
加花?信不信我
dump了丢给F5?收手吧,外边都是人手F5了。
问:看起来又是攻的一方没有办法了?
答:有点小困境,不过虚拟机加密把上面两种有效的办法融合在了一起。它的核心是把关键代码变成另一种指令集,这下算是混淆指令集的最高形式了,但是算不上混淆代码的最高形式。同时虚拟机加密实际上就是融合壳,它同时在对抗静态和动态分析。
问:看起来确实不好搞。那么对抗方没招了?
答:实际上确实是有效手段。不过恢复handler也不是不行,除非你自己写虚拟机。而且你也不能把所有代码都在你虚拟机里面吧?
问:为什么不行?
答:那样导致你的软件非常慢。不过确实上兵伐谋,软件烂成那样破解者也不感兴趣了。常规的操作,还是用虚拟机保护关键代码。不过这个思路带来副作用就是此地无银三百两,直接找你的入口就行了。所以加密方还要加一些花,对于本来无需加密的地方加密。而且你只保护加密算法部分,我可以爆破掉啊。所以你还是要把代码的抽出来保护。
问:没有声音再好的戏也出不来,看起来虚拟机保护用好还得要基础知识啊。
答:这使得我想起来另一种保护方式,这是真的和虚拟机没关系,但是也很有意思,同时也可以用在虚拟机保。它通过混淆控制流来实现混淆代码逻辑,就是ollvm。这个思路和虚拟机保护也一致,“只要我足够烂,那就没人破得了我”。
问:说到混淆,对于Java这种基于虚拟机的语言的基本保护里面就有一种叫混淆,它是在做什么?
答:对于Java来说,Proguard这种软件所谓混淆保护主要目的还是把其中的类名字替换成不能理解和识读的字符串。因为Java用来分发的介质是字节码,而字节码的数据里面就有各种字符串信息,比如类名。而由于软件工程的要求,这些类名很多包含了非常多的信息,有利于破解者闻名知意。所以需要把它替换成a、b、c这种。
问:不过现在好像也不用Proguard了。
答:没错,现在用R8,只是实质也差不多,针对dex它还有很多优势。不要抱残守缺嘛,一切革命同志都要拥护这个变动,否则他就站到反革命立场上去了。
1.5.5 其他大型软件的虚拟机
问:除了这些,还有其他地方能用到虚拟机嘛。
答:一些大型软件会用到虚拟机。比如,SQL这种带DSL的软件。
问:SQL在哪里用的到虚拟机?
答:SQL实际上是一种DSL的执行器,它要将用户提交的语句执行。实际上是完成从一个ISA到另一个ISA 的转变。比如SQLite用的VDBE就是一种虚拟机。解决SQL语句查询效率方法也有很多,比如将SQL语句转成JVM字节码,然后用成熟的引擎解决,比如Presto SQL、Spark SQL都用了codegen手段来完成这一点。当然,以一个专业虚拟机的眼光来看,这些系统里面的虚拟机相关代码都比较幼稚,改进空间很大。
1.5.6 计算机的任何问题
问:有句话叫,计算机领域的任何问题,都可以通过增加一个虚拟层来解决,是这样嘛?岂不是学了虚拟机什么都会了?
答:这话绝对是搞虚拟机的人瞎扯的,我发现别管干什么领域,都喜欢无限放大自己领域的重要性以彰显自己的重要性。这种东西是一种领域无敌论。这话来自David Wheeler,原话是All problems in computer science can be solved by another level of indirection,原话说的是“indirection”,也谈不上都是虚拟层。 如果真的要说的话,虚拟层算是一种indirection。另外,这话据说还有欧亨利式的下半句,exceot for the problem of too many layers of indirection。
问:David Wheeler这话好有意思。他还有什么事迹?
答:他还有另一句,我觉得更好。Compatibility means deliberately repeating other people's mistakes.这话就很黑色幽默了,感觉从王尔德嘴里冒出来的。
1.5.7 之后的文章
问:之后每天我们会怎么聊呢?
答:每天的主要内容就是一类虚拟机,有详有略。不过在进行一切之前,我们要聊聊一些共有的东西,它们被用在所有虚拟机的实现上。我们明天就会聊这些东西。