《操作系统:设计与实现》第一章

发布于 2025年4月25日
最后修改于 2025年5月4日

介绍

如果没有软件,计算机基本上就是一堆无用的金属。有了软件,计算机就能存储、处理和检索信息;播放音乐和视频;发送电子邮件,搜索互联网;以及进行许多其他有价值的活动来证明其价值。计算机软件大致可以分为两类:系统程序,用于管理计算机本身的运行;应用程式,用于执行用户想要的实际工作。最重要的系统程序是操作系统,其任务是控制计算机的所有资源,并提供一个基础,让应用程式得以在其上编写。本书讨论的主题是操作系统,特别是以一个名为 MINIX 3 的操作系统作为模型,用以说明设计原则和实现设计的现实情况。

现代计算机系统包括一个或多个处理器、主存储器、磁盘、打印机、键盘、显示器、网络接口以及其他输入/输出设备。总的来说,这是一个复杂的系统。编写程序来跟踪所有这些组件并正确使用它们,更不用说优化使用,是极其困难的工作。如果每个程序员都必须关心磁盘驱动器的工作原理,以及读取磁盘块时可能出现的几十种错误,那么几乎不可能编写出多少程序。

多年前,人们逐渐认识到必须找到一种方法来屏蔽程序员免受硬件复杂性的影响。逐渐演变出的方法是在裸硬件之上放置一层软件,用于管理系统的所有部分,并为用户提供一个更易于理解和编程的接口或虚拟机。这层软件就是操作系统。

操作系统的位置如图 1-1 所示。最底层是硬件,在许多情况下,硬件本身也由两个或更多层级(或层)组成。最低层包含物理设备,包括集成电路芯片、电线、电源、阴极射线管等类似的物理设备。这些设备如何构建以及如何工作属于电气工程师的领域。

image-20250424152651054

接下来是微架构层,在这一层中,物理设备被组织在一起形成功能单元。通常,这一层包含中央处理器(CPU)内部的一些寄存器和一个包含算术逻辑单元的数据通路。在每个时钟周期中,从寄存器中获取一个或两个操作数,并在算术逻辑单元中进行组合(例如,通过加法或布尔与运算)。结果存储在一个或多个寄存器中。在某些机器上,数据通路的操作由称为微程序的软件控制。在其他机器上,它由硬件电路直接控制。

数据通路的目的是执行一组指令。其中一些指令可以在一个数据通路周期内完成;其他指令可能需要多个数据通路周期。这些指令可能使用寄存器或其他硬件设施。硬件和指令共同构成了汇编语言程序员可见的指令集体系结构(ISA,Instruction Set Architecture),这一层通常称为机器语言

机器语言通常包含50到300条指令,主要用于在机器内部移动数据、执行算术运算和比较值。在这一层,输入/输出设备通过向特殊设备寄存器加载值来控制。例如,可以通过将磁盘地址、主存储器地址、字节计数和方向(读或写)加载到磁盘的寄存器中来命令磁盘进行读取。实际上,还需要更多的参数,并且操作后磁盘返回的状态可能很复杂。此外,对于许多输入/输出(I/O)设备,编程中时序起着重要作用。

操作系统的一个主要功能是隐藏所有这些复杂性,并为程序员提供一组更方便的指令。例如,读取文件的块在概念上比担心移动磁盘磁头、等待磁头稳定等细节要简单得多。

在操作系统之上是其余的系统软件。这里包括命令解释器(shell)、窗口系统、编译器、编辑器以及类似的与应用无关的程序。重要的是要认识到,这些程序绝对不是操作系统的一部分,即使它们通常由计算机制造商预装,或者在购买后与操作系统一起打包提供。这是一个关键但微妙的区别。操作系统(通常)是运行在内核模式管理模式下的软件部分。它通过硬件保护,防止用户篡改(暂时忽略一些没有硬件保护的较旧或低端微处理器)。编译器和编辑器运行在用户模式。如果用户不喜欢某个特定的编译器,$\uparrow$也1可以自由选择编写自己的编译器;但他不能自由编写自己的时钟中断处理程序,因为这是操作系统的一部分,通常通过硬件保护,防止用户尝试修改。

然而,这种区别在嵌入式系统(可能没有内核模式)或解释型系统(如基于Java的系统,使用解释而非硬件来分离组件)中有时会变得模糊。尽管如此,对于传统计算机,操作系统是在内核模式下运行的部分。

尽管如此,在许多系统中,有些程序在用户模式下运行,但它们辅助操作系统或执行特权功能。例如,通常有一个程序允许用户更改密码。这个程序不是操作系统的一部分,也不在内核模式下运行,但它显然执行敏感功能,必须以特殊方式进行保护。在某些系统中,包括 MINIX 3,这种理念被推向极致,传统上被认为是操作系统的一部分(如文件系统)也在用户空间中运行。在这样的系统中,很难划定清晰的界限。所有在内核模式下运行的内容显然是操作系统的一部分,但一些运行在其外的程序也可以说属于操作系统,或者至少与之密切相关。例如,在 MINIX 3 中,文件系统只是一个在用户模式下运行的大型 C 程序。

最后,在系统程序之上是应用程式。这些程序由用户购买(或编写)以解决特定问题,例如文字处理、电子表格、工程计算或在数据库中存储信息。

什么是操作系统?

大多数计算机用户都曾接触过操作系统,但要精确定义操作系统却颇为困难。问题的一部分在于,操作系统执行两个基本不相关的功能:扩展机器和管理资源。不同的讨论者会更多地强调其中一个功能。让我们现在来探讨这两个功能。

操作系统作为扩展机器

如前所述,大多数计算机在机器语言层面上的体系结构(指令集、内存组织、输入/输出和总线结构)较为原始且编程复杂,尤其是对于输入/输出操作。为了更具体地说明这一点,我们简要看看如何使用许多基于英特尔的个人计算机上常见的NEC PD765兼容控制器芯片进行软盘输入/输出操作。(在本书中,我们将交替使用“软盘”和“磁盘”这两个术语。)PD765有16条命令,每条命令通过向设备寄存器加载1到9个字节来指定。这些命令用于读写数据、移动磁盘臂、格式化磁道,以及初始化、检测、复位和校准控制器及驱动器。

最基本的命令是读和写,每条命令需要13个参数,这些参数被压缩到9个字节中。这些参数指定了诸如要读取的磁盘块地址、每磁道扇区数、物理介质上使用的记录模式、扇区间隙间距,以及如何处理删除数据地址标记等内容。如果您不理解这些术语,不用担心;这正是问题的关键——这些内容相当深奥。操作完成后,控制器芯片会返回23个状态和错误字段,压缩到7个字节中。不仅如此,软盘程序员还必须时刻注意电机是开启还是关闭。如果电机关闭,必须先开启(伴随较长的启动延迟)才能进行数据的读写。然而,电机不能长时间保持开启,否则软盘会磨损失效。因此,程序员不得不面对启动延迟长与软盘磨损(以及数据丢失)之间的权衡。

无需深入细节,显然普通程序员可能并不希望过于深入地参与软盘(或同样复杂且完全不同的硬盘)的编程。相反,程序员想要的是一个简单的高级抽象来处理这些问题。以磁盘为例,典型的抽象是磁盘包含一组命名的文件。每个文件可以被打开进行读或写操作,然后进行读写,最后关闭。诸如是否应使用修正频率调制记录以及电机当前状态等细节,不应出现在呈现给用户的抽象中。

隐藏硬件真相并为程序员呈现一个简单、易用的命名文件读写视图的程序,毫无疑问就是操作系统。正如操作系统屏蔽了磁盘硬件的复杂性并提供了一个简单的面向文件的接口,它同样隐藏了有关中断、定时器、内存管理以及其他低级功能的诸多繁琐细节。在每种情况下,操作系统提供的抽象都比底层硬件提供的抽象更加简单且易于使用。

从这个角度看,操作系统的功能是为用户提供一个等效的扩展机器或虚拟机器,这一虚拟机器比底层硬件更容易编程。操作系统如何实现这一目标是一个复杂的过程,我们将在本书中详细研究。简而言之,操作系统通过提供一系列服务来实现这一目标,程序可以通过称为系统调用的特殊指令来获取这些服务。我们将在本章稍后探讨一些常见的系统调用。

操作系统作为资源管理器

将操作系统视为主要为用户提供便捷接口的概念是一种自顶向下的观点。另一种自底向上的观点认为,操作系统的存在是为了管理复杂系统的各个组成部分。现代计算机包括处理器、内存、定时器、磁盘、鼠标、网络接口、打印机以及各种其他设备。在这种观点下,操作系统的任务是为竞争这些资源的各个程序提供处理器、内存和输入/输出设备的有序且可控的分配。

想象一下,如果一台计算机上运行的三个程序同时尝试在同一台打印机上打印输出,会发生什么。打印输出的前几行可能来自程序1,接下来的几行来自程序2,然后是程序3的几行,依此类推。结果将是一片混乱。操作系统可以通过将所有发送到打印机的输出缓冲到磁盘上来避免这种潜在的混乱。当一个程序完成时,操作系统可以将存储在磁盘文件中的输出复制到打印机,同时其他程序可以继续生成更多输出,而无需察觉输出尚未真正发送到打印机。

