《Operating System:Three Easy Pieces》阅读笔记<十五>—完整的虚拟系统

899 阅读13分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第19天,点击查看活动详情

完整的虚拟系统

到目前为止我们已经学习了很多CPU虚拟化和内存虚拟化的底层机制和设计策略,现在我们以两个经典的操作系统:VAX/VMS和Linux,来看一下现代操作系统比较完整的虚拟化所用的技术都有哪些。

VAX/VMS

VAX/VMS operating system是一款在七十年代末由Digital Equipment Corporation(DEC)开发出来的经典操作系统,其中有关虚拟化的技术很多是今天现代操作系统的来源,当时VAX系列系统需要在各种各样硬件条件的机器上运行,从大型机房到嵌入式设备,因此,VAX/VMS操作系统系统必须有支持其良好泛用性的机制和策略。

基本信息

VAX-11是一款 32-bit address space,512-bytes page size的系统,一条virtual address中包含23-bit VPN和9-bit offset,并且头两位的VPN用于标记page所属的是哪个segment。因此,这个系统基本内存策略是hybrid of paging and segmentation(记不住这是什么了?看看前面的文章)。

VAX地址空间

在VAX-11中系统地址空间分为上下两半部分,上半部分P用于用户进程使用,下半部分(S)用于内核进程使用。这两段分别有不同的page table,而用户地址空间上半部分又分为两个部分P0和P1,P0用于code和heap,P1用于stack。如下图所示。

图中 page0被标注为 invalid,我们规定 page 0是无效的页,任何更改 page 0的操作都会触发 trap。这对代码调试有很大的帮助。

VAX-11的page size十分的小,为了确保VAX-11不会因为page table占太多空间。VAX-11通过两种方法缓解page table占用问题。

第一个方法是两端page table,P0和P1两段分别有一个page table,因此stack和heap中间一大段没被使用的空间是不需要占用页表的。第二种方法是在内核虚拟空间中放置用户的page table,也就是在图中unused部分,这进一步的减少了内存占用。

在上下文切换的时候,操作系统更换P0和P1的寄存器,但是S的寄存器始终是固定的。将系统进程放进address space的好处有很多,例如它只需要负责本进程的系统调用即可,进行数据交互也无需额外的步骤,就像一个固定的程序库一样,虽然比较占空间,但是这种构造因其优越的性能得到现代操作系统的广泛采用。

VAX替换策略

在VAX系统的page table中,一个entry包含valid bit、protection field、dirty bit、一个保留给OS使用的5 bits字段以及PFN。并没有present bit,因为研究者发现present bit是可以被替代的。

VAX使用近似LRU策略作为替换策略,因为LRU策略并不是进程公平的,如果某一个进程一直大量申请空间,LRU会将其它进程的几乎所有page都swap出去。VAX使用不依赖present bit的segmented FIFO来进行page replacement。具体规则是:

每个进程都有它可以在内存中保存的最大页数,当页数超过限额时“先入”的页会被换出,这似乎与普通的FIFO一样,关键在于分段FIFO还引入了两个列表,分别时clean-page free list和dirty-page list。当有页面要换出时,会根据dirty bit放入两个列表中,当另一个进程需要空间时,先从clean-page free list中取页面进行覆盖,可以免去与disk进行交互。对于dirty-page list,因为磁盘在大的传输中表现更好,为了使交换I/O更有效率,VMS使用clustering策略将list中的page分组一起交换出去。

其它优化

VAX系统另外有两种“延迟“优化方法,也叫lazy optimal。它们分别是demand zeroing和copy-on-write。

demand zeroing是大多数现代操作系统都会有的一种lazy策略,当进程申请空间的时候,操作系统只在page table中放一个标记表示已经占用,当实际做IO的时候才触发trap让操作系统找到对应物理内存,清零并将其映射回地址空间。

