共计 5492 个字符,预计需要花费 14 分钟才能阅读完成。
1. 概述
操作系统作为计算机硬件的管理软件直接运行在物理硬件之上,完全占有全部的硬件资源。自 1965 年以来,半导体硬件一直遵循摩尔定律快速发展。硬件的计算能力不断增强,一套硬件上运行一个操作系统就显得有些浪费,这种现象在数据中心尤为明显。于是如何共享和整合硬件资源,提升硬件利用效率就成了一个需要解决的问题,虚拟化技术便应运而生。最近十几年,随着云计算的快速发展,虚拟化技术广泛应用于数据中心,已经成为云计算的核心技术之一。
虚拟化需要解决三个基本问题,共享 , 隔离 和性能 。在一套硬件环境上运行多个系统来共享硬件资源提升硬件利用效率是虚拟化的出发点,系统之间相互隔离保证安全是一种基本需求,操作系统运行在虚拟化的环境之上性能会有所下降,如何提升虚拟化的性能,一直伴随着虚拟化的发展。虚拟化经历了 二进制翻译 , 半虚拟化 , 硬件辅助虚拟化 的发展。现代 CPU 基本都已经支持硬件辅助虚拟化。那么,如何实现一个简单的硬件辅助虚拟化系统呢?
图 1:虚拟化要解决的问题
ASOR, 是一个简单的虚拟化管理软件(VMM),用来帮助人们了解硬件辅助虚拟化的基本概念以及虚拟化管理软件的实现。虚拟化管理软件又被称作 Hypervisor, 有些文献将 Hypervisor 分成 TYPE 1 和 TYPE 2 两种类型,如图 2 所示。所谓 TYPE 1 类型也就是 Hypervisor 直接运行在硬件上,虚拟操作系统运行在 Hypervisor 之上,而 TYPE 1 类型的 Hypervisor 则运行在一个被称之为 HOST 的操作系统之上,其上再运行虚拟操作系统。其实,如果把 TYPE 2 中的 HOST 操作系统与 hypervisor 并成一个整体来看,这个整体也可以认为是一个 TYPE 1 的 Hypervisor。实现一个简单的 TYPE 1 类型的 Hypervisor 和实现一个简单的操作系统类似,前者比后者多了虚拟化的软件支持。从这个意义上,ASOR 作为一个 TYPE 1 的 Hypervisor 也可以用来学习研究如何实现一个简单的操作系统。
图 2:虚拟机类型 TYPE 1 vs TYPE 2
支持虚拟化的硬件架构
用户态,内核态,特权指令,敏感指令
x86 架构提供四个特权级别给操作系统和应用程序来访问硬件,从 Ring 0 到 Ring 3。其中 Ring 0 为最高级别,Ring 3 为最低级别。计算机指令可以分成特权指令和非特权指令。操作系统内核代码在最高级别 Ring 0 上运行,可以执行特权指令直接访问硬件,而应用程序代码运则行在 Ring 3 上,只能执行非特权指令,这些指令不能做受控操作。当一个应用程序需要访问硬件时,需要通过执行系统调用,CPU 的运行级别从 Ring 3 转换到 Ring 0, 并跳转执行内核代码来完成硬件访问,访问完成后再从 Ring 3 切回 Ring 0。这个过程被称作用户态和内核态切换。计算机指令还可以划分成敏感指令和非敏感指令。所谓敏感指令,指的是那些会改变系统硬件资源配置的指令或其执行的行为依赖硬件资源配置的指令。一个可以虚拟化的硬件架构需要满足的条件是,敏感指令集必须是特权指令集的子集,如图 3 所示。如果存在敏感指令是非特权指令,这样的硬件就存在虚拟化空洞,是不可虚拟化的硬件架构。这种硬件虽然不能完整支持辅助虚拟化,但仍然可以通过软件方法来实现虚拟化。
图 3:可虚拟化架构
早期的硬件没有辅助虚拟化的功能。虚拟化通过修改操作系统来实现,也就是对执行的非特权敏感指令进行修改,来填补虚拟化空洞来实现虚拟化,即所谓的半虚拟化。随着硬件的发展,硬件辅助虚拟化已经成为主流。
2. ASOR 的启动流程
计算机上电启动后开始执行 BIOS 的代码,BIOS 程序首先进行硬件自检。如果硬件出问题,主板会根据具体问题发出不同类型的蜂鸣声。如果没有问题,BIOS 会找到启动顺序排位第一的存储设备,读取该设备的起始扇区的 512 个字节。如果这 512 个字节的最后两个字节是_0x55_和_0xAA_,表示这个设备可以用于启动,如果不是,表示设备不能用于启动,则从下一个存储设备查找。在找到可启动的设备后,控制权交给 bootloader,例如我们这里用的 GRUB。通过 GRUB 的界面选择 ASOR 启动。之后程序开始执行_x86/cstart.c_(对于 32 位系统)或_x86/cstart64.c_(对于 64 位系统)。asor.img是一个虚拟硬盘,用于方便在在 qemu 中调试 ASOR。在系统中安装 qemu-kvm 后,运行 make qemu 便可看到如 图 4 所示的 ASOR 启动界面。
图 4:ASOR 启动流程
以 x86/cstart.c 为例,其中 start 标记的地址是 AOSR 启动代码的入口地址。从 start 启动代码的入口地址到 ASOR C 语言的 main 函数 asor_entry 的流程如图 5 所示。
图 5:从 start 入口到 asor_entry
3. 分段与分页
x86 体系结构中 CPU 看到的地址是逻辑地址,经过分段单元后逻辑地址被转换为线性地址,又叫虚拟地址,最后分页单元再将虚拟地址转换为物理地址,如图 6。操作系统为了隔离内核空间与用户空间,通常都会定义四个段,这些段相互重叠都从地址 0 起始,覆盖整个线性地址空间。在特权级 0 上定义内核空间代码段和内核空间数据段,在特权级 3 上定义用户空间代码段和用户空间数据段。以此来将操作系统与用户程序隔离开来,然后再通过为每一个进程创建不同的页表来将不同的进程彼此隔离开来。图 7 展示了分段与分页是如何结合起来运行的。
图 6:逻辑地址,线性地址(虚拟地址),物理地址
图 7:分段与分页机制
多级页表
在早期 32 位时代广泛使用的是两级页表模式如图 8 图 9 所示,而现代 64 位 CPU 多采用四级页表模式如图 10,图 11,图 12 所示。为什么需要多级页表呢?主要是为了节约内存。我们可以做一个简单计算。假设我们在一个 32 位的系统使用一级页表,每页占_4K_大小,那么为了寻址_4G_空间,我们需要_1M_个页表项,如果每个页表项占 4 个字节,那么我们需要一共_4M_的大小来存储页表。假如我们有 100 个程序,每个程序使用不同的页表,那么则一共需要_400M_来存储页表。而如果使用二级页表,第一级页表需要_4K_大小,可以存储_1K_个页目录项,每个页目录项指向一个_4K_大小第二级页表,则我们一共需要 4K+4M 的大小。这岂不是比一级页表使用的_4M_要多了吗?事实上,得益于计算机理论中的局部性原理,第二级页表并不需要全部都存在,可以按需分配,4K+4M只是最坏的情况,最少的情况下我们只需要 4K+4K 内存,这样 100 个程序在最少的情况下只需要_800KB_,这比一级页表的_400M_要小得多。对于 100 个程序而言,两级页表需要的内存介于 800KB ~ 400K+400M 之间,大多数情况下远小于_400M_。此外,使用多级页表的好处还在于,两级及其以后的页表可以不存放在内存而存放于磁盘,需要的时候再交换回内存。
图 8:采用 4KB 页面大小的两级页表模式
图 9:采用 4MB 页面大小的两级页表模式
图 10:采用 4KB 页面大小的四级页表模式
图 11:采用 2MB 页面大小的四级页表模式
图 12:采用 1GB 页面大小的四级页表模式
ASOR 用到了四级页表模式,具体映射如图 13 所示,静态映射是程序初始化时就建立好的页表,而动态映射是按需建立和删除页表。
图 13:内存映射
4. 中断与异常
中断作为一种异步机制被广泛采用。理解中断主要是理解两部分内容,即,中断控制器如何发送中断给 CPU 以及 CPU 收到中断后如何处理。前者主要涉及,PIC, APIC (LAPIC, I/O APIC), MSI/MSI-X,后者主要就是 IDT 中断描述表。我们这里暂时只讨论后者。
IDTR 是一个寄存器,存有 IDT 表的基地址和长度。当 CPU 从中断控制器收到中断号后,便可以通过 IDTR 来找到 IDT 中对应的中断门(一个 8 个字节的描述符,如图 14)。通过这个中断门可以在全局描述符中找到对应的描述符,最后找到对应的中断处理函数的逻辑地址,再结合前面的分段与分页的机制找到中断处理函数的实际物理地址跳转执行。具体流程如图 15 所示。
图 14:中断 / 陷阱门
图 15:CPU 收到中断后处理流程
x86 体系结构一共有 256 个中断号(0 ~ 255),其中 (0 ~ 31) 保留给体系结构定义的异常使用,见表 2,(32 ~ 255)为用户自定义外部中断。异常一般可以分为四种,即 中断(Interrupt),陷阱(Trap),故障(Fault),终止(Abort),其区别如表 1 所示。
表 1:异常的种类及比较
表 2:x86 保留中断号
一个简单的外部中断软件处理流程通常会是如图 16 所示。首先定义一系列 IRQ(N) 函数,将这些函数的地址填写到中断描述表对应 IDT(N) 位置。这些 IRQ(N) 有着差不多的实现,可以用一个统一的模板来定义,主要就是保存调用前的必要的上下文,调用一个通用的处理函数假设叫做_common_handler_, 这个函数里需要完成一些通用的处理比如_send EOI_, 除此之外,最重要的就是调用 handler(N),handler(N) 是用户通过类似 register_irq(N, handler) 的用户接口注册的处理函数。最后还需要恢复调用前的上下文继续执行。对于其他异常也是类似的流程。
图 16:中断软件处理
5. x86 虚拟化扩展(VMX)
我们知道,只有特权指令才能运行在 ring0 上。在引入虚拟化之后,guest 操作系统同样也存在 ring0 ~ ring3。然而按照概述中可虚拟化架构的定义,guest 操作系统的 ring0 显然不能有运行特权指令的权限。x86 对此给出的解决方案是引入 root 和 non-root 模式(见图 17)。guest 操作系统运行在 non-root 模式下,虚拟机管理软件运行在 root 模式下。这样即便是运行在 non-root 模式下的 ring0 的指令,也可以由硬件截获并重新加以模拟。在 VM 中执行特权指令时,触发 VM Exit 回到 root 模式,特权指令执行完之后通过 VM Entry 进入 Non-Root 的 guest 继续执行。
图 17:Root / Non-Root 模式
VMX 一共引入了 13 条指令如表 3,包括 5 条指令用于 控制管理 VMCS 结构 (_VMPTRLD, VMPTRST, VMCLEAR, VMREAD, VMWRITE_),4 条指令用于 管理 VMX 操作 (_VMLAUNCH, VMRESUME, VMXON, VMXOFF_), 2 条指令用于TLB 管理 (_INVEPT, INVVPID_), 2 条指令 供 Guest 软件调用 VMM 服务使用(_VMCALL, VMFUNC_)。
表 3:VMX 指令
VMM 软件的生命周期如图 18 所示
- 软件通过执行 VMXON 指令进入 VMX 操作。
- VMM 软件可通过执行 VMLAUNCH 或者 VMRESUME 发生 VM entry 进入 Guest, 在 Guest 发生 VM exit 后 VMM 软件重新获得控制权。
- VMM 软件可以在 VMCS 结构中指定进入 Guest 的入口点以及 Guest 发生 VM exit 的入口点,VMM 在执行完 VM exit 的相应处理后可以重新通过 VM entry 进入 Guest。
- VMM 软件可以决定是否退出 VMX 操作,通过运行 VMXOFF 指令来实现。
图 18:VMM 软件生命周期
具体软件处理流程如图 19 所示。当执行 vmx_run()后,就会运行 Guest 代码,如果运行 Guest 代码发生 VM EXIT,便针对具体原因做相应处理。
图 19:VMM 软件处理流程
6. 虚拟机控制结构 (VMCS)
虚拟机控制结构(VMCS, 图 20),是 x86 硬件辅助虚拟化提供的用于软硬件交互的数据结构。这块区域又被细分为六块子区域,分别是:
- 客户机状态区域(Guest-state area): 当 Guest 发生 VM EXIT 时,会将当前的一些状态信息存储到这个区域,当下次 VM Entry 时,从这里加载状态信息继续运行。
- 主机状态区域(Host-state area): 当 Guest 发生 VM EXIT 进入 VMX-root 时,从这块区域加载运行状态信息开始运行。
- 虚拟机执行控制域(VM-execution control fields): 控制处理器在 VMX non-root 模式下的行为,决定是否产生 VM exits。
- 虚拟机退出控制域(VM–exit control fields): 控制 VM exits 时的行为。
- 虚拟机进入控制域(VM–entry control fields): 控制 VM entry 时的行为。
- 虚拟机退出信息域(VM-exit information fields): 包含虚拟机退出的原因。
虚拟机执行控制域,虚拟机退出控制域,虚拟机进入控制域有时也被统称为VMX 控制域。
图 20:VMCS 结构
在首次执行客户机代码之前,可以通过客户机状态区域中的 RIP,来指定客户机代码的起始执行地址。
7. 虚拟化地址转换扩展 (EPT)
早期的地址访问虚拟化通常通过影子页表的方式来实现。所谓影子页表,也就是对每一个客户机的页表在 VMM 中建立一个与之对应的影子页表。如图 21 所示。
图 21:影子页表
由于影子页表的虚拟化地址转换在其实现上的复杂性以及大量的 VM exits 和 TLB flush 导致的性能问题,现代虚拟化系统已经不再使用影子页表,取而代之的是硬件辅助扩展分页 (Extended Page Table)。客户机操作系统维护一张页表用于将客户机虚拟地址(GVA) 转换为客户机物理地址 (GPA),VMM 软件维护一张页表用于将客户机物理地址转(GPA) 换为真正的物理地址(HPA)。当发生内存访问时,客户机可以通过这两张表直接获得真正的物理地址。其原理如图 22 所示。
图 22:EPT 原理
// 待续
参考
- ASOR 代码