【梳理】简明操作系统原理 虚拟内存子系统案例选讲(VAX/VMS + Linux)(内含文档高清截图)

参考教材:
Operating Systems: Three Easy Pieces
Remzi H. Arpaci-Dusseau and Andrea C. Arpaci-Dusseau
在线阅读:
http://pages.cs.wisc.edu/~remzi/OSTEP/
University of Wisconsin Madison 教授 Remzi Arpaci-Dusseau 认为课本应该是免费的。
————————————————————————————————————————
这是专业必修课《操作系统原理》的复习指引。
在本文的最后附有复习指导的高清截图。需要掌握的概念在文档截图中以蓝色标识,并用可读性更好的字体显示 Linux 命令和代码。代码部分语法高亮。

虚拟内存子系统案例选讲

下面我们以VAX/VMS系统和Linux系统两个实例为例,来讲解虚拟内存子系统。VAX/VMS开发于1970年代到1980年代,但里面有许多方法和技巧一直被应用于今天。Linux系统则应用于多个行业。小到嵌入式系统、手机,大到数据中心,都能见到Linux的身影。这充分反映了Linux的虚拟内存子系统(VM System)能够灵活适应各种规模、各种场景。
VAX-11小型机(mini-computer)在1970年代末由DEC(Digital Equipment Corporation)开发。在小型机时代,DEC是计算机行业的佼佼者。但很遗憾,由于一系列错误决定和PC(个人计算机)时代逐渐到来,DEC最终于1998年被Compaq收购。而Compaq在2002年又被HP(Hewlett Packard)并购。VAX/VMS是VAX系列计算机运行的操作系统,Dave Cutler为首席设计师。后来Cutler参与开发了Windows NT。VMS当年也在各种不同价格的计算机上运行得很好。
VMS通过软件上的一系列创新来隐藏硬件问题。虽然VMS常常依赖硬件来建立各种抽象和隔离机制,但硬件设计师常常没法把硬件设计得比较完美。在接下来的学习中,我们可以看到一些VMS如何改进硬件缺陷的例子。
VAX-11支持32-bit的地址空间,每页512 B。因此VPN(虚拟页号)有23位,剩下9位是偏移。VPN的高两位用于刻画一个页位于哪个段。因此VAX属于前面介绍过的把分页机制和分段机制混合运用的计算机。进程空间(process space)占地址空间的一半。用户程序存在于进程空间,堆向上生长。两者都在进程空间的前一半(P0)地址。在进程空间的后一半(P1)地址中,则是栈空间,且栈向下生长。被保护的操作系统代码和数据都放在地址空间的后半部分(高地址),为所有进程共享。
VAX的页大小是历史遗留问题。因此VMS中每个进程的页表可能很大。VMS的设计师为此很头疼,他们要保证页表不会占用全部内存空间。所以进程空间被分成两段P0和P1,每段一个页表。这样栈和堆之间的空白部分就不用被记在页表中。VMS已经有基址寄存器和界限寄存器了。用户部分的页表也位于内核的虚拟地址,且VMS已经具有页交换机制。VMS进行地址翻译时,需要先获取页表的物理地址,然后再到页表中查询相应的虚拟地址的物理地址。虽然这个过程看起来很慢,但是在硬件管理TLB的加速下,常常能避免这种复杂的查询带来的速率降低。

