sofes / OS / 基于微内核的虚拟化技术之NOVA

0 0

   

基于微内核的虚拟化技术之NOVA

2017-08-07  sofes

由于最近要发论文,一直在跟踪学术动态,决定写一篇博客介绍下现如今主要的微内核虚拟化研究动态。
在整个微内核虚拟化的研究领域,NOVA和Microvisor可以说是两个最有代表性的成果了,关于这两个部分我会分别写两篇博客来做介绍,本文先介绍NOVA的虚拟化方案,在介绍的过程中,我还会适当加入一些与KVM的对比,因为KVM在目前的虚拟化领域非常有代表性,它虽然是基于宏内核的虚拟化方案,但是很多虚拟化技术我觉得NOVA和Microvisor都是参照KVM进行设计的。
下文将首先对做一个总体介绍,包括对L4微内核介绍,之后从cpu、页表和安全性的角度进行分析。
需要说明的是,本文并不适合初学者,很多概念默认读者已经掌握,不会再展开叙述,读者需要自行Google或者参考Intel Development Manual和ARM Development Manual。

总体介绍

L4微内核

我觉得任何一项技术的发展都来自于现有技术存在的缺陷,最初UNIX系列的os取得了很大的成功,但是它也存在很多问题。下图为宏内核的系统架构。

可以看到,宏内核里面的内容很多,现如今的Linux内核代码量在2000k-3000kloc,而L4/fiasco微内核只有22-24kloc。在一般意义上,我们假设编写内核代码的程序员水平相同,那么我们可以大致认为他们写相同数量代码出错的比率相同(假设每一万行出现1处bug),可见宏内核比微内核出现bug的几率会高很多,因此也存在更高的崩溃甚至安全风险。从另一个角度看,把驱动放在内核里,抛开性能因素不谈,驱动本身是由第三方单独开发的,很多时候我们甚至不能得到驱动的源码,因此驱动是十分不可信的,也是最容易出问题的,很多内核崩溃都是由驱动的问题导致的,而且驱动也需要频繁修改(随着硬件升级进行对应升级),那么宏内核本身是没有办法被形式化验证的,因为驱动的每次更改都需要重新对内核进行形式化验证,这是不合理的。在无人机领域等需要高可靠性的操作系统上,被形式化验证的微内核已经大显身手,seL4已经在美国军方的无人机上得到应用。还有一个原因是宏内核的可扩展性不如微内核,由于微内核把各个模块独立起来放在用户空间,因此非常易于扩展新的模块。
针对宏内核存在的问题,学术界提出了微内核的想法,下图为微内核的系统架构。

微内核虽然解决了一些宏内核的存在的问题,但是它也带来了新的问题。Liedtke在设计第二代微内核的时候提出,一个模块只有当它移出内核导致系统功能无法实现的情况下,它才应该放在内核里,事实上,Liedtke认为微内核应该提供三个关键抽象:address space,threads and inter-process communication(IPC)。这种最小化的设计思路贯穿整个微内核的发展,微内核本质上是一个pipe,它不提供任何的policy而只提供mechanism,这里翻译过来可以认为它把所有的策略(算法)放在用户层的单独模块实现,而只提供一种调用到对应模块的通道,因此所有的服务请求都是通过IPC完成的,因此微内核的IPC性能成为制约整个微内核发展的大问题,由于本篇文章主要讨论虚拟化的技术,对微内核的IPC性能不做过多介绍,但是有一点需要指出,微内核的IPC在标识通信双方的时候是通过(capability)能力集完成的,这种通过内核来实现的隔离机制能够很好的提高系统的安全性。关于capability我会在以后补充介绍,读者也可以访问L4/fiasco的官网下载L4/fiasco的白皮书。

NOVA

NOVA是由Dresden University开发的,它的开发团队和L4/fiasco微内核的团队同属一个大学,第二代微内核在设计之初就考虑到对虚拟化的支持,L4/fiasco微内核对虚拟化支持这部分应该就是NOVA做的(Dresden University在介绍vcpu的时候主推的也是NOVA),因此NOVA在微内核领域虚拟化的引导地位不言自明。NOVA主要讨论有硬件feature情况下的虚拟化,因此NOVA采用的是full-virtualization的方式,它可以在不修改guest os的情况下获得很好的性能。下图为NOVA的总体架构。