当计算机(或网络)有多个用户时,管理和保护内存、输入/输出设备及其他资源的需求变得更加重要,因为用户之间可能会相互干扰。此外,用户通常不仅需要共享硬件,还需要共享信息(文件、数据库等)。简而言之,这种观点认为操作系统的主要任务是跟踪哪些用户正在使用哪些资源、授予资源请求、记录使用情况,以及调解来自不同程序和用户的冲突请求。

资源管理包括以两种方式多路复用(共享)资源:时间多路复用和空间多路复用。在时间多路复用中,不同的程序或用户轮流使用资源。第一个程序或用户先使用资源,然后是另一个,依此类推。例如,当只有一个CPU而多个程序希望运行时,操作系统首先将CPU分配给一个程序,在其运行足够长时间后,另一个程序获得CPU使用权,然后是下一个程序,最终可能再次轮到第一个程序。确定资源如何进行时间多路复用——下一个是谁以及使用多长时间——是操作系统的任务。另一个时间多路复用的例子是共享打印机。当多个打印作业在单一打印机上排队等待打印时,必须决定下一个打印哪个作业。

另一种多路复用方式是空间多路复用。在这种情况下,不是用户轮流使用资源,而是每个用户获得资源的一部分。例如,主内存通常被分配给多个运行中的程序,这样它们可以同时驻留在内存中(例如,以便轮流使用CPU)。假设内存足以容纳多个程序,同时在内存中保存多个程序比将全部内存分配给一个程序更高效,尤其是当某个程序只需要总内存的一小部分时。当然,这会引发公平性、保护等问题,而这些问题的解决依赖于操作系统。另一个进行空间多路复用的资源是(硬)磁盘。在许多系统中,单个磁盘可以同时存储来自多个用户的文件。分配磁盘空间并跟踪谁在使用哪些磁盘块是操作系统典型的资源管理任务。

操作系统历史

操作系统多年来不断发展。在接下来的章节中,我们将简要回顾一些重要的里程碑。由于操作系统历史上与它们运行的计算机体系结构密切相关,我们将按计算机的代际发展来审视相应的操作系统。这种将操作系统代际与计算机代际对应的方式较为粗略,但它为原本杂乱无章的内容提供了一些结构。

第一台真正的数字计算机由英国数学家查尔斯·巴贝奇(Charles Babbage,1792-1871)设计。尽管巴贝奇耗费了毕生精力与财富试图建造他的“分析机”,但由于它完全是机械结构,且当时的技术无法生产出他所需的高精度轮子、齿轮和齿牙,因此从未成功运行。不用说,分析机并没有操作系统。

作为一个有趣的历史插曲,巴贝奇意识到他的分析机需要软件,于是聘请了一位名叫阿达·洛夫莱斯(Ada Lovelace)的年轻女性作为世界上第一位程序员。她是英国著名诗人拜伦勋爵的女儿。编程语言Ada®就是以她的名字命名的。

第一代(1945-1955):真空管与接线板

在巴贝奇的失败尝试之后,直到第二次世界大战期间,数字计算机的构建才取得进展。1940年代中期,哈佛大学的霍华德·艾肯(Howard Aiken)、普林斯顿高等研究院的约翰·冯·诺伊曼(John von Neumann)、宾夕法尼亚大学的J·普雷斯珀·埃克特(J. Presper Eckert)和约翰·莫奇利(John Mauchley),以及德国的康拉德·楚泽(Konrad Zuse)等人,成功建造了计算引擎。最初的机器使用机械继电器,运行非常缓慢,周期时间以秒为单位。继电器后来被真空管取代。这些机器体积庞大,占据整个房间,包含数万个真空管,但其速度仍比当今最便宜的个人计算机慢数百万倍。

在早期,每台机器都由一个团队设计、建造、编程、操作和维护。所有编程都使用绝对机器语言,通常通过接线板连接来控制机器的基本功能。编程语言尚不存在(甚至连汇编语言也没有)。操作系统更是闻所未闻。通常的操作模式是程序员在墙上的登记表上预约一段时间,然后来到机房,将自己的接线板插入计算机,接下来的几个小时里祈祷约20,000个真空管在运行期间不会烧毁。几乎所有问题都是直接的数值计算,例如生成正弦、余弦和对数表。

到1950年代初,情况略有改善,引入了打孔卡片。现在可以编写程序到卡片上并读取它们,而无需使用接线板;但其他程序仍然相同。

第二代(1955-1965):晶体管与批处理系统

1950年代中期晶体管的引入彻底改变了局面。计算机变得足够可靠,可以制造并出售给付费客户,并预期它们能够持续运行足够长时间以完成一些有用的工作。首次出现了设计师、建造者、操作员、程序员和维护人员之间的明确分工。

这些机器,现在称为大型机(mainframes),被锁在专门配备空调的计算机房内,由经过特殊培训的专业操作员团队运行。只有大公司、主要政府机构或大学才能负担得起它们数百万美元的价格。要运行一个作业(即一个程序或一组程序),程序员首先在纸上编写程序(使用FORTRAN或可能是汇编语言),然后将其打在卡片上。他会将卡片组带到输入室,交给操作员,然后去喝咖啡,直到输出准备好。

当计算机完成当前运行的作业时,操作员会走到打印机旁,撕下输出并将其带到输出室,以便程序员稍后领取。然后,他会从输入室取来一组卡片并读取。如果需要FORTRAN编译器,操作员必须从文件柜中取出并读取。操作员在机房内走动时,浪费了许多计算机时间。

鉴于设备的高成本,人们很快开始寻找减少浪费时间的方法。普遍采用的解决方案是批处理系统。其背后的想法是在输入室收集一大盘作业,然后使用一台小型(相对)便宜的计算机(如IBM 1401)将它们读取到磁带上。IBM 1401非常擅长读取卡片、复制磁带和打印输出,但完全不擅长数值计算。其他更昂贵的机器,如IBM 7094,则用于真正的计算工作。这一情况如图1-2所示。

image-20250425191842427

在收集一批作业大约一小时后,磁带会被倒带并带到机房,安装在磁带驱动器上。操作员随后加载一个特殊程序(即今日操作系统的雏形),该程序从磁带中读取第一个作业并运行。输出被写入第二个磁带,而不是直接打印。每个作业完成后,操作系统会自动从磁带读取下一个作业并开始运行。当整个批处理完成后,操作员会移除输入和输出磁带,将输入磁带替换为下一批作业,并将输出磁带带到IBM 1401进行离线(即不连接到主计算机)打印。

一个典型输入作业的结构如图1-3所示。它以$JOB卡开始,指定最长运行时间(以分钟为单位)、收费的账户编号和程序员的姓名。接着是$FORTRAN卡,指示操作系统从系统磁带加载FORTRAN编译器。随后是被编译的程序,然后是$LOAD卡,指示操作系统加载刚编译的目标程序。(编译后的程序通常被写入临时磁带,必须显式加载。)接下来是$RUN卡,告诉操作系统运行程序并使用其后的数据。最后,$END卡标记作业的结束。这些原始的控制卡是现代作业控制语言和命令解释器的前身。

image-20250425192003018

第二代大型计算机主要用于科学和工程计算,例如求解物理学和工程中常见的偏微分方程。它们大多使用FORTRAN和汇编语言编程。典型的操作系统包括FMS(FORTRAN监控系统)和IBSYS(IBM为7094开发的操作系统)。

第三代(1965-1980):集成电路与多道程序设计

到1960年代初,大多数计算机制造商拥有两条截然不同且完全不兼容的产品线。一方面是面向字的大型科学计算机,如7094,用于科学和工程中的数值计算;另一方面是面向字符的商用计算机,如1401,广泛用于银行和保险公司进行磁带排序和打印。

开发、维护和营销两条完全不同的产品线对计算机制造商来说是一项昂贵的任务。此外,许多新客户最初需要小型机器,但后来需求增加,希望获得与现有机器架构相同但速度更快的大型机器,以便运行所有旧程序。

IBM通过推出System/360试图一举解决这两个问题。System/360是一系列软件兼容的机器,从1401的规模到远超7094的性能。这些机器仅在价格和性能(最大内存、处理器速度、允许的输入/输出设备数量等)上有所不同。由于所有机器具有相同的架构和指令集,理论上为一台机器编写的程序可以在所有其他机器上运行。此外,System/360设计为能够处理科学(即数值)和商用计算。因此,一个机器系列可以满足所有客户的需求。在随后的几年中,IBM推出了采用更现代技术的兼容后继产品,包括370、4300、3080、3090和Z系列。

System/360是首个使用(小型)集成电路(ICs)的主要计算机系列,与第二代基于单个晶体管的机器相比,具有显著的性价比优势。它立即获得成功,兼容计算机系列的理念很快被其他主要制造商采纳。这些机器的后代至今仍在计算机中心使用。如今,它们常用于管理大型数据库(如航空公司预订系统)或作为处理每秒数千请求的万维网服务器。