这是VMS的地址空间的简化模型。首先0地址被设定为不可访问,用于为空指针访问的检测提供支持。且每个进程空间的高半部分都是内核的虚拟地址空间。上下文切换时,只改变P0和P1寄存器,不会改变系统段(S段)的基址寄存器和界限寄存器。
把内核映射到全体进程的地址空间的理由有多个。首先这样做使得内核更容易工作。例如:当OS获得一个来自用户程序的指针(例如进行write()系统调用时),很容易就可以从那个指针开始复制数据到OS自己的数据结构中。在编写、编译OS时可以更加自然,因为不用考虑正准备访问的数据从哪里来。如果不这样做而是单独给内核划分地址空间,那么交换页面、在用户进程和内核之间交换数据之类的操作就相当困难。内核映射到地址空间的后半部分后,内核就显得向所有进程都可以调用的一个库(虽然是受保护的)。
VAX的页表中含有保护位。在访问内存之前,会考察相应页面的保护位和CPU的运行模式。系统代码和数据显然具有更高的保护级别,当非法访问时,就会产生陷阱,一般导致进行非法访问的进程被结束。
VAX的页表项含有如下内容:有效位1位、保护位4位、修改位1位、给OS保留的5位,以及页帧号。页表项里没有使用位(引用位),因此进行交换时不能直接得知一个页是否被访问过,必须专门设法得到其访问情况。
许多交换页面的策略无法应对进程大量占用内存的情况。例如LRU不能确保进程之间的内存分配相对公平。VMS使用分段FIFO(segmented FIFO)算法来进行页交换。每个进程在内存中保留的页数,记为RSS(resident set size),会受到限制(剩下的页在交换区或者根本没被加载)。当进程在内存中的页数达到RSS后,就把最先进入内存的页移走。这个算法不用硬件支持,易于实现。
纯的FIFO算法表现不太好。所以VMS引入了二次机会列表(second-chance lists),将准备移到交换区的页面暂时保留在此内存区域。如果被移到列表中的页没被修改过,就移到clean-page列表里;否则,移到dirty-page列表里。
当一个进程Q需要新的页面时,就从clean-page list中直接获取一页。但是如果在Q获取该页之前,该页所属的进程P先尝试访问了,P就把这一页回收。这就避免了P要从磁盘上把这一页重新读入内存的情况。当这种列表比较大时,分段FIFO的表现会接近LRU。
VMS的页面大小只有512 Bytes。因此如果直接与磁盘的交换区进行页交换,效率是非常低的。所以每次将页面写入交换区时,VMS总是写入一组已修改页面(这些页面的修改位要置零)。
VMS有两个优化技巧,现在仍为许多系统所使用。这些优化我们称为懒优化(lazy optimizations)。
第一个技巧是访问时清零(demand zeroing)。当没有这个优化时,假设你要为堆扩充一页,那么OS从物理内存中找到未使用的页,将其清零(防止正在运行的程序获得原来用到此页的程序残存的数据),然后映射到你的程序的地址空间(在页表中写入相应的物理页号)。那么,如果新申请的这一页暂时未被使用,这个过程就显得比较浪费。
这个优化的实现方法是:不要清零,而是直接将新的页写入页表,但标记其为不可访问。如果尝试访问,就产生陷阱,找到对应的物理页后将其清零后再标记为可访问。
第二个技巧是写入时复制(copy-on-write,COW)。它的历史可以追溯到TENEX系统。它的思想很简单:当OS需要把一页从一个地址空间复制到另一个时,不要复制,而是直接映射到另一个地址空间,但要在两个进程的页表中标记该页为只读。如果有进程尝试写入这一页,就分配新的页并执行真正的内容复制(当然要把相关权限的标记也修改)。如果进程对这一页只读不写,节省的用时和内存空间就很可观了。
在UNIX系统中,使用fork()和exec()时,也会用到这个机制。而且使用exec()时,由于先调用fork()创建了当前进程的副本,但是子进程的代码段会被用户要求运行的新程序的代码段覆盖,所以如果不采用COW机制,fork()就复制了一大堆完全不会用到的代码。COW的引入避免了大量不必要的复制操作。
在其它方面,这种思想也十分有用:例如,复制文件的时候,系统可能很快会提示复制完毕,但实际上文件被暂存到(系统的和 / 或磁盘的)写入缓存中,后续才会完全写入到磁盘。写入文件的操作也可以被适度延缓:万一用户马上要把这个文件删除呢?如果用户修改这个文件后不久就将其删除,那么就可以放弃暂缓的写入操作了。

对Linux的虚拟内存机制,我们不会把每个方面都细讲,而是只讲最重要的部分。下面的讲解基于运行在x86架构上的Linux系统,因为从桌面到服务器几乎都使用x86架构的CPU运行Linux。