NOVA相较于KVM的虚拟化最大的优势在于安全性,虽然NOVA声称自己的性能要好过KVM,但是可以看到它的实验环境是屏蔽了所有和其他模块的交互,在这种条件下评判宏内核和微内核的性能我觉得是不公平的,因为微内核在交互上的性能损耗是要高过KVM的(频繁的IPC),而且两者性能差距微乎其微(只有不到0.4%)。NOVA把hypervisor的一部分功能合到内核里,构成我们在最底层的Microhypervisor,由于Microhypervisor代码量较小,因此我们认为它的attack surface较小,安全性较高(NOVA的论文这样论述的)。可以看到对于NOVA来说,很好的分层隔离导致错误不会蔓延,virtual application的错误不会导致guest os被入侵,guest os的错误不会导致vmm被入侵,直至最后导致内核被入侵,关于这部分会在后面的安全性论述上详细分析,其实这种有效的隔离和阻断机制正是NOVA安全性的核心,也是整个微内核的虚拟化相较于过去的宏内核的虚拟化的研究意义之所在。
在NOVA的架构图上,和虚拟化相关的模块有三个,分别是microhypervisor,root partition manager,virtual-machine monitor(VMM),下面将分别介绍。

microhypervisor

在介绍microhypervisor之前,我们先抛开微内核,分析一下一般意义下的hypervisor的功能。这里引用一段对hypervisor的定义“The most privileged component of a virtual environment,which runs directly on the hardware of the host machine is called hypervisor”。通过这段定义我们可以得出,hypervisor在整个虚拟环境中拥有最高特权级,可以直接和硬件打交道。关于hypervisor的功能,这里我还要引用一段英文(之所以引入英文,是因为有些概念英语理解起来很容易,翻译的话有时候找不到贴切的词反倒显得非常“绕”),“The functionality of the hypervisor is similar to that of an OS kernel:abstracting from the underlying hardware platform and isolating the components running on top of it”。可以看出hypervisor是对硬件的一种抽象,而且提供一种硬件的并发访问和隔离机制(多个guest os同时运行,共享硬件,但是又互相不影响),这些功能与操作系统的功能十分类似,于是我们自然的想到能不能把hypervisor和os kernel结合起来?答案是肯定的,这就构成了我们的microhypervisor,微内核与hypervisor有许多共性,使得它们的结合巧妙而自然。关于这一点,在后续Microvisor的分析上我还会深入讨论。这里还需要说明的是,microhypervisor并不完全包括了hypervisor的所有功能,事实上hypervisor的功能是由microhypervisor+VMM一起完成的,这个在后续会展开叙述。

Virtual-Machine Monitor(VMM)

VMM在这里的功能是负责管理Virtual Machine(VM)和host os(microkernel)之间物理资源的交互。每一个VMM都向它的VM提供一组类似于物理硬件的接口,让所有的guest os觉得自己好像运行在真实的物理机器上一样,同时microhypervisor保证它们之间的隔离性。需要强调的是,在NOVA里面,hypervisor和VMM不是同义词。一般情况下,我们认为hypervisor和VMM都可以译为虚拟机管理器,都是为了实现对硬件资源的抽象和复用以便支持多个os,但是在NOVA中,hypervisor是代表的privileged kernel(microhypervisor)而VMM是代表一个deprivileged user component(从NOVA的架构图上很容易得出)。

Root Partition Manager

microhypervisor本质上是微内核,前面已经提到过,微内核是不包含policy的,所以关于资源分配的策略也应该放在用户空间单独实现。其实这里的Root Partition Manager我认为就是Sigma0,Sigma0是L4/fiasco微内核的第一个用户进程,除了内核自己使用的内存之外,所有的内存、外设端口都交给Sigma0进程来管理,Sigma0负责向其他用户进程分配内存和外设资源。这也正体现了L4微内核的递归内存分配,而且所有的分配策略都在用户层决定。可以通过IPC通信的方式传递内存、I/O端口和capability,在传递的过程中,发送进程可以降低接收进程对应的访问权限。发送进程可以递归的撤销它分配出的capability。

CPU的分析

对于guest os来说,它使用的是virtual cpu(后文简写为vcpu),vcpu在guest os看来是一个硬件,类似于真实的cpu,但是对于microhypervisor来说就是一个thread。解释了vcpu的基本概念之后,我们来讨论它的调度和状态切换的问题。

调度

microhypervisor采用的是基于优先级的、可抢占的、时间片轮转调度策略,每个物理cpu都保存一个runqueue。每一次调度的时候,调度器都会在就绪队列里选择出当前优先级最高的线程进行执行,把当前上下文切换到要调度的线程的上下文(context switch)。对于调度器来说,它并不区分调度的线程是native thread还是vcpu thread。一旦新调度的线程开始执行,那么只有两种情况它会让出执行权限:一种情况是它的时间片用完了,另一种情况是有一个更高优先级的进程从等待状态变为就绪状态,当前执行线程的执行权限会被抢占。