copy-on-write也是几乎所有的现代操作系统都会有的一种lazy策略,当操作系统需要将一个页面从一个地址空间复制到另一个地址空间时,它可以将其映射到目标地址空间,并在两个地址空间中都标记为只读,当某一个进程对其进行写操作的时候,触发一个trap,操作系统将其内容真正复制到另一个物理地址上并映射回地址空间。

lazy optimal是一种十分有效且应用广泛的优化设计思想,其它的例子还有操作系统在接收到文件处理的指令后立即返回处理成功,但是实际在后台慢慢的处理文件。

Linux

Linux的虚拟内存系统功能齐全、特性丰富且相当复杂,Linux的发展是由真正的工程师解决生产中遇到的实际问题而推动的,因此它的功能也在不断迭代。Linux和老系统有一些共同点,但是也有很多方面超越了传统的虚拟机系统(如VAX/VMS)。我们讨论最主要的英特尔x86的Linux上的虚拟内存系统。

同样的,Linux的地址空间有内核和用户两部分,同样内核部分在不同进程中是相同的。在经典的32位Linux中,地址空间的用户和内核部分的分割发生在地址0xC0000000处,或者是地址空间的四分之三处,而64位的Linux分割的点略有不同。

两种内核虚拟地址

从下图我们可以看出,在它的内核空间中有两种内核虚拟地址。

第一种被称为kernel logic address,大多数内核的数据结构都在这里,它与物理内存有直接的映射联系,例如内核逻辑地址0xC0000000转换为物理地址0x00000000,是有一一对应关系的。这么做有简化内核逻辑地址访问的作用,同时这使得在内核地址空间的这一部分分配的内存适合于需要连续的物理内存才能正常工作的操作,比如通过目录内存访问(DMA)进行的设备间的I/O传输。

另一种是kernel virtual address,这是一片是由操作系统主动申请,可以自由访问的空间,与内核逻辑内存不同,内核虚拟内存通常不是连续的,它的具体作用有很多,这里就不展开细讲了。

页表结构

x86的Linux有基于硬件的多级page table structure(不记得了?看看之前的文章),操作系统只需在其内存中设置映射,将一个privileged register指向页目录的开始,其余部分由硬件处理。具体的,目前64位系统使用四级页表。然而,虚拟地址空间的全部64位性质还没有被使用,而只是底部的48位,随着系统内存的增长,地址空间的更多部分将被启用,从而产生五级乃至六级的页表树结构,如下图所示

可变页大小

Intel x86架构允许使用多种页面大小page,最近的设计在硬件上支持2MB甚至1GB的页面。Linux也使用这些huge page来提升TLB和其它相关的性能。因为如果page总是那么小,那么一个大内存的访问就会把TLB迅速填满,造成大量的TLB miss,最近的研究表明,一些应用程序花了足足10%的周期来处理TLB miss。

Linux对huge page的支持是以一种渐进的方式发生的,起初,Linux的开发者知道这种支持只对少数应用程序(eg.数据库)很重要,提供系统调用来申请huge page,由于对更好的TLB行为的需求在许多应用程序中更为普遍,Linux开发者增加了transparent huge page support。具体的说,操作系统系统会自动寻找机会来分配huge page提升TLB性能,进程不需要主动去调整页大小。

页缓存

为了提升访问硬盘的性能,大多数操作系统包括Linux都会建立cache subsystem,将活跃的数据保留在内存中,内存中保留的page来自三个方面:内存映射的文件、来自设备(例如硬盘)的文件数据、元数据(程序中总是被访问的数组变量)以及每个进程的heap和stack(有时被称为anonymous memory,因为它的下面没有命名的文件,而是交换空间)。这些entry被保存在一个page cache hash table中,当需要上述数据时,可以快速查找。

page cache跟踪这些entry是clean还是dirty。脏数据被后台线程定期写到支持存储(如果是文件数据,写到对应的文件中,如果是heap和stack则写到交换空间中),这种后台活动要么在某个时间段后进行,要么在有太多页面被认为是脏的情况下进行。这么做是为了帮助Linux判断哪些page是要从内存中swap出去的。