这是Linux的地址空间的简化模型。用户部分主要包括代码、栈和堆。内核部分包括内核代码、内核栈、内核堆等。同样,当上下文切换时,只有与地址空间的非内核部分相关的寄存器才会被一同切换。用户模式下,不允许访问内核空间。
32-bit Linux中,虚拟地址是32位的,内核部分从0xC0000000开始,占整个地址空间的1 / 4。64-bit Linux则有些不同。
内核的虚拟地址有两种。一种是内核逻辑地址(kernel logical address)。要申请新的这部分空间,需要调用kmalloc。这部分包含许多内核的数据结构,包括页表、每个进程的内核栈等。这一部分空间也不允许被交换到硬盘上。
事实上,地址空间的内核部分一般被映射到物理内存的低地址。可能是直接映射的,例如0xC0000000映射到物理内存的0x0,0xC0000FFF映射到物理内存的0xFFF。这样直接映射有两个好处:一是地址翻译更简单,二是如果在这里构造了一段连续的内存空间,那么其在物理内存中也连续(总体性能更好)。
另一种地址是内核虚拟地址(kernel virtual address)。如果要申请新的这部分空间,需要调用vmalloc。不同于内核逻辑内存,内核虚拟内存一般是不连续的。不过不要求连续意味着分配这部分空间比较容易。不过如果想在这里面的数据在物理内存上连续分布就比较困难了。
32-bit Linux中,引入内核虚拟地址的一个目的是让内核可以访问大于约1 GB的内存空间。当然在64-bit的Linux中,已经没有这个限制了。
前面我们提到,页表是需要硬软配合的。x86提供了硬件管理的多级页表,因此运行在x86上的操作系统只需要在内存层面启用映射,并将一个特权寄存器指向页目录的开头,剩下的就可以交由硬件处理。OS负责进程的创建与删除、上下文切换,保证在任何情况下MMU都能使用到正确的页表。
32位系统不能获得64位处理器带来的性能提升。如果你还在使用32位的系统,应该尽快升级至64位。
64-bit下,地址空间最多也可以达到64位。但是目前x86只启用了48位:

由图可见,页表分为4级,必须一级一级翻译,才能找到相应的页。每一页的大小是4 KB。日后,当内存继续增大,就会启用5级页表。当然,x86不止支持4 KB的页大小,还支持2 MB甚至1 GB的页大小。Linux也允许应用程序使用很大的页大小。
如果页大小非常大,页表的占用空间就可以降低。但是这并不是大页面的主要推进原因。如果一个程序占用的内存非常多,那么TLB很快就会用尽。这时只有很小一部分页面能够在TLB中直接获得翻译结果。当大量页面(总大小达到GB级)的访问都需要查询页表时,性能开销就不能忽略了。研究表明一些应用程序的运行时间中,解决TLB未命中的过程占用达到10 %。如果增加页面大小,那么就有更大比例的内存块的翻译结果可以写入TLB。而且TLB未命中发生时,查找页表也更快(页表中的条目大幅减少了)。而且在特定场景下,分配内存的速率也会变得很快。
向操作系统引入新特性时,要充分考虑其优缺点,不要盲目改进。起初,Linux开发者发现大页面只对少数应用有效,例如对性能需求异常高的大型数据库。所以,分配大页面的权利交给了应用程序,由应用程序在需要时通过mmap()或shmget()来申请。
后来,因为许多程序都需要TLB具有更佳的表现,因此Linux开发者添加量透明的大页面机制。当该特性启用时,OS寻找机会分配大的页面(通常为2 MB,但有些系统上分配1 GB)给应用程序,而无需程序手工请求。
但是大页面的代价也是有的。最大的影响就是较多的内部碎片,一个页很大,但可能很大一部分没有被应用程序使用。交换机制无法解决这类问题,还会徒增IO次数。不过有一件事可以确定:4 KB的页大小虽然已经在OS中应用多年,但不一定总是最好的方案。事实上,如果Linux开始慢慢采纳基于硬件的新技术,那么这通常预示着一个重大改变即将来临。
许多系统包含主动式的缓存子系统(caching subsystem),用于减少为了进行管理与维护而使用的存储空间。Linux的页缓存(page cache)统一把内存页按三个不同的来源维护:映射到内存的文件(一段虚拟内存,是文件或类似文件的资源的一部分。这个资源通常是磁盘上的文件,但也可以是设备或共享内存的对象,也可以是其它可以由OS通过文件描述符引用的资源。),文件数据和设备的元数据(metadata)(一般用read()和write()访问),以及每个进程的栈和堆的页面(有时称作匿名内存(anonymous memory),因为这里面没有任何已命名的文件)。这些实体用哈希表索引,允许快速查询需要的数据。
页缓存检查缓存的每一项是否已修改。已修改的数据通过后台进程(pdflush)周期性回写到磁盘,且在已修改的页数累积到一定数量(可以修改相关参数来调整)才会写入。
Linux采用2Q替换算法来进行页交换。
虽然LRU算法很高效,但是在一些常见的情况里,其表现也不佳。如果一个进程重复访问一个大文件(文件大小可用内存或更大),那么如果采用LRU算法,就会踢掉内存中的其余全部文件。更坏的情况是:这个新载入的文件有很大一部分在被踢出内存之前从来不会被再次访问。
2Q替换法维护两个列表,并在它们之间划分内存。当第一次访问时,页被放入非活动列表(inactive list)。当这一页再次被访问时,就放到活动列表(active list)。当需要进行交换时,先从非活动列表交换。活动列表中的一些页会被周期性移动至非活动列表,并保持页面缓存中活动列表占用约2 / 3。
理想状态下,这两个列表用LRU算法维护。但是用LRU算法获得最优解的花费不小,所以Linux采用LRU的近似算法。
现代的支持虚拟内存机制的系统(Linux,BSD等)与早期的(VAX/VMS)最大的不同很可能是对安全的格外重视。
当今系统面临的最大威胁之一就是缓冲区溢出攻击(buffer overflow attacks)。这可以用来攻击用户程序和内核。其思想是利用系统漏洞来向目标地址空间注入任意数据。这个漏洞的常见起因是:开发者想当然地认为输入一定不会溢出,于是信任任何输入和复制操作。当输入超过缓冲区的长度时,超出的部分就会写入到缓冲区以外的区域去。例如strcpy函数就是引发缓冲区溢出攻击的一个常见函数。
许多情况下,缓冲区溢出的后果不是灾难性的,顶多只是导致一个程序或系统崩溃,无其它影响。但是恶意程序可以构造特定的输入,使得溢出部分为可以被解析为代码并执行的指定代码。例如构造一段远大于栈空间限制的数据,末端有一部分是恶意代码。如果OS不在栈溢出时结束进程,如果恶意代码能够随着栈向低地址生长而进入代码段,就可以被计算机执行。当然也有其它的实现方式。可见,一旦攻击成功,理论上就可以执行任意代码。如果被攻击的程序是联网的,那么还可能波及网络上的其它计算机。如果针对操作系统进行攻击,还可能获得执行特权指令的权限。
一个最简单的预防办法是:不要把代码区以外的空间的任何数据识别为代码。AMD、Intel、ARM处理器中,分别用NX(Non-execute) bit、XD(execute disable) bit和XN(execute never) bit来标识。如果一个页的执行禁用位被设为1,那么任何试图将该页的内容识别为代码(机器语言)并执行的操作都会被拒绝。
另一种攻击方式是返回导向编程(return-oriented programming,ROP)。在汇编语言课上,我们学习过调用子程序的过程。返回地址会在执行子程序前先被压入栈中,待子程序执行完毕后返回时,返回地址就会被从栈中弹出来,将指向下一条指令的地址的寄存器内容修改为原先压入栈中的返回地址。正常情况下,返回地址应该是调用子程序的指令的下一条指令的地址。但是黑客如果设法修改了返回地址为自己编写的恶意代码的开头,那么在函数执行完毕并返回时,就会开始执行指定的恶意代码。
为了防止ROP攻击,Linux和Windows均引入了地址空间布局随机化(address space layout randomization,ASLR)。因此实际的系统中,代码、栈、堆等各个区域不但在物理内存中的位置无法得知,其在地址空间中的位置也不是固定的。
如果你编译并执行如下的C / C++程序:
int main(int argc, char* argv[]) {
int stack = 0; printf("%p\n", &stack);
return 0;
}
在非ASLR的系统上,每次显示的值都是一样的;而在ASLR的系统上,显示的值一般都不同。