通信(VM和VMM之间)

这里讨论的通信主要是指VM和VMM之间的通信。在每一个virtual machine被创建的时候,VMM都会在这个virtual machine的capability space上安装一个VM-exit portal。事实上为了兼容多核的情况,每一个vcpu线程都有它自己的VM-exit portal和一个在VMM中的handle execution context。这里的handle execution context实际上是一个handle函数加上一个硬件寄存器的状态Execution Context(在内存中虚拟的硬件寄存器),Execution Context在下文缩写为EC。这里的handle函数实际上就是为了模拟特权指令的执行。VM和VMM的通信过程如下图所示。

图中的SC是schduleing context的缩写,这个scheduling context是一个数据结构,包含当前执行线程的一些属性,比如优先级、时间片大小、当前线程是普通线程还是vcpu线程等等。当vcpu线程执行到一条敏感指令时,vcpu线程会产生一个VM-exit,其实这里的VM-exit我认为就是一个概念,是KVM的提法。当线程执行到敏感指令时,无论是vcpu线程还是native thread都会陷入到中断向量表,只不过这里面的中断向量表会做一些额外的工作,它会先检查当前线程的vcpu tag,发现当前线程是vcpu线程的话,它不会进行处理,而是在protection domain里找到当前线程对应的portal,其实就是一个预先设定好的handle函数,在NOVA里把这个函数连带传过去的参数称为EC,全称是handle execution context。这个handle函数所在的进程就是我们的VMM,注意这里的切换过程,vcpu线程先从VM进程执行,碰到敏感指令陷入内核,内核把执行权限交给VMM,只要vcpu线程的时间片没有用完并且没有被更高优先级的就绪线程抢占执行权限,vcpu线程将会一直执行下去,这也就是这里面说的Scheduling Context的传递,因为vcpu线程在VM进程和VMM进程共享一个Scheduling Context,不需要额外的调度,这样做能够很大程度提高性能和响应时间。
当VMM执行完毕时,也就是这里的handle函数处理完毕,其实相当于调整了虚拟硬件的state,这时候会执行一个vcpu_resume()函数,这个函数的功能是返回到上一次在VM进程发生陷入的敏感指令的下一条指令开始执行,它的执行过程是,首先vcpu线程由用户态陷入到内核态,注意这里的vcpu线程是在VMM进程里。然后内核会检查vcpu线程的状态,发现vcpu执行完毕handle函数导致的陷入,它会首先完成一个context switch,从VMM进程切换到VM进程,然后执行一个类似于iret(intel x86的系统调用返回)的过程,返回到VM进程中陷入内核的敏感指令的下一条指定进行执行,这里说的下一条只是一个笼统概念,在intel和arm下不完全一样,而且根据陷入的情况也不完全一样,需要参看文档。
vcpu线程一个很难理解的部分是,常规意义下的线程应该属于某一个固定的进程,但是vcpu线程不同,它同时属于两个进程,一个VM进程,一个VMM进程,这样做的好处是当线程从VM切换到VMM或者从VMM返回到VM中,都不需要再额外的调度一次。而且这也符合我们的设计逻辑,这里的VMM模块其实存着大量的寄存器,这些寄存器的内容实际都保存在内存中,去模拟物理寄存器,我们根据这些寄存器的值去模拟硬件的各种状态。其实可以想象,在真实的os和硬件之间也不应该发生额外的调度(其实L4/fiasco内核里关于vcpu的设计是一个非常酷炫的技术,这里没法儿深入展开,有机会我会单独开一篇博客,结合我阅读的源码分析一下它在底层和用户层是如何支持的)。

内存管理

NOVA的内存管理与KVM采用EPT(Extension Page Table)十分类似,读者可以参考KVM的内存管理来阅读本部分内容。
首先介绍4个主要主要概念:

  • HVA:host virtual address,本地程序的虚拟地址。
  • HPA:host physical address,本地程序的物理地址。
  • GVA: guest virtual address,虚拟程序的虚拟地址。
  • GPA:guest physical address,虚拟程序的物理地址。

microhypervisor为每个进程提供一个protection domain,这个protection domain里3个东西:

  1. 这个进程的页表;
  2. 这个进程的I/O地址空间(通过一些I/O bit位去标识外设的访问权限);
  3. 这个进程的capability space,保存的是这个进程可以访问的kernel object。

