Modern Operating System
介绍
写在最前面: 这是由 Andrew S. Tanenbaum 著成的《Modern Operating Systems》的第一章,对应的中文书是《现代操作系统》。本人网购了中文书,因为某原因,等到现在依然没有收到。自己从网上下载了英文原版,并翻译了一部分。只会放这一章。
如果侵犯了中文版《现代操作系统》版权,可以随时联系删除本内容。
一个现代计算机由一个或多个处理器、主存、硬盘、键盘、鼠标、显示器、网络接口以及各种输入输出设备组成,总而言之,它是一个复杂的系统。如果一个应用开发程序员需要所有这些内容都有全局的知识,那么他将不可能再有时间写代码。管理这些组件已经何理地使用这些组件是一个极具挑战的工作。基于这个原因,计算机配备了一个称作操作系统的软件层,这个软件层用来为用户提供操作前面提到的资源的简单、高效、干净的管理接口。操作系统就是本书的介绍对象。
大部分读者可能已经具备了使用某些操作系统的经验,比如 Windows
、Linux
、FreeBSD
或 OS X
,但是有些表象会具有欺骗性。与用户交互的程序,如果是基于文本的则通常称作 shell
,如果是基于图形的则通常称为 GUI
,它们实际上并不是操作系统的一部分,它们使用操作系统实现了它们的工作任务。
下面一个简单的结构图介绍了操作系统的一个总览。我们可以看到最底层为硬件。硬件由芯片、主板、硬盘、键盘、显示器等物理组件构成。在硬件上运行的就是软件。大部分计算机一般具有两种操作模式:内核态与用户态。操作系统,是软件的基石,运行在内核态(也称为超管模式),在这个模式下能够访问所有的硬件资源,同时也可以执行所有设置支持的指令。软件的剩余部分运行在用户态,在这个模式下,只有机器指令的一部分可以运行,在这一模式下,一些会能够影响到设备 I/O 的指令的执行是受到限制的。
图 1 - 1 操作系统简图
用户接口程序,无论是 shell
或 GUI
,是用户态下最底层的软件,允许用户启动其他程序,比如 WEB
服务器、邮件阅读器或音乐播放器。这些程序将会使用操作系统来提供服务。
操作系统与普通软件之间一个重要的区别在于可替代性,就是说,如果一个用户不喜欢某一款邮件阅读器,他可以自由选择其他的软件做为替代。但是如果他写自己的时钟中断处理函数,则并不容易实现,因为这一服务是操作系统的一部分,由硬件保护,一般拒绝用户修改。
不过,这个区别在一些嵌入式系统(可能不具备内核态)或解释性系统中(比如基于 Java
的系统)是模糊的。
同样,在一些系统中,一些程序虽然运行在用户态,却会辅助操作系统的运行,或者执行一些需要特权的功能。比如,有的程序允许用户修改密码,这并不是操作系统的一部分,也不会以内核态运行,但是它们会涉及一些敏感功能,因此会以某种特殊方式加以保护。在一些系统中,这个理念被用到极致,一些在传统观念中属于操作系统一部分的内容(比如文件系统)运行在用户态。在这样的系统中,很难划分一个清晰的边界。所有以内核态运行的部分都是操作系统的一部分,但是一些外部的程序虽然有争议,但也属于操作系统的一部分,至少与操作系统相关联。
现代操作系统一般都很大,复杂,且生命周期很长。像 Linux
或 Windows
操作系统的核心部分都会超过五百万行代码。如果算上重要的库 Windows
的代码会超过七千万行,而这已经排除了一些基础应用软件(比如浏览器,播放器)。
通常操作系统的生命周期都很长,操作系统很难写,操作系统的拥有者并不会在写完一个操作系统之后,马上将这个操作系统释放出来,不管不顾,转而去实现一个新的操作系统。Windous 95/98/Me
在概念上是同一个操作系统,而 Windows NT/2000/XP/Vista/Windows 7
在概念上是另一个操作系统。用户看它们可能没什么大的差别,是因为微软的开发者有意为之。我们会在第十一章进行一些详细介绍。
除了 Windows
,本书中会用到 UNIX
以及它的变体。比如 System V
、Solaris
、FreeBSD
、Linux
等。
本章中,我们会介绍一些操作系统的基础概念,包括什么是操作系统,它们的历史,有哪些操作系统,操作系统的基础概念,以及操作系统结构。后面章节中,会具体介绍这些内容。
什么是操作系统
很难说操作系统是什么,我们可以称它是运行在内核态的软件,即便这样的描述并不准确。一个问题在于,操作系统提供的两个重要功能:为应用程序员提供操作海量硬件的干净抽象集;管理这些硬件资源。
做为设备扩展
大部分计算机架构(指令集、内存组织、I/O以及总线结构)在机器语言层级对于程序而言是简陋且丑陋的,尤其对于输入输出而言。空口无凭,这里我们以很多计算机使用的 SATA: Serial ATA
硬盘为例来阐述。一本书(Anderson, 2007)描述了硬盘的早期版本的接口,介绍了成需要需要了解地使用硬盘的知识,超过450页。从这本书出版,接口已经被修改了很多遍了变得更加复杂。显然,任何一位不希望自讨苦吃的程序员都不想在硬件层直接处理硬盘。相反,有一种程序称作硬盘驱动,直接处理硬件并为上层提供硬件接口来读写硬盘块,而上层无需了解它操作的细节。操作系统包含很多驱动来控制 I/O 设备。
但是,即便这个层级对于很多应用程序而言依旧太过底层。所以,所有操作系统也为使用硬盘提供了另一层抽象:文件。使用这个抽象,程序可以创建、写、读文件,而不需要面对过多关于硬件工作的细节。
这个抽象是管理这种复杂内容的关键。良好的抽象将不可能的任务转化为两个便于管理的问题。第一个是定义与实现抽象,第二个是使用这些抽象来解决手头的问题。几乎所有计算机用户都理解的一个抽象就是文件,它是有用的信息集,比如一张数字照片,一封保存的电子邮件,一首歌曲,一个网页灯。我们处理照片、邮件、歌曲、网页相比于处理 SATA
硬盘要简单得多。操作系统的工作就是创建一个好的抽象,之后就是实现并管理这些抽象对象。在本书中,我们将会讨论这些抽象,它们是理解操作系统的关键。
在此向精心设计了麦金塔计算机的工程师致敬。真实的处理器,内存,硬盘以及其他设备是十分复杂的,对于写软件来使用这些硬件的程序员而言,它可能都不具备代际的延续性。有时候,这是因为要向后兼容一些老版的硬件,有时候是为了节约成本。操作系统的一个重要的任务就是隐藏硬件,并为上层提供一个漂亮、干净、优雅、连续的抽象接口。就像下面图片描述的一样:
图1-2操作系统将丑陋的硬件封装成漂亮的抽象接口
需要注意的是,操作系统的真正顾客是应用程序。这些应用程序是直接与操作系统及其抽象打交道的。做为对比,终端用户使用用户界面提供的抽象,或者通过命令行访问,或者通过图形化界面访问。考虑 Windows 系统的桌面环境以及命令行环境,两者都是运行在 Windows 操作系统上的程序,并且都使用 Windows 提供的抽象,但是它们提供给用户的界面是全然不同的。相似的,运行有 Gnome 或 KDE 软件的 Linux 与运行 X Window 系统的 Linux 是全然不同的,但是它们的操作系统都是一样的。
做资源管理器
操作系统做为向应用程序提供抽象接口,这是自顶向下的视角。如果,自底向上的视角看,操作系统是管理系统复杂内容的实现。现代操作系统包括处理器、内存、定时器、硬盘、鼠标、网口、打印机以及其他种种设备。以自底向上的视角看,操作系统的可以在众多上层程序希望使用它们时,有序受控地分配处理器、内存以及 I/O 设备。
现代操作系统允许同时在内存中放置、运行多个程序。想象一下三个程序同时希望使用同一台打印机打印内容,如果首先打印了第一个程序的一部分内容,再次打印第二个程序的一部分内容,接着打印第三个程序的一部分内容,并循环往复,这显然是混乱的。操作系统可以将这种潜在的混乱情形通过使用硬盘做缓冲区,当一个程序缓冲结束后,操作系统可以将它从硬盘中考出来打印它,在运行这项工作的同时,另一个程序可以生成更多要打印的内容交给操作系统,但这时显然不是直接将内容给到打印机。
当一个计算机(或网络)拥有超过一个用户时,那么对内存、I/O 设备以及其他资源的保护变得更加重要,以防影响到其他用户使用。用户除了要分享硬件资源外,还要分享信息(文件,数据库等)。简言之,以这个视角看操作系统,它就在做一些管理哪些程序使用哪些资源的工作,保障资源需求,记录使用情况,防止冲突发生。
资源管理包括以两种方式进行复用:时间,空间。当一个资源是分时复用,不同的程序与用户按次序使用这个资源。比如,如果只有一个 CPU 而又有多个程序希望在 CPU 上运行,那么操作系统首先会将 CPU 分配给其中一个程序,在这个程序运行足够的时长之后,另一个程序获取到 CPU 的使用权,之后再换一个程序占用 CPU,直到切换回到第一个程序,如此循环往复。在分时复用方式运行时,操作系统负责确定哪一个会排到下一个,并确定它要占用多长时间。
另一种复用方式是分空间复用。此时不是依次占有资源,而是占用资源的一部分。比如,主内存通常是多个运行程序共享的。假设有足够的内存给多个程序使用,显然将程序都放在内存中显然是高效的,而不是单独将一个程序放在内存中,尤其在一个程序只需要占用内存的一小部分时。当然,这会引入一个新的问题,比如对内存的保护以及公平性等。当然这是操作系统要解决的问题。另一个分空间使用的例子是对硬盘的使用。在很多系统中,一个硬盘可以放置多个用户的诸多文件。分配硬盘空间并跟踪谁在使用哪个硬盘块是典型的操作系统任务。
操作系统历史
操作系统随历史发展已经有了诸多革新。在后面的小节中,我们会简单看一下这些内容。在过去,操作系统与它们运行的计算机的架构相关,我们会逐步看这些计算机以及它们的操作系统发展。这里对计算机的代际发展以及操作系统代际发展的映射关系是粗糙的,但聊胜于无。
下面介绍的内容是按照时间顺序安排的,但实际上发展过程是崎岖的。每一个新的革新并不是在前一个时代完全过去之后才开始。它们会有一段重复的时间,更不要说一些技术抢跑与老技术的苟延残喘。
第一个真正的数字计算机由英国数学家 Charles Babbage (1792 - 1871)设计。虽然 Babbage 几乎耗费了他的一生来尝试构建他自己的 ”分析机",因为设备是纯机械的,在他所在的时代,不能实现高精度的转轮、齿轮,他没能让它准确地工作,更不能期望它有一套操作系统了。
做为补充,Babbage 意识到他的分析仪需要一个软件,因此他雇佣了英国诗人 Lord Byron 的女儿 Ada Lovelace 做为世界上第一个程序员,编程语言 Ada 就是以她的名字命名的。
第一代(1945 - 55)真空管
在 Babbage 的不成功地努力后,直到第二次世界大战才在构造数字计算机上取得了一点进展。John Atanasoff 教授和他的研究生 Clifford Berry 在 Iowa 州立大学创建了在今天被认为是第一台的功能数字计算机。这台计算机使用了 300 个真空管。大约在相同的时间,Konrad Zuse 在柏林使用继电器构建了 Z3 计算机。在 1944 年,一组科学家(包括 Alan Turing)在英格兰 Bletchley Park 构建了 Colossus,Howard Aiken 在 Harvard 构建了 Mark I,William Mauchley 和他的研究生 J. Presper Eckert 在 Pennsylvania 大学构建了 ENIAC。一些是二进制的,一些使用真空管,一些是可编程的,但是所有的计算机都是很初级的,而且即便进行简单的数值计算都要花费数秒。
在这段时间,一小部分工程师设计、构建、编程、操作并维护这些设备。所有的编程都是以绝对的机器语言进行的,甚至更糟糕的情况下,是通过连接数以千计的线缆插在插线板上来控制设备的基础行为。编程语言还是未知的内容(即便汇编语言都还没有被设计出来),操作系统就更不要提了。通常这些程序员操作这个计算机的方式是先签名在某段时间使用计算机,然后到设备室,将他的插线板连接到计算机,并在后续的几个小时内期望20000个左右的真空管不会在这个时期内烧毁。解决的问题基本上都是简单直接的数值计算,比如三角函数计算等。
在十九世纪五十年代,上面的流程在引入穿孔卡片之后效率变得更高了。
第二代 (1955 - 65)晶体管
在十九世纪50年代的中期由于晶体管的引入,实现了彻底的变革。计算机变得更加可靠,而且可以由工厂生产并售卖给消费者,并期望消费者可以拿来做一些预设的工作。这是第一次在设计者、构建者、操作者、程序员、个人维护者有了清晰的划分。
这些机器现在称作大型机,放在一个巨大的特殊空调房中,由专业的操作员运行它们。只有大型的组织、政府或大学可以负担得起上百万的设备。为了运行一个任务(程序或程序集),程序员首先将程序写到纸上(FORTRAN 或汇编),之后记录到卡片上。将卡片上交给操作员等待结果计算出来。
当计算机工作结束之后,操作员去到输出室并将打印机打印的输出放到输出室,程序员就可以拿到这些输出了。
第三代(1965 - 1980)集成电路与多处理器
第四代(1980至今)个人电脑
第五代(1990至今)移动电脑
计算机硬件总览
操作系统最初是与它运行的硬件相关联的。它扩展了计算机的指令集并管理计算机的资源。为了能够正常工作,操作系统必须清楚地了解硬件,至少要了解硬件暴露给程序员的部分。基于此,我们先简单的看一下现代的个人计算机。之后我们可以开始详细地看一下操作系统是如何工作的。
在概念上,一个简单的个人计算机可以抽象为图 1- 6 所示。CPU 内存以及 I/O 设备通过系统总线连接到一起。现代个人计算机具有更加复杂的结构,涉及到更多的总线。现在,这个模型已经足够了。在下面的小节中,我们会简单介绍这些组件,并确定在设计操作系统时需要考虑哪些问题。这些介绍只是简介。
图 1 - 6
处理器
计算机的核心就是 CPU。它从内存中拿指令并执行这些指令。CPU 的指令执行流程是,取指、解码、执行,取指、解码、执行,如此往复,直到结束。
每一类 CPU 有它自己可以执行的指令集。因此 x86 处理器不能执行 ARM 程序,ARM 处理器不能执行 x86 程序。因为访问内存获取指令或数据的时间远长于执行一条指令,所有 CPU 都包含一些寄存器来存放关键变量与临时结果。因此,指令集一般包括从内存载入字到寄存器的指令,有将寄存器的值写入内存的指令。还有处理两个来自寄存器、内存或者分别来自寄存器与内存的指令,比如两数相加并存储结果到寄存器或内存的指令。
此外,有一组通用寄存器用来存放变量以及临时结果,大部分计算机具有几组特殊寄存器,可供程序员使用。其中一个是程序计数器,它指向下一个取指令的内存地址,在这个指令取出后,程序计数器更新指向下一条指令。
另一个寄存器是栈指针,它指向内存当前栈的栈顶。栈包含已经进入但没有执行完毕的程序的栈帧。一个程序的栈帧存储着没有在寄存器中保存的输入参数、局部变量与临时变量。
另一个寄存器是 PSW,程序状态字。这个寄存器包含条件码,由比较指令、CPU 优先级、模式(用户或内核态)以及其他的控制比特置位。用户程序可能能够读取完整的 PSW 字,但是只能改写其中的某些字段。PSW 在系统调与与 I/O 中扮演着重要的角色。
为了提升性能,CPU 设计者已经放弃了简单的一次进行一个取指、解码、执行的模型。一些现代 CPU 能够同时执行多条指令。比如,一个 CPU 可以具有分隔的取指、解码、执行单元,因此在它执行指令 n 的同时,可以解码 n + 1,并取指 n + 2。这样的组织称为流水线如图 1- 7(a) 所示。在大部分流水线设计中,一旦一条指令被取指,它必须被执行,即便预测指令是一条条件分支指令。流水线令编译器编写者以及操作系统的编写者头疼,是因为需要直接面对并处理这些问题。
图 1 - 7
相比于流水线设计更高级的是超标量 CPU,如图 1 - 7(b) 中所示。在这个设计中,有多个执行单元,比如一个是整数计算,一个是浮点数计算,一个是布尔操作。一次取指、解码两个或多个指令,并将它注入到缓冲区中,等待被执行。在执行单元可以访问时,它会查看缓冲区,查看是否有它能够执行的指令,如果具有可执行指令,它将指令从缓冲区中取出并执行指令。这样的设计程序指令通常不是按顺序执行的。大多数情况下,它是由硬件来确定处理结果,但是它依然给操作系统设计带来了复杂度。
大部分 CPU,除了嵌入式系统中那些比较简单的 CPU,具有两个模式,内核态与用户态。通常情况下,在 PSW 中的一个比特位控制着整个模式。当运行在内核态,CPU 可以执行它指令集中的所有指令,并利用硬件的所有特性。在桌面设备与服务器设备中,操作系统通常运行在内核态,来完全访问硬件。而在大部分嵌入式系统中,操作系统的一部分运行在内核态,剩下的大部分运行在用户态。
用户程序通常运行在用户态下,这个模式下仅允许执行指令集的一个子集。通常情况下,所有涉及 I/O 以及内存保护的指令都不允许在用户态下使用。此时设置 PSW 的模式比特位来进入内核态也是禁止的。
为了从操作系统获取服务,用户程序必须进行系统调用,陷入内核并调用操作系统。TRAP
指令执行,从用户态切换到内核态开始操作系统处理,当工作完成之后,返回到用户程序系统调用紧跟着的指令。我们将在后面详细介绍系统调用机制的细节。
值得注意的是,计算机使用陷阱而不是指令来进行系统调用。大部分其他的陷阱是由硬件异常触发,比如企图使用 0 做为除数或者浮点数向下溢出。所有的这些情况,操作系统都需要对其做出合适的处理。有时候,程序必须以一个错误结束。有时候错误可以忽略。当程序提前告知操作系统,它想要处理特定的条件时,操作系统需要能够处理这些问题。
多线程与多核芯片
摩尔定律阐述了每隔 18 个月片上的晶体管都会翻倍。这个定律并不是什么物理定律,而是通过对发展规律的一种总结。在过去的三十年的发展都是符合摩尔定律的,在一段时间内,摩尔定律依旧生效。直到晶体管中的原子数量变得足够少量子效应将不可忽略,这限制了集体管的尺寸。
丰富的晶体管带来了一个问题:怎么利用它们。我们看到前面的一个解决方案:超标量架构,它带有多个功能单元。但是随着晶体管的数量增加,需要更多的结构。比如在 CPU 芯片中放一个更大的缓存。这就是正在发生的事,但很快就会达到边际效益的最高点。
显然下一步不只是放置多个功能单元,可以放置多个控制逻辑块。Intel Pentium 4 基于 x86 处理器引入了这一内容,称作多线程或超线程。它允许 CPU 存储两个不同线程的状态,并以纳秒级进行两个线程之间运行切换。比如,如果需要从内存中读取一个字(这会消耗多个时钟周期),超线程 CPU 可以切换到另一个线程。超线程并不提供真正的并行,同时只能有一个程序在运行,只不过两个程序的切换时间降低到了纳秒级。
超线程对操作系统是有影响的,因为对于操作系统而言,每一个线程都呈现为一个独立的 CPU。考虑一个系统具有两个 CPU,每一个 CPU 有两个线程,操作系统会将其视作具备四个 CPU。如果我们具有的任务只能保证两个 CPU 繁忙,这可以在相同的 CPU 上调度两个线程,另一个 CPU 完全是空闲的。这样的选择显然对于每个线程占用一个物理 CPU 而言是低效的。
除了超线程,一些 CPU 芯片现在具有四个、八个或更多的处理器核心。多核芯片如图 1 - 8 所示,一些处理器,像 Intel Xeon Phi 以及 Tilera TilePro,已经支持在单个芯片上的 60 个核心。使用这个多核芯片就需要多处理器操作系统。
顺便提一嘴,GPU 是一个处理器,具有上千个微核。它能够并行计算简单的计算问题,像是图形渲染。不过 GPU 并不适合执行线性任务。GPU 也很难编程,GPU 对于操作系统而言是十分有用的(加密或者处理网络负载),但这不意味着操作系统要运行在 GPU 上。
图 1 - 8
内存
在很多计算机中第二重要的是内存。理想情况下,内存要尽可能块(比执行一条指令块,这样 CPU 不会因内存导致延迟),足够大,并足够便宜。当前的技术没有完全满足这些条件的内存,因此不同的需求采取不同的方式实现。内存系统由不同层级构造,像图 1 - 9 中介绍的一样。最顶层具有最快的速度但它容量最小,但是更贵。
顶层是 CPU 的寄存器。它们使用与 CPU 相同的材料制作,因此与 CPU 一样快,访问它们也不存在延时。它们的存储容量在 32 位 CPU 中通常是 32 x 32 比特,在 64 比特 CPU 中为 64 x 64 比特,这两种情况都小于 1KB。程序必须在软件中自己管理寄存器。
问题
下一层就是 cache 缓存,通常由硬件控制。主内存被划分到 cache 行中,通常有 64 个字节,其中地址 0 - 63 在行 0,64 - 127 在行 1,以此类推。重度使用的 cache 行指向内存保存在高速 cache 中或放在十分靠近 CPU 的位置。当程序需要读取内存字时,cache 硬件检查它所在的行是否在 cache 中,若它位于 cache 中,则称其 命中,那么这次访问请求可以从 cache 中获取到,不需要通过总线向主内存发送读取请求。缓存命中通常花费两个时钟周期。若没有命中,就需要访问内存,花费更长的时间。因为缓存的价格昂贵,缓存的大小是有限制的。一些设备具有两级甚至三级缓存,当然不同级缓存的价格、大小与速度是显而易见的。
缓存在计算机科学中扮演着重要角色,并不只是缓存 RAM 行。只要资源能够分片划分,且这些资源的其中某一部分相较于其他部分使用频率更高,缓存就可以用来提升访问性能。操作系统总是会使用这个模块。比如,大部分操作系统存储重度使用的文件在主内存中以避免反复从硬盘中读取这些内容。相似的,将长路径名 /home/ast/projects/minix3/src/kernel/clock.c
转换为文件位于硬盘的地址可以缓存在 cache 中,以避免重复查找。最后,如果 web 页面 (URL) 的地址转换为网络地址 (IP
地址),这个结果可以缓存在 cache 中,以供后续使用。当然还有其他使用场景存在。
在任何缓存系统中,一般都会有下面的问题:
- 什么时候将新条目放到 cache 中
- 新条目放在哪一条 cache 行
- 如果需要一个新的 cache 行,移除哪一个老的 cache 行
- 将刚移除的条目放到更大的内存的哪里
当然并非所有的缓存场景都会遇到上面的问题。主存在 CPU cache 中的条目,一般是在需要但没有该条目时插进来的。要使用的缓存行条目内容一般使用引用内存地址的高位地址组成。比如,一个 4096 cache 行的缓存,每行指向 32 位地址空间开始的 64 字节空间,cache 行的位 6 - 17 可能用来指示 cache 行,位 0 - 5 指示 cache 行中的字节数。这种情况下,移除的条目将会是新的数据进入的位置,但是其他系统可能不是这样。最后,当一个 cache 行需要重写入主内存 (在它缓存后被修改),需要重写的内存位置可以被唯一确定。
现代 CPU 一般有两个缓存。第一级缓存 L1 cache 总是位于 CPU 的内部,通常用来将解码指令送给 CPU 的执行引擎。大部分芯片具有第二个 L1 cache 服务于那些常用的数据字。L1 cache 通常有 16KB。有时,可能会有第二级缓存称作 L2 cache,用来存放上兆字节最近常用的内存字。访问 L1 cache 不会有任何延迟,访问 L2 cache 通常会有 2 个时钟周期的延迟。
在多核芯片上,设计者需要确定缓存放置的位置。在图 1- 8(a) 中,一个 L2 cache 由多个核心共享。这个方法用在 Intel 多核芯片中。做为对比图 1- 8(b) 中,每一个核心都有自己的 L2 cache,这个方法由 AMD 使用。每一种策略都有它的优缺点。比如,Intel 共享 L2 cache 需要更加复杂的 cache 控制器,而 AMD 维护 L2 cache 的一致性则更加困难。
在图 1- 9 下一级就是主内存。这是内存系统的主要组成。主内存通常称作 RAM(Random Access Memory)。
除了主存,一些计算机具有少量的非易失随机访问内存,不像 RAM,非易失内存在掉电时数据不会丢失。ROM(Read Only Memory) 是在工厂时编程的,此后不能修改。它价格便宜访问快速。在一些计算机中,启动载入阶段的程序放在 ROM 中,同样的,一些带 ROM 的 I/O 卡用来处理底层的设备控制。
EEPROM(Electrically Erasable PROM) 以及闪存也是非易失存储,但与 ROM 不同,可以被擦除重写。不过,写 EEPROM 的时间相较于写 RAM 的时间会超过多个数量级,因此它们的使用方式与 ROM 相同,只是它们可以用来修改里面的字段来修正程序的问题。
闪存也是移动设备中常用的存储媒介。闪存的访问速度在 RAM 与硬盘之间。不像硬盘,如果它擦除次数过多,会发生磨损。
另一类内存是 CMOS,这是易失存储。一些计算机使用 CMOS 内存来存储当前时间与日期。时钟电路以及 CMOS 由小的电池供电,时钟电路会更新 CMOS 中的存储数据,这样即便在电脑断电时,时钟电路也能够正常运行了。CMOS 内存也可以用来存储配置数据,比如从哪个硬盘启动,使用 CMOS 的原因在于它十分省电,出厂电池可以使用几年时间。不过,如果它开始失效,计算机会患上阿兹海默综合征,忘掉那些它已经牢记了许久的事,比如从哪个硬盘启动。
硬盘
下一个层级是磁盘(机械硬盘)。机械硬盘存储相较于 RAM 价格会便宜许多,存储容量也大上许多,但是随机访问速度会慢上许多。这个原因在于机械硬盘是机械设备,如图 1 - 10 所示:
图 1 - 10 机械硬盘驱动结构
一个机械硬盘包含一个或多个金属盘,以 5400 、7200、10800 RPM 或更高的速度旋转。一旁的机械臂在金属盘上扫过,用来读写数据。信息在一系列同心圆上存储在机械硬盘上。在任何给定的机械臂位置,可以读取的区域的头称作轨道。由轨道组成扇区。
每一个轨道被划分到多个段,通常有 512 字节。在现代机械硬盘上,扇区的外部相较于内部具有更多的段。将机械臂从一个扇区移向另一个扇区会消耗约 1 毫秒。将机械臂移动到随机一个位置平均会消耗 5 - 10 毫秒。一旦机械臂移动到正确的轨道上,驱动必须等待磁盘的段旋转到头部,需要额外的 5 - 10 毫秒的等待时间。一旦到达头部,读写速度会以 50 MB/秒 - 160 MB/秒的速度进行。
现在我们再谈论到硬盘,可能不再是传统的机械硬盘而是固态硬盘 SSD(Solid State Disks)。固态硬盘没有金属盘,没有机械臂,数据存储再闪存中。
一些计算机支持虚拟内存策略,这会在第三章讨论到。这个策略通过将它们放在硬盘上,支持运行相较于物理内存更大的程序。这种方式使用主存做为重度使用部分的缓存。这个策略需要重映射内存地址,以将程序生成的地址转换为 RAM 中的实际地址,这个重映射是通过 CPU 内部称作 MMU(Memory Management Unit) 单元进行的。
缓存以及 MMU 的存在对性能有很大的影响。在多程序系统中,当从一个程序切换到另一个程序时,有时称作上下文切换,可能需要冲刷所有缓存中被修改的块,并修改 MMU 中的映射寄存器。这两个都是代价高昂的操作,程序员也会努力尝试避免这些行为。
I/O设备
CPU 以及内存并不是操作系统唯一要管理的资源。I/O 设备也会与操作系统重度交互。I/O 设备通常包含两个部分:控制器以及设备本身。控制器是芯片或一簇芯片在物理上控制着设备。它接收操作系统发来的命令,比如读从设备读取数据的命令,并将数据提取出来。
在一些情况下,实际的设备控制是复杂并精细的,因此控制器的任务就是用来向操作系统提供精简的接口。比如,一个硬盘控制器可能需要接收命令来从硬盘 2 读取段 11206。控制器之后需要将这个线性段号转换为扇区、段以及头部,控制器来移动机械臂、旋转盘片等操作,为了完成这些操作,控制器通常包含一个小型嵌入式计算机,里面编程了它们的工作模式。
另一个部分是设备本身。设备具有相较简单的接口,因为它们为了符合标准不能做太过的操作。具有一个标准是有必要的,比如 Serial ATA 以及 ATA(AT Attachment) 的标准 SATA, SATA 盘控制器能够处理任何 SATA 硬盘。我们可能好奇 AT 标准是什么,这是 IBM 基于在当时极为高级的 6-MHz 80286 处理器构建的高级个人计算机技术。
SATA 在当前计算机中依然是一个标准类型。因为实际的设备接口隐藏在设备控制器之后,所有的操作系统都与控制器打交道,这可能与直接跟控制器打交道可能有很大不同。
因为每一类控制器都是不同的,需要不同的软件控制它们。与控制器打交道的软件,给出命令并接收响应,这称作设备驱动。每一个控制器制造商都会为它支持的操作系统提供驱动。
想要使用驱动,驱动必须放到操作系统中,这样它就能够运行在内核态。驱动也可以运行在内核的外部,像 Linux 以及 Windows 的操作系统现在也支持这样的操作。大部分驱动都运行在内核边界以下。只有很少的操作系统,比如 MINIX 3,将所有的驱动运行在用户空间。必须允许运行在用户空间的驱动以受控的方式访问到设备。
有三种方式将驱动插入到内核中。第一种方式是将新的驱动与内核链接到一起,并重启系统。一些老的 UNIX 系统以这种方式工作。第二种方式是在操作系统文件中制造一个入口,并告知它它需要这个驱动并重启系统。在启动时,操作系统取查找它需要的驱动,并载入它们,Windows 以这种方式工作。第三种方式是操作系统能够在运行时安装驱动而不需要重启系统。这种方式使用比较少,但是在现今变得愈发平常。热插拔设备比如 USB 以及 IEEE 1394 设备总是需要动态载入驱动。
每一个控制器都有一些寄存器用来与它们进行沟通。比如,一个最小盘控制器可能有寄存器来指定盘地址,内存地址,段计数器以及读写方向。为了实现控制器,驱动从操作系统获取到命令,之后将它们转化到写入设备寄存器合适的值。
在一些计算机中,设备寄存器被映射到操作系统地址空间(它使用的地址),因此它们可以像是普通的内存字一样被访问。在这样的计算机上,不需要特殊的 I/O 指令,同时通过将这些内存地址与用户程序隔离,将用户程序与硬件隔离。在其他计算机上,设备寄存器被放在特殊的 I/O 端口空间,每一个寄存器都具备一个端口地址。在这些设备上,内核模式需要特殊的 IN 以及 OUT 指令来允许驱动读写这些寄存器。前一个策略消除了对特殊 I/O 指令的需求,但是会占用一些地址空间。相对的,后一个策略不需要地址空间,但是需要特殊的指令。
输入与输出可以使用三种特殊的方式完成。在最简单的方法中,一个用户程序会启用一个系统调用,内核将它翻译成合适的驱动,驱动之后启动 I/O 并使用一个小的持续的循环来论学设备以确定它是否完成。当 I/O 完成后,驱动将数据放到它该去的地方。操作系统之后返回到调用者。这个方法称作忙等待,缺点在于 CPU 会轮询设备直到它完成。
第二种方式,驱动启动设备并告知它在完成时产生一个中断,在告知设备这一点之后,驱动返回。操作系统之后阻塞调用者,并进行其他工作。当控制器完成工作后,生成完成中断信号。
在操作系统中,中断是十分重要的,因此,让我们仔细的查看一下它吧。在图 1-11(a) 中我们看到 I/O 处理的三个步骤。在步骤 1 中,驱动通过向寄存器中写值告知控制器它要怎么做,控制器之后启动设备,当控制器完成指定的读写操作之后,在步骤 2 中,设备控制器通过特定总线告知中断控制器芯片这一信息。如果中断控制器可以接受新的中断(有时在它处理具有更高优先级的中断时,可能并不能响应),在步骤 3 中,它使用连接到 CPU 的引脚告知 CPU。在步骤 4 中,中断控制器将中线号通过总线告知 CPU,CPU 可以读取这一信息,来确定哪一个设备完成了它的工作(同时可能有多个设备在工作)。
图 1 - 11
一旦 CPU 决定去处理中断,程序计数器以及 PSW 通常会入栈,CPU 之后切换到内核态。设备号可能被用来做为去到这个设备中断处理函数的在内存位置的索引。这个内存区域称为中断向量表。一旦中断处理器启动,它移除入栈的程序计数器以及 PSW 并保护它们,之后访问设备来学习它的状态。当中断处理器完成后,它返回到之前运行的用户程序继续执行,这些步骤在图 1 - 11(b) 中展示。
第三种执行 I/O 操作的方式是使用 DMA(Direct Memory Access) 芯片,它可以控制在内存以及一些设备控制器之间的比特流,而不需要 CPU 的参与。CPU 设置 DMA 芯片,告知它需要传输的字节数,以及会涉及到的设备与内存地址与传输方向,让它开始工作。之后在 DMA 芯片完成工作后,它产生中断,处理方式与上面介绍的一样。DMA 以及 I/O 硬件会在第五章中详细介绍。
中断可能会在不合时宜的时候发生,比如,当另一个中断处理函数正在运行时发生。基于这个原因,CPU 可以关闭中断并在之后重新开启。当中断被关闭时,完成的设备将会持续发送中断信号,但是 CPU 不会响应这个中断直到它使能了中断处理。如果多个设备在中断关闭时候产生了中断,中断控制器会决定让哪一条先通过,通常基于为每一个设备赋予的静态优先级。具有最高优先级的设备会赢得这次竞争,获取到中断处理服务,其他的中断则必须等待。
总线
图 1 - 6 中的组织在小型计算机中使用了多年,同时也是 IBM PC 最初的设计。不过,随着处理器以及内存速度变得更快速,单总线处理所有负载需要突破点。需要添加一点新东西。做为解决方案,添加了额外的总线,来为快速 I/O 设备以及 CPU 到内存的负载。做为这个革命的结果,一个大型的 x86 系统当前看起来像是下图 1 - 12 中介绍的一样:
大型 x86 系统架构
这个系统具有多个总线(cache,内存,PCIe,PCI,USB,SATA 以及 DMI),每一个总线都具有不同的传输速率及功能。操作系统必须清楚地认识这些总线才能够对这些总线进行配置、管理。主要的总线是 PCIe(Peripheral Component Interconnect Express) 总线。
PCIe 总线由 Intel 发明并做为 PCI 总线的继任者,PCI 总线在过去做为原本的 ISA(Industry Standard Architecture) 总线的替代品。PCIe 总线可以以每秒数十吉比特的速率进行数据传输,相较于先行者速度更快。在 2004 年它被创造出来时,大部分总线是并行且是共享的。一个共享的总线架构意味着多个设备使用相同的总线传输数据。因此当多个设备有数据要发送时,你需要一个仲裁器来确定谁可以使用总线。相反,PCIe 用作确定性的点对点的连接。在传统的 PCI 中使用的并行总线架构,意味着数据通过多条线路发送数据。比如,一个常规的 PCI 总线,32 位的比特数通过 32 条并行线路发送。做为对比,PCIe 总线使用穿行总线架构,在单条路径上将所有的比特数据通过消息发送,有点像网络包。这更加简单,因为你不需要确保 32 根线路上的数据同时到达目的地。并行依旧是在使用的。比如,我们可能使用 32 条路径来并行承载 32 个信息包。外设如网卡与图形适配器的速度在迅速提升,PCIe 标准每过 3 - 5 年会进行一次升级。比如,16 线的 PCIe 2.0 提供 64 吉比特每秒的数据传输速率。升级到 PCIe 3.0 后,将能够提供两倍的速度,到 PCIe 4.0 速度将再次倍增。
不过,我们依然有一些落后的设备只支持 PCI 标准。我们可以在图 1 - 12 中看到,这些设备被挂在单独的拓展坞上。在未来,当我们意识到 PCI 技术需要淘汰时,所有的 PCI 设备将会挂在拓展坞上,这个拓展坞再连接到主拓展坞上,形成总线树。
在这个结构中,CPU 通过 DDR3 总线与内存通讯,通过 PCIe 总线与外部图形设备通讯,通过 DMI(Direct Media Interface) 总线与挂在拓展坞上的其他设备通讯。其他设备连接到了拓展坞上,拓展坞通过统一串行总线与 USB 设备通讯,使用 SATA 总线与硬盘以及 DVD 驱动通讯,通过 PCIe 总线与以太网设备通讯。我们之前提到过老的 PCI 设备使用传统的 PCI 总线。
另外,这个结构中,每一个核心具有一个独享的 cache,又有一个较大的 cache 供多个核心之间共享。
USB(Universal Serial Bus) 发明来连接所有的低速设备,比如键盘、鼠标。不过,如果将现在速度能够达到 5Gbps 的 USB 3.0 称作低速显然是小觑它了。USB 使用四线或八线的连接器(取决于版本),可以为 USB 设备供电或接地。USB 是一个是一个中心化的总线,一个根设备每毫秒轮询所有 I/O 设备,查看是否有任何负载。USB 1.0 可以处理多达 12Mbps 的负载,USB 2.0 速度增加到 480Mbps,到 USB 3.0 之后最高速度能超过 5Gbps。任何 USB 设备可以连接到一台计算机,它的功能可以马上使用,而不需要重启。
SCSI(Small Computer System Interface) 总线是致力于快速硬盘、扫描仪以及其他需要高带宽的高性能总线。现在我们发现,我们通常能够在服务器或工作站中发现,它们可以运行到 640MB 每秒。
为了能够在像图 1 - 12 这样的环境中运行,操作系统必须清楚哪些外设连接到了计算机,然后配置它们。这个需求令 Intel 以及 Microsoft 设计了称作热插拔的 PC 系统,这是基于苹果公司首先在 Macintosh 中使用的相似的技术而来。在热插拔之前,每一个 I/O 卡具备一个固定的中断请求等级,以及固定地址的 I/O 寄存器。比如键盘是中断 1 并使用 0x60 到 0x64 的 I/O 空间,软盘控制器是中断 6 并使用 0x3F0 到 0x3F7 的 I/O 地址空间,打印机是中断 7 并使用 0x378 到 0x37A 地址空间等。
这样就带来一个问题,如果用户买了一个声卡以及一个调制解调器,这两个设备都是用中断 4,从而产生了冲突,不能一起工作。可以使用 DIP 切换或跳接器,询问用户选择一个不与任何设备冲突的中断以及 I/O 地址。
热插拔技术令系统自动收集 I/O 设备信息,集中分配中断等级以及 I/O 地址,之后告知每一个卡它的号码。这个工作与启动计算器紧密相关,我们会在后面看到这些内容。
启动电脑
简单地说,启动过程如下。每一个 PC 的主板上有程序称作系统 BIOS(Basic Input Output System)。BIOS 包含底层 I/O 软件,包括读键盘的程序,写屏幕的程序,已经做硬盘 I/O 的程序等。现今,BIOS 存储在闪存中,这是非易失存储设备,可以由操作系统升级。
当启动计算机时,BIOS 启动,它首先检查有多少 RAM 空间可用,检查键盘以及其他基本设备是否可以正常使用。它通过扫描 PCIe 以及 PCI 总线来探测所有连接的设备。如果存在的设备与上次系统启动时不同,会配置新的设备。
BIOS 之后通过尝试使用存储在 CMOS 内存中的设备列表来检查启动设备。用户可以在 BIOS 启动后进入 BIOS 配置程序来修改这个列表。通常情况下,如果有 CD-ROM(光盘)(或USB),会尝试在 CD-ROM(或者 USB)驱动上启动。若这个过程失败,系统会从硬盘启动。启动设备的第一段被读入内存并被执行。这个段包含一个程序,通常用来查看段尾的分区表来确定哪一个分区是活跃分区。确定后,从这个分区读取第二阶段的启动引导程序。这个引导程序从活跃分区读取操作系统并启动操作系统。
操作系统之后请求 BIOS 以获取配置信息。对于每一个设备,它查看是否有对应的设备驱动。如果没有,它会询问用户插入包含这个驱动的 CD-ROM 或从网络上下载对应的驱动。一旦它有了所有的设备驱动,操作系统将它载入到内核。之后,它初始化它的表,创建后台需要的进程,启动登录程序或 GUI。
操作系统公园
操作系统已经有快半个世纪的历史了。在这段时间,发展了很多内容,很多并不是广为人知的。在本小节中,我们将会简单介绍它们其中的九个,在本书的后续部分再介绍其中几个的详细内容。
大型机操作系统
最高端的就是大型设备的操作系统了,这些如房间一般大的计算机现在能够在商业数据中心找到。这些计算机与个人计算机的不同点在于它们的 I/O 容量。一台大型机具有 1000 个硬盘以及上百万吉的数据都是稀松平常的事。大型机可以做为高端 WEB 服务器,可以是大型电子商务中心,可以做为 B2B 交易中心。
大型机的操作系统主要需要同时处理多个任务,大部分需要海量的 I/O。通常提供三类服务: 批处理,交易处理,共享。一个批处理系统主要处理常规任务,而不需要与用户交互,比如保险公司或供应链的销售情况可以用批处理系统。交易处理系统处理大量简单请求,比如一个银行的支票业务以及航班服务,每一个工作量都很小,但是每秒可以处理成千上万的工作量。共享系统可以为多个远程用户提供服务,比如请求数据库。这几个功能都是相互关联的,大型机的操作系统通常可以同时提供这几个特性。大型机操作系统的一个例子是 OS/390。现在大型机的操作系统逐渐由类 UNIX 系统如 Linux 替代。
服务器操作系统
再然后就是服务器操作系统了。它们运行在服务器上,这些服务器可以是大型个人计算机,工作站甚至可以是大型机。它可以使用网络为多个用户提供服务,允许用户共享硬件与软件资源。服务器可以提供打印服务,文件服务,WEB 服务。网络供应商运行着许多服务器来允许多用户访问网页处理网页请求等。典型的服务器操作系统是 Solaris,FreeBSD,Linux 以及 Windows Server 201x。
多处理器操作系统
一种提升计算机性能的方法是将多个 CPU 放进同一个系统中。取决于这些 CPU 是如何连接到一起的,它们可以被称作并行计算机,多计算机或多处理器。它们需要特殊的擦操作系统,不过通常是服务器操作系统的变体,不过会带有 CPU 之间的通讯、连接与同步的特性。
现在我们个人电脑基本上都是多核计算机了,即便是便捷笔记本电脑或者平板电脑都已经在使用小型的多核芯片。现在的系统基本上都是能够适配多核芯片的,比较困难的是对应用的开发,以能够全面使用多核性能。一些流行的操作系统,包括 Windows 以及 Linux,都可以运行在多处理器系统上。
个人电脑操作系统
下一类就是个人计算机操作系统。现如今它们基本都支持多程序运行,通常在启动时就启动了数十个程序。它们的工作是为单个用户提供良好的服务。它们被广泛的应用于文字处理,表单,游戏以及互联网访问。常见的系统是 Linux,FreeBSD,Windows7,Windows8 以及 Apple's OS X。
手持设备操作系统
继续向更小的设备介绍,我们来到平板电脑、智能手机以及其他的手持计算机。一台手持计算机,最初的 PDA(Personal Digital Assistant),是一个可以手持操作的小型计算机。智能手机以及平板电脑现在是广为人知了。现在我们都知道,如今这些手持设备的市场被 Google 的 Android 以及 Apples 的 iOS 占领。这些设备大部分都带有多核 CPU,GPS,相机以及其他传感器,带有可观的内存以及复杂的操作系统。另外,它们都具有海量的第三方应用 (APP)。
嵌入式操作系统
嵌入式操作系统运行在一般不被认为是计算机的控制设备上,这些设备不接受用户安装软件。典型的例子是微波炉,电视机,汽车,传统手机,MP3 播放器。嵌入式系统与手持设备的主要区别在于不会有恶意软件运行在嵌入式系统中。比如,你不能下载应用到你的微波炉中,所有的软件都在 ROM 中。常见的嵌入式系统有嵌入式 Linux,QNX 以及 VxWorks 等。
传感节点操作系统
网络或小型传感节点部署越来越多。这些节点是小型计算机,可以通过无线通信技术互相通讯并与基站进行通讯。传感网可以用作建筑周边保护,边界监控,森林防火,温度测量,用作天气预测的温度测量,战场上的敌军移动信息监测等。
传感器是使用电池供电的计算器,并带有内部无线电。它们的功率有限,并且需要长期在户外工作,而且频繁的在恶略环境下使用。网络必须具有强鲁棒性来在节点出现故障时依旧能够正常工作。
每一个传感节点都是一个真实的计算机,带有 CPU、RAM、ROM 以及一个或多个环境传感器。它们运行一个小型的操作系统。通常是事件触发响应外部事件,或基于内部时钟周期测量外部环境。操作系统需要足够小,并足够简单,因为这些节点具有很小的 RAM 以及并需要考虑有限的电池使用周期。与嵌入式系统中一样,所有程序已经预先加载了,用户不能从网络上下载程序到这些设备中,这令设计变得简单。TinyOS 是广为人知的传感节点。
实时操作系统
另一类操作系统是实时操作系统。这些系统的时间是一个关键参数。比如,在工业处理控制系统中,实时计算机必须手机产品信息并用它来控制设备。通常必须满足某个时间期限。比如,如果一个生产线上制造一辆汽车,必须在某个时刻完成某个操作任务,如果生产线上的机械臂操作稍早一点或稍晚一点,这辆车可能就被毁掉了。如果在某个时刻必须发生某件事,我们需要有一个硬实时系统。工业处理、航空电子、军事电子应用都是这个范畴。
一个软实时系统,会偶尔不满足这个时间期限要求,这可能不会造成灾难性的后果。多媒体系统以及数字语音可以划归到这类应用情景中。智能手机也是这类软实时系统。
在硬实时系统中,时间是极为关键的因素,这样的系统,有时是将应用程序简单链接到一起,而不需要对这些应用之间加以保护。一个硬实时操作系统是 eCos。
手持设备、嵌入式系统以及实时系统有一些是重合的。它们几乎都有软实时的特性。嵌入式系统以及实时系统只运行系统设计者设计的程序,用户不能添加他们自己的软件,这会对程序运行保护变得简单。手持设备以及嵌入式系统面向消费者,而实时系统更偏向工业用途。
智能卡操作系统
最小的操作系统运行在智能卡上,它们是信用卡大小带有 CPU 芯片的计算机。它们的功耗极低内存极小。一些由插入的读卡器供电,一些无接触的智能卡使用感应式供电,这限制了它们的应用场景。很多情况下,它们只能处理十分有限的功能,比如电子支付,有些具有多个功能。通常它们具有专有系统。
一些是能卡式面向 Java 的。这意味着智能卡上的 ROM 具有一个 JVM(Java Virtual Machine) 解释器。Java 小程序下载到卡片上,并由 JVM 解释器解释。这些卡有的可以同时处理多个 Java 小程序,这会有多程序运行,因此需要对这些程序进行调度。当两个或多个小程序同时存在时,对资源的管理与保护变得重要。这些问题可以通过卡上的操作系统进行处理。
操作系统概念
大部分操作系统提供某些特定的概念以及抽象,比如进程、地址空间、文件等内容,了解这些内容是理解这个系统的关键。在下面的小节中,我们会简单介绍这些基础概念。在本书的后续部分,会对这些概念进行详细介绍。
进程
在所有操作系统中,一个关键的概念是进程。一个进程是一个在执行中的程序。与进程相关的是它的地址空间,这个地址空间从 0 到某个最大值,这个进程可以对这个地址空间进行读写。地址空间包含可执行程序,程序的数据、栈。与进程相关的还有一系列资源,通常包括寄存器(包括程序计数器以及栈指针),一系列打开的文件,一些警告,一些相关进程,以及其他运行程序需要的信息。一个进程可以认为是存有所有运行程序相关信息的容器。
我们将会在第二章再详细介绍进程这个概念。现在我们先简单的介绍一下吧。让我们考虑一个多程序的系统。用户启动了一个视频编辑程序将某个一小时的视频转换成指定格式,之后去刷网页了,并且有一个后台程序周期唤醒来检查是否有新邮件。现在,我们有了至少三个活跃进程: 视频编辑器、WEB 浏览器以及邮件接收器。操作系统会周期性地决定停止某一个程序并开始运行另一个程序,可能是因为一个程序消耗完 CPU 分配给它的 CPU 时间。
当一个进程暂时被挂起时,它必须再在后面以停止时的状态被恢复。这意味着一个进程被挂起时所有的这一进程的信息必须被保存在某个位置。比如,这个进程可能打开了几个文件读取数据。与之相关的是文件指针指向当前的位置(既字节数或下一个要读取的字节)。当这个进程被临时挂起,所有的这些指针必须被保护,这样 read 调用在程序重新运行时会继续读取合适位置的数据。在一些操作系统中,进程除了它自身地址空间的所有信息,保存在操作系统的进程表中,这是一个结构体数组,每一个结构体代表一个进程。
因此, 一个进程由它的地址空间(core image)、它的进程表(包含它的寄存器以及其他重新开始进程时需要的项)构成。
创建、终止进程的系统调用是关键的进程管理系统调用。一个称作命令解释器或 shell 的进程从终端读取命令。用户输入一个命令请求编译一个程序,shell 必须创建一个新的进程来运行编译器。当这个进程结束编译任务之后,它执行一个系统调用来终止自己。
如果一个进程可以创建一个或多个其他进程(这是子进程)而这些进程也可以创建子进程,我们会得到一个进程树如图 1 - 13 所示。相关的进程需要与其他进程进行通讯,来同步一些信息。这种通讯称作进程间通讯(IPC),会在第二章详细介绍。
图 1 - 13
其他进程系统调用可以用来请求更多的内存(或释放没有使用的内存),可以用来等待子进程终止,可以用来使用另一个程序替代它。
有时候,我们需要传递信息给一个运行中的进程,而这个进程没有必要一直停留并等待这个信息。比如一个进程要与另一台计算机上的一个进程通过网络通讯。为了确保信息不会丢失,发送者需要请求它自己的操作系统在特定的时长之后通知它,以确保在它没能接收到确认信息时重传信息。在设置这个定时器之后,程序可能会继续执行其他工作。
当过去指定的时长之后,操作系统发送一个告警信号给进程。这个信号会令进程暂时挂起它在做的工作,将它的寄存器保存到栈上,并开始运行特定的信号处理程序,比如,重传丢失的信息。当信号处理函数完成时,进程会继续它回到信号处理函数前的状态继续运行。信号是硬件中断的模拟,可以由定时器超期以及其他情况生成。一些由硬件检测的陷阱,比如非法指令或对无效地址的使用,操作系统也会给罪魁祸首发送对应信号量。
每一个使用系统的认证用户都有系统管理者分配的 UID(User IDentification)。每一个启动的进程具有 UID 来记录谁启动了它。一个子进程拥有它父进程相同的 UID。用户可以是组成员,这样的成员具有 GID(Group IDentification)。
有一个 UID,称作超级用户(在 UNIX 中),或管理员(在 Windows 中),具有特殊的能力以及可以无视一些保护性规则。
地址空间
每一台计算机都具有主存用来存储可执行程序。在一个非常简单的操作系统中,只有一个程序在内存中。为了运行第二个程序,第一个程序需要从主存中移除,并放入第二个主存。
一个更加复杂的操作系统允许内存中同时有多个程序。为了防止它们互相干扰(并可能干扰操作系统),需要某些保护机制。硬件也需要这样的机制,这些机制都由操作系统提供。
上面的视角主要考虑了管理与保护计算机主存。另一个重要的内存相关的操作是管理进程的地址空间。通常,每一个进程具有一些可以使用的地址,通常从 0 到某个最大值。最简单的情况是,地址空间最大值比主存容量小。这样,一个进程可以将所有的地址空间全部放到主存中。
然而,有一些计算机地址是 32 或 64 位的,地址空间分别是 2^32 与 2^64 字节。如果一个进程的地址空间相较于计算机的主存而进程又希望使用这个主存会发生什么呢?刚开始的计算机可能不太行。现如今,有一种技术称作虚拟内存,操作系统将进程的部分地址空间保存在主存中,而将其他内容保存在硬盘上,并按需滑动修改主存中的内容。本质上,操作系统创建了地址空间的抽象做为一个进程使用的地址集。这个地址空间是从设备物理内存计算得到,并可能大于或小于物理内存。管理地址空间与物理内存是操作系统重要的工作。第三章会讲到。
文件
几乎所有的操作系统都支持的一个概念就是文件。就像前面提到的那样,操作系统会将丑陋的硬盘以及其他 I/O 设备封装,并向程序员提供一个简洁漂亮的抽象,与设备隔离的文件。显然需要系统调用进行文件创建、移除文件、读取文件、写文件等。在一个文件能够被读取,它必须能够在硬盘上被定位,并被打开,在读取完成后,需要将文件关闭,因此需要完成这些操作的调用。
为了提供一个位置来存储文件,大部分 PC 操作系统具有目录的概念,将文件分组。比如,一个学生可能具有一个目录存储所有他所选的课程,另一个目录存储他的电子邮件,另一个目录做为它的万维网页面。那么我们就需要系统调用来实现创建与移除目录。当然,我们也需要系统调用来将文件放到目录,以及从目录中移除。这个模型组成了如图 1 - 14 这样的文件系统:
图 1 - 14
进程与文件系统都以树结构组织,但是它们的相似性到此为止。进程等级一般不会很深(超过三层都很少见),而文件系统则通常会有四层、五层甚至更多。进程层级通常生命周期较短,通常最多只有几分钟,而目录架构可能会存续纪念。进程与文件的拥有者与保护也不同。通常,只有父进程会控制或访问子进程,但是,文件与目录则会被各种组访问。
每一个具有目录层级的文件都可以通过从根目录开始的文件路径确定。这样的绝对路径名必须从根文件开始直到要访问的文件,使用斜杠分隔各组成。在图 1 - 14 中,文件 CS101 的路径是 /Faculty/Prof.Brown/Courses/CS101。开头的斜杠表示这个路径是绝对路径,从根路径开始。做为对比,在 Windows 系统中,使用反斜杠 \
来分隔各组成,因此上面的路径在 Windows 中表示为 \Faculty\Prof.Brown\Courses\CS101。在本书中,我们会使用 UNIX 风格的路径。
在每一个实例中,每一个进程具有当前工作目录,寻找文件可以不需要以斜杠开始。比如在图 1 - 14 中,如果 /Faculty/Prof.Brown 是工作目录,使用路径 Courses/CS101 可以得到上面提到的绝对路径访问的同一个文件。进程可以通过系统调用来切换工作目录。
在文件可以读写之前,必须打开文件,此时会检查权限。如果允许访问,系统返回一个称作文件描述符的整数以供后续操作。如果访问被禁止,会返回错误码。
在 UNIX 中另一个比较重要的概念是挂载文件系统。大部分桌面计算机都有一个或多个光驱,可以用来插入 CD-ROM、DVD、Bluray 盘。桌面计算机也总是有 USB 端口,可以插入 USB 盘,一些计算机可以插入软盘或外部硬盘。为了提供一种优雅的方式处理这些可移除的媒介,UNIX 允许光盘连接到主目录树上。考虑图 1 - 15(a) 中的情况,在 mount 调用前,在硬盘上的根文件系统与在 CD-ROM 上的第二文件系统是分隔开,不相关的。
不过,现在 CD-ROM 中的文件是不可以使用的,因为不知道一个确定的路径访问它。UNIX 不允许一个路径名以驱动名或驱动号开始,操作系统需要消除这类对设备的依赖。使用 mount 系统调用,允许 CD-ROM 文件系统挂载在根文件系统上,可以任意指定挂载位置。在图 1 - 15(b) 中,CD-ROM 中的文件系统挂载到了目录 b 下,因此允许通过 /b/x 以及 /b/y 的方式访问这些文件。如果目录 b 中包含任何文件,那么在 CD-ROM 挂载过程中,它们不可以被访问,因为 /b 会指向从根目录到 CD-ROM 的路径(不能访问挂载目录下的文件,并不是什么恐怖的事,因为几乎所有挂载行为总是会使用空目录)。如果系统包含多个硬盘,它们可以同时挂载在单个文件树下。
图 1 - 15
在 UNIX 中另一个关键的概念是特殊文件。特殊文件是令 I/O 看起来是一个文件(一切皆文件)。以这种方式,它们可以通过与读写文件相同的接口进行读写操作了。系统中有两类文件存在: 块文件与字符文件。块文件用来用来抽象随机地址访问的设备,比如硬盘。通过打开一个块设备文件进行读取第四块,程序可以直接访问设备的第四块内容,而不需要了解它上面具有的是什么文件系统。相似的,字符设备文件用来抽象打印机、调制解调器以及其他以字符形式输入或输出的字符流设备。做为传统,这类特殊的设备文件都放在 /dev 目录下。比如 /dev/lp 可能是一台打印机。
最后一个特性,我们将要讨论的是进程与文件的结合体: 管道。管道是一个伪文件,可以用来连接两个进程,像图 1 - 16 中描述的那样。如果进程 A 与 进程 B 希望使用管道彼此沟通,它们必须先建立这个管道。当进程 A 与 希望发送数据给进程 B,它像像写文件一样写管道。实际上,管道实现也与文件实现相似。进程 B 可以像读文件一样读管道。因此 UNIX 中两个进程的沟通像两个常规文件读写一样。进程能够发现这是一个管道而不是一个文件的唯一方式是通过特殊的系统调用。文件系统是十分重要的,我们将会在第四章、第十章、第十一章中详细讨论它们。
图 1 - 16 两个被管道连接的进程
输入/输出
所有的计算机都有物理设备来获取输入向外输出。如果一台计算机是一个孤岛,不能获取输入,不能向外输出,那这个计算机还有什么意义呢。有很多种输入、输出设备存在,包括键盘、显示器、打印机等。操作系统负责对这些设备进行管理。
每一个操作系统具有一个 I/O 子系统用来管理 I/O 设备。一些 I/O 软件是与设备无关的,这样,它们就能够平等的对待所有的 I/O 设备。当然,也有一些设备驱动,只服务某些 I/O 设备。我们会在第五章详细介绍 I/O 软件。
保护
计算机中包含大量的用户数据,我们希望保持这些数据的机密性。这些信息可能包括邮件、商业计划、退税等。我们需要操作系统来保护安全性,比如文件只能被授权用户访问。
做为一个简单的例子,以 UNIX 为例,看一下安全性是怎么工作的。在 UNIX 中的文件具有 9 比特的二进制保护码,对文件进行保护。这个保护码包含三组 3 比特字段,一组表示拥有者,一组表示拥有者所在组,一组表示其他所有人。每一组字段的 3 个中,一个比特表示读权限,一个比特表示写权限,一个比特表示执行权限。这 3 个比特是 rwx 比特。比如保护码是 rwx r-x --x 意味着拥有者可以读、写、执行文件,小组成员可以读、执行文件,其他人只能够执行文件。做为一个目录,x 表示搜索权限。一个横杠表示没有对应位置的权限。
在第九章我们会介绍保护的具体信息。
Shell
操作系统是带有系统调用的代码。编辑器、编译器、汇编器、连接器、小工具程序以及命令解释器绝对不是操作系统的一部分,尽管它们是十分重要并且十分有用的。为了防止这些内容混淆,本小节我们会简单看一下 UNIX 的命令解释器 shell。虽然它不是操作系统的一部分,但依然是很多操作系统的特性,这也是系统调用怎么使用的一个例子。如果用户没有使用图形化界面,终端就是与操作系统交互的主要接口。有很多 shell 存在,包括 sh
、csh
、ksh
以及 bash
。所有 shell 都支持下面描述的功能,都是从原初 shell 发源而来。
当任意用户登录,shell 启动。shell 具有一个终端,做为标准输入与标准输出。它以提示符开始,比如一个美元符号,这告知用户 shell 等待着接收命令,如果用户键入:
$ date
以此为例,shell 将会创建一个子程序,运行 date
程序做为子进程。当子进程运行过程中,shell 等待它终止。当子进程结束时,shell 重新打印提示符,并尝试读取下一行输入。
用户可以使用输出重定向,将标准输出输出到文件,比如:
$ date > file
相似的,标准输入也可以重定向,比如:
$ sort < file1 > file2
调用 sort
程序,从 file1
中提取数据,并将输出发送到 file2
。
通过管道,一个程序的输出可以用来做为另一个程序的输入:
$ cat file1 file2 file3 | sort > /dev/lp
调用 cat
程序将三个文件中的内容发送给 sort
然后以字母顺序进行排序,将 sort
输出给到 /dev/lp
,通常是一个打印机。
如果一个用户在命令后放一个 &
符号,shell 不会等待它执行结束,而是直接打印新的输入提示符:
$ cat file1 file2 file3 | sort > /dev/lp &
这个命令会将 sort
程序在后台运行,允许用户在 sort
执行过程中,继续其他工作。shell 具有其他有趣的特性,我们现在不展开讨论了。
大部分个人计算机具有图形用户界面 GUI。实际上,GUI 只是运行在操作系统顶部的程序,就像 shell 一样。在 Linux 系统中,这一特性是显而易见的,因为用户可以选择自己的 GUI: Gnome 以及 KDE 或者不使用 GUI(使用 X11 上的终端窗口)。在 Windows 中,可以通过在注册表中修改一些内容,也可以关闭 GUI 桌面。
一个概述
略
系统调用
我们已经看到操作系统具有两个主要功能: 为用户程序提供抽象,并管理计算机资源。大部分情况下,用户程序与操作系统之间的交互处理前一个问题,比如,创建、写、读与删除文件。资源管理部分大部分是自动完成而与用户无关的。因此,在用户程序与操作系统之间的接口主要是处理抽象。为了真正理解操作系统做了什么,我们必须靠近这些接口仔细观察一下它们。不同的操作系统具有的系统调用是不同的(虽然它们的概念是类似的)。
我们现在有两个选择,(1) 含糊的概括(操作系统都具有系统调用来读文件),(2) 一些指定系统(UNIX 具有带三个参数的 read
系统调用,一个指定文件,一个告知数据放在哪里,一个告知要读多少字节)。
我们选择了后一种方法。它需要更多的工作量,但是这种方式也能帮助我们看到操作系统实际上在做什么。虽然这个讨论参考了 POSIX,而 UNIX、System V、BSD、Linux、MINIX 3 等,大部分现代操作系统,具有相同的系统调用接口,可能具体实现不同。因为实际的系统调用是与硬件相关的,而且必须以汇编码表示,一个过程库提供来使用 C 程序做系统调用或使用其他语言也是一样。
我们需要牢记一点。一个单 CPU 计算机,每次只能执行一条指令。如果一个进程在以用户态运行用户程序,需要一个系统服务,比如从一个文件读取数据,需要执行一个陷阱指令来将控制权交给操作系统。操作系统检查参数来确定进程所需要的。之后操作系统会执行系统调用并在完成后返回到用户进程继续执行后续的指令。在某些角度,进行系统调用就像是执行一个特殊的程序调用,只是系统调用会进入内核而简单的程序调用则不会。
为了让系统调用机制更加清晰,让我们简单看一下 read 系统调用。就像之前提到的那样,它具有三个参数: 第一个指定文件,第二个指定 buffer,第三个给到要读取的字节数。就像所有系统调用一样,它是使用 C 语言调用库程序 read。C 程序中的调用可能是下面这样:
count = read(fd, buffer, nbytes);
这个系统调用返回真实读取的字节数给 count。一般情况下这个数值是与 nbytes 一致,也有可能比 nbytes 小,比如还没读取到指定数量的数据,文件就结束了。
如果系统调用因某种原因比如传参错误、硬盘故障等,count 将会是 -1,并且错误码被放在一个全局变量 errno
。程序应该总是检查系统调用的结果,以确定系统调用是否成功。
系统调用是一步一步执行的,为了令这个概念清晰,我们看一下之前讨论的 read 调用。为了准备调用 read 库程序,调用程序首先需要将参数入栈,就像图 1 - 17 的 1 - 3 步一样。
C 以及 C++ 编译器将参数以逆序的方式入栈。第一个以及第三个参数是值传递,而第二个参数是指针传递。在第四步来到真正的库程序调用过程,这个指令是调用所有程序的常规调用过程。
库程序,可能是以汇编写就,通常将系统调用号放在操作系统期望的某个位置,比如一个寄存器(步骤5)。之后操作系统执行一个 TRAP 指令,来将用户态切换为内核态,并执行内核中的某个固定位置的指令。TRAP 指令与程序调用 CALL 指令相似的指令,它们二者后面跟着的指令都是另一个内存位置开始,返回地址都存在于栈上。
图 1 - 17
实际上,TRAP 与程序调用 CALL 指令在基础概念上还是不同的。首先,做为副作用,会切换到内核态,而程序调用是不会进行模式切换的。第二,它不是给出相对或绝对的地址,TRAP 指令不能跳转到任意地址。取决于架构,它可能会跳转到一个固定的位置或给定指令的 8 比特字段指示的跳转地址。
紧跟着 TRAP 指令的内核代码确定系统调用号,并确定到真正的 系统调用处理器,通常使用系统调用号通过一个指向系统调用的表查询确定。这时会运行系统调用处理器(步骤 8)。一旦调用处理器完成它的工作后,可能会返回到 TRAP 后面的用户空间指令(步骤 9)。这个过程之后返回到用户空间的程序调用(步骤 10).
为了结束工作,用户空间必须清理栈,就像它在任何过程调用做的那样(步骤 11)。假设栈是向下生长的,编译的代码会增加它的指针来将参数出栈到调用 read 之前的情况。程序现在就可以自由继续执行了。
在步骤 9 中,我们说了可能会返回到用户空间库程序。系统调用可能会阻塞调用者,阻止它继续运行。比如,如果它尝试从键盘读取数据,但是目前还没有键入任何内容,那么必须阻塞调用者。这个情况下,操作系统会去执行其他进程。之后,当有输入可用时,这个被阻塞的进程可以被系统通知退出阻塞,继续完成后面的 9 - 11 步。
在下面的小节中,我们会介绍一些重度使用的 POSIX 系统调用,或某些会进行系统调用的库程序。POSIX 具有接近 100 个程序调用。一些比较重要的在图 1- 18 中列出,并对它们进行了分组。我们会简单看一下这些系统调用都做了什么。
在很大程度上,这些调用提供的服务都是操作系统要做的操作。这些服务包括创建、终止进程,创建、删除、读取以及写文件,管理目录以及执行输入输出。
做为补充,POSIX 程序调用到系统调用并不是一一映射的。POSIX 标准指定一系列的程序,但是并不明确规定这些程序的具体实现是系统调用,库调用还是其他什么。如果一个程序实现不需要系统调用,那么通常是因为性能考虑。不过大部分 POSIX 程序都会进行系统调用,通常是一个程序映射到一个系统调用。
进程管理系统调用
第一组调用在图 1 - 18 中列出,处理进程管理。fork
是一个不错的开始。fork
是在 POSIX 中唯一的创建新进程的方法。它创建原进程的一个副本,包括所有的文件描述符、寄存器等。在 fork
之后,原进程及其副本(父进程与子进程)成为了分支。所有的变量在 fork
时是一样的,但是在父进程的所有数据复制到子进程后,它们二者中变量的修改将不再互相影响(程序的上下文是不会改变的,父进程与子进程的程序上下文是一样的)。fork
调用会返回一个值,在子进程中返回值为 0,而在父进程中返回值为子进程的 PID(Process IDentifier)。使用返回的 PID,两个进程可以确定哪个是父进程哪个是子进程。
进程管理
调用 | 描述 |
---|---|
pid = fork() |
创建一个子进程 |
pid = waitpid(pid, &statloc, options) |
等待子进程终止 |
s = execve(name, argv, environp) |
替换一个进程的核心镜像 |
exit(status) |
终止进程执行并返回状态 |
文件管理
调用 | 描述 |
---|---|
fd = open(file, how, ...) |
为读、写或读写打开文件 |
s = close(fd) |
关闭一个打开的文件 |
n = read(fd, buffer, nbytes) |
从一个文件读取数据到缓冲区 |
n = write(fd, buffer, nbytes) |
将缓冲区的数据写到文件 |
position = lseek(fd, offset, whence) |
移动文件指针 |
s = stat(name, &buf) |
获取文件状态信息 |
目录与文件系统管理
调用 | 描述 |
---|---|
s = mkdir(name, mode) |
创建一个新的目录 |
s = rmdir(name) |
移除一个空的目录 |
s = link(name1, name2) |
创建一个新入口 name2 指向 name1 |
s = unlink(name) |
移除一个目录入口 |
s = mount(special, name, flag) |
挂载一个文件系统 |
s = unmount(special) |
取消一个文件系统挂载 |
其他
调用 | 描述 |
---|---|
s = chdir(dirname) |
更换工作目录 |
s = chmod(name, mode) |
修改文件保护比特 |
s = kill(pid, signal) |
发送信号到进程 |
seconds = time(&seconds) |
获取从 1970 - 1 -1 到现在过去了多久 |
图 1 - 18. 一些 POSIX 系统调用,如果错误发生,返回值为 -1
,返回字如下:pid
是一个进程号,fd
是一个文件描述符,n
是字节计数值,position
是文件的偏置位置,seconds
是时间长度
大部分情况下,在 fork
之后,子进程需要执行与父进程不同的代码。考虑 shell
中的情况,它从终端读取命令,fork
一个子进程,等待子进程执行命令,在子进程结束后,读取下一个命令。为了等待子进程结束,父进程执行 waitpid
系统调用,等待直到子进程结束(任何子进程,可以是多个)。waitpid
可以等待指定的子进程,或通过将第一个参数置为 -1
等待任意的子进程。当 waitpid
完成,会将第二个参数 statloc
置为进程的退出状态(正常或异常的退出值)。由第三个参数给出不同的选择,比如如果没有子进程则理课返回。
现在考虑 fork
是怎样由 shell 使用的。当命令键入,shell fork
子进程。这个子进程必须执行用户命令。它使用 execve
系统调用完成的,这个指令将核镜像替换为它第一个参数给到的镜像。(实际上,系统调用自身是 exec
)。一个简单的例子如下图 1 - 19:
#define TRUE 1
while(TRUE){
type_prompt(); /* 永久重复 */
read_command(command, parameters); /* 将提示符保留在屏幕上 */
if(fork() != 0){ /* fork 子进程 */
/* 父进程代码 */
waitpid(-1, &status, 0); /* 等待子进程退出 */
}else{
/* 子进程代码 */
execve(command, parameters, 0); /* 执行命令 */
}
}
图 1 - 19. 一个 shell 的简单阐述,本书中,TRUE 定义为 1
在大部分情况下,execve
具有三个参数: 要执行的文件名,参数数组指针,以及环境数组的指针。这些会在后面做简单描述。不同的库程序,包括 execl
、execv
、execle
以及 execve
,提供了不同的方法,允许省略某些参数或以其他的形式给定。在本书中,我们将会使用 exec
来表示所有的系统调用使用的。
让我们看一下下面的命令吧:
cp file1 file2
这个命令将文件 file1
复制为 file2
。在 shell 读取了命令,确定了子进程位置并执行可执行文件 cp
并将两个参数做为源文件与目标文件传递过去。
cp
主程序包(大部分 C 程序)含下面的语句:
main(argc, argv, envp)
其中 argc
是命令行上的参数数量,这个计数值包括程序名,比如 cp file1 file2
的 argc
为 3。第二个参数 argv
,是指向数组的指针,数组的元素 i
指向第 i
个命令行上的字串。在我们的例子中,argv[0]
是 cp
,argv[1]
指向 file1
,argv[2]
指向 file2
。main
的第三个参数 envp
是指向环境变量的指针,这是字符串数组,传递诸如终端类型以及家目录名给程序。我们可以调用一些库程序开获取到环境变量,通常用来客制化用户希望怎样执行某个特定的任务。在图 1- 19 中,没有将环境变量传递给子进程,因此 execve
的第三个参数是 0。
是否感觉到 exec
有些复杂,不要失望,它是所有 POSIX 系统调用中最复杂的了。做为一个简单的例子,考虑 exit
,进程结束时会用到它。它具有一个参数,退出状态 0 - 255
,使用 statloc
返回到父进程的 waitpid
系统调用中。
在 UNIX 中,将它们的内存分成三个段: 代码段、数据段以及栈段。数据段向上生长,而栈段向下生长,就像图 1 - 20 中的那样。在它们之间是一些未使用到的地址空间。栈会在它需要的时候自动向下生长,但是对数据段的扩展是显式地使用系统调用 brk
实现的,brk
将会指定数据段将会在哪里终止。这个调用,不是在 POSIX 标准中定义的,鼓励程序员使用 malloc
库程序进行动态存储分配,malloc
的底层实现不适宜标准化。
图 1 - 20
文件管理系统调用
一些系统调用是针对文件系统的。在本小节中,我们会讨论这些调用。
为了读或写一个文件,我们需要首先打开这个文件。这个调用指定要打开的文件名,可以是一个绝对路径或基于当前工作目录的相对路径,以及 O_RDONLY
、O_WRONLY
或 O_RDWR
选项,着意味着打开文件做读操作、写操作或读写操作。为了创建一个新文件,使用 O_CREAT
选项。
返回的文件描述符在后面可以用来读写操作。之后,可以使用 close
来关闭打开的文件,这样文件描述符可以在后续的 open
中使用。
最重度使用的是 read
以及 write
,我们之前已经看过 read
了,write
具有相同的参数。
虽然大部分程序都是以顺序形式读写文件,但有时候,需要随机访问文件的任意位置。与此相关的是一个指向当前文件位置的指针。当读/写顺序进行时,它通常指向下一个要读/写的字节位置。lseek
调用修改指针指向的位置,因此 read
以及 write
可以在任何位置读写。
lseek
具有三个参数,第一个是文件的文件描述符,第二个是文件位置,第三个指示要相对于哪个位置,是文件头,是当前位置,还是文件末尾。lseek
返回值是修改之后的相对于文件开头的绝对文件位置。
对于每一个文件,UNIX 保持对文件模式(常规文件,特殊文件,目录等)、文件大小,上次修改时间以及其他信息的跟踪。程序可以通过 stat
系统调用来查询这些信息。第一个参数指定要查询的文件,第二个参数是一个结构体指针,将查询到的信息放入这个指针中。而 fstat
调用对一个打开的文件执行相同的操作。
目录管理系统调用
在本小节中,我们将会查看与目录或文件系统相关的系统调用,而不是像上一小节中介绍的针对文件的系统调用。前两个调用,mkdir
以及 rmdir
,创建以及删除空目录。下一个调用是 link
,它用来允许同一个文件拥有多个名字,可以在不同的目录中打开。一个典型的使用是允许多个小组成员分享同一个文件,这个文件可以出现在他们自己的目录中。共享一个文件不是将文件简单的复制到他们的目录下,拥有一个共享文件,意味着任何小组成员对文件的修改,可以被其他小组成员看到,从始至终只有一个文件。而复制则是创建现有文件的副本,若文件后续被修改,不会体现在这个文件副本中。
为了认识 link
是怎么工作的,我们考虑一下图 1 - 21(a) 中的情况,我们又两个用户,ast
以及 jim
,每一个都有他们自己的目录,目录中有很多文件。如果 ast
现在执行一个命令:
link("/usr/jim/memo", "/usr/ast/note");
在 jim
目录中的文件 memo
将会进入到 ast
目录,并被命名为 note
。之后,/usr/jim/memo
以及 /usr/ast/note
指向同一个文件。做为补充介绍,用户目录是放在 /usr
、/user
、/home
还是其他的目录,是由系统管理员决定的。
图 1 - 21
理解 link
是怎么工作的,将会理解它究竟做了什么。在 UNIX 文件中,每一个文件都有位置的数字,它的 i-number
,可以用来定位文件。这个 i-number
是 i-node
表中的索引,每一个表示一个文件,告知谁拥有这个文件,文件位于硬盘的哪个块等信息。一个目录是一个文件,不过它包含一系列文件信息对(i-number
,ASCII
名)。在 UNIX 的第一个版本中,每一个目录入口是 16 个字节,2 个字节是 i-number
,14 个字节是名称。现在引入了更加复杂的结构,以支持更长的文件名,不过目录依旧是文件信息对(i-number
,ASCII
名)的集合。在图 1 - 21 中,mail
的 i-number
是 16。link
做的工作是,创建一个入口分支,使用相同的 i-number
70。如果后续想要移除链接,使用 unlink
系统调用,这会保留另一个链接。如果文件的入口都消失,文件会被从硬盘上删除。
就像我们在前面提到过的,mount
系统调用允许两个文件系统融合成为一个。一个常见的情景是将那些常用命令、其他重度使用的文件、硬盘分区以及其他分区上的用户文件组合成为根文件系统。而且,永和可以插入 USB 盘来读写上面的文件。
执行 mount
系统调用,USB 文件系统可以连接到根文件系统上,就像图 1 - 22 中描述的那样,在 C 中一个典型的语句是:
mount("/dev/sdb0", "/mnt", 0);
第一个参数是指定 USB 块设备文件 0,第二个参数是指定要挂载的位置,第三个参数确定挂载文件系统后可以进行的操作,是否可以读、写。
图 1 - 22
在 mount
调用后,在驱动 0 上的文件可以根据根文件系统上的路径或相对工作目录的位置访问到,而不需要关心它是什么设备。实际上,第二个、第三个以及第四个驱动也可以挂载在文件树的任何位置。mount
调用将那些可移动媒介整合到文件层级结构中,而不需要担心文件在哪个设备上。当不需要文件时,可以使用 unmount
命令解除挂载。
其他系统调用
还有其他的系统调用存在。我们这里就看其中四个。chdir
调用修改当前工作目录:
chdir("/usr/ast/test");
在执行这个调用后,对 xyz
的 open
操作将会打开 /usr/ast/test/xyz
文件。工作目录在使用绝对路径时将不再有意义。
在 UNIX 中,每一个文件具有一个模式用作保护。模式拥有者、拥有者所在组、其他人的包括读、写、执行比特。chmod
系统调用可以修改文件的这些比特。比如,可以使用下面的调用让文件除拥有者外只有读权限:
chmod("file", 0644);
kill
系统调用允许用户向用户进程发送信号。如果一个进程准备好捕获某个信号,那么当信号到来时,会运行信号处理函数。如果进程没有准备去处理一个信号,那么信号到来时将会杀死进程。
POSIX 定义了程序来处理时间。比如 time
以秒为单位返回当前时间,0 表示 1970 年 1 月 1 日的 00:00。在计算机上使用 32 比特的字,最大值可以是 2^32 - 1 秒。这个值可以表示超过 136 年。因此在 2106 年 32 为的 UNIX 系统将出现问题,不像著名的千年虫问题那样,太快出现了这样的问题,在 2106 年之前,大概率都会更换成 64 比特替代的计算机了。
Windows Win32 API
在此之前,我们专注于 UNIX 系统。现在我们简单看一下 Windows 系统。Windows 以及 UNIX 系统在它们各自的编程模型上不太一样。UNIX 程序做系统调用来进行某个服务。而 Windows 程序通常是事件驱动的。主程序等待某些事件发生,之后调用程序来处理它。典型事件,如键盘按键按下,鼠标被移动,鼠标按钮被按下,一个 USB 驱动被插入。处理器之后调用来处理这个事件,更新屏幕并更新内部程序状态。总体来说,这会令 UNIX 跟 Windows 有很大的不同。不过因为本书主要关注操作系统功能与接口,这些编程模型的不同点我们就不会太关注了。
当然 Windows 也有系统调用。与 UNIX 对比,Windows 几乎是有一一对应的系统调用。换言之,对于每一个系统调用,都有库程序调用它,就像图 1 - 17 一样。POSIX 之后 100 个程序调用。
在 Windows 中,情况有很大不同。库调用与实际的系统调用是高度解耦的。微软定义了一系列程序称作 Win32 API(Application Programming Interface),程序员可以用它们来获取到系统服务。这个接口在自 Windows 95 之后的所有版本的 Windows 都支持。通过解耦 API 与实际的系统调用,微软可以修改系统调用,而不需要更改现有的应用程序。真正困扰 Win32 的是,最新版本的 Windows 具有一些新的调用,而它自己是没有的。在本小节,Win32 表示所有 Windows 版本都支持的接口。Win32 提供 Windows 版本之间的兼容。
Win32 API 调用是很多的,多达上千。另外,它们的一部分会进行系统调用。因此,需要注意在 Windows 中什么是系统调用(由内核执行),那些由用户空间执行库调用,实际上,在某个 Windows 版本中的系统调用,在另一个版本的 Windows 中可能就库调用了,反之亦然。当我们在本书中讨论 Windows 系统调用时,我们会使用 Win32 版本。
Win32 API 具有大量的调用来管理窗口、图形、文本、字体、滚动条、对话框、菜单以及其他 GUI 特性。如果图形子系统以内核态运行,那么这些是系统调用,否则它们仅仅是简单的库调用。因为它们并不涉及真正的操作系统功能,我们决定不再展开讨论它们,即便它们可能确实由内核实现。读者对这些 API 感兴趣的话,可以阅读针对它们的书。
介绍所有的 Win32 API 都是超出本书范围的,因此我们仅对图 1 - 18 中的 UNIX 类似的接口进行讨论。这些接口在图 1 - 23 中列出。
UNIX | Win32 | 描述 |
---|---|---|
fork |
CreateProcess |
创建一个新进程 |
waitpid |
WaitForSingleObject |
等待进程退出 |
execve |
- | 创建进程 (fork + execve ) |
exit |
ExitProcess |
终止执行 |
open |
CreateFile |
创建一个文件或打开一个现有文件 |
close |
CloseHandle |
关闭一个文件 |
read |
ReadFile |
从一个文件读取数据 |
write |
WriteFile |
写数据到文件 |
lseek |
SetFilePointer |
移动文件指针 |
stat |
GetFileAttributesEx |
获取文件属性 |
mkdir |
CreateDirectory |
创建一个新目录 |
rmdir |
RemoveDirectory |
移除一个空目录 |
link |
- | Win32 不支持链接 |
unlink |
DeleteFile |
摧毁现有文件 |
mount |
- | Win32 不支持挂载 |
unmount |
- | Win32 不支持挂载,因此 |
chdir |
SetCurrentDirectory |
修改当前工作目录 |
chmod |
- | Win32 不支持安全 |
kill |
- | Win32 不支持信号 |
time |
GetLocalTime |
获取当前时间 |
图 1 - 23: Win32 API 调用与 UNIX 类似的,需要注意的是 Windows 具有大量的其他系统调用,大部分并不与 UNIX 相关
让我们简单看一下图 1 - 23,CreateProcess
创建一个新进程。它会结合 UNIX 中的 fork
以及 execve
。它具有多个参数给到新创建的进程。Windows 没有像 UNIX 那样的进程等级,因此没有父进程与子进程的概念。在一个进程创建之后,进程的创建者与被创建者是同等的。WaitForSingleObject
用来等待一个事件,可以等待很多可能的事件。如果参数给到一个进程,那么调用者等待特定的进程退出,这是通过使用 ExitProcess
实现的。
下面六个调用用来操作文件,与 UNIX 中类似,参数与细节可能不同。文件可以打开、关闭、读、写。SetFilePointer
以及 GetFileAttributesEx
调用设置文件位置以及获取文件属性。
Windows 具有目录而且它们使用 CreateDirectory
以及 RemoveDirectory
API 调用创建。Windows 也有当前目录的概念,由 SetCurrentDirectory
设置。当前事件是由 GetLocalTime
获取。
Win32 没有文件链接,挂载文件系统,安全以及信号,因此与之相关的调用都没有。不过,Win32 具有的很多调用在 UNIX 中也没有,尤其是对 GUI 的管理。Windows Vista 具有系统安全以及文件链接。Windows 7 以及 8 具有更多的系统调用特性。
操作系统结构
现在我们看到了操作系统在外部是什么样的(程序员视角),现在让我们从系统内部观察一下它。在下面的几个小节中,我们将会讨论六种尝试过的结构。
单体系统
到目前为止最常见的组织形式,是整个系统以单个程序运行在内核态。操作系统是由一系列程序集合构成,链接到一起成为一个大的可执行二进制程序。当使用这个技术,系统中的每一个程序可以自由调用另一个程序。能够调用任何需要的程序是十分高效的,但是,如果让上千个程序可以互相调用而不加限制,显然是一场灾难。而且,这些程序的任何一个崩溃,都会令整个操作系统崩溃。
为了构建实际的操作系统对象程序,首先编译所有单独的程序,之后使用连接器将他们链接到一起成为一个单独的可执行文件。为了信息隐藏,并不是所有的程序都能被其他程序直接调用。
即使在单体系统中,也可以具有一些结构,操作系统提供的服务(系统调用)是通过将参数放在一个预定的位置,之后执行陷阱指令。这个指令将设备从用户态切换到内核态,并将控制权交给操作系统。操作系统之后抓取参数,确定要执行哪个系统调用。在此之后,它在表中索引来找到这个系统调用。
这个组织结构建议的基础操作系统架构为:
- 一个主程序调用请求服务程序
- 一系列服务提供系统调用
- 一系列程序提供服务程序
图 1 - 24 一个单体系统简单结构
在启动过后为了给核心操作系统提供补充,一些操作系统支持可加载扩展,比如 I/O 设备驱动以及文件系统。这些组件可以按需加载。在 UNIX 中它们称作共享库,在 Windows 中它们称作 DLLs(Dynamic Link Libraries)。它们具有文件扩展名 .dll
在 C:\Windows\system32
目录下有上千个这样的文件。
分层系统
下面的系统有六个层次,第 0 层用来分配处理器,当中断发生或定时器逾期时切换进程。在 0 层上面,不用担心这个系统是多进程的系统。换言之第 0 层提供 CPU 的基础多程序运行环境。
层次 | 功能 |
---|---|
5 | 操作者 |
4 | 用户程序 |
3 | 输入输出管理 |
2 | 进程通信 |
1 | 内存管理 |
0 | 处理器分配与多程序 |
图 1 - 25 分层操作系统结构
第 1 层进行内存管理。它为在主存中为进程分配空间。在第 1 层以上,程序不需要关心它们自己的内存问题。第 2 层提供进程间通讯服务。第 3 层对 I/O 进行管理,缓冲信息流。在第 3 层以上,每一个进程可以处理抽象 I/O 设备。第 4 层是用户程序。它们不用关心进程、内存、终端、I/O 管理。
微内核
在上面的分层设计中,设计者可以划分内核与用户的边界。所有的层次可以划分到内核中,但这是没必要的。实际上,如果可以将尽可能少的内容放入内核中,可以提升系统的鲁棒性,因为任何内核中的问题都会是系统大灾难。用户程序如果有问题,不会造成灾难性的后果。
微内核的基础概念是,将内核做的尽可能小,尽可能精,只有微内核运行在内核态,其余部分都是以用户态的形式运行。实际上,将设备驱动与文件系统做为分隔的用户进程,一个出现问题,只会影响到这个组件,而不会让整个系统崩溃。
分层 | 例子 |
---|---|
用户程序(进程) | Shell、Make |
服务(进程) | FS、Proc.、Reinc. |
驱动(进程) | Disk、TTY、Netw、Print |
微内核 | 时钟、系统,处理中断、进程调度,进程间通讯 |
图 1 - 26 MINIX 系统的简化结构
一个广为人知的微内核是 MINIX。
客户服务器模型
微内核的变体是将所有内容分成两类,一类是服务器,提供某些服务,另一类是客户,使用这些服务。通常最底层是微内核,但这不是最重要的,重要的是客户进程与服务进程。
在客户与服务器之间的通讯通常由信息传递者提供。为了获取到服务,客户进程构造一个信息,并将它发送给合适的服务。这个服务之后响应客户。如果客户以及服务在同一个设备上运行,可以做优化,但是概念上,我们依旧有信息传递。
这个设计可以将客户与服务运行在不同的计算机上,通过局域网或广域网连接,因为客户与服务通讯通过信息通讯,客户不需要知道这个信息是否是本设备自己处理的还是其他远程设备处理的。
图 1 - 27 跨网络的客户服务器模型
虚拟机
略
VM/370
略
图 1 - 28 VM/370 带 CMS 结构
重新设计虚拟机
略
Java 虚拟机
略
外内核
略
图 1 - 29 (a) 单