“单一家族”理念的最大优势同时也是其最大弱点。目标是所有软件,包括操作系统OS/360,必须在所有型号上运行。它必须在小型系统(常用于替代1401进行卡片到磁带的复制)和大型系统(常替代7094进行天气预报等高强度计算)上运行。它必须在外围设备较少和较多的系统上表现良好,必须适应商用和科学环境。最重要的是,它必须对所有这些不同用途都高效。

IBM(或任何其他公司)无法编写一个软件来满足所有这些相互冲突的需求。结果是一个庞大且极其复杂的操作系统,比FMS大两到三个数量级。它包含数百万行由数千名程序员编写的汇编语言代码,包含数以万计的错误,需要不断发布新版本以尝试修复这些错误。每个新版本修复了一些错误但引入了新的错误,因此错误数量可能随时间保持恒定。

OS/360的设计者之一弗雷德·布鲁克斯(Fred Brooks)随后写了一本诙谐而深刻的书,描述了他与OS/360的经历(Brooks, 1995)。虽然无法在此总结该书,但书的封面显示一群史前巨兽陷在焦油坑中,足以说明问题。Silberschatz等人(2004)的封面同样将操作系统比喻为恐龙。

尽管 OS/360 系统体积庞大、问题不少,它和其他厂商推出的第三代操作系统,实际上还是在很大程度上满足了用户的需求。这些系统还引入了几个在第二代操作系统中没有的重要技术。其中最重要的可能就是 多道程序设计(multiprogramming)

在第二代的 IBM 7094 机器上,如果一个任务因为需要读写磁带或其他输入/输出设备而暂停时,CPU 就只能干等着,直到 I/O 操作完成。对于以计算为主的科学运算,这种等待不常见,因此 CPU 的空闲浪费不太严重;但在商业数据处理场景中,I/O 等待可能占据总时间的 80% 到 90%,这意味着昂贵的 CPU 很大一部分时间都处于“吃白饭”的状态,显然不能接受。

为了解决这个问题,操作系统引入了一种新的方法:把内存划分成多个区域(分区),每个区域运行一个不同的作业(job),如下图 1-4 所示。当一个作业在等待 I/O 时,另一个作业就可以使用 CPU。只要能同时在内存中“塞下”足够多的作业,CPU 就可以几乎一直都有事做,效率大大提高。不过,要想让多个作业同时安全地驻留在内存中,就需要特殊的硬件机制,防止作业之间相互干扰或“偷看”彼此的数据。IBM 的 360 系列以及其他第三代系统都配备了这种保护硬件。

image-20250502192216005

第三代操作系统的另一个重要特性是:可以在作业刚被送到计算机房时,就立刻从打孔卡片读入并存到磁盘上。这样一来,只要当前作业一运行完,操作系统就可以立即从磁盘中加载一个新的作业到空闲的内存分区中并开始运行。这个技术叫做 假脱机(spooling),全称是 Simultaneous Peripheral Operation On Line,意思是“外围设备的在线并行操作”。这个技术也用于输出。使用 spooling 后,以前用于输入和输出的 IBM 1401 小型机就不再需要了,磁带之间的来回搬运也大大减少。

尽管第三代操作系统非常适合大规模科学计算和商业数据处理,它们本质上仍是批处理系统。很多程序员怀念第一代时的“独占式”编程体验——那时他们可以连续几个小时独享整台机器,方便快速调试程序。而在第三代系统中,从提交作业到拿到输出结果,往往需要等待几个小时,一个小小的错误(比如一个逗号写错)都可能导致编译失败,程序员可能因此白白浪费大半天时间。

这种对快速响应的渴望促成了 分时系统(timesharing) 的诞生。这其实是多道程序设计的一种变体,它允许每个用户通过在线终端与计算机交互。在分时系统中,假设有 20 个用户登录,其中 17 个在思考、聊天或者喝咖啡,系统就可以把 CPU 轮流分配给那 3 个正在操作的用户。由于调试程序时一般只需要发出一些很短的命令(比如编译一段五页的代码),而不是执行那种像“排序一百万条记录”那样的大作业,计算机就能为多个用户提供快速、交互式的服务——同时还能在 CPU 空闲时,后台处理大型的批处理任务。第一个真正实用的分时系统是麻省理工学院(M.I.T.)开发的 CTSS(Compatible Time Sharing System),它运行在一台经过特殊改造的 IBM 7094 上(Corbató 等人,1962)。不过,直到第三代系统普及了所需的硬件保护机制后,分时才真正流行起来。

在 CTSS 系统取得成功之后,麻省理工学院(MIT)、贝尔实验室(Bell Labs)和通用电气公司(General Electric,当时是主要的计算机制造商之一)决定联手开发一个“计算机公共服务系统”(computer utility)——一种可以同时支持数百名用户分时使用的大型计算机。他们的灵感来源是电力系统:当你需要用电时,只需要把插头插入墙上的插座,就能获得几乎无限的电力。他们希望打造的系统就是这样的一种计算机服务,命名为 MULTICS(MULTiplexed Information and Computing Service,复用信息与计算服务),设想是有一台超大型的计算机,能为整个波士顿地区的用户提供计算能力。在当时,这个设想就像科幻小说一样夸张。因为谁也没想到,仅仅三十年后,计算能力远超 GE-645 主机的计算机将以不到一千美元的价格被大量售卖,甚至成为普通家庭的设备。就好像我们今天设想“超音速的跨大西洋海底列车”一样遥不可及。

MULTICS 的成果算是“喜忧参半”。它的目标是支持上百名用户,但所使用的机器仅比一台基于 Intel 80386 的个人电脑强一点,虽然 I/O 能力更强。当时的程序员习惯写简洁高效的代码,而不是现在这种资源消耗型的开发方式,因此这个目标并非完全不可实现。MULTICS 没能全面流行的原因有很多,其中之一就是它使用了 PL/I 语言 编写,而 PL/I 编译器不仅开发严重拖延,而且最终发布时几乎不可用。此外,MULTICS 的设计非常超前,几乎可以说是当时计算界的“巴贝奇分析机”——极富远见但实现困难。

尽管最终产品化很难,MULTICS 还是引入了许多开创性的思想,对计算机科学文献产生了深远影响。贝尔实验室后来退出了项目,通用电气也干脆退出了整个计算机行业。但 MIT 坚持了下来,并最终让 MULTICS 正式运作起来。MULTICS 最终被 GE 计算机业务的接手公司——霍尼韦尔(Honeywell) 商业化推出,并在全球大约 80 家主要公司和大学部署。虽然用户数量不多,但他们对 MULTICS 极为忠诚。例如,通用汽车(GM)、福特(Ford)以及美国国家安全局(NSA)直到 1990 年代末才关闭其 MULTICS 系统。加拿大国防部是最后一个关停 MULTICS 的用户,他们一直用到 2000 年 10 月。尽管 MULTICS 商业上并不成功,但它对后来的操作系统产生了巨大影响。关于 MULTICS 的研究文献非常丰富(如 Corbató 等,1972;Corbató 和 Vyssotsky,1965;Daley 和 Dennis,1968;Organick,1972;Saltzer,1974 等),目前仍然可以在官方网站 www.multicians.org 上找到大量关于该系统、其设计者和用户的信息。

如今,“计算机公共服务”(computer utility)这个词已经不常听到了,但这一理念近年来却获得了新的生命。在最简单的形式下,一个企业或教室中的个人电脑(PC)或工作站(高端PC)可以通过局域网(LAN, Local Area Network)连接到一个文件服务器上,这个服务器上存储着所有的程序和数据。这样,系统管理员只需要安装和维护一份软件和数据副本,就能轻松应对某台电脑出故障的情况,快速恢复系统,而不必担心数据丢失或恢复麻烦。在更复杂的异构网络环境中,发展出了一类被称为 中间件 (middleware)的软件,用来弥合本地用户和远程服务器之间的差距。中间件能让网络上的远程资源在用户看来就像本地资源一样,并提供一致的用户界面,尽管底层可能使用的是完全不同种类的服务器、PC 或工作站。万维网(World Wide Web) 就是一个例子。网页浏览器以统一的方式向用户展示内容,一个网页可能包括来自不同服务器的文字、图片,甚至样式都由另一个服务器上的样式表(style sheet)决定。如今,企业和大学经常使用网页界面来访问数据库或运行远程计算机上的程序,这些计算机可能位于不同的大楼,甚至不同的城市。中间件看起来就像是分布式系统的操作系统,但它本质上并不是操作系统,因此本书不会详细讨论。关于分布式系统的内容可以参考 Tanenbaum 和 Van Steen(2002)的书。

第三代计算机发展的另一大进展是 小型机(minicomputer) 的快速发展,始于 1961 年数字设备公司(DEC)推出的 PDP-1。这台机器只有 4K 的 18 位字内存,但售价仅为 12 万美元(还不到 IBM 7094 的 5%),因此非常受欢迎。对于某些非数值计算任务,它的速度几乎能与 7094 媲美,由此催生了一个全新的产业。随后,DEC 又推出了一系列 PDP 系列(与 IBM 不同,这些机器之间并不兼容),最终发展到 PDP-11