因此对于普通的用户进程,protection domain里的host page table保存的是HVA to HPA的转换;对于virtual machine,protection domain保存的是GPA to HPA的转换。对于VM里面运行的guest os,还要为每一个guest application创建一个从GVA to GPA的页表。
当一个guest application需要访问内存时,转换过程是这样的,它首先去查guest os的页表,从GVA变为GPA,然后去查VM在microhypervisor里的protection domain,从GPA转为HPA才能进行访问,如果没有硬件支持,对于x86来说,只有一个CR3寄存器,那么这种two-demensional translation是没办法做的。以前KVM在没有硬件支持的时候,也是通过影子页表才实现的,影子页表可以理解为把两步并作一步来做,但是这里面设计一些复杂的同步问题和TLB额外的flush,不仅设计起来困难,而且性能也不好,根据NOVA的实验报告,大概相比于有硬件支持的情况会下降23%的性能(在查找页表方面),关于影子页表的详细分析请参看KVM的介绍(KVM是影子页表的开创者,但是目前影子页表已经被KVM废弃,但在一些没有硬件支持的嵌入式平台上,还有很多人在使用影子页表)。
其实NOVA的内存管理完全是参照KVM的,几乎和KVM一模一样,所以读者在阅读这部分内容的时候也可以参看KVM关于内存管理的介绍。

Virtual-Machine Monitor

NOVA里关于VMM的设计比较复杂,所以我决定把这部分单独拿出来进行详细介绍。VMM提供的功能主要有两部分:对敏感指令的仿真和提供虚拟外设。
先做一个总体介绍,这里说的敏感指令其实是一种笼统的叫法,敏感指令分很多种,每一种敏感指令所导致的异常情况也不一样,而他们和虚拟外设引起的page falut导致的异常情况也不一样。NOVA的论文里是这样说的“It creates a dedicated portal for each event type and sets the transfer descriptor in the portals such that the microhypervisor transmits only the architectural state required for handling the particular event”这里的event指的是VM-exit event,这段话的意思可以这样理解:每一类的VM-exit event都会对应到一个特定的仿真函数,根据VM-exit的类型传递的数据结构也不一样,只传递仿真特定VM-exit event所需要的硬件状态寄存器的值。其实对于虚拟外设的仿真将比敏感指令还要复杂, 由于虚拟外设对应的MMIO会产生page falut从而陷入VMM,此时除了MMIO的地址(导致page falut的address)和当前的pc指针之外,VMM一无所知,此时需要额外的信息才能继续进行判断。这一部分内容的设计与实现比较重要,我将从源码的角度进行分析。虽然NOVA主要是在x86平台下的,但是由于这部分内容x86平台和arm平台十分类似,而我个人的研究环境主要是基于arm平台,所以我将给出arm平台的运行分析(结合源码)。
无论fiasco还是NOVA,通过Google查到的内容总是很有限,真正深入研究过的人更少,所以下面的内容我都是自己打的log验证的,如果读者有疑问或者发现问题欢迎与我交流,博客上面有我的邮箱,我最迟每周会看一次。首先从概念上进行分析。

指令仿真

当vcpu线程从VM进程陷入到VMM进程时,VMM进程可以读取到导致vcpu线程陷入时的guest os的pc指针,根据pc指针的内容可以找到对应的那条指令,我们在VMM进程中需要有一个decoder,从陷入的指令中提取出操作数和操作码。如果是一个内存读取操作,VMM里的指令仿真器就去读取对应内存的值。如果要模拟执行一个简单指令,VMM就调用一段汇编代码即可,如果是一个复杂指令,可以通过调用一个c函数。guest os的执行可能会产生异常,VMM模块负责处理这些异常。当对敏感指令的仿真结束以后,VMM进程把执行结果写回到对应的内存或寄存器中去,并且将pc指针指向敏感指令的下一条指令。现在NOVA的仿真器已经可以运行一些特殊程序比如bootloader。

设备仿真

VMM进程需要为它的guest os提供virtual device。每一个virtual device都是一个由软件实现的state machine,这里的state machine应该说是一个machine,但是可以仿真物理设备的各种state。当一条指令去读写外设端口(I/O port)或者MMIO register时,VMM进程更新virtual machine的state去模拟外设更新它的内部寄存器的状态。不是所有的对virtual machine的读写操作都需要VMM模块的参与。比如,物理外设的frame buffer就可以直接被映射给VM进程。同样,读操作不会影响的设备寄存器可以直接映射给VM进程,只要将对应寄存器的读写权限设置为只读就可以了。在设计VMM仿真外设的时候,应该尽可能减少VM进程陷入到VMM进程进行处理的情况,这样会减少外设仿真的开销。对设备仿真时,越少的VM-exit event越好,这样会减少陷入到VMM进程并返回的过程。对于那些支持DMA和interrupt coalescing的设备,可以减少copy数据和处理中断的开销。