替换策略

Linux使用一种修改过的2Q replacement策略来进行page replacement。如果一个进程读取大量数据,但是却使用,那么LRU这时是没用的,2Q replacement创建两个列表分别称为active list活动列表和inactive list非活动列表,并将内存分给它们,当page第一次被访问时被放入非活动列表中,当被重新引用时,这个page被提升到活动列表中,当需要进行替换时,替换的候选页会从非活动列表中取出。Linux也会定期地将页面从活动列表的底部移到非活动列表中,使活动列表保持在总页面缓存大小的三分之二左右。这样做就可以处理LRU无法处理的情况。

硬盘与内存的映射出现的比Linux早一些年,在Linux中有一个系统调用mmap()能够指出内存映射在地址空间上的位置,事实上在Linux中每个普通的Linux进程都是内存映射的文件,我们调用pmap命令行工具能够查看搭配进程中文件的映射情况

从上图可以看到来自tcsh二进制的代码,以及来自libc、libcrypt、libtinfo的代码,还有来自动态链接器本身(ld.so)的代码都被映射到了地址空间。此外,还有两个匿名区域,即堆(第二个条目,标记为anon)和栈(标记为stack)。存映射的文件为操作系统构建地址空间提供了一种直接而有效的方法。

系统安全(选择性阅读)

以上都不是Linux和老操作系统的最大区别,它们之间的最大区别在于现代操作系统对安全的重视

buffer overflows是一种针对系统缓冲区溢出的攻击手段,这种漏洞的出现有时是因为开发者设计失误,当系统得到一个过长的输入,会发生缓冲区溢出,有人将溢出的恶意程序部分覆盖到目标的内存,成功注入到目标系统中,如果在与网络连接的用户程序上攻击成功,攻击者可以在被攻击的系统上运行任意的计算(比如挖矿程序)。

AMD在他们的x86版本中引入NX bit位来标记地址空间的某些区域不能够运行代码来防御这种攻击,但是攻击者发现即使注入的代码不能被攻击者明确添加,任意代码序列也能被恶意代码执行。这个想法在其最一般的形式下被称为面向返回的编程(ROP)。

为了防御ROC,Linux(和其他系统)增加了另一种机制,称为地址空间布局随机化(ASLR)。将代码、堆栈和堆放在虚拟地址空间的随机位置,从而使实现这类攻击所需的复杂代码序列变得相当有挑战性,在Linux中可以模拟这种随机放置,如右图所示,变量stack的地址每次都不一样。

ASLR对于用户级程序来说是一个非常有用的防御手段,因此它也被纳入了内核,其功能被称为内核地址空间布局随机化(KASLR)然而安全问题不止这些,在2018年,system相对安全的世界被两个新的相关攻击颠覆了。第一个叫做Meltdown,第二个叫做Spectre。它们大约在同一时间被四组不同的研究人员/工程师发现,并导致我们对计算机硬件和上述操作系统提供的基本保护提出深刻质疑。

这些攻击所利用的是现代CPU处理推测性行为的方式,CPU猜测哪些指令将在未来很快被执行,并提前开始执行它们。如果猜测是正确的,程序就会运行得更快,这样做能够显著增加系统运行速度,但问题是,它往往会在系统的各个部分留下其执行的痕迹,这种状态可以使内存的内容变得脆弱,甚至是我们认为受到MMU保护的内存。因此,增加内核保护的一个途径是将尽可能多的内核地址空间从每个用户进程中删除,因此,不再将内核的代码和数据结构映射到每个进程中,而是只保留最基本的内容,当切换到内核时,现在需要切换到内核页表,这种策略称为kernel table isolation。这样做提高了安全性,但是也降低了一部分性能,并且没有解决上面列出的所有的安全问题,而最简单的解决方案就是终止对推测性行为的优化,但这是不可能的,因为系统的运行速度会慢几千倍。

从上面的描述我们可以看出想要真正理解现代操作系统,就一定要理解操作系统的安全问题。