在贝尔实验室(Bell Labs)参与 MULTICS 项目的计算机科学家 Ken Thompson 后来发现有一台没人用的 PDP-7 小型机,于是他动手编写了一个简化版、仅支持单用户的 MULTICS 系统。这项工作后来发展成了 UNIX 操作系统。UNIX 在学术界、政府机构和许多公司中广泛传播。UNIX 的发展历史已经有专门著作详述(例如 Salus,1994)。由于 UNIX 的源代码广泛开放,不同机构纷纷开发了各自的不兼容版本,导致了混乱。最终形成了两个主要的 UNIX 分支:System V(AT&T 推出)和BSD(伯克利软件发行版,来自加州大学伯克利分校)。这两个分支还衍生出多个变种,包括现在常见的 FreeBSDOpenBSDNetBSD。为了让程序能在不同的 UNIX 系统上运行,IEEE 制定了一个标准,称为 POSIX,几乎所有 UNIX 系统都遵循该标准。POSIX 规定了 UNIX 系统必须支持的最小系统调用接口。实际上,其他一些非 UNIX 操作系统也开始支持 POSIX 接口。有关如何编写符合 POSIX 标准的软件的资料可以在相关书籍中找到(如 IEEE,1990;Lewine,1991),也可以在 www.unix.org 上查看 Open Group 的“单一 UNIX 规范(Single UNIX Specification)”。本章后面所提到的 UNIX,除非特别说明,通常都指遵循 POSIX 标准的所有这些系统。虽然它们内部实现不同,但对程序员来说,接口几乎一致,使用体验相似。

第四代(1980年至今):个人计算机

随着LSI(大规模集成,Large Scale Integration)电路的发展,芯片上包含成千上万的晶体管,微处理器时代的个人计算机诞生了。在架构方面,个人计算机(最初称为微型计算机,microcomputers)与PDP-11类的迷你计算机并没有太大区别,但在价格上却大不相同。迷你计算机使得公司或大学的某个部门可以拥有自己的计算机,而微型计算机则使得个人能够拥有自己的计算机。

有几类微型计算机。英特尔于1974年推出了8080,这是第一款通用8位微处理器。许多公司使用8080(或兼容的Zilog Z80)和由Digital Research公司推出的CP/M(微型计算机控制程序)操作系统,开发了完整的系统。许多应用程序是为CP/M编写的,并且它在个人计算机领域主导了大约5年。

摩托罗拉也生产了一款8位微处理器——6800。一组摩托罗拉工程师因摩托罗拉拒绝他们对6800的改进建议而离开,成立了MOS技术公司,制造了6502中央处理单元(CPU)。6502是多个早期系统的核心处理器之一。其之一的Apple II成为了CP/M系统在家庭和教育市场上的主要竞争者。然而,由于CP/M非常流行,许多Apple II的拥有者购买了Z-80协处理器附加卡,以便运行CP/M,因为6502 CPU与CP/M不兼容。这些CP/M卡由一个名为微软的小公司销售,微软还通过为运行CP/M的多台微型计算机提供BASIC解释器,获得了市场份额。

下一代微处理器是16位系统。英特尔推出了8086,而在1980年代初,IBM设计了基于英特尔8088(内部是8086,但外部数据路径为8位)的IBM PC。微软为IBM提供了一个包括BASIC和操作系统DOS(磁盘操作系统)的包,DOS最初由另一家公司开发——微软收购了该产品,并聘请了原作者进行改进。修订后的系统被更名为MS-DOS(微软磁盘操作系统),并迅速在IBM PC市场上占据主导地位。

CP/M、MS-DOS和Apple DOS都是命令行系统:用户需要在键盘上输入命令。多年之前,斯坦福研究所的Doug Engelbart发明了图形用户界面(GUI),包括窗口、图标、菜单和鼠标。苹果的史蒂夫·乔布斯看到了打造一个真正用户友好的个人计算机的可能性(适合那些对计算机一无所知并且不愿学习的人),并于1984年初宣布了Apple Macintosh的推出。Macintosh使用了摩托罗拉的16位68000 CPU,并且拥有64KB的只读存储器(ROM)以支持GUI。Macintosh在多年中不断发展,随后摩托罗拉的CPU变成了真正的32位系统,后来苹果转向了IBM的PowerPC CPU,采用RISC 32位(后来的64位)架构。2001年,苹果进行了一次重大的操作系统变更,发布了Mac OS X,将新的Macintosh GUI与伯克利UNIX结合在一起。2005年,苹果宣布将切换到英特尔处理器。

为了与Macintosh竞争,微软发明了Windows。最初,Windows仅仅是一个运行在16位MS-DOS上的图形环境(更像是一个外壳,而不是一个真正的操作系统)。然而,当前版本的Windows是Windows NT的后代,Windows NT是一个完整的32位系统,从零开始重写的。

个人计算机领域的另一个主要竞争者是UNIX(及其各种衍生版本)。UNIX在工作站和其他高端计算机中最为强大,比如网络服务器。它特别受到高性能RISC芯片驱动的机器的欢迎。在基于Pentium的计算机上,Linux正成为学生和越来越多的企业用户在Windows之外的流行替代品。(在本书中,我们将使用“Pentium”一词来指代整个Pentium家族,包括低端的Celeron、高端的Xeon,以及兼容的AMD微处理器)。

尽管许多UNIX用户,尤其是经验丰富的程序员,更倾向于使用基于命令的界面而非GUI,但几乎所有的UNIX系统都支持一个名为X Window系统的窗口系统,这个系统是在麻省理工学院(MIT)开发的。这个系统处理基本的窗口管理,允许用户使用鼠标创建、删除、移动和调整窗口大小。通常,像Motif这样的完整GUI系统也可以在X Window系统之上运行,为那些希望拥有类似Macintosh或Windows界面体验的UNIX用户提供帮助。

一个有趣的发展趋势是从1980年代中期开始,运行着计算机网络式操作系统分布式操作系统(Tanenbaum和Van Steen,2002)的个人计算机的兴起。在网络操作系统中,用户知道多个计算机的存在,并可以登录到远程机器并将文件从一台计算机复制到另一台计算机。每台计算机都运行自己的本地操作系统,并有自己的本地用户(或多个用户)。基本上,这些计算机是相互独立的。

网络操作系统与单处理器操作系统在本质上并无根本区别。它们显然需要一个网络接口控制器以及一些低级软件来驱动它,同时也需要程序来实现远程登录和远程文件访问,但这些附加功能并不改变操作系统的基本结构。

与此相对,分布式操作系统是指它对用户看起来像是传统的单处理器系统,尽管它实际上由多个处理器组成。用户不应该知道他们的程序在哪里运行或他们的文件在哪里存储;这些应该由操作系统自动有效地处理。

真正的分布式操作系统需要的不仅仅是向单处理器操作系统中添加一些代码,因为分布式系统和集中式系统在关键方面有很大的不同。例如,分布式系统通常允许应用程序在多个处理器上同时运行,这就要求使用更复杂的处理器调度算法,以优化并行度。

网络中的通信延迟往往意味着这些(以及其他)算法必须在不完整、过时甚至不正确的信息下运行。这种情况与单处理器系统完全不同,因为单处理器操作系统可以获取有关系统状态的完整信息。

MINIX 3的历史

当UNIX还年轻时(版本6),其源代码在AT&T的许可下广泛发布并经常被研究。澳大利亚新南威尔士大学的约翰·莱昂斯(John Lions)甚至写了一本小册子,逐行描述了它的操作(Lions,1996)。这本小册子得到了AT&T的许可,并在许多大学的操作系统课程中作为教材使用。

当AT&T发布版本7时,它开始意识到UNIX是一个有价值的商业产品,因此它以一种许可方式发布版本7,禁止在课程中研究源代码,以避免其作为商业机密的地位受到威胁。许多大学遵守了这一规定,干脆不再教授UNIX,改为只讲授理论。

不幸的是,单纯教授理论使得学生对操作系统的真实情况形成了偏颇的看法。课程和书籍中通常详细讲解的理论主题,如调度算法,在实践中并不那么重要。真正重要的主题,如I/O和文件系统,通常被忽视,因为关于它们的理论较少。

为了弥补这一情况,本书的作者之一(塔内鲍姆)决定从头开始编写一个新的操作系统,它从用户的角度兼容UNIX,但内部完全不同。通过不使用AT&T的任何一行代码,这个系统避免了许可限制,因此可以用于课堂或个人学习。这样,读者可以像生物学学生解剖青蛙一样,剖析一个真实的操作系统,看看它内部是如何工作的。

这个系统被称为MINIX,并于1987年发布,完整的源代码对任何人开放,供学习和修改。MINIX的名称代表mini-UNIX,因为它足够小,即使是非专家也能理解它是如何工作的。