与Host Device Driver的交互

当guest os需要访问virtual device的时候,比如它请求一个disk read操作,VM进程需要首先陷入VMM进程,由VMM进程将对应的command发送给对应的host device的device driver。下图展示了VMM处理guest os的磁盘读请求(read disk request)的过程,其中虚线(dashed lines)表示进程间的IPC通信,点线(dotted line)表示硬件操作。

我们按照图示的步骤进行讨论:(1)vcpu想要去访问一个MMIO(memory-mapped I/O),产生一个VM-exit event(2)其实这个VM-exit event会产生一个page falut,因为这个MMIO(实际是一个guest physical address)没有映射到VM进程的虚拟地址空间,这个page falut由于是vcpu线程产生的,microhypervisor会把对应的信息传给VMM进程中去,同时把执行权限交给VMM进程,VMM进程首先会对陷入的指令进行decode,然后根据这条指令的内容进行操作,在例子中这条指令是一条磁盘读操作,那么它就会去访问virtual disk controller,其实是去更新虚拟磁盘的state machine。当虚拟磁盘的state machine(其实是command register)被更新成都一块block之后,VMM就会向disk server发送一个消息去请求对应block的数据。(3)disk server程序里的device driver会去发出一条命令去修改physical disk controller(注意之前修改的都是virtual disk controller)从磁盘上把一块block读到内存中来。(4)一般情况下,磁盘读写都是support DMA的,所以这时Disk Driver会去申请一个DMA transfer,将数据直接写到VM进程的地址空间中。(5)这时会调用一个vcpu_resume函数,vcpu线程从VMM进程切回到VM进程,到VM陷入VMM时的下一条指令继续执行。(6)当对应的block从磁盘中读写完毕的时候,physical disk controller会产生一个中断,相当于发起一个signal标识已经完成了。(7)disk server与VMM进程之间有一个共享内存(如图所示的shared memory)当disk server接到磁盘的interrupt的时候,它知道磁盘读写已经完成了,它会往这块共享内存中存一些对应的数据,可以理解为一个signal告诉VMM进程磁盘操作顺利完成。(8)当VMM进程收到这个notification message的时候,它就会更新virtual device的state machine并且向VM进程发送一个vIRQ去标识磁盘读写操作已经完成(这中间还涉及一个从VM到VMM的round-trip)。

BIOS Virtualization

为什么要把BIOS拿来单独讨论呢?我们可以把BIOS理解为固化在硬件里的一段程序,它主要完成两个功能,一是硬件自检,另一个是从磁盘中读取bootloader然后把执行权限交给bootloader(PC指针执行bootloader的入口地址)。这个过程涉及到磁盘访问,但是这时os对应的驱动还没有被加载到内存。因为这个过程在裸机上都是硬件完成的,我们需要用软件模拟这个过程,该如何做呢?有两种方法常用方法,一种是在guest os之前插入一段virtual BIOS然后在VM进程里先执行这段virtual BIOS就行了。这种方法的缺陷是,每一次I/O操作都会导致VM-exit event,而且BIOS是工作在实模式的,里面特权指令非常多,会多次陷入VMM进程仿真,其实说白了就是性能不太好...但是我觉得也没啥,因为BIOS只是开机的时候执行一下就OK啦,应该对运行性能没啥影响吧,但是NOVA没有采用这种方法。它是第二种设计方法,在vmm进程里虚拟BIOS,当然我认为这种方法最好,倒不是因为性能关系,而是从逻辑上更合理,VMM本来就是模拟硬件的,BIOS虽然是软件,但是出厂时就固化在硬件里了,这种固件应该属于硬件的一部分,在guest os前面加一段代码我觉得逻辑上太别扭。
所以这里BIOS就放在VMM进程里实现了,可以提高性能因为VMM进程可以直接访问外设(这里的直接是指不需要额外的VM和VMM之间的round-trip,VMM进程也是运行在user态,想要访问外设当然也要陷入内核,然后做对应那些操作)。其实我更看重的是通过这样的方法可以给guest os提供一个标准的BIOS。

Multiprocessor Virtualization

NOVA的接口支持多核的guest os,microhypervisor提供核间同步信号量,并且实现一个hypercall允许VMM调回与它关联的VM的vcpu。这个所谓的从VMM调回VM的过程其实就是vIRQ。这部分我们从源码的角度进行一个简单分析。首先在VMM进程中有一个main thread,这个main thread不是vcpu thread,它执行下面这句话:

irq->attach(2000,vcpu_cap);

这里的vcpu_cap是一个vcpu thread的capability,在内核标识一个vcpu线程,2000是一个中断号,这样就把一个vIRQ的中断号和一个线程关联起来了,接下来执行下面这一段代码。

while(1)
{
    irq->trigger();
    l4_sleep(500);
}

这段代码我想不需要解释读者也能看懂,就是每隔500毫秒向vcpu线程发一个irq,vcpu线程就会跳转到我们的handle函数里去处理这个irq,不就相当于每隔500毫秒从VM进程切回到VMM进程做一次处理么,这有点类似于硬件的tick,当然了这里的用处和tick不大一样,事实上这种用法在这里我只是举个例子(其实这是fiasco官方提供的代码例子)。在多核的应用中,用这种方法去实现核间中断(Inter-Processor Interrupt,IPI)。为什么要用到核间中断呢?设想一下,在多核情况下,VMM进程需要为VM进程创建等同于它所需要的物理核的数量的vcpu,可以认为每一个vcpu相当于一个核,这里需要指出一点,当创建的vcpu数量多于物理cpu的数量时,这种多核是无意义的,不仅不能提高物理核的利用率,频繁的核间信息同步和各种锁的操作还会额外消耗性能。每一个vcpu都在VMM中有一个明确的handle函数与之对应,这个handle函数就是VM-exit的出口。因此可以认为每个核独立的做自己的instruction emulator,因此这种情况下的多核(多vcpu)是允许并发的,不需要再额外加锁(临界区的互斥访问)和核间信号量(同步)。
但是当多核同时访问虚拟外设时情况就不一样了,VMM必须将这些并发操作排成一个序列,一个一个来,保证对外设操作的原子性。比方说,修改一个虚拟外设的一个控制寄存的一个bit的值,就必须去模拟原子指令。还有一些更复杂的数据操作就需要采用信号量机制去确保同步互斥。上述的vIRQ就可以很好的实现核间同步,比如,当guest os想要去执行一个global TLB shootdown,那么VMM就可以recall所有的vcpu,让所有的vcpu都执行TLB flush操作。

运行分析

4f040024:       ee11cf10        mrc     15, 0, ip, cr1, cr0, {0}

上面这条代码是Trusty OS(一个基于little kernel修改而成的小型os,这里不做深入讨论)在引导过程中的一条指令,这条指令的作用是从cp15协处理器的cr1寄存器中取出对应的值放到ip寄存器,这条指令是一条特权+敏感指令,在VM内部运行的话,由于VM本身运行在用户态,将会产生Undefined Instruction异常,这会陷入到microkernel的中断向量表。microkernel的中断向量表如下所示,读者如果想查阅源码可以参看我给出的文件路径(代码的第一行)。

//kernel/fiasco/src/kern/arm/ivt.S
exception_vector:
    nop             /* RESET    */
    b   undef_entry     /* UNDEF    */
    b   swi_entry       /* SWI      */
    b   inst_abort_entry    /* IABORT   */
    b   data_abort_entry    /* DABORT   */
    nop             /* reserved */
    b   irq_entry       /* IRQ      */
    b   fiq_entry       /* FIQ      */

接下来会调到undefined instruction的入口函数,也就是这里的undef_entry,代码如下:

undef_entry:
    switch_to_kernel 0 0 1
    exceptionframe
    enter_slowtrap 0x00100000

这里的代码前两行都是一个宏,分别调整陷入时的lr寄存器和sp寄存器,重要的是第三行,跳转到enter_slowtrap去执行,代码如下:

.macro  enter_slowtrap errorcode
    stmdb   sp!, {r0 - r12}
    make_tpidruro_space sp
    enter_slowtrap_w_stack \errorcode
.endm
.macro  enter_slowtrap_w_stack errorcode
    mov r1, #\errorcode
    stmdb   sp!, {r0, r1}
    mov     r0, sp
    adr lr, exception_return
    ldr pc, .LCslowtrap_entry
.endm

可以看到,这里将跳转到slowtrap_entry去继续执行,代码如下。

