|
发表于 2005-8-3 22:03:30
|
显示全部楼层
不想下的、英语困难的凑合看看吧。翻译的很粗略,多多包含。
操作系统功能概述
1.介绍
1.1 仅仅是程序!
首先而且最重要的是,明白操作系统(OS)仅仅是一个程序,虽然它非常庞大、非常复杂,不过它仍然是一个程序。OS提供对加载和处理其他程序的支持(我们以下将这些程序称为应用程序),并且操作系统能够建立某些机制,获取某些特权,这些特权是应用程序所没有的,不过最后还是要提醒你,操作系统仅仅只是一个程序。
举个例子,当你的一个程序,假如是a.out处在运行状态,不过OS没有运行,这样你的OS将没有能力在a.out程序运行时加载、中断该程序-—-因为OS没有运行!这是一个关键概念,所以让我们先来了解这个描述是什么意思。
上面我们描述a.out处于运行状态,那么什么是运行状态?计算机的CPU会不间断的执行获取代码/执行代码/获取代码/执行代码...的循环,每一次获取过程,CPU都会去取得Program Counter指针所指向的指令。如果当前的PC指针指向你的程序当中的一条指令,那么我们称你的程序将处于运行状态。每次你程序中的一条指令执行后,CPU的循环机制会更新PC指针的值(一般就是加一),使之指向你的程序中的下一条指令(通常情况)或是你程序中的任意位置处的指令(若遇到jump指令).
需要注意的是,能使你的程序停止运行的唯一方法是使PC指针指向另一个程序,比如指向OS。
它是怎样发生的呢?只有两种方法:
a.你的程序可以自动将CPU释放给OS。通常你的程序会通过系统调用---由OS提供的一系列实现某种有用功能的函数---来达到上述目的。
比如,假设a.out是由C源程序编译而来,其中调用了scanf()函数。scanf()函数是C语言库函数,它在a.out源程序编译的过程中链接进了目标文件a.out,但是,scanf()这个库函数实际上又调用了read()函数,而read()函数正是一个系统调用函数(它被包含在操作系统中)。当a.out这个程序运行至scanf()指令时,对scanf()的调用会导致对OS的调用,不过当OS读入键盘输入后,它又会将资源返回给a.out程序。
b.另外一种可能原因是产生硬件中断。它是一种发送给CPU的信号-—-实际上是在总线中的中断请求线路上的一个物理电流脉冲,来自诸如键盘之类的I/O设备。当CPU收到该中断后,就会转入一段我们指定的在启动计算机之后的内存空间中。它会存在于OS中,因此这时OS就会运行。这时OS就会监控I/O设备,比如,记录键盘的敲击情况,最后,返回到被中断的应用程序。
注意,在这个敲击键盘的例子中,敲击不一定是由你来完成。当你的程序运行时,其他用户可能会使用键盘。中断会导致你的程序被挂起;OS会运行发起中断请求的设备的驱动-—-比如键盘,如果该用户使用一台设备的console或者internet接口,如果该用户是从远程登陆,比如通过telnet-—-这会在该用户的缓冲区里记录该用户的击键情况;然后OS会执行iret指令从该中断程序中返回,这样你的程序就恢复了。
硬件中断还可以由CPU自己产生,比如说,如果你的程序有除以零的操作,或者试图获取一段超过你的程序限制的内存空间时,CPU就会自己产生中断了。
所以,当你的程序处在运行状态时,它就是老大,OS没有停止它的能力。使你的程序停止运行的唯一方法就是自动交出执行权或者是由I/O设备的中断行为强行停止。
1.2 OS有什么作用?
一台计算机可能没有操作系统,比如在一些嵌入式的应用中。嵌入在洗衣机中的“计算机”只会运行一个程序(在ROM中)。所以,不会为它担心文件情况、不会有时钟共享的问题、等等...所以,它不需要OS。
不过在一台通用计算机上,我们还是需要操作系统来完成以下功能:
a.加载需要运行的应用程序;
b.提供一些服务,比如:I/O、一些应用程序需要调用的系统函数(比如read()函数);
c.提供时钟共享机制,这种机制通过硬件的协调使得那些本来独立的程序采用分占时钟、循环执行的方法达到看似同时运行的目的;
d.为应用程序提供虚拟存储器,这一机制可以使内存使用更有弹性,并且加强了安全性,这一机制同样是由硬件协调的;
e.维护文件系统(比如记录磁盘中所有文件的位置);
f.控制I/O设备,包括网络。
需要仔细掌握OS和硬件之间的相互作用。OS控制I/O设备,因此禁止了应用程序的编制者直接处理这些I/O设备。比如,假设你希望你的程序读取在磁盘中的某个文件,那么如果使用处理磁盘物理存储单元获取该文件的方法会是一场灾难,与此相反的是,实际上你只是简单的调用了fopen()和fread()函数,接下来细节的工作是由OS来完成的。由于文件的创建就是由OS来完成的,所以OS也知道文件的具体位置;当你想完成读取文件的任务时,OS会找到该文件的位置,并且记录下它们。
另外,如果硬件允许、OS设计允许,那么OS不仅仅减轻程序员直接处理磁盘的工作,并且将会阻止他这么做。这是出于安全考虑的,我们不会希望程序员有意或无意删除别人存储在磁盘中的文件。
以下部分会讨论OS如何实现这些作用,我们会在UNIX系统中进行建模讲解,不过关于这些功能的描述适用于大多数现代操作系统。这里假设读者熟悉基本的UNIX明令。如果不熟悉的可以参看该教程:http://heather.cs.ucdavis.edu/~matloff/unix.html。
2.系统启动
正如将在后面介绍的,当我们希望运行一个应用程序时,操作系统会将它装载进内存。但是操作系统本身是如何被装载进内存并且执行的呢?处理上述过程的数语称为bootup。
经过设计的CPU将会使计算机启动后的PC指针指向一个特殊的位置,比如Intel的处理器会使PC指针指向0xfffffff0这个内存地址。并且那些组装计算机的人(就是将CPU,内存,总线系统等等装配在一起的人)会把0xfffffff0这个地址付予一块ROM存储器,该ROM存储器里就包含了boot loader程序。所以,一旦计算器启动,boot loader程序就会运行。
boot loader程序的目的是将OS从磁盘中装载进内存中,一种比较简单的方式是,boot loader程序会读取磁盘中的一段特定的区域,将该区域中的内容(就是OS 了)拷贝进内存中,然后在boot loader程序的最后执行一条JMP指令(或类似的跳转指令)跳转到该内存单元-—-此时OS便开始运行了。还有一种比较复杂的方式是,boot loader程序只将操作系统的一部分装载入内存,然后就跳转至该内存单元运行OS,并且由OS自己将它的剩余部分装载进内存。
让我们看看具体的例子,以Intel为例,在ROM中的程序称为BIOS,它包含了该计算机的部分硬件驱动,并且包含了boot loader程序的第一部分。
在BIOS当中的boot loader程序用来读取某些信息,现在就可以告诉你的是,那些信息存储在物理磁盘的第一个块中。这个块也被称为Master Boot Record(MBR)。
典型的,一个操作系统会定义磁盘的分区,如果假设一块磁盘有1000个柱面,那么我们可以将前200个柱面作为一个分区,剩下的800柱面作为第二个分区。这些分区由MBR中的分区表来确定。主分区中的确切的一个将在这个表中被标记为active,意味着可引导的。
MBR中还包含了boot loader程序的第二部分。在BIOS中的boot loader将会加载这部分代码至内存,然后跳转过去,因此该程序就会运行。该程序将读取分区表,以决定哪个分区是被active的。然后该程序会转到该分区的第一部分,在那里将boot loader的第三部分读入内存,然后跳转至该部分程序。
现在,如果计算机最初是安装的Windows 操作系统,那么在MBR中的第二部分boot loader代码会将Windows分区中的第三部分代码加载入内存。另一方面,如果该机器安装的是Linux或是其他的OS,那么MBR中的代码也会做相应变化,以使对应的操作系统能被加载进内存中。
很多Linux用户在他们的机器中保留了Windows操作系统,并且有一个双启动配置。他们最开始使用Windows,不过后来又装上了Linux,这样,这台机器上就同时存在两个OS。作为Linux安装过程的一部分,旧的MBR被拷贝至别处,一个新的叫做LILO(Linux Loader)的程序将被写入MBR中。换句话说,LILO将会称为boot loader的第二部分程序。LILO会让用户选择是启动Windows还是Linux,然后到相应的分区装载被处理第三部分引导程序。
无论怎样,当OS被第三部分引导程序装载进内存中后,该引导程序会跳转至OS代码的位置,这时OS就开始运行了。
3. 应用程序的装载
假设你在Linux操作系统下编译完成了一个可执行文件a.out,你使用如下明令运行它:
% a.out
如果这次你使用的是非常简单的设备/OS组合,没有虚拟内存,那么下面是发生的事情:
a.当你敲击上述命令时,shell处在运行状态,可能是tcsh或bash。另外,shell仅仅是一个程序,它使用printf()函数输出%提示符,并且在一个循环中使用scanf()函数记录你敲击的命令;
b.然后shell会产生一个系统调用-—-execve(),要求OS运行a.out应用程序。OS在这时开始运行;
c.该OS会查看它的磁盘索引,以决定a.out在磁盘的哪个位置。它会读取a.out的最开始部分,该部分包括该应用程序的大小、该程序使用的数据段列表等等;
d.OS会查看它的内存分配表(只是OS中的一个数组)以查找一段足够大的未用的内存空间来给a.out使用。(值得注意的是,OS正是这样加载了目前所有的正在运行的程序,所以它知道目前有哪些内存已经被使用,哪些内存是空闲的。)我们需要为a.out的指令(UNIX数语称为程序中的text部分)和数据-—-静态数据项(标量和数组变量,在UNIX中称为data段),以及栈空间和堆空间(被calloc()函数和malloc()函数所使用)留出足够的空间;
e.接下来OS将a.out(包括text和data)装载进上一步所分配出来的内存空间里,然后相应的更改它的内存分配表;
f.OS会检查a.out文件的某一特定部分,在这里留下了链接器对a.out文件入口点的记录,也就是文件执行的第一条指令;(以我们前面的编译语言为例,这里是__start指令。)
g.现在OS准备好初始化a.out的执行了。它会将栈指针指向先前为a.out分配好的栈空间。(操作系统会保存它自己的寄存器值,包括之前的栈指针的值。)然后它会将所有命令行参数送到a.out的栈空间里,然后开始执行a.out,比如执行一条JMP指令(或类似功能的)跳转至a.out的入口点;
h.现在,a.out就开始运行了!
需要注意的是,a.out可能会调用execve()来运行其他程序。比如,gcc就这么做。它先运行cpp C预处理器(它会预编译你的C源文件中的#include、#define语句)。然后gcc会运行真正的编译器-—-cc1(正如你所见的,gcc本身只是运行其他组件的管理者),这一过程会产生一个汇编语言文件。然后gcc会运行as-—-汇编语言编译器-—-来生成a.o机器代码文件。后者必须被某些代码,比如/usr/lib/crt1.o,和一些其他的构成main()结构的文件,比如argv明令行参数的入口,所所链接,也要链接到C库,/lib/libc.so.6;所以,gcc会运行链接器-—-ld。以上所有例子中,gcc都是通过调用execve()系统调用来运行这些程序的。
3.1 巩固上述概念:自己尝试这个命令
UNIX系统命令strace会报告你执行的应用程序的系统调用。使用方法如下:
% strace a.out
你将会看到在一系列不同的系统调用之前有execve()的身影,正是由它来启动a.out程序的。
4.分时
4.1 许多进程,那么轮换吧
分时是一种使许多正在运行的程序看上去是同时运行一样的方法。因为系统仅仅拥有一块CPU(在这里我们排除多处理器系统的特例),所以这种“同时”只是一种假象,因为在一个给定的时间里,只能运行一个程序,不过正如我们将要看到的,这是一种很有价值的假象。
第一个问题是,我们如果获得这种假象呢?答案是,我们让所有程序轮换着运行,使每个程序能占用的轮循时间-—-称为量子或时间片-—-都很短,比如50毫秒。假设我们马上就要运行四个程序,u,v,x和y,想想下面这样会造成什么:首先u运行50毫秒,接着u被挂起、v运行50毫秒,然后v被挂起、x运行50毫秒......就象这样继续(当y完成它的50毫秒后,u开始第二次运行)。因为轮循(一般称为现场交换)发生的如此之快(每50毫秒一次),所以我们人类会觉得每个程序都是同时在运行的(虽然只有四分之一的实际速度),而不会查觉到程序的运行、挂起、运行挂起......
但是OS是如何强话这些时间片的呢?举例来说,OS如何使上面的程序u在运行50毫秒之后准时停止呢?结合前面的想想,答案是:“操作系统不能,因为在u运行的时候,OS并没有运行!”取而代之的,这些轮换是通过一个计数设备来实现的,这个设备会在合适时间发送一个硬件中断。比如,我们可以让这个设备每50毫秒发出一个中断,我们可以编写一个计时设备的驱动,并将它并入OS中。
计数设备驱动可以储存u程序的所有寄存器值,包括它的PC指针值和EFLAGS寄存器值。稍后,当u程序获得执行机会时,这些值会恢复,这样u程序就可以看似连续的执行下去了。不过现在,按照OS的计划,最后将恢复程序v先前的所有寄存器值,特别要保证恢复上一轮的PC值,这最后一步会强制执行一个从OS到程序v的跳转,跳转到它上一次时间片挂起的正确位置。(另外,CPU“只操心自己的事情”,而并不会知道OS已经把控制权交给另一个程序v了;CPU只是不断循环获取PC指针指向的数据并且处理这些数据罢了。)
最新的CPU会运行于两个或多个特权级别。我们的例子由于安全原因不会涉及到普通的存取I/O设备的应用程序,比如磁盘驱动。因此CPU被设计为让一定的指令-—-比如那些处理I/O的-—-只能在运行在高特权级别上,这个级别称为内核模式(内核这个术语取决于OS)。除了别的之外,计数器发出的中断就会将CPU置于内核模式,所以该中断不仅使OS得以运行,还是OS获得它需要的权限。
在任何给定时间,内存中都会有许多不同的进程。它们是程序执行的实例。如果现在有三个用户在同一台给定设备上运行gcc编译程序,那么会对应于一个程序产生三个进程。
4.2 OS代码例子:Linux For Intel CPUs
这里是一个在Intel架构设备、Linux操作系统下的关于现场交换的例子。
操作系统会维护一张由结构数据量数组构成的进程表,每一个结构数据量都为一个进程存在。这个结构数据量称为对应进程的TSS(Task State Segment),并且存储了该进程的不同信息,比如对应程序最后一次执行完成后的寄存器值。
作为描述上述行为的例子,并且为了实实在在的向你说明OS就是由真实代码组成的程序,下面是一段典型的代码摘录:
1 pushl %esi
2 pushl %edi
3 pushl %ebp
4 movl %esp, 532(%ebx)
5 ...
6 movl 532(%ecx), %esp
7 ...
8 popl %ebp
9 popl %edi
10 popl %esi
11 ...
12 iret
这段代码是计时器的ISR(中断服务例程)。紧接着入口的背景是u程序刚刚执行完,并且被计时器所中断。OS已经指向了刚刚结束的进程u,以及即将开始的进程v的TSS中的寄存器EBX和ECX。
下面是这段代码的作用。Linux的源代码包括一个变量tss,它是目前进程的TSS结构变量。在该结构中是一个名为esp的字段。因此,tss.esp包含了这个进程以前存储的ESP值、栈指针;这一字段恰巧占据TSS的头532个字节。
现在,在进入上面这段OS代码之前,ESP仍然指向u程序的栈,所有,前面三条PUSH指令将u程序的ESI、EDI、EBP寄存器值存储到它自己的栈空间中。其他u程序的寄存器值也必须被存储,包括它的ESP的值。后者由MOV指令完成,该指令将目前的ESP值-—-也就是u的ESP值-—-复制到u程序的TSS结构中的tss.esp中。其他寄存器值的存储方式差不多,所以不在这里介绍了。
现在,OS必须准备开始执行这一轮的v程序进程了。因此v程序上一轮的寄存器值必须被重载入寄存器中。为了理解这是如何完成的,你必须清楚在v的上一轮结束时,也执行了和上面相同的指令。因此,v程序的ESP值就存储在它自己的TSS的tss.esp中,上面代码的第二条MOV指令正式将这个值重新赋予ESP,因此,我们现在使用的就是v程序的栈空间了。
下一步,注意到在v程序的上一轮结尾,它的ESI、EDI、EBP值被push进了它自己的栈中,并且理所当然的,这几个值仍然在相应栈空间中。所以,后面的三个POP指令将这几个值重新装载进相应的寄存器中了。
最后,实际上是什么使v程序的新轮循开始的呢?要回答这个问题,需要明白如下机制:导致v程序的上一轮结束的是从计数器发出的一个硬件中断信号。那时,FLAGS寄存器、CS寄存器、PC寄存器的值是被push进相应栈空间中了的。现在,你看到的IRET指令将这些玩意儿全都重新装载回对应的寄存器中了。由于v程序上一轮存储的PC值重新装入PC寄存器中,因此v程序就开始运行了。
4.3 进程状态
OS维护一张进程表(process table),这张表显示了内存中每个进程的状态,其中最主要的是Run状态和与之相反的Sleep状态。一个进程处于Run状态意味着它已经作好了运行的准备只待下一轮循的到来了。OS会循环的检查进程表,并且将处于Run状态的进程调入运行轮循,然后忽略掉那些处于Sleep的进程。处于Sleep状态的进程一直在等待某些触发物,典型的如一次I/O操作,因此它们都暂时不能进入轮循。所以,每次轮循结束后,OS都会浏览它的进程表,寻找一个处在Run状态的进程并且让它进入运行轮循。
假设我们上面的u程序包括一个scanf()函数来记录键盘输入。回忆起scanf()函数调用了系统函数read()来完成它的功能,后者会检查在键盘缓冲器中是否有任何准备输入的字符。一般情况不会有任何准备好的字符,因为用户还没开始输入。那么此时,OS就会将该进程置为Sleep状态,并且开始另一进程。
那么一个进程如何从Sleep状态转换为Run状态呢?假设如上面所说,u程序由于等待用户输入而正处于Sleep状态(假设它只用等待一个字符的输入)。正如前面所解释的,当用户敲打键盘时,会产生一个硬件中断信号,该中断将会强制一个到OS的跳转。假设当时v程序的进程正好处于时间片的中间,CPU将会暂时将v程序挂起,然后跳转执行OS中的键盘驱动程序,OS会发现u进程目前处于等待键盘输入的Sleep状态,因此OS将会把u进程转至Run状态。
需要注意的是,尽管OS将u进程置于Run状态了,但是u的轮循并没有开始,u只是简单的处在可以运行的状态了。请回忆,每当一轮进程结束时,OS会从处于Run状态的进程中选择进入下一轮的进程,而u就处于该状态了。
为了巩固上面所述的,让我们看个例子,假如u程序是运行vi编辑器,v是运行一个冗长的运算程序,并且用户w也在运行一个大计算量的程序。记住,vi是一个程序,它的源程序中也许会包含以下代码片段:
while (1) {
scanf("%c",&KeyStroke);
if (KeyStroke == ’x’) DeleteChar();
else if (KeyStroke = ’j’) CursorDown();
else if ...
}
这里你可以看到vi是如何读入用户输入的,比如x(删除一个字符)、j(使光标下移一行)等。当然,DeleteChar()等上面出现的函数的源代码并没有在这里写出。在u程序的轮循中,vi程序会遇到scanf()行,后者调用read()系统调用,于是此时OS就会运行。假设在u程序在运行时,用户还没有从键盘输入,这时OS就会将u进程设置为Sleep状态。
接下来OS会选择进程表中的一个处于Run状态的进程,并且使它进入CPU的下一轮处理。这里假设是v程序,它会一直运行知道直到计时器中断的到来。这一电流中断脉冲会导致CPU跳转至OS,这时OS就会再次运行,这次,假设程序w会被执行。
假如在w的运行期间,u程序终于得到一个键盘输入,那么如上文所说明的那样,这个键盘输入产生的硬件中断会强行导致CPU跳转至OS,OS会读入u程序的键盘输入,需要注意的是,这时OS会将进程表中原本处于Sleep状态的u进程设置为Run状态。接着,OS会执行IRET指令,这会使w进程继续运行,不过当下一个计时器中断到来之后,OS可能会使u进程再次运行。
4.4 关于后台作业
假如你有一个名位a.out的程序,你想长时间的运行它。而在它运行时,你还想去处里其他事,比如使用vi编辑器编辑文件xyz。在UNIX操作系统中,你可以输入以下命令:
% a.out &
% vi xyz
使用"&"号的意思是:“在后台运行该作业。” 这又意味着什么呢?
答案是实际上它对于OS来说什么都不意味。在OS进程表中a.out进程是后台还是前台是不会被说明的,它仅仅就是个简单的进程。
"&"只会对shell有意义。我们使用该符号告诉shell:“请为我运行a.out程序,不过不要等到它运行完了才给我‘%’提示符,请立即给我该提示符,因为我想做一些其他事情。”
4.5 巩固上述概念:自己尝试这些命令
首先试试ps命令,在UNIX系统中,它会告诉你很多关于目前进程的信息,包括:
a.状态 (Run,Sleep等)
b.页使用情况 (有多少页、多少页故障等;参看下面的虚拟存储部分)
c.族谱 (该给出进程的父进程)
我们极力推荐读者自己试试。你会在看到ps命令的输出后更加明白我们上面提到的概念。该命令的格式会因系统不同而不同(在UNIX系统上,我建议使用ps ax),所以请查看man手册页获取更多细节,不过使用的选项越多越有可能得到完全的输出信息。
另一个需要尝试的命令是w,该命令所给出的信息中有一项是在过去几分钟里处于Run状态的进程的平均数。这个数字越大,那么用户能查觉的计算机的响应时间就越慢,就象它的程序和更多其他用户的程序一同运行一样。
在Linux系统(以及一些其他的UNIX系统)上,你也可以试试pstree命令,该命令以图示的方式显示了关于每个进程的“家族树”(族谱关系)。举个例子,下面是我的CSIF PC机上使用该命令的输出:
% pstree
init-+-atd
|-crond
|-gpm
|-inetd---in.rlogind---tcsh---pstree
|-kdm-+-X
| ‘-kdm---wmaker-+-gnome-terminal-+-gnome-pty-helpe
| | ‘-tcsh-+-netscape-commun---netscape-+
| | ‘-vi
| |-2*[gnome-terminal-+-gnome-pty-helpe]
| | ‘-tcsh]
| |-gnome-terminal-+-gnome-pty-helpe
| | ‘-tcsh---vi
| ‘-wmclock
|-kerneld
|-kflushd
|-klogd
|-kswapd
|-lpd
|-6*[mingetty]
|-2*[netscape-commun---netscape-commun]
|-4*[nfsiod]
|-portmap
|-rpc.rusersd
|-rwhod
|-sendmail
|-sshd
|-syslogd
|-update
|-xconsole
|-xntpd
‘-ypbind---ypbind
%
如果是UNIX系统,当系统启动后OS做的第一件事就是开始一个名为init的进程,该进程会是其他所有进程的父进程(或是爷爷进程、祖父进程等等)。Init进程然后启动一些OS守护进程。守护进程是UNIX的一种说法,意思是某种服务程序。在UNIX中,该种程序以"d"结尾,代表"daemon"。
举个例子,你能从上面的pstree输出中发现init进程启动了lpd守护进程,这是关于打印机的服务进程。当用户使用lpr或其他打印命令,OS会将这些命令交给lpd守护进程,该进程会实际分配完成打印任务。
守护进程经常产生更进一步的程序。看看下面这条线:
|-inetd---in.rlogind---tcsh---pstree
Inetd是Internet请求服务,系统管理员可以运行它来代替那些不怎么使用的网络守护进程。这里该用户(也就是我)已经完成了一个rlogin,这是一个登陆程序比如ssh,用来远程登陆到该系统。系统管理员猜想rlogin不会频繁使用,所以它们不会在启动之后由init来启动相应的守护进程,也就是in.rlogind。替代的是,任何不在启动时运行的网络守护进程都会由inetd来启动,正如你在这里看到的如in.rlogind守护进程这样的。后者又为登陆上来的用户(也就是我)启动一个shell,然后该用户(我)运行pstree命令。再次注意的是,shell才是pstree运行的实体。
5. 虚拟存储
5.1 确定明白你的目的
下面我们介绍虚拟存储技术(VM)。VM 实现以下基本目的:
a. 克服内存容量的局限:
我们想要能够运行一个或一系列占用超过物理内存空间的程序和程序组。
b. 在程序运行时释放编译器和链接器的负担,使它们不用知道哪些内存可以使用:
我们想促进程序的再装载,意味着编译器和链接器不用再关心程序运行时应该从内存的哪个位置将程序装载。
c. 加强安全:
我们想要确保一个程序不会有意无意的通过写入其他程序占据的内存空间而破坏后者
d. 开启共享:
我们想要能够将一个大程序的文本部分仅仅在内存中保存一份拷贝(比如一个编译器),尽管有一些用户都在运行该程序的实例。
5.2 虚拟自然地址的例子
“虚拟”在这里意味着“看上去的”。看上去一个程序的整体都会驻留在主内存中,但是事实上只有一部分;看上去(从编译器的观点上来看)程序会从内存中的最开始部分进行装载,但并非如此。
(为了简单,在讲解VM的时候不会考虑缓存的情况,后者会在稍后部分介绍。)
为了使上述概念形象化,假设我们a.out这个程序的C源程序代码中包含一行声明:
int x;
并且假设编译器和链接器将地址200付予了x。换句话说,在我们源程序中如下一行的代码:
printf("%d",&x);
会打印出x的地址:200。
那么,在一台Intel设备上a.out将会包含类似的汇编指令:
movl 200, %eax
这条指令将内存中200地址单元的内容拷贝给了CPU寄存器EAX。
在a.out被OS载入内存的时候,OS会将指令部分和数据部分分成块,并且在内存中寻找未被使用的空间放置这些块。这些块被称为程序的页,而OS寻找到的在内存中的相同大小的空间称为内存的页。OS将会建立一张页表,这是由OS维护的数组,OS在里面记录相关信息,也就是记录程序的每一页放置在内存的哪一相应页中。
所以,在上述代码中,看似在内存中200字空间中的内容可能实际上却在1024字空间中。当CPU处理该指令时,CPU会判断“200字空间”实际在哪个位置,通过查询页表。在这个例子中,我们实际上要寻找的内容是在1024字单元中,因此,CPU实际上会从那个地址中读取数据。
在这个例子中,我们称200为虚拟地址,1024为物理地址。
5.3 关于怎样实现这一目标的概览
让我们根据5.1节中的目标来看看如何实现它们:
a. 克服内存容量的局限:
为了保存内存容量,OS最开始只会将a.out部分装载入内存中,剩下的留在硬盘中。那些未被装载的程序的页会在页表中被标记为目前未安置,并且在该表中可以查到它们在硬盘中的位置。在程序处理阶段,如果该程序需要某个未安置的页,那么CPU将会注意到该页是未安置的(这被称为“页错误”),并会产生一个内部中断。这会导致到OS的跳转,然后OS 会将该未安置页从硬盘中装入内存,然后跳转回该程序,后者将会开始执行需要读取该页的指令。
注意到在这种管理机智下页经常在硬盘和内存中移动。每当程序需要一张未安置页,那么该页就从硬盘中读入内存,同时内存中会有一页回到硬盘中以给这个新成员腾个空间。
一个大问题是,面对在页错误发生后从硬盘中带来的缺失页,OS使用什么样的算法来决定将哪张内存页移回硬盘(也即是哪页被取代)。这些概念超过了本文档的说明范围,不过要知道,该算法的选择将基于使大多数程序都良好运行这一点。对有些程序来说,不良的算法会导致许多页错误,由于该种算法会造成在很短的时间内被替换的页又需要被载入内存这一问题。
b. 在程序运行时释放编译器和链接器的负担,使它们不用知道哪些内存可以使用:
这一点已经由上面的例子说清楚了,由编译器和链接器为x设置的位置200在程序被装载时由OS改成1024了。OS将这一信息记录在页表中,在程序运行过程中,CPU中的VM硬件查询该表以获得正确的地址。
c. 加强安全:
在页表中,每一页都包含一个入口。如前述,该入口包含了该程序页当前在内存中的地址信息,或者如果该页目前还未安置的话,它在硬盘中存储的位置信息。不过更多的,该入口还会罗列相应程序能够获得的这页的权限-—-读、写、执行-—-同文件权限一样。如果程序想获取页未付予它的权限,也就是说,发生了访问破坏,那么CPU中的VM硬件就会产生一个内部中断,导致OS运行。OS将会杀死该进程,也就是将它从进程表中移除。
d. 开启共享:
假设现在一台设备上有两名用户想运行gcc。它是个巨大的程序,所以,保留内存就相当重要了。OS会采取的措施显而易见的会包括将gcc的指令部分只做一份拷贝,当然,数据部分的拷贝将会分开,因为这两名用户的数据(C源文件)很可能不同。
VM允许我们实现上述目标。每个用户的页表会拥有相同的访问指令部分的入口,因此他们会共享程序的指令部分。
5.4 创建和维护页表
请仔细弄清楚参与者的角色:是软件,也就是OS创建和维护页表,但是实际上是硬件使用页表来产生地址、检查页的位置情况、检查安全情况的。
当OS产生一个新的进程时,它必须从内存中找到正确的页来分配给新进程的部分程序使用。它会为该进程创建页表,并在该表中记录页的位置(当然也会把那些没有装入内存中的程序在硬盘的位置记录下来)。
相应的硬件拥有一个名为页表寄存器(PTR)的特殊寄存器,用来指向当前进程的页表。当OS将该进程置于新的运行轮中,该进程会重新装载上一轮保存的PTR的值,使该进程的页表有效。
5.5 关于页表的使用细节
5.5.1 虚拟-物理 地址翻译,页表查询
每当运行的程序产生于一个地址时-—-无论该地址是指令地址还是数据地址-—-该地址都是虚拟的。它必须被转换成存储实际内容的物理地址。CPU中的轮循就被设计来完成该翻译,通过查询页表实现。
地址空间被分成页。为了方便起见,假设页大小为4096字节。对每个虚拟地址而言,虚拟页号等于该地址除以页大小,也就是4096,该页的偏移量等于该地址除以4096的余数。因为4096等于2的12次方,这意味着一个32位的虚拟地址,它的高20位构成页地址,低12位是偏移量。
参看下面的Intel指令:
movl $3,0x735bca62
该指令会将实数3拷贝至0x735bca62地址单元中(10进制就是1935395426)。也即说明了虚拟页号为0x735bc,偏移量为0xca62。也就是说,我们将把第一个字节写入0x735bc这一页的0xca62这个虚拟地址字空间中。
假设我们页表中的入口为32位,也即是每入口占据一个字单元。让我们来标明入口的0到31位,其中Bit 31处在最左边,Bit 0处在最右。假设入口的格式如下:
a. Bit 31 — Bit 12: 代表物理页地址如果Resident置位,如果Not置位,代表硬盘地址;
b. Bit 11 : 如果该位为1代表Resident置位,0代表Not置位;
c. Bit 10 : 如果为1,代表拥有读权限,0则相反;
d. Bit 9 : 如果为1,代表拥有写权限,0则相反;
e. Bit 8 : 如果为1,代表拥有执行权限,0则相反;
f. Bit 7 — Bit 0 : 一些其他信息,这里不会涉及。
那么,CPU处理上面哪条MOV指令会发生什么呢?
a. 首先,CPU发现该虚拟页地址为0x735bc后,会从页表中获得相应的入口,假设PTR的内容为0x256a1000,那么实际的位置将是0x735bc * 4 + 0x256a1000 = 0x2586e6f0。然后,CPU从该位置获取入口数据,假设是0xc2248eac。
b. 然后CPU查看Bit 11-8位,获得0xe,知道该页Resident置位,并且该程序拥有读写权限但是没有可执行权限。该MOV指令的需要是获得写权限,由此看来没有问题。
c. 接着CPU查看Bit 31-12位,获得0xc2248,虚拟偏移量为之前确定的0xca62,因此,CPU会获知虚拟位置0x735bca62的物理地址为0xc2248a62。CPU将该地址存入MAR(内存地址寄存器),将3放入MDR(内存数据寄存器),并且从总线中找出写入线,并将3写入0x2248a62这个地址单元中。至此我们就完成了相应任务。
顺便说一下,所有这都是为了完成该MOV指令的c步骤。步骤a也会去做相同的事情。PC指针会被分成两部分:虚拟页号和偏移量,虚拟页号作为页表的索引,然后将查看页表元素的10位和8位,以确定是否拥有读和执行该指令的权限;假设这些权限都是够的,那么物理页号会通过页表元素的31到12位的值来确定;该物理号会和偏移量一起组成物理地址,该物理地址会被放入MAR,通过它获取相应的指令。
5.5.2 页错误
假设上面那个例子中页表入口的第11位为0,这代表了需要的页并没有装入内存中,这一事件就被称为页错误。如果它发生了,CPU会产生一个内部中断,会导致到OS的强行跳转。OS首先会决定将内存中的哪个已置位页写回到硬盘以腾出空间,然后它会将请求的页从硬盘中写入相应位置,此后OS会更新页表中的两个入口参数:(a)它会修改被覆盖的页的入口参数,将第11位置0,并重置31到12位;(b)它会更新新载入的页的入口参数,指明该页已经装载入内存了,并且说明它的位置。
因为读写硬盘的速度远远慢于读写内存的速度,因此,如果页错误出现的太多,程序的运行速度就会非常迟缓。举个例子,假如你家里的PC机内存不够,那么你会发现装载大型软件的时候需要等待很长时间,并且硬盘指示灯会闪得很厉害,就是因为OS要将许多当前置位的页写回硬盘、并从硬盘中装载入新的页。
5.5.3 访问破坏
另一方面,如果发生一个访问破坏,那么OS会声明一个错误-—-在UNIX系统中,把这个错误称为段错误-—-并会导致进程被杀死,也就是从进程表中移除。
举个例子,参看下面的代码:
int q[200];
main()
{ int i;
for (i = 0; i < 2000; i++)~ {
q = i;
}
}
注意到编程者很显然在循环结构中犯了个错误,他将叠代数200替换成了2000。C编译器在编译过程中会忽略该错误,并且编译出的机器代码在执行过程中也不会检查到数组索引超界了。
如果该程序在非VM平台上运行,那么它可以畅快的没有任何明显错误的运行。它会简单的在q数组后写入1800个字,这能否造成破坏就取决于多余的字数据的目的了。
不过在VM平台上,这里就是UNIX系统下,实际上会报告一个错误,和一个“段错误”的信息。不过,在我们深入理解该错误之前,它出现的时间或许会另你吃惊。这个错误不像是在i=200时出现的,而像是在此之后很长一段时间才出现的。
为了说明它,我选择在gdb下运行此程序以便我能够查看q[199]的地址。在运行该程序之后,我发现段错误不是出现在i=200处,而是出现在i=728的时候。让我们看看这是为什么。
通过查询gdb我发现数组q是在地址0x080497bf处结束的,也就是说,q[199]这个最后的元素处于该地址空间中。对于Intel的机器,页大小为4096字节,所以一个虚拟地址被分成20位的页号和12位的偏移量,正如在5.5.1节中所述的那样。在我们目前这个例子中,q结束于虚拟页号为0x8049、十进制为32841,偏移量为0x7bf、十进制为1983的位置处。所以,在q[199]之后,还有4096-1984=2112个字节空间在同一个页中可供使用。这写空间如果用于存储int型变量,可以存储2112/4=528个,也就是说,可以储存从q[200]到q[727]这528个元素。当然,实际上它们并不是q数组的元素,不过正如前面讲的,编译器并不会对此提出异议。同样的,硬件也不会,由于我们拥有对页的写权限,因此我们可以把相应的数据写入这些空间中。不过当i自增到728时,会使程序使用一个新页,该页不会付予我们写权限(或者其他任何权限),这会被硬件发现,并且硬件会触发段错误。
不仅试图读写超过范围的数据项会导致段错误,同样,试图执行超过范围的指令也会导致段错误。举例说明,考虑下面的代码:
1 int f(int x)
2 {
3 return x*x;
4 }
5
6 int (*p)(int);
7
8 main()
9 {
10 p = f;
11 u = (*p)(5);
12 printf("%d\n",u);
13 }
如果我们忘了写如下一行:
u=(*p)(5);
那么变量p不会指向任何函数,也就是说,我们会企图执行超出我们程序范围的空间中的代码,这就会导致段错误。
5.6 改进优化
当对页表的查询过频时,虚拟内存就会面临巨大的消耗。由于这个原因,典型情况下硬件会包括一个翻译查询缓冲(TLB)。这是一个特殊的缓存,用来报错CPU的页表的部分拷贝,这样,就减少了对内存中页表信息的读取次数。
5.7 在VM机智下缓存扮演什么角色?
直到目前为止,我们都没有涉及缓存。但是事实上很多使用VM机制的设备都拥有缓存,那么在这种情况下,缓存扮演的是什么角色呢?
核心是:速度依然是个问题。CPU会首先在缓存中寻找它需要的,因为缓存一般都嵌在CPU内部、或至少离它最近。如果查询缓存后没有获得CPU想要的,CPU才会查询内存。如果这些数据在内存中,那么整个数据块就会被拷贝至缓存中。如果数据还没载入内存,也就是说发生了一个页错误,那么我们应该从硬盘中相应位置读入正确的页,在一个缓存缺失发生之前。
另外一个问题是,缓存中使用的地址是虚拟地址还是物理地址呢?假设现在要执行的指令来自虚拟地址200的位置,假如缓存使用的虚拟地址机制,那么CPU会使用200作为索引进行缓存查询;如果使用物理地址机制,那么CPU首先会将200转换为物理地址,然后再基于这个物理地址进行缓存查询。
需要注意的是,缓存设计整个儿就是一硬件问题。缓存的查询和页表的替换都是由电路中的某些硬件走线实现的。相反的,VM机制却是硬件和软件的综合机制。硬件完成页表查询、检查页的装载情况和权限问题;不过是由软件-—-OS-—-来创建和维护页表的,更多的,当发生一个页错误时,完成页替换和更新页表的也是操作系统。
所以,你可能在一台电脑上拥有两个不同版本的UNIX,使用同样的编译器等,不过它们的页错误发生的频率仍然可能不一样。也许其中一个操作系统对这个程序的页替换机制要比另一个版本的系统优秀。
注意到OS能够告诉你你运行的程序发生了多少次页错误(看下面的time命令);每次页错误都会导致到OS的跳转,也就是说导致OS的运行,所以,OS可以追踪你的程序遭遇了多少此页错误。相反的,OS不能追踪你程序的缓存缺失有多少次,因为OS不能处理它,它完全是由硬件来处理的。
5.8 巩固上述概念:自己尝试这些命令
UNIX系统命令time可以告诉你你的程序执行了多少轮、该程序发生了多少页错误等信息。在命令行中输入该命令,后面跟上相应的程序作为参数,假设,你有个名为x的程序,并且该程序以12为参数,那么使用命令:
%time x 12
来替换命令:
%x 12
同样的,top命令也可以提供给你很多有用的信息。
6 关于系统调用
回忆前面讲的,OS给应用程序提供I/O服务等,举个例子,当你调用printf()时,该函数只是在C的函数库中,而没有在OS的函数库中,不过,接下来它会调用在OS库中的write()函数。这个对write()函数的调用(你也可以直接把这个函数写在源程序中)就称为系统调用。
再回忆,出于安全考虑,我们只想给OS授权以通过Intel的指令如in、out等进行实际I/O操作的权利,其他程序都是通过OS来访问I/O的。因此,硬件的设计会使得这些指令只能以内核模式执行。
由于这个原因,一般不能直接通过普通的子程序CALL指令来完成系统调用,因为我们需要一种机制来使设备进入内核模式。(很明显的,不可能用一条指令就将系统设为内核模式了,因为如果这样的话,那么任何一个普通的用户程序都能执行这条指令,然后进入内核模式大加破坏!)另外一个问题是链接器不知道所需的子程序处于OS的哪个位置的。
取而代之的是,系统调用是通过一种被称为软件中断类型的指令来完成的。在Intel设备中,这是由int指令来实现的,该指令只有一个操作数。
下面的例子都是在Linux系统下完成的,并且在例子中,int的操作数为0x80。换句话说,在你的C程序中对write()的调用(或是对printf()的调用)将会翻译成如下代码:
... # code to put parameters values into designated registers
int 0x80
int指令如同硬件中断一样工作,这说明了该指令会产生一个到OS的强行跳转,然后将特权等级改变至内核模式,使OS能执行它所需要的特权指令。你需要记住的是,这里的“中断”是由“被中断”的程序特意发出的,通过指令int。这和完全与被中断程序无关的硬件中断有很大的不同。
上面的操作数,也就是0x80,是硬件中断设备号的模拟量,CPU会跳转至由c(IDT)+8*0x80所指示向量位置。
当中断处理完毕后,OS会执行一条iret指令返回到被中断的应用程序,并且,将把内核模式变回至用户模式。
如上面所讲的,系统调用一般都有参数,就象普通的子程序调用一样。有一个参数几乎所有服务都通用-—-服务类型号,该类型号通过寄存器EAX传递给OS。也可能使用其他寄存器,取决于不同的服务了。
作为一个例子,下面的在Linux系统中写的Intel汇编源程序会向屏幕输出“ABC/n”,然后退出:
.data
hi: .string "ABC\n"
.text
_start:
# write "ABC\n" to the screen
movl $4, %eax # the write() system call, number 4 obtained
# from /usr/include/asm/unistd.h
movl $1, %ebx # 1 = file handle for stdout
movl $hi, %ecx # write from where
movl $4, %edx # write how many bytes
int $0x80 # system call
# call exit()
movl $1, %eax # exit() is system call number 1
int $0x80 # system call
对于这个特定的OS服务,write()函数,参数通过寄存器EBX,ECX,EDX传递(并且,正如前面提到的,EAX指明了我们想要获得什么服务)。
下面是一些其他系统服务的服务类型号(要完成相应的服务,就在int $0x80执行之前将类型号写入寄存器EAX中):
read 3
file open 5
execve 11
chdir 12
kill 37
7. OS文件管理
OS会维护一个显示所有文件在硬盘中的起始扇区信息的表。(这个表本身也在硬盘中。)关于该表只需要存储给定文件的起始扇区信息的原因是,该文件的不同扇区能够通过“linked-list”的方式链接起来,换句话说,在一个文件起始扇区的最末部分,OS会存储关于开始跟踪和下一部分文件扇区的信息。
OS也会维护一张关于未用扇区的表。当用户新创建一个文件时,OS会核查这张表以找到一个位置安放该文件,当然,接下来OS会更新该表。
如果用户删除一个文件,OS会同时更新上面两张表,首先删除在第一张表中该文件的入口信息,然后将这个文件占用的空间信息写入第二张表中。
文件的创建和删除动作贯穿于整个设备使用过程中,对未用扇区的设置就像是干一件东拼西凑的活路一样,因为这些未用的扇区任意散落在硬盘中。这对性能有写负面影响,特别是在查询时。因此很多OS中都有类似的碎片整理工具,这些工具用来重新排列硬盘中文件的位置,使得硬盘上每个独立文件的扇区能够彼此靠近。 |
|