除了消除法律问题的优势外,MINIX还相对于UNIX具有另一个优势。它是在UNIX发布十年后编写的,结构上更加模块化。例如,从MINIX的第一个版本开始,文件系统和内存管理器并不作为操作系统的一部分,而是作为用户程序运行。在当前版本(MINIX 3)中,这种模块化已扩展到I/O设备驱动程序,除了时钟驱动程序外,所有驱动程序都作为用户程序运行。另一个区别是,UNIX被设计为高效,而MINIX则被设计为可读(尽管可以说任何几百页长的程序都是可读的)。例如,MINIX的代码有成千上万的注释。

MINIX最初是为兼容版本7(V7)UNIX而设计的。选择版本7作为模型是因为它简洁而优雅。有时有人说版本7不仅在所有前代中是最好的改进,而且在所有后代中也是最好的改进。随着POSIX的出现,MINIX开始朝着新标准发展,同时保持与现有程序的向后兼容性。这种进化在计算机行业中是常见的,因为没有任何供应商愿意推出一个所有现有客户都无法使用的系统,除非进行大规模的变动。本书所描述的MINIX版本是基于POSIX标准的。

与UNIX一样,MINIX是用C编程语言编写的,旨在易于移植到各种计算机。最初的实现是为IBM PC设计的,随后MINIX被移植到其他多个平台。遵循“简洁即美”的哲学,MINIX最初甚至不需要硬盘就能运行(在1980年代中期,硬盘仍然是一种昂贵的新奇设备)。随着MINIX功能和体积的增长,它最终达到了需要硬盘才能运行的程度,但按照MINIX的哲学,200MB的分区就足够了(对于嵌入式应用,甚至不需要硬盘)。相比之下,即使是小型的Linux系统也需要500MB的磁盘空间,并且安装常用应用程序需要几个GB的空间。

对于坐在IBM PC前的普通用户来说,运行MINIX就像运行UNIX一样。所有基本的程序,如cat、grep、ls、make和shell,都存在并执行与它们的UNIX同类程序相同的功能。像操作系统本身一样,所有这些实用程序都完全从头开始重写,由作者、他的学生和其他一些热心人士编写,没有使用AT&T或其他专有代码。目前已经有许多可以自由分发的程序,并且许多程序已经成功地在MINIX上重新编译。

MINIX在发展了十年后,MINIX 2于1997年发布,并且与本书的第二版一起发布,描述了新版本的特性。

MINIX从第1版到第2版经历了较大的变化(例如,从在8088处理器上以16位实模式运行、使用软盘启动,过渡到在386处理器上以32位保护模式运行、使用硬盘),虽然变化显著,但属于逐步演进的过程。

开发在之后缓慢但有条不紊地进行,直到2004年,Tanenbaum 认为当今软件变得越来越臃肿、不可靠,于是决定重新拾起当时已经稍显沉寂的 MINIX 项目。他与阿姆斯特丹自由大学的学生和程序员合作,推出了 MINIX 3——对系统进行了一次重大重构,彻底改写内核,显著缩小其体积,并强调模块化和可靠性。这个新版本既面向个人电脑,也面向嵌入式系统,这两个领域都对紧凑性、模块性和稳定性有极高的要求。虽然小组中有些人主张给这个系统起一个全新的名字,但最终还是决定继续使用“MINIX”,称为 MINIX 3,因为这个名字已经有了一定的知名度。类比一下,当苹果公司放弃自家的操作系统 Mac OS 9,转而采用基于 Berkeley UNIX 的新系统时,仍然沿用了 Mac OS 的名字,称其为 Mac OS X,而不是另起炉灶叫个比如 APPLIX 的名字。Windows 系统的发展过程中也经历了类似的根本性改变,但名字始终没有改变。

MINIX 3 的内核代码行数不到 4000 行,而 Windows、Linux、FreeBSD 等主流操作系统的可执行内核代码行数则以百万计。内核尽可能小非常重要,因为内核中的错误比用户态程序中的错误更致命;代码越多,潜在的错误也越多。一项严谨的研究表明,每1000行可执行代码中,通常能发现6到16个已报告的错误(Basili 和 Perricone, 1984)。实际上,错误数量可能远高于此,因为研究者只能统计已经被报告的错误,无法统计未被发现的。另一项研究(Ostrand 等, 2004)指出,即使一个软件已经发布了十多个版本,平均仍有6%的文件包含后来被报告的错误,并且错误率在一定程度上趋于稳定,而不是无限接近于零。这一结论也得到了另一个实验的支持:研究者使用一个非常简单的自动化模型检测工具扫描稳定版的 Linux 和 OpenBSD 内核,结果发现了数百个内核漏洞,大多数集中在设备驱动中(Chou 等, 2001;Engler 等, 2001)。这也是 MINIX 3 将设备驱动移出内核的原因——将它们放在用户态可以大大减少潜在的破坏。

在本书中,我们将以 MINIX 3 作为讲解示例。不过,需要注意的是,尽管我们谈论的是 MINIX 3 的系统调用,所说的大部分内容也适用于其他 UNIX 系统,这点在阅读时要牢记。

关于 Linux 与 MINIX 的关系,也许有些读者会感兴趣。MINIX 发布后不久,一个名为 comp.os.minix 的 USENET 新闻组成立了,用于讨论 MINIX。短短几周内,这个新闻组的订阅人数就达到了4万。大多数用户希望为 MINIX 添加大量新功能,以让它变得更强大(或者至少更庞大)。每天都有数百人发表建议、提出想法,甚至直接发布源代码片段。MINIX 的作者 Tanenbaum 在面对这股潮流时坚持了数年,努力保持系统的简洁与清晰,便于学生理解,同时确保其体积足够小,能够运行在学生们买得起的电脑上。对于那些不满 MS-DOS 的人来说,拥有完整源代码的 MINIX 是一个值得购买 PC 的理由。

其中一个人就是芬兰的学生 Linus Torvalds。他在新买的 PC 上安装了 MINIX,并仔细研究了它的源码。Torvalds 想在自己电脑上阅读 USENET 新闻组(例如 comp.os.minix),而不是非得在学校上网,但 MINIX 缺少他所需的某些功能,于是他自己写了一个程序来实现。不过很快他发现还需要一个不同的终端驱动程序,于是又自己写了一个。然后他想把帖子下载并保存起来,于是他写了磁盘驱动,接着又写了文件系统。到了 1991 年 8 月,他已经写出了一个简易的内核。1991 年 8 月 25 日,他在 comp.os.minix 上发布了公告。这个公告吸引了其他人加入开发,最终在 1994 年 3 月 13 日,Linux 1.0 正式发布。Linux 就这样诞生了。

Linux 成为了开源运动的一个重要成果(而 MINIX 在早期也促成了开源精神的传播)。Linux 在许多应用场景中已开始挑战 UNIX(和 Windows),部分原因是现在普通 PC 的性能已经能与早年那些专用 RISC 系统相媲美,而后者曾是许多 UNIX 系统的硬性要求。其他开源软件,如 Apache 网页服务器和 MySQL 数据库,也在商业领域与 Linux 配合良好。Linux、Apache、MySQL,以及开源的 Perl 和 PHP 编程语言常常一起被用在网站服务器中,这一组合被称为 LAMP(Linux、Apache、MySQL、Perl/PHP 的首字母缩写)。想进一步了解 Linux 和开源软件的历史,可以参考 DiBona 等人(1999)、Moody(2001)以及 Naughton(2000)的著作。

操作系统的基本概念

操作系统与用户程序之间的接口是由一组“扩展指令”来定义的,这些扩展指令由操作系统提供。传统上,这些指令被称为“系统调用”(system calls),尽管它们可以通过多种方式来实现。

要真正理解操作系统的工作原理,我们必须深入研究这个接口。不同操作系统所提供的系统调用各不相同(尽管底层的原理往往是相似的)。

因此,我们在讲解时面临一个选择:

  1. 要么只谈一些模糊的共性(比如“操作系统提供了读取文件的系统调用”);
  2. 要么深入到某个具体系统中讲解(比如“MINIX 3 有一个 read 系统调用,它接受三个参数:一个指定文件、一个表示数据放置位置、一个表示读取的字节数”)。

我们选择了后者。虽然这种方式更费力,但却能更深入地揭示操作系统究竟是如何运作的。在第 1.4 节中,我们将重点讲解 UNIX(包括多个 BSD 版本)、Linux 和 MINIX 3 中的基础系统调用。为了简化表述,我们主要以 MINIX 3 为例来说明,但需要说明的是,这些系统调用在 UNIX 和 Linux 中通常也有对应版本,它们多数基于 POSIX 标准。不过,在正式介绍这些系统调用之前,我们先从宏观上了解一下 MINIX 3 的整体结构,以帮助我们更清楚地认识操作系统的基本职责。这种总体结构同样适用于 UNIX 和 Linux。

MINIX 3 的系统调用大致可以分为两个主要类别:一类是与进程管理有关的,另一类是与文件系统操作有关的。接下来,我们将分别对这两类系统调用进行详细介绍。

进程