void slowtrap_entry(Trap_state *ts)
{
    if (0)
      printf("Trap: pfa=%08lx pc=%08lx err=%08lx psr=%lx\n", ts->pf_address, ts->pc, ts->error_code, ts->psr);
    Thread *t = current_thread();

    LOG_TRAP;

    if (Config::Support_arm_linux_cache_API)
      {
    if (   ts->error_code == 0x00200000
            && ts->r[7] == 0xf0002)
      {
            if (ts->r[2] == 0)
              Mem_op::arm_mem_cache_maint(Mem_op::Op_cache_coherent,
                                          (void *)ts->r[0], (void *)ts->r[1]);
            ts->r[0] = 0;
            return;
      }
      }
    if (ts->exception_is_undef_insn())
      {
    Unsigned32 opcode;

    if (ts->psr & Proc::Status_thumb)
      {
        Unsigned16 v = *(Unsigned16 *)(ts->pc - 2);
        if ((v >> 11) <= 0x1c)
          goto undef_insn;

        opcode = (v << 16) | *(Unsigned16 *)ts->pc;
      }
    else
      opcode = *(Unsigned32 *)(ts->pc - 4);

        if (ts->psr & Proc::Status_thumb)
          {
            if (   (opcode & 0xef000000) == 0xef000000 // A6.3.18
                || (opcode & 0xff100000) == 0xf9000000)
              {
                if (handle_copro_fault[10](opcode, ts))
                  return;
                goto undef_insn;
              }
          }
        else
          {
            if (   (opcode & 0xfe000000) == 0xf2000000 // A5.7.1
                || (opcode & 0xff100000) == 0xf4000000)
              {
                if (handle_copro_fault[10](opcode, ts))
                  return;
                goto undef_insn;
              }
          } 

        if ((opcode & 0x0c000000) == 0x0c000000)
          {
            unsigned copro = (opcode >> 8) & 0xf;
            if (handle_copro_fault[copro](opcode, ts))
              return;
          }
      }

    undef_insn:
    // send exception IPC if requested
    if (t->send_exception(ts))
      return;
    t->halt();
  }
} 

这段代码比较复杂,我们挑重点的看(这段代码可能和德国人用的编码工具不一样,显得好乱)。首先关注下面这一行。

opcode = *(Unsigned32 *)(ts->pc - 4);

这一行是取出当前陷入中断向量表的指令,为什么需要pc-4呢?通常情况下arm采用三级指令流水,分为三个阶段:取值,译码,执行,所以当前pc指针指向的指令还在取值阶段,每条指令应该占4个字节(32位机器),真正执行的指令是pc-8才对。但其实对arm来说,每种异常情况对应的指针便宜都不一样,通过查表我们得知,undefined instruction异常还真就是pc-4。我们接下来重点关注下面这两个条件:

    if ((opcode & 0xef000000) == 0xef000000 // A6.3.18
         || (opcode & 0xff100000) == 0xf9000000)
    if ((opcode & 0xfe000000) == 0xf2000000 // A5.7.1
        || (opcode & 0xff100000) == 0xf4000000)

这两个条件根据操作码判断出对应的指令是运行在特权级的指令(读者可根据备注里的标号去查找arm文档),它不进行处理,而是一致执行下面这条语句。

goto undef_insn;

接下来我们转到undef_insn去执行。

    undef_insn:
    // send exception IPC if requested
    if (t->send_exception(ts))
      return;

    t->halt();

可以看到,这里将跳转到send_exception(ts)去执行,这里的t是一个指向当前线程的类的指针,接下来我们去看这个函数的定义。

int Thread::send_exception(Trap_state *ts)
{
  assert(cpu_lock.test());

  Vcpu_state *vcpu = vcpu_state().access();

  if (vcpu_exceptions_enabled(vcpu))
    {
      // do not reflect debug exceptions to the VCPU but handle them in
      // Fiasco
      if (EXPECT_FALSE(ts->is_debug_exception()
                       && !(vcpu->state & Vcpu_state::F_debug_exc)))
        return 0;

      if (_exc_cont.valid())
    return 1;

      // before entering kernel mode to have original fpu state before
      // enabling fpu
      save_fpu_state_to_utcb(ts, utcb().access());

      spill_user_state();

      if (vcpu_enter_kernel_mode(vcpu))
    {
      // enter_kernel_mode has switched the address space from user to
      // kernel space, so reevaluate the address of the VCPU state area
      vcpu = vcpu_state().access();
    }

      LOG_TRACE("VCPU events", "vcpu", this, Vcpu_log,
      l->type = 2;
      l->state = vcpu->_saved_state;
      l->ip = ts->ip();
      l->sp = ts->sp();
      l->trap = ts->trapno();
      l->err = ts->error();
      l->space = vcpu_user_space() ? static_cast<Task*>(vcpu_user_space())->dbg_id() : ~0;
      );
      memcpy(&vcpu->_ts, ts, sizeof(Trap_state));
      fast_return_to_user(vcpu->_entry_ip, vcpu->_sp, vcpu_state().usr().get());
    }

  // local IRQs must be disabled because we dereference a Thread_ptr
  if (EXPECT_FALSE(_exc_handler.is_kernel()))
    return 0;

  if (!send_exception_arch(ts))
    return 0; // do not send exception

  unsigned char rights = 0;
  Kobject_iface *pager = _exc_handler.ptr(space(), &rights);

  if (EXPECT_FALSE(!pager))
    {
      /* no pager (anymore), just ignore the exception, return success */
      LOG_TRACE("Exception invalid handler", "exc", this, Log_exc_invalid,
                l->cap_idx = _exc_handler.raw());
      if (EXPECT_FALSE(space()->is_sigma0()))
    {
      ts->dump();
      WARNX(Error, "Sigma0 raised an exception --> HALT\n");
      panic("...");
    }

      pager = this; // block on ourselves
    }

  state_change(~Thread_cancel, Thread_in_exception);

  return exception(pager, ts, rights);
}