对于内核,也有内核地址空间布局随机化(kernel ASLR),防止与内核协作的用户程序恶意破坏内核或通过内核执行任意代码。
2018年8月,世界的系统安全局势遭遇巨大振荡。Meltdown和Spectre两个硬件级漏洞先后被多个团队的科研人员或工程师曝光。这些事件引发了对硬件和操作系统提供的保护机制的深切拷问。
这两个致命漏洞与CPU用于提升性能的机制有关。预测执行(speculative execution)是高性能CPU的必备技术。CPU通过猜测接下来将要执行哪些指令并提前执行。如果预测正确,性能就得到提升;如果预测错误,就需要重试指令。
预测执行会在缓存、分支预测器等众多部件中留下痕迹。我们常常认为这些部件会被MMU保护好,但实际上确实可以通过这些短板进行攻击。
一个加强保护内核的方法是:从用户进程中尽可能移除内核的地址空间,并用专门的内核页表来管理内核的内存数据。这称为内核页表隔离(kernel page table isolation,KPTI)。这时候,如果从用户进程的代码切换至内核的代码并执行,为了使用正确的页表,额外的工作就要做了。虽然这样确实提高了安全性,但是损失了性能。因为切换页表的开销是很大的,而且也为编程带来了不方便。
不幸的是,KPTI不能解决曝光的全部安全问题。但是又不能关闭预测执行,因为这甚至可能导致在一些极端场景下,性能损失到只有原来的数千分之一。

【梳理】简明操作系统原理 虚拟内存子系统案例选讲(VAX/VMS + Linux)(内含文档高清截图)_第1张图片【梳理】简明操作系统原理 虚拟内存子系统案例选讲(VAX/VMS + Linux)(内含文档高清截图)_第2张图片【梳理】简明操作系统原理 虚拟内存子系统案例选讲(VAX/VMS + Linux)(内含文档高清截图)_第3张图片【梳理】简明操作系统原理 虚拟内存子系统案例选讲(VAX/VMS + Linux)(内含文档高清截图)_第4张图片在这里插入图片描述【梳理】简明操作系统原理 虚拟内存子系统案例选讲(VAX/VMS + Linux)(内含文档高清截图)_第5张图片
【梳理】简明操作系统原理 虚拟内存子系统案例选讲(VAX/VMS + Linux)(内含文档高清截图)_第6张图片【梳理】简明操作系统原理 虚拟内存子系统案例选讲(VAX/VMS + Linux)(内含文档高清截图)_第7张图片
【梳理】简明操作系统原理 虚拟内存子系统案例选讲(VAX/VMS + Linux)(内含文档高清截图)_第8张图片【梳理】简明操作系统原理 虚拟内存子系统案例选讲(VAX/VMS + Linux)(内含文档高清截图)_第9张图片【梳理】简明操作系统原理 虚拟内存子系统案例选讲(VAX/VMS + Linux)(内含文档高清截图)_第10张图片

#include
int main() {
	int stack = 0; printf("%p\n", &stack);
	return 0;
}

【梳理】简明操作系统原理 虚拟内存子系统案例选讲(VAX/VMS + Linux)(内含文档高清截图)_第11张图片【梳理】简明操作系统原理 虚拟内存子系统案例选讲(VAX/VMS + Linux)(内含文档高清截图)_第12张图片

你可能感兴趣的:(专业课,#,操作系统原理)