在 MINIX 3 以及所有操作系统中,一个关键的概念就是进程。进程本质上是一个正在执行的程序。 每个进程都关联着它的地址空间,也就是一段内存地址的列表,从某个最小值(通常是 0)到某个最大值,进程可以对这些地址进行读写。这个地址空间包含了可执行程序、程序的数据以及其栈(stack)。 每个进程还关联着一组寄存器(registers),包括程序计数器(program counter)、栈指针(stack pointer)和其他硬件寄存器,以及运行该程序所需的所有其他信息。

我们将在第 2 章中更详细地讲解进程的概念,但就目前而言,理解进程最直观的方式是结合多道程序系统(multiprogramming systems)来思考。操作系统会定期决定暂停当前运行的进程并切换到另一个进程。例如,因为前一个进程在过去的一秒中已经使用了过多的 CPU 时间。

当一个进程被临时挂起时,之后必须能以完全相同的状态恢复。这就意味着,在挂起期间,关于这个进程的所有信息都必须被显式保存。举例来说,一个进程可能同时打开了多个文件进行读取。每个文件都关联着一个指针,用于指示当前读取的位置(即将读取的下一个字节或记录的编号)。当进程被挂起时,所有这些指针必须被保存,以便在进程恢复后继续正确读取数据。在许多操作系统中,除了进程自己的地址空间之外,进程的所有信息都会被保存在一个被称为“进程表(process table)”的操作系统表中。这个表是一个结构体数组(或链表),每个结构体对应一个当前存在的进程。

因此,一个(被挂起的)进程由两个部分组成:它的地址空间,通常被称为“核心映像(core image)”,这个术语源于早期使用磁芯存储器(magnetic core memory)的时代;它在进程表中的表项,其中包含寄存器状态等其他信息。进程管理中最重要的系统调用是创建和终止进程的调用。举个典型的例子:

一个被称为命令解释器(command interpreter)或 shell 的进程从终端读取用户命令。假设用户刚刚输入了一条编译程序的命令,那么 shell 就需要创建一个新的进程来运行编译器。当该编译进程完成任务后,它会执行一个系统调用来自行终止。

在 Windows 和其他图形用户界面(GUI)操作系统中,(双击)桌面图标来启动程序的方式和在命令行中输入命令本质上是一样的。尽管我们不会详细讨论 GUI,其实 GUI 也只是一个简单的命令解释器。

如果一个进程可以创建一个或多个其他进程(通常称为子进程,childprocesses),而这些子进程又可以创建自己的子进程,我们就会很快形成一个进程树(process tree)的结构,如图 1-5 所示。

image-20250502195225330

当一些相关进程协同完成某项任务时,它们通常需要彼此通信并进行同步。这种通信称为进程间通信(interprocess communication,IPC),我们将在第 2 章中详细讲解这一内容。

除了用于创建和终止进程的系统调用外,操作系统还提供了其他一些进程系统调用,例如请求分配更多内存(或释放未使用的内存)、等待子进程终止、以及将当前进程的程序覆盖为另一个程序。

有时候,需要向一个正在运行但并未主动等待输入的进程传递信息。例如,一个进程正在通过网络与另一台计算机上的进程通信。为了防止消息或其响应丢失,发送方可能会请求操作系统在指定时间后发出通知,以便在未收到确认的情况下重新发送消息。设置这个定时器之后,该程序就可以继续执行其他工作。

当指定的秒数过去后,操作系统会向该进程发送一个警报信号(alarm signal)。这个信号会让进程暂时中断当前操作,将寄存器内容保存在栈中,然后启动一个专用的信号处理程序,例如用来重新发送可能丢失的消息。等信号处理程序执行完毕后,进程会从中断前的状态恢复并继续运行。信号是软件中断的模拟机制。除了定时器到期之外,还有多种情况可以触发信号。例如:执行了非法指令,或者使用了无效的内存地址,这些由硬件检测到的陷阱通常也会被转换为信号,并发送给引发错误的进程。

每个被授权使用 MINIX 3 系统的人,都会由系统管理员分配一个 UID(用户标识符,User Identification)。任何被启动的进程都会带有其创建者的 UID。子进程继承其父进程的 UID。用户还可以属于一个或多个组,每个组有一个 GID(组标识符,Group Identification)。

有一个特殊的 UID,被称为超级用户(superuser,在 UNIX 中),拥有特别权限,可以绕过许多系统保护规则。在大型系统中,只有系统管理员知道成为超级用户所需的密码。但许多普通用户(尤其是学生)会投入大量精力尝试寻找系统漏洞,以便在无需密码的情况下获得超级用户权限。

我们将在第 2 章中详细学习进程、进程间通信以及相关问题。

文件

系统调用的另一大类与文件系统相关。如前所述,操作系统的一个主要功能是屏蔽磁盘和其他 I/O 设备的细节,为程序员提供一个简洁、干净、与设备无关的抽象文件模型。显然,需要系统调用来创建文件、删除文件、读取文件和写入文件。在读取文件之前,必须先打开文件;读取完后,应该关闭它,因此系统调用还包括打开和关闭文件的功能。

为了存储文件,MINIX 3 引入了目录的概念,以此将文件进行分组。例如,一个学生可能会为他修的每一门课程创建一个目录(用于保存该课程的程序),另一个目录用来保存电子邮件,还有一个目录用于保存其网页主页。因此,需要系统调用来创建和删除目录。还提供了将现有文件放入目录以及从目录中删除文件的调用。目录项可以是文件,也可以是其他目录。这种模型形成了一个层次结构——即文件系统,如图 1-6 所示。

进程层次结构和文件层次结构都被组织为树形结构,但相似之处仅止于此。进程层次结构通常不会很深(超过三级就很少见),而文件层次结构通常有四级、五级,甚至更多级别。进程层次结构的寿命通常很短,一般只有几分钟,而目录层次结构可以存在多年。进程和文件在所有权和保护机制上也有所不同。通常,只有父进程可以控制或访问子进程,而文件和目录则几乎总是允许比所有者更广泛的用户组访问。

在目录层次结构中,每个文件都可以通过从根目录开始的路径名来唯一标识。这样的绝对路径名由从根目录开始、通向目标文件所经过的目录名称构成,目录名称之间用斜杠(/)分隔。如图 1-6 所示,文件 CS101 的路径名是 /Faculty/Prof.Brown/Courses/CS101。开头的斜杠表示该路径是绝对路径,即从根目录开始。在 Windows 中,路径分隔符使用反斜杠(\)而非正斜杠(/),所以该路径在 Windows 中应写作 \Faculty\Prof.Brown\Courses\CS101。本书中将统一使用 UNIX 的路径表示方法。

image-20250502200313289

在任何时刻,每个进程都有一个当前工作目录。在该目录中查找不以斜杠开头的路径名。例如,在图 1-6 中,如果当前工作目录是 /Faculty/Prof.Brown,那么路径名 Courses/CS101 与绝对路径名 /Faculty/Prof.Brown/Courses/CS101 所指的文件相同。进程可以通过系统调用更改其当前工作目录。

在 MINIX 3 中,文件和目录通过一个 11 位的二进制保护码来实现访问保护。该保护码由三个 3 位字段构成:分别对应所有者、所属用户组成员(系统管理员将用户分组)、其他用户;剩下的 2 位稍后讨论。每个字段分别表示读(r)、写(w)、执行(x)权限。例如,保护码 rwxr-x--x 表示所有者可以读、写和执行该文件,组内其他用户可以读和执行(但不能写),其他人只能执行(不能读或写)。对于目录而言,x 表示有搜索权限。横线(-)表示相应权限不存在(位为 0)。

文件在读取或写入之前,必须先被打开,此时系统会检查权限。如果允许访问,系统将返回一个称为“文件描述符”的小整数,供后续操作使用;如果不允许访问,则返回错误码 -1。

MINIX 3 中的另一个重要概念是“挂载文件系统”。几乎所有个人计算机都有一个或多个 CD-ROM 驱动器,用户可以插入或弹出 CD-ROM。为了以一种简洁的方式支持可移动介质(如 CD-ROM、DVD、软盘、Zip 驱动器等),MINIX 3 允许将 CD-ROM 上的文件系统挂载到主文件树上。如图 1-7(a) 所示,挂载调用之前,硬盘上的根文件系统和 CD-ROM 上的文件系统是独立的。然而,CD-ROM 文件系统尚不可访问,因为没有办法为它指定路径名。MINIX 3 不允许路径名前缀使用驱动器名或编号;那种做法正是操作系统试图消除的设备依赖性。

image-20250502200734570

通过 mount 系统调用,程序可以将 CD-ROM 文件系统挂载到主文件系统中的任意目录位置。如图 1-7(b) 所示,驱动器 0 上的文件系统被挂载到了目录 b 上,从而允许访问文件 /b/x/b/y。如果目录 b 原本包含文件,那么在挂载期间这些文件将不可访问,因为 /b 此时对应的是驱动器 0 的根目录。(这并不像乍看之下那样严重:文件系统通常被挂载到空目录上。)如果一个系统有多个硬盘,也可以将它们全部挂载到同一个树形结构中。