这个函数依然非常复杂,我第一次看的时候也吐血了...而且因为内核是用c++写的,还要额外读好多构造函数,如果真的有读者通过本篇博客学习vcpu的话,希望能保持耐心吧..但是不管怎么说,看到这里终于和vcpu相关了,接下来我们分析这个函数,前面第一段先判断当前线程是否开启vcpu状态
,如果不是的话,就不管啦,直接返回,让fiasco自己去处理。接下来保存vcpu线程在VM进程的各种状态,然后把我们之前存起来的ip指针和sp指针取出来,其实这里的ip指针指向的就是我们handle函数,sp指针就是在VMM进程的堆栈指针。然后执行一个关键函数。

fast_return_to_user(vcpu->_entry_ip, vcpu->_sp, vcpu_state().usr().get());

接下来我们转到它的定义。

PUBLIC inline
void FIASCO_NORETURN
Thread::fast_return_to_user(Mword ip, Mword sp, Vcpu_state *arg)
{
  extern char __iret[];
  Entry_frame *r = regs();
  assert_kdb((r->psr & Proc::Status_mode_mask) == Proc::Status_mode_user);

  r->ip(ip);
  r->sp(sp); // user-sp is in lazy user state and thus handled by
             // fill_user_state()
  fill_user_state();

  load_tpidruro();

  r->psr &= ~Proc::Status_thumb;

    {
      register Vcpu_state *r0 asm("r0") = arg;

      asm volatile
    ("mov sp, %0  \t\n"
     "mov pc, %1  \t\n"
     :
     : "r" (nonull_static_cast<Return_frame*>(r)), "r" (__iret), "r"(r0)
    );
    }
  panic("__builtin_trap()");
}

ok,看到这里终于看到曙光了。很明显,这里的操作是把我们之前存的ip和sp的内容返回到物理的sp和pc寄存器里面去,我们终于从kernel态再一次返回到用户态了。这样vcpu线程就完成了从VM进程跨到VMM进程的伟大壮举。而且根据陷入内核的情况不同,我们会给VMM进程传递不同的数据信息,一般来说,无论哪种陷入方式,都会给VMM进程一份VM进程的snapshot(r0-r15的值),再加上额外的信息(不同的陷入不一样)。相信绝大部分读者看到这里还是一头雾水..这部分还是有点复杂,需要慢慢看才行。如果想深入学习这一块儿内容,我建议还是要把源码下载下来,自己用qemu做一些实验,打一些log才能明白。

安全性分析

在讨论虚拟化对安全的影响之前,我们先引用一段NOVA对它的论述,“Virtualization can positively or negatively impact security,depending on how it is employed”。那么为什么说它是negatively的呢?很显然,我们除了运行guest os之外,还要额外加一层虚拟层,也就是hypervisor+VMM,那么attackers除了攻击guest os本身,还可以攻击hypervisor和VMM的漏洞,这无疑加大了风险,降低了安全性。那么怎么样才能做到positively呢?我们可以把在non-virtualized环境运行的os按照其功能分为不同的模块,这些模块在non-virtualized环境中是融合在一起的,但在virtualized环境中,我们可以把它们分开,然后把一部分功能从原先的os中移出,作为一个单独的trusted virtual appliance,能够让它继续保持原先的功能,那么即使guest os被攻破,这部分功能还是可以继续运行。比如:我们把防火墙从guest os中剥离出来单独作为一个可信应用,这样即使guest os被攻破,防火墙仍然可以正确运行。

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。如发现有害或侵权内容,请点击这里 或 拨打24小时举报电话:4000070609 与我们联系。

    来自: sofes > 《OS》

    猜你喜欢

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多
    喜欢该文的人也喜欢 更多