MINIX 3 的另一个重要概念是“特殊文件”。为了使 I/O 设备看起来像普通文件,系统提供了特殊文件,这样可以使用和文件读取写入相同的系统调用来读写设备。特殊文件有两类:块特殊文件和字符特殊文件。块特殊文件通常用于模拟由可随机寻址的数据块组成的设备,比如磁盘。打开块特殊文件后,程序可以直接访问设备上的某个块,比如第 4 块,而不考虑其上文件系统的结构。

字符特殊文件用于模拟接受或输出字符流的设备,比如打印机、调制解调器等。按照惯例,所有特殊文件都放在 /dev 目录下,例如 /dev/lp 可能对应于行式打印机。

我们要讨论的最后一个特性同时涉及进程和文件:管道(pipe)。管道是一种伪文件,用于连接两个进程,如图 1-8 所示。如果进程 A 和 B 想通过管道通信,必须事先建立它。当进程 A 想发送数据给进程 B 时,它向管道写数据,就像写文件一样;进程 B 通过读取该管道就能接收数据,就像读文件一样。因此,在 MINIX 3 中,进程之间的通信看起来和普通的文件读写几乎没有区别。甚至更强的一点是:除非显式发出特殊系统调用,否则进程无法察觉其正在写入的“文件”实际上是一个管道。

image-20250502200759444

Shell(壳)

操作系统(operating system)是执行系统调用(system calls)的那部分代码。像文本编辑器(editors)、编译器(compilers)、汇编器(assemblers)、链接器(linkers)和命令解释器(command interpreters)虽然都非常重要和实用,但它们并不属于操作系统的组成部分。虽然这样说可能有点让人混淆,但本节我们还是会简要介绍 MINIX 3 的命令解释器,叫做 Shell。虽然 Shell 不是操作系统的一部分,但它大量使用了操作系统提供的功能,因此是一个很好的例子,展示了系统调用可以怎样使用。Shell 也是用户在终端(terminal)上与操作系统交互的主要界面,除非用户使用的是图形用户界面(graphical user interface)。目前存在很多种 Shell,比如 cshkshzshbash,它们都支持下面将要介绍的功能,这些功能最初来源于最早的 Shell——sh

当任何用户登录系统时,Shell(命令解释器)就会被启动。此时,Shell 会把终端(terminal)作为标准输入(standard input)和标准输出(standard output)设备。Shell 启动后,首先会显示一个提示符(prompt),比如一个美元符号 $,表示它正在等待用户输入命令。

例如,如果用户此时输入:

date

Shell 会创建一个子进程(child process),并在该子进程中运行 date 这个程序。当子进程正在运行时,Shell 会处于等待状态,直到该子进程结束(terminate)。当子进程执行完毕后,Shell 会再次显示提示符,并准备读取下一行输入。

用户可以指定将标准输出(standard output)重定向到一个文件,例如:

date > file

这表示将 date 命令的输出写入到名为 file 的文件中,而不是显示在屏幕上。

类似地,也可以进行标准输入(standard input)的重定向,例如:

sort < file1 > file2

这表示从 file1 读取输入数据,经过 sort 命令处理后,将排序结果写入到 file2 文件中。

一个程序的输出可以通过管道(pipe)连接,作为另一个程序的输入。例如,下面这个命令:

cat file1 file2 file3 | sort > /dev/lp

调用 cat 程序把 file1file2file3 这三个文件的内容合并,然后通过管道 | 把结果传递给 sort 命令,对所有行进行按字母顺序排序。排序后的输出被重定向到 /dev/lp 设备文件,通常代表的是打印机。

如果用户在命令末尾加上一个和号(ampersand)&,Shell 就不会等待这个命令执行完成,而是立即返回提示符(prompt),让用户继续输入其他命令。例如:

cat file1 file2 file3 | sort > /dev/lp &

这条命令会把排序操作作为一个后台任务(background job)运行,用户此时可以正常继续其他工作,而不必等待排序完成。Shell 还有许多其他有趣的功能,但由于篇幅限制,这里不再展开。对于想深入了解系统使用方法的 MINIX 3 用户来说,大多数入门级 UNIX 书籍都非常有帮助,例如 Ray and Ray(2003)和 Herborth(2005)。

系统调用

在我们已经大致了解了 MINIX 3 如何处理进程(process)和文件(file)之后,现在可以开始了解操作系统与应用程序之间的接口,也就是一组称为系统调用(system calls)的机制。尽管我们接下来的讨论是基于 POSIX(国际标准 9945-1),因此适用于 MINIX 3、UNIX 和 Linux,但大多数现代操作系统也都有执行类似功能的系统调用,尽管具体实现细节可能不同。由于发起系统调用的实际机制高度依赖于底层硬件平台(machine dependent),而且通常需要用汇编语言(assembly code)来实现,因此操作系统会提供一个过程库(procedure library),使得开发者可以用 C 语言来调用这些系统功能。

需要记住的一点是:任何单核(single-CPU)计算机一次只能执行一条指令。当一个进程(process)正在以用户模式(user mode运行某个用户程序,并且需要使用某项系统服务(比如读取文件中的数据)时,它必须执行一个陷入指令(trap instruction)系统调用(system call),将控制权交给操作系统。接下来,操作系统会通过检查参数,搞清楚这个进程到底请求了什么服务。然后,它会执行相应的系统调用,完成服务之后,再把控制权返回给用户程序中紧接着系统调用的那条指令。从某种意义上说,系统调用就像是一种特殊的过程调用(procedure call),但不同之处在于:系统调用会进入内核(kernel)或其他具有特权的操作系统部分,而普通的过程调用不会。

为了让系统调用(system call)机制更清晰,我们来看一个简单的例子 —— read 系统调用。这个调用有三个参数:第一个参数指定要读取的文件;第二个参数指定用于存放数据的缓冲区(buffer);第三个参数指定要读取的字节数(number of bytes)。在 C 语言程序中,对 read 的调用可能是这样的:

count = read(fd, buffer, nbytes)

这个系统调用(以及库函数)会返回实际读取的字节数,存储在 count 变量中。这个返回值通常与请求读取的字节数 nbytes 相同,但在某些情况下(例如,读取时遇到文件结束符(end-of-file, EOF))可能会更小。

如果系统调用无法执行,可能是由于无效的参数或磁盘错误,count 会被设置为 -1,同时错误码会被放入全局变量 errno 中。因此,程序应该始终检查系统调用的返回结果,以确认是否发生了错误。

MINIX 3 总共有 53 个主要系统调用。这些系统调用如图 1-9 所示,为了方便起见,按六个类别进行了分组。虽然还有一些其他的系统调用,但它们用途非常专业,因此我们在这里不讨论。在接下来的章节中,我们将简要地检查图 1-9 中的每个系统调用,了解它们的功能。很大程度上,这些系统调用提供的服务决定了操作系统的大部分工作内容,因为个人计算机上的资源管理相对简单(至少与拥有多个用户的大型机器相比)。

在这里值得指出的是,POSIX(国际标准 9945-1)过程调用与系统调用之间的映射不一定是一对一的。POSIX 标准指定了一个合规系统必须提供的一系列过程,但它并没有明确规定这些过程是系统调用、库调用,还是其他类型的调用。在一些情况下,MINIX 3 通过库函数来支持 POSIX 过程。在其他情况下,多个必需的过程只是彼此之间的细微变化,一个系统调用就能处理所有这些过程。

用于进程管理的系统调用

图 1-9 中的第一组系统调用(system calls)涉及进程管理(process management)。我们从 fork 开始讲解。在 MINIX 3 中,fork创建新进程的唯一方式。它会创建出当前进程的一个完全拷贝(exact duplicate),包括所有的文件描述符(file descriptors)寄存器(registers)等等。在调用 fork 之后,原始进程和它的副本(也就是父进程(parent)**和**子进程(child))就会分开运行。此时它们所有的变量值都是相同的,但由于子进程的数据是从父进程中复制出来的,所以之后一方对变量所做的更改不会影响另一方。(程序代码部分是不可更改的(immutable),因此父子进程是共享这部分代码的。)fork 调用会返回一个值:在子进程中返回的是 0,而在父进程中返回的是 子进程的进程标识符(process identifier,PID)。这样,两个进程就可以根据返回值来判断自己是父进程还是子进程。

image-20250510160045146

在大多数情况下,执行完 fork(创建子进程)之后,子进程需要执行与父进程不同的代码。比如在 shell(命令行解释器)中,它从终端读取一个命令,调用 fork 产生一个子进程,然后等待这个子进程执行命令,子进程结束后再读取下一个命令。为了等待子进程结束,父进程会调用 waitpid 系统调用。这个调用会阻塞,直到子进程终止(如果有多个子进程,它也可以等待其中任意一个)。通过将 waitpid 的第一个参数设置为 -1,可以表示“等待任意子进程”。当 waitpid 调用完成后,它会将子进程的退出状态(包括是否正常退出以及退出码)写入第二个参数 statloc 所指向的地址中。这个调用还可以通过第三个参数指定一些选项(options)。waitpid 是对早期 wait 系统调用的替代,wait 现在已经过时(obsolete),但仍保留用于向后兼容(backward compatibility)。

接下来我们看看 shell 是如何使用 fork 的:当用户输入一条命令后,shell 会通过 fork 启动一个新的子进程,这个子进程必须执行用户指定的命令。它通过 execve 系统调用来实现,这个调用会用一个新程序替换掉子进程当前的整个内存映像(core image)。实际上,系统调用的名字是 exec,但在 C 语言中有多个库函数(library procedures)封装了它,带有不同的参数形式和略有差别的名字。这里我们统一将它们看作是系统调用(system calls)。图 1-10 展示了一个非常简化的 shell 的代码示例,演示了如何使用 forkwaitpidexecve

image-20250510160144140

在最一般的情况下,execve 接受三个参数:要执行的文件名、一个指向参数数组的指针(argument array),以及一个指向环境变量数组(environment array)的指针。我们稍后会详细说明这些内容。为了简化使用,系统还提供了多个库函数(library routines),例如 execlexecvexecleexecve,允许以不同方式指定或省略参数。在本书中,我们统一将它们看作是同一个系统调用 exec

以命令

cp file1 file2

为例,它用于将文件 file1 复制为 file2。Shell 在 fork 出子进程后,子进程会查找并执行程序 cp,并将源文件名和目标文件名作为参数传递过去。

cp 的主函数(就像大多数 C 程序的主函数一样)通常是这样定义的:

main(argc, argv, envp)

其中 argc 是命令行参数的数量(包括程序本身的名字),在上面的例子中,argc 的值是 3。

第二个参数 argv 是一个字符串指针数组,数组的每个元素都是命令行中的一个字符串:argv[0] 指向字符串 "cp",argv[1] 指向字符串 "file1",argv[2] 指向字符串 "file2"

第三个参数 envp 是一个指向环境变量的指针,它本质上是一个字符串数组,每个字符串的形式为 name=value,用于向程序传递信息,比如终端类型或用户的主目录路径。在图 1-10 所示的示例代码中,子进程没有传入环境变量,所以 execve 的第三个参数为 0。

如果你觉得 exec 的用法有些复杂,不要担心;它确实是所有 POSIX 系统调用中语义上最复杂的一个,其他的系统调用都简单得多。例如 exit 就很简单。它用于在进程执行完毕时退出程序。它接受一个参数,即退出状态码(范围 0 到 255),这个值会通过 waitpid 系统调用中的 statloc 返回给父进程。状态码的低位(low-order byte)表示是否正常终止,0 表示正常结束,其他值表示不同的错误原因。高位(high-order byte)则是程序中 exit 所传的退出码。

例如,如果父进程执行了:

n = waitpid(-1, &statloc, options);

它会一直阻塞,直到有某个子进程终止。如果子进程通过 exit(4) 退出,那么 n 会被设置为该子进程的 PID,statloc 会被设置为 0x0400(这里的 0x 是 C 语言中十六进制数的前缀)。

在 MINIX 3 中,每个进程的内存被划分为三个段:代码段(text segment):程序代码;数据段(data segment):变量; 栈段(stack segment):栈空间。数据段向上增长,栈段向下增长,中间留有一段空白地址空间。如图 1-11 所示。栈段会自动增长,而数据段的扩展必须显式通过系统调用 brk 实现,brk 的参数是数据段新的结束地址。这个地址可以比当前地址大(表示增长)或者小(表示缩小),但必须小于当前的栈指针,否则数据段和栈段就会重叠,这是不允许的。

image-20250510161421962

为了方便程序员使用,还提供了库函数 sbrk,它也能改变数据段大小,但参数是“要增加多少字节”(负数表示缩小)。sbrk 内部维护了数据段的当前大小(brk 返回的值),然后计算新大小,再调用系统接口请求对应的字节数。不过,brksbrk 并不是 POSIX 标准定义的一部分。标准推荐程序员使用 malloc 来动态分配内存,而 malloc 的实现细节被认为没有标准化的必要,因为很少有程序员会直接调用底层机制。

下一个系统调用是 getpid,也是最简单的一个:它返回当前调用者的进程号(PID)。记住,在 fork 时只有父进程知道子进程的 PID。如果子进程想知道自己的 PID,就需要调用 getpidgetpgrp 返回当前进程所在进程组的 PID。setsid 创建一个新的会话(session),并把进程组的 PID 设置为调用者自身的 PID。会话和 POSIX 的可选功能“作业控制(job control)”有关,而 MINIX 3 并不支持该功能,因此我们不再深入讨论它。

最后一个与进程管理有关的系统调用是 ptrace,它用于调试程序。ptrace 允许调试器读写被调试程序的内存、控制其执行等。

信号的系统调用

尽管大多数形式的进程间通信都是事先规划好的,但也确实存在某些情况下需要非预期通信的情形。例如,如果一个用户误操作让文本编辑器列出一个非常大的文件内容,并在意识到错误后想要中断该操作,就需要一种手段来打断编辑器。在 MINIX 3 中,用户可以按下键盘上的 CTRL-C 键,向编辑器发送一个信号。编辑器捕获到这个信号后,会停止打印内容。信号还可以用于报告硬件检测到的某些异常,如非法指令浮点溢出等陷阱。超时机制也通过信号实现。

当某个信号被发送给一个未声明接受该信号的进程时,进程会被直接终止,不做其他处理。为了避免这种情况,进程可以使用 sigaction 系统调用,声明自己准备好接受某种信号,并提供该信号处理程序的地址及当前处理程序的存储位置。一旦调用 sigaction 后,如果某种信号被触发(例如用户按下 CTRL-C),进程当前的状态会被压入自己的栈中,然后调用相应的信号处理程序。信号处理程序可以运行任意时间,并可执行系统调用。但在实践中,信号处理程序通常都较为简短。当信号处理程序完成后,它调用 sigreturn 以从被中断的位置继续执行。sigaction 替代了旧的 signal 调用(现在以库函数形式保留,用于兼容旧程序)。

在 MINIX 3 中,信号可以被阻塞。阻塞的信号不会被立即处理,但也不会丢失。可以使用 sigprocmask 系统调用向内核提供一个位图,定义当前阻塞的信号集合。进程还可以使用 sigpending 系统调用查询当前处于等待但因被阻塞而尚未处理的信号集合,该集合也以位图形式返回。最后,sigsuspend 系统调用允许进程原子性地设置阻塞信号的位图并挂起自身

程序除了可以指定一个函数来捕捉信号外,还可以使用常量 SIG_IGN 来表示忽略该类型的所有后续信号,或使用 SIG_DFL 来恢复信号的默认行为。默认行为可能是终止进程,也可能是忽略信号,这取决于信号的类型。例如,假设 shell 通过 command & 启动了一个后台进程,我们不希望用户按 CTRL-C 时发送的 SIGINT 信号影响到后台进程。因此,在调用 fork 创建子进程后但在 exec 执行程序前,shell 会调用:

sigaction(SIGINT, SIG_IGN, NULL);

sigaction(SIGQUIT, SIG_IGN, NULL);

来忽略 SIGINTSIGQUIT 信号。(SIGQUIT 是由 CTRL-\ 产生的,它的作用类似于 SIGINT,但如果该信号未被捕获或忽略,则会使进程终止并生成一个 core dump。)对于前台进程(即没有 & 的命令),这些信号不会被忽略。

发送信号不仅仅限于按下 CTRL-C。系统调用 kill 允许一个进程向另一个进程发送信号(前提是它们拥有相同的用户 ID,即 UID——无关的进程之间不能互相发送信号)。继续上述后台进程的例子:假设某个后台进程已经启动,但后来决定要终止它,由于之前已经忽略了 SIGINTSIGQUIT,这时可以使用 kill 程序,它调用 kill 系统调用来向该进程发送信号。通过发送信号 9(SIGKILL),可以强制杀死该后台进程。SIGKILL 不能被捕获或忽略。

在许多实时应用中,进程需要在一定的时间间隔后被中断来完成某些任务,比如在不可靠的通信线路上重发可能丢失的数据包。为此,系统提供了 alarm 系统调用。该调用的参数是一个以秒为单位的时间间隔,指定在多少秒后向进程发送 SIGALRM 信号。一个进程在任何时刻最多只能有一个生效的 alarm。如果第一次调用 alarm(10) 表示 10 秒后发送信号,但 3 秒后又调用了 alarm(20),则最终只会在 20 秒后发送一次信号(第一次被取消)。如果 alarm(0) 被调用,任何等待的信号都将被取消。如果进程没有捕获 SIGALRM 信号,则系统会执行默认操作,通常是终止该进程。

有时,一个进程在收到信号之前什么也做不了。例如,一个用于测试阅读速度和理解力的计算机辅助教学程序,显示一段文本后调用 alarm 设置 30 秒后提醒信号。在学生阅读期间,程序本身无事可做。如果它在一个紧循环中空转,那会浪费宝贵的 CPU 时间,更好的方法是调用 pause,告诉 MINIX 3 挂起进程直到收到下一个信号为止。


  1. $\uparrow$也 在整本书上应该被读作他或她 ↩︎