UNIX 分时系统

Dennis M. Ritchie and Ken Thompson

Bell Laboratories

UNIX 是一个通用的、多用户的、交互式操作系统(operating system),运行在 Digital Equipment Corporation 的 PDP-11/40 和 11/45 计算机上。它提供了许多即使在更大型的操作系统中也很少见的特性,包括:(1) 一个支持可卸载卷的层次化文件系统;(2) 兼容的文件、设备以及进程间 I/O;(3) 启动异步进程(asynchronous process)的能力;(4) 可按用户选择的系统命令语言(command language);以及 (5) 超过 100 个子系统,其中包括十几种语言。本文讨论文件系统(file system)的性质与实现,以及用户命令接口。关键词与短语: 分时、操作系统、文件系统、命令语言、PDP-11

CR Categories: 4.30, 4.32

版权所有 © 1974,Association for Computing Machinery, Inc. 一般性地允许重印本文全部或部分内容,但不得用于营利;条件是必须保留 ACM 的版权声明,并注明原始出版物、出版日期,以及说明重印权限由 Association for Computing Machinery 授予。本文是 1973 年 10 月 15-17 日于纽约 Yorktown Heights 的 IBM Thomas J. Watson Research Center 举办的第四届 ACM Symposium on Operating Systems Principles 上所提交论文的修订版。作者地址:Bell Laboratories, Murray Hill, NJ 07974。本电子版本由 Eric A. Brewer(University of California at Berkeley,brewer@cs.berkeley.edu)重建。如发现与原文不符之处,请通知我;原文中的错误我保留未改。

1 引言

UNIX 已经有过三个版本。最早的版本(约 1969-70 年)运行在 Digital Equipment Corporation 的 PDP-7 和 PDP-9 计算机上。第二个版本运行在没有保护机制(protection mechanism)的 PDP-11/20 计算机上。本文只讨论 PDP-11/40 和 /45 \[1\] 上的系统,因为它更现代,而且它与旧版 UNIX 系统之间的许多差异,都是由于对已发现存在缺陷或不足的特性进行了重新设计。自 PDP-11 UNIX 于 1971 年 2 月投入运行以来,大约已有 40 个安装点投入使用;它们通常都比本文描述的系统要小得多。其中大多数用于诸如专利申请和其他文本材料的编写与排版、Bell System 内各类交换设备故障数据的收集与处理,以及电话业务工单的记录与核查等应用。我们自己的安装主要用于操作系统、语言、计算机网络及其他计算机科学主题的研究,同时也用于文档编写。

也许 UNIX 最重要的成就在于表明:一个功能强大的交互式操作系统,在设备成本和人力投入上都不必十分昂贵。UNIX 可以运行在价格低至 4 万美元的硬件上,而主系统软件的开发投入不到两个人年。然而,UNIX 却包含了许多即使在大得多的系统中也不常见的特性。我们希望,UNIX 的使用者最终会发现,这个系统最重要的特征是它的简洁、优雅和易用。

除系统本身之外,UNIX 下的主要程序还有:汇编器(assembler)、基于 QED \[2\] 的文本编辑器、链接装入程序(linking loader)、符号调试器(symbolic debugger)、一个带有类型和结构、语法类似 BCPL \[3\] 的语言编译器(C)、某种 BASIC 方言的解释器、文本排版程序(text formatting program)、Fortran 编译器、Snobol 解释器、自顶向下的编译器编译器(compiler-compiler, TMG)\[4\]、自底向上的编译器编译器(compiler-compiler, YACC)、套信生成器、宏处理器(M6)\[5\],以及置换索引程序(permuted index program)。

此外,还有大量维护、实用、娱乐和新奇程序。所有这些程序都是在本地编写的。值得注意的是,这个系统是完全自给自足的。所有 UNIX 软件都在 UNIX 之下维护;同样,UNIX 文档也是由

UNIX 编辑器和文本排版程序生成并格式化的。

2 硬件与软件环境

我们这套 UNIX 安装运行在 PDP-11/45 上;它是一台 16 位字长(8 位字节)的计算机,拥有 144K 字节的核心内存;UNIX 占用 42K 字节。不过,这个系统包含了大量设备驱动程序,并为 I/O 缓冲区和系统表分配了相当充裕的空间;而一个能够运行前述软件的最小系统,

其核心内存总量可以低至 50K 字节。PDP-11 配有一个 1M 字节的固定磁头磁盘,用于文件系统存储和换出换入;四台可移动磁盘盒的活动磁头磁盘驱动器,每台提供 2.5M 字节;以及一台使用可移动 40M 字节磁盘包的活动磁头磁盘驱动器。系统还配有高速纸带读打机、九轨磁带,以及 D-tape(一种可以按记录寻址并重写的磁带设施)。除控制台打字机之外,还有 14 个接到 100 系列数据集上的可变速通信接口,以及一个 201 数据集接口,主要用于将输出假脱机(spool)到公用行式打印机。系统中还包括若干独一无二的设备,例如 Picturephone® 接口、语音响应单元、语音合成器、照相排字机、数字交换网络,以及一台作为卫星机的 PDP-11/20,它在 Tektronix 611 存储管显示器上生成向量、曲线和字符。

UNIX 软件的绝大部分是用前面提到的 C 语言 \[6\] 编写的。操作系统的早期版本使用汇编语言编写,但在 1973 年夏天它被改写成了 C。新系统的规模大约比旧系统大三分之一。由于新系统不仅更易于理解和修改,而且还包含许多功能上的改进,包括多道程序设计以及在多个用户程序之间共享可重入代码(reentrant code)的能力,我们认为这样的尺寸增加是完全可以接受的。

3 文件系统

UNIX 最重要的职责是提供一个文件系统。从用户的角度看,文件有三种:普通磁盘文件(ordinary file)、目录(directory)和特殊文件(special file)。

3.1 普通文件

文件中可以包含用户放入其中的任意信息,例如符号程序或二进制(目标)程序。系统本身并不期待文件具有某种特定结构。文本文件只是字符的序列,以换行符划分各行。二进制程序则是一串字,它们在程序开始执行时将按原样出现在核心内存中。有些用户程序会操作结构更复杂的文件:例如汇编器会生成某种特定格式的目标文件,而装入程序也期待这种格式。不过,文件的结构是由使用它的程序控制的,而不是由系统控制的。

3.2 目录

目录负责在文件名与文件本身之间建立映射,因此也为整个文件系统赋予了一种结构。每个用户都有一个存放自己文件的目录;他也可以创建子目录,把方便一起处理的一组文件归入其中。目录的行为与普通文件完全一样,只是非特权程序不能向其中写入,因此目录内容由系统控制。不过,任何拥有适当权限的人都可以像读普通文件一样读取目录。

系统还维护着若干供自身使用的目录。其中之一是根目录(root directory)。系统中的所有文件都可以通过沿着目录链追踪路径而找到,直到到达目标文件为止。这类搜索通常从根目录开始。另一个系统目录包含了所有供一般使用的程序,也就是所有命令。不过,正如后文所示,一个程序完全没有必要驻留在这个目录里才能被执行。

文件名由长度不超过 14 个字符的字符串构成。当向系统指定一个文件名时,它可以是路径名(path name)的形式;路径名是由斜杠“/”分隔的一串目录名,并以文件名结束。如果这个序列以斜杠开头,搜索就从根目录开始。名字 /alpha/beta/gamma 会使系统先在根目录中寻找目录 alpha,然后在 alpha 中寻找 beta,最后在 beta 中找到 gammagamma 可以是普通文件、目录或特殊文件。作为极限情况,名字“/”指的就是根目录本身。

如果路径名不以“/”开头,系统就从用户当前目录(current directory)开始搜索。因此,名字 alpha/beta 指的是当前目录的子目录 alpha 中名为 beta 的文件。最简单的名字,比如 alpha,指的是当前目录中直接找到的文件。另一个极限情况是,空文件名指当前目录。

同一个非目录文件可以以不同名字出现在多个目录中。这种特性叫作 linking;目录中指向某个文件的一项有时就被称为一个 link。与允许 linking 的其他系统不同,UNIX 中一个文件的所有 link 地位完全相同。也就是说,文件并不是“存在于”某个特定目录中;目录项只包含它的名字以及一个指向实际描述该文件信息的指针。因此,文件独立于任何目录项而存在,尽管在实际中,文件会随着它最后一个 link 的消失而消失。

每个目录至少总有两个目录项。每个目录中的名字“.”指向目录自身。因此,程序无需知道当前目录的完整路径名,也可以用“.”这个名字读取当前目录。按照约定,名字“..”指向它所在目录的父目录,也就是创建该目录时所处的目录。

目录结构被限制成一棵有根树。除特殊项“.”和“..”外,每个目录都必须恰好作为一个项出现在另一个目录中,而那个目录就是它的父目录。这样做的原因,一方面是为了简化遍历目录子树程序的编写,更重要的是为了避免层次结构某一部分与其余部分分离。如果允许对目录建立任意的 link,那么就会很难检测从根到某个目录的最后一条连接何时被切断。

3.3 特殊文件

特殊文件是 UNIX 文件系统最不寻常的特性。UNIX 所支持的每一个 I/O 设备,至少都与这样一个文件相关联。特殊文件和普通磁盘文件一样可读可写,但对它们发出的读写请求会导致对应设备被激活。每个特殊文件在目录 /dev 中都有一个项,不过也可以像普通文件一样对它建立 link。例如,要打孔纸带,只需向文件 /dev/ppt 写数据即可。每条通信线路、每个磁盘、每台磁带机,以及物理核心内存,都有对应的特殊文件。当然,正在使用的磁盘和核心内存特殊文件会受到保护,避免被任意访问。

用这种方式处理 I/O 设备有三重好处:文件 I/O 与设备 I/O 尽可能相似;文件名和设备名具有相同的语法与意义,因此一个原本期待文件名作为参数的程序也可以被传入设备名;最后,特殊文件与普通文件一样受到同样的保护机制约束。

3.4 可卸载文件系统

尽管文件系统的根总是存放在同一个设备上,但整个文件系统层次并不一定都驻留在这个设备上。有一个 mount 系统请求,它有两个参数:一个现有普通文件的名字,以及一个直接访问型特殊文件的名字;后者所关联的存储卷(例如磁盘包)应当具有一个独立文件系统的结构,并包含自己的目录层次。mount 的作用是:让原本指向那个普通文件的引用,改为指向可卸载卷上该文件系统的根目录。实际上,mount 是用一整棵新的子树(存储在可卸载卷上的那棵层次树)替换了层次树中的一片叶子(那个普通文件)。

在挂载之后,可卸载卷上的文件与永久文件系统中的文件几乎没有区别。例如,在我们的安装中,根目录位于固定磁头磁盘上,而包含用户文件的大型磁盘驱动器由系统初始化程序挂载;四台较小的磁盘驱动器则供用户挂载自己的磁盘包。向其对应的特殊文件写数据即可生成一个可挂载文件系统。系统提供了一个实用程序用来创建空文件系统,当然也可以直接复制一个现有文件系统。

唯一违背“不同设备上的文件都被同等对待”这一规则的例外是:一个文件系统层次与另一个文件系统层次之间不能存在 link。之所以要施加这一限制,是为了避免当可卸载卷最终卸下时,还需要复杂的簿记工作来保证这些 link 被正确移除。特别地,在所有文件系统的根目录中,无论它们是否可卸载,名字“..”都指向目录自身,而不是它的父目录。

3.5 保护

尽管 UNIX 的访问控制(access control)方案非常简单,但它也有一些不同寻常的特点。系统中的每个用户都被赋予一个唯一的用户标识(user ID)号。创建文件时,文件会被标记上其所有者的用户 ID。同时,新文件还会得到七个保护位(protection bits)。其中六个位分别为文件所有者和其他所有用户独立指定读、写、执行权限。如果第七位被置上,那么每当该文件作为程序执行时,系统会暂时把当前用户的用户标识改为该文件创建者的用户标识。这种用户 ID 的改变只在那个程序执行期间有效。

set-user-ID 这一特性为特权程序提供了支持,使它们可以使用其他用户无法访问的文件。例如,一个程序可以维护一个记账文件,而这个文件既不应被读取,也不应被修改,除该程序本身之外不允许其他程序访问。如果这个程序的 set-user-ID 位被置上,那么即使该访问对运行此程序的用户所调用的其他程序来说是被禁止的,这个程序仍然可以访问该文件。由于任何程序调用者的真实用户 ID 始终是可用的,set-user-ID 程序可以采取它所希望的任何措施来核实调用者的身份凭据。

这种机制被用来允许用户执行那些经过精心编写、会调用特权系统入口的命令。例如,前面提到有一个系统入口只能由“超级用户(super-user)”调用,它用于创建空目录。正如前面所说,目录中预期会有“.”和“..”两个项。创建目录的那个命令归超级用户所有,并设置了 set-user-ID 位。在它检查了调用者确有权创建指定目录之后,它就创建该目录,并建立“.”与“..”两个项。

由于任何人都可以在自己的文件上设置 set-user-ID 位,因此这一机制一般不需要管理员介入就可以使用。例如,这种保护方案可以很容易地解决 \[7\] 中提出的 MOO accounting 问题。系统把某个特定用户 ID(即“超级用户”的 ID)视为不受通常文件访问约束的限制;因此,举例来说,人们可以编写程序来转储和重新装入文件系统,而不会受到保护系统的不必要干扰。

3.6 I/O 调用

系统中进行 I/O 的调用,设计目标是消除不同设备和不同访问方式之间的差异。系统并不区分“随机”I/O 与顺序 I/O(sequential I/O),也不强加任何逻辑记录大小(logical record size)。普通文件的大小由写入其中的最高字节位置决定;文件大小既不需要也不可能预先确定。

为了说明 UNIX 中 I/O 的基本要点,下面用一种匿名语言概括若干基本调用,只展示所需参数,而不涉及机器语言编程的复杂细节。每次系统调用理论上都可能返回错误;为简洁起见,下述调用序列中不表示错误返回。

要读写一个假定已经存在的文件,必须先通过如下调用打开它:

filep = open (name, flag)

name 表示文件名。它可以是任意路径名。参数 flag 表示该文件是以读、写,还是“更新”方式打开,也就是同时读写。返回值 filep 称为文件描述符(file descriptor)。它是一个小整数,在随后的 readwrite 或其他操作中用来标识该文件。

要创建一个新文件,或完全重写一个旧文件,可以使用 create 系统调用:如果给定文件不存在,就创建它;如果存在,就把它截断为零长度。create 还会把新文件打开为可写,并且像 open 一样返回一个文件描述符。

文件系统中没有用户可见的锁,也不限制同时打开同一个文件进行读写的用户数量;尽管当两个用户同时向同一文件写入时,文件内容可能会变得混乱,但在实践中这类困难并不常见。我们的看法是,在我们的环境里,锁既非必要,也非充分,不能用来防止同一文件多个用户之间的相互干扰。它们之所以不是必要的,是因为我们并不面对由独立进程维护的大型单文件数据库;它们之所以也不充分,是因为通常意义上的锁,例如阻止某个用户向另一个用户正在读取的文件写入,无法防止这样的混乱:两个用户都在用一个会先复制被编辑文件的编辑器来编辑同一个文件。

需要指出的是,系统内部具有足够的互锁机制,能够在两个用户同时进行一些不方便的操作时,保持文件系统的逻辑一致性,例如同时向同一文件写入、在同一目录中创建文件,或者删除彼此打开着的文件。

除下文另有说明外,读和写都是顺序进行的。这意味着,如果文件中的某个字节是上一次写入(或读取)的最后一个字节,那么下一次 I/O 调用隐式地指向紧随其后的第一个字节。对于每个打开文件,系统都维护一个指针,用来指示下一个要读或写的字节。如果读或写了 n 个字节,这个指针就前移 n 个字节。

文件一旦打开,就可以使用如下调用:

n = read(filep, buffer, count)
n = write(filep, buffer, count)

在由 filep 指定的文件与 buffer 指定的字节数组之间,最多传送 count 个字节。返回值 n 是实际传送的字节数。在写的情况下,除 I/O 错误或特殊文件的物理介质末端之类的异常情况外,ncount 相同;而在读的情况下,n 可以在没有错误的前提下小于 count。如果读指针已经接近文件末尾,以至于读取 count 个字符会越过文件尾,那么只会传送足够到达文件尾的那些字节;此外,类似打字机的设备永远不会一次返回多于一行的输入。当 read 调用返回时若 n 为零,就表示文件结束。对于磁盘文件来说,这发生在读指针等于文件当前大小时。对于打字机,也可以通过某种取决于设备的转义序列来产生文件结束标志。

向文件写入字节时,只会影响由写指针位置和 count 所隐含的那部分文件,其余部分不会改变。如果最后一个字节超出了文件末尾,文件就会按需增长。

要进行随机(直接访问)I/O,只需要把读指针或写指针移动到文件中的适当位置:

location = seek(filep, base, offset)

filep 相关联的指针会被移动到一个新位置:这个位置距离文件开头、当前指针位置或文件末尾 offset 个字节,具体取决于 baseoffset 可以为负。对于某些设备(例如纸带和打字机),seek 调用会被忽略。实际移动到的、相对于文件开头的偏移量,会作为 location 返回。

3.6.1 其他 I/O 调用

还有若干与 I/O 和文件系统有关的系统入口,本文不再讨论。例如:关闭文件、获取文件状态、改变文件的保护模式或所有者、创建目录、为一个已有文件建立 link,以及删除文件。

4 文件系统的实现

正如 §3.2 中提到的,目录项只包含关联文件的名字和一个指向该文件本身的指针。这个指针是一个整数,称为文件的 i-number(索引号)。当访问该文件时,它的 i-number 被用作系统表(i-list)中的索引;该表存放在该目录所在设备上的一个已知位置。由此找到的那一项(该文件的 i-node)包含如下关于文件的描述:

  1. 所有者。
  2. 保护位。
  3. 文件内容在物理磁盘或磁带上的地址。
  4. 文件大小。
  5. 最后修改时间。
  6. 指向该文件的 link 数,也就是它在目录中出现的次数。
  7. 一个指示该文件是否为目录的位。
  8. 一个指示该文件是否为特殊文件的位。
  9. 一个指示该文件是“大”还是“小”的位。

opencreate 系统调用的目的,是通过搜索显式或隐式指定的目录,把用户给出的路径名转换成一个 i-number。文件一旦打开,它的设备、i-number 以及读写指针就会存放在一个以文件描述符为索引的系统表中。因此,后续 readwrite 调用里提供的文件描述符,就可以很容易地关联到访问该文件所需的信息。

当创建新文件时,会为它分配一个 i-node,并建立一个目录项,其中包含文件名和 i-node 号。为已有文件建立 link,就是创建一个带有新名字的目录项,把原文件项中的 i-number 复制过来,并把该 i-node 的 link-count 字段加一。删除文件则通过对目录项所指定的 i-node 的 link-count 减一并擦除目录项来完成。如果 link-count 降为 0,文件所占用的所有磁盘块就会被释放,i-node 也会被回收。

所有包含文件系统的固定磁盘和可卸载磁盘,其空间都被划分为若干个 512 字节块,并从 0 开始按逻辑地址编号,直到设备相关的上限。每个文件的 i-node 中有空间存放 8 个设备地址。一个小型(非特殊)文件可以放在 8 个或更少的块中;这种情况下,块的地址就直接存放在那里。对于大型(非特殊)文件,这 8 个设备地址中的每一个都可以指向一个间接块,而每个间接块包含 256 个构成该文件的块地址。

这样的文件最大可以达到 8⋅256⋅512,即 1,048,576 (2^20) 字节。前面的讨论适用于普通文件。当对一个其 i-node 表明自己是特殊文件的文件发出 I/O 请求时,最后七个设备地址字便无关紧要,而那份地址表会被解释成一对字节,构成一个内部设备名。这两个字节分别指定设备类型(device type)和子设备号(subdevice number)。设备类型指明由哪一个系统例程来处理该设备上的 I/O;子设备号则例如可以选中挂在某个控制器上的某台磁盘驱动器,或者若干类似打字机接口中的某一个。

在这种环境下,mount 系统调用(§3.4)的实现相当直接。mount 维护一个系统表,其参数是挂载时所指定普通文件的 i-number 与设备名,而对应值则是所指定特殊文件的设备名。在 opencreate 期间扫描路径名时,每遇到一个 (i-number, device) 对,就在这个表中搜索;如果找到匹配项,就把 i-number 替换为 1(也就是所有文件系统根目录的 i-number),并把设备名替换为表中的对应值。

对于用户而言,文件的读与写都表现得像是同步且无缓冲的。也就是说,read 调用一返回,数据就已经可用;相反,write 一返回,用户的工作区就可以重新使用。实际上,系统维护着一套相当复杂的缓冲机制,它极大减少了访问文件所需的 I/O 操作数。假设某个 write 调用只要求传送一个字节。

UNIX 会先搜索它的缓冲区,看受影响的磁盘块当前是否已驻留在核心内存中;如果没有,就从设备读入。然后,把受影响的字节替换进缓冲区,并在待写块列表中登记一项。随后 write 调用就可以返回,尽管真正的 I/O 可能直到稍后才完成。相反,如果读入一个字节,系统会判断该字节所在的辅助存储块是否已经在某个系统缓冲区中;如果在,就可以立即返回这个字节;否则,就把该块读入缓冲区,再从中取出该字节。

以 512 字节为单位读写文件的程序,相比逐字节读写的程序会占有一些优势,但这种收益并不十分巨大;它主要来自避免系统开销。一个很少使用、或者 I/O 量不大的程序,完全可以合理地按任意小的单位读写数据。

i-list 的概念是 UNIX 的一个不同寻常之处。实践证明,这种组织文件系统的方法相当可靠,也容易处理。对系统自身来说,它的一个优势在于:每个文件都有一个简短且无歧义的名字,这个名字又与访问该文件所需的保护、地址等信息以简单方式相关联。它还使得检查文件系统一致性的算法可以做得相当简单而快速,例如验证设备上包含有效信息的部分与可供分配的空闲部分彼此不相交,并且二者合起来恰好耗尽设备空间。这个算法独立于目录层次,因为它只需要扫描线性组织的 i-list。

与此同时,i-list 的概念也带来了某些其他文件系统组织里没有的特殊问题。比如,既然一个文件的所有目录项都具有同等地位,那么该文件占用的空间应当记在谁头上就是个问题。一般来说,把费用记在文件所有者头上并不公平,因为一个用户可能创建了文件,另一个用户可能对它建立了 link,而第一个用户随后又删除了该文件。第一个用户仍然是文件所有者,但费用其实应当记到第二个用户头上。一个最简单而又大体公平的算法,似乎是把费用平均分摊给所有链接到该文件的用户。当前版本的 UNIX 则通过根本不收取任何费用来回避这个问题。

4.1 文件系统的效率

为了给出 UNIX 尤其是文件系统整体效率的一个印象,我们对一个 7621 行程序的汇编过程做了计时。汇编是在机器上单独运行的;总时钟时间为 35.9 秒,速度是每秒 212 行。时间划分如下:63.5% 为汇编器执行时间,16.5% 为系统开销,20.0% 为磁盘等待时间。我们不打算对这些数字作出解释,也不打算与其他系统比较;这里只想指出,我们对系统的整体性能总体上是满意的。

5 进程与映像

映像(image)是计算机的一个执行环境。它包括核心映像(core image)、通用寄存器值、打开文件的状态、当前目录等等。映像就是一台伪计算机的当前状态。进程(process)则是一个映像的执行过程。当处理器代表某个进程执行时,该映像必须驻留在核心内存中;而在执行其他进程时,它仍然保留在核心中,除非出现了一个更高优先级的活动进程,迫使它被换出到固定磁头磁盘上。映像中用户核心部分被划分为三个逻辑段。程序文本段(text segment)从虚拟地址空间中的位置 0 开始。执行期间,这一段受到写保护,并且正在执行同一程序的所有进程共享它的单个副本。在虚拟地址空间中高于程序文本段的第一个 8K 字节边界处,开始一个非共享、可写的数据段(data segment),其大小可以通过系统调用扩展。从虚拟地址空间的最高地址开始的是一个栈段(stack segment),它会随着硬件栈指针的变化自动向下增长。

5.1 进程

除 UNIX 正在自举启动自身之外,一个新进程只能通过 fork 系统调用产生:

processid = fork (label)

当某个进程执行 fork 时,它会分裂成两个独立执行的进程。这两个进程各自拥有原核心映像的独立副本,并共享所有已打开文件。两个新进程的唯一区别在于:其中一个被视为父进程;在父进程中,控制直接从 fork 返回;而在子进程中,控制转移到位置 labelfork 调用返回的 processid 是另一个进程的标识。由于父子进程中的返回位置不同,fork 之后存在的每个映像都可以判断自己是父进程还是子进程。

5.2 管道

进程可以利用与文件系统 I/O 相同的 readwrite 系统调用,与有关联关系的进程通信。调用

filep = pipe( )

会返回一个文件描述符 filep,并创建一个称为管道(pipe)的进程间通道。这个通道和其他打开文件一样,会在 fork 调用时从父进程传给子进程。对某个管道文件描述符执行 read 时,会等待直到另一个进程通过同一个管道的文件描述符执行 write。此时,数据就在两个进程映像之间传送。两个进程都无需知道这里涉及的是管道而不是普通文件。尽管通过管道进行进程间通信(interprocess communication)是一个非常有价值的工具(见 §6.2),但它并不是一种完全通用的机制,因为这个管道必须由相关进程的共同祖先事先建立。

5.3 程序的执行

另一个主要的系统原语由下式调用:

execute(file, arg1, arg2, ..., argn)

它请求系统读入并执行名为 file 的程序,并把字符串参数 arg1, arg2, ..., argn 传给它。通常,arg1 应当与 file 相同,这样程序就能确定自己是以什么名字被调用的。执行 execute 的那个进程中的全部代码和数据,都会被来自该文件的内容替换掉,但已打开文件、当前目录以及进程间关系都保持不变。只有当调用失败时,例如找不到 file,或它的执行许可位没有置上,execute 原语才会返回;它更像是一条“跳转”机器指令,而不是一次子程序调用。

5.4 进程同步

另一个进程控制系统调用

processid = wait( )

会使调用者挂起执行,直到它的某个子进程执行完毕。随后 wait 返回该终止进程的 processid。如果调用进程没有后代,就会得到一个错误返回。某些来自子进程的状态信息也可以获得。wait 也可能呈现一个孙进程或更远后代的状态;见 §5.5。

5.5 终止

最后,

exit (status)

会终止一个进程,销毁它的映像,关闭它打开的文件,并在一般意义上将其彻底抹去。当父进程通过 wait 原语得知这一点时,所指示的 status 对父进程可用;如果父进程已经终止,那么这个状态对祖父进程可用,如此类推。进程

也可能因各种非法动作或用户产生的信号而终止(见下文 §7)。

6 Shell

对大多数用户来说,与 UNIX 的交互都是借助一个名为 Shell 的程序进行的。Shell 是一个命令行解释器:它读取用户键入的各行,并把它们解释为执行其他程序的请求。最简单的形式下,一行命令由命令名和其后的若干参数构成,彼此以空格分隔:

command arg1 arg2 ... argn

Shell 会把命令名和参数拆分成独立的字符串。然后,它寻找名为 command 的文件;command 可以是包含“/”字符的路径名,从而指定系统中的任意文件。如果找到了 command,它就被调入核心并执行。Shell 收集到的参数对该命令是可访问的。命令结束后,Shell 恢复执行,并通过打印一个提示字符表明自己已经准备好接受下一条命令。如果找不到名为 command 的文件,Shell 就在 command 前加上字符串 /bin/ 再试一次。目录 /bin 包含所有打算供一般使用的命令。

6.1 标准 I/O

§3 中对 I/O 的讨论似乎意味着,一个程序使用的每个文件都必须由该程序自己打开或创建,以获得该文件的文件描述符。然而,由 Shell 执行的程序在启动时已经带有两个打开文件,其文件描述符分别为 0 和 1。程序开始执行时,文件 1 以写方式打开,最好把它理解为标准输出(standard output)文件。在下文所述特殊情形之外,这个文件就是用户的打字机。因此,希望输出信息性或诊断性信息的程序,通常都会使用文件描述符 1。反过来,文件 0 在启动时以读方式打开,希望读取用户键入消息的程序通常就读这个文件。

Shell 能够改变这些文件描述符与用户打字机打印端和键盘之间的标准对应关系。如果某个命令参数以前缀“>”开头,那么在该命令执行期间,文件描述符 1 将指向“>”后所命名的文件。例如,

ls

通常会在打字机上列出当前目录中的文件名。命令

ls >there

会创建一个名为 there 的文件,并把该列表放入其中。因此,参数“>there”的意思是“把输出放到 there 中”。另一方面,

ed

通常会进入编辑器,该编辑器通过用户的打字机接受请求。命令

ed <script

则把 script 解释为一个编辑器命令文件(command file);因此,“<script”的意思是“从 script 读取输入”。尽管“<”或“>”后面的文件名看起来像是命令的参数,但实际上它完全由 Shell 解释,并不会传递给命令本身。因此,每个命令内部都不需要专门编写处理 I/O 重定向的代码;命令只需在适当之处使用标准文件描述符 0 和 1 即可。

6.2 过滤器

标准 I/O 概念的一个扩展,是把一个命令的输出定向到另一个命令的输入。用竖线分隔的一串命令,会使 Shell 同时执行所有这些命令,并安排让每个命令的标准输出成为序列中下一个命令的标准输入(standard input)。因此,对下面这行命令来说:

ls | pr -2 | opr

ls 列出当前目录中的文件名;它的输出被传给 pr,后者在输入前加上带日期的页眉进行分页。参数“-2”表示双栏。随后,pr 的输出又成为 opr 的输入。这个命令会把输入假脱机到一个文件中,以供离线打印。

这个过程本来也可以用一种更笨拙的方式完成:

ls >temp1
pr -2 <temp1 >temp2
opr <temp2

然后再删除这些临时文件。如果没有输出重定向和输入重定向的能力,那就必须采用一种更笨拙的方法:要求 ls 命令自己接受用户的请求,既能对输出分页,又能以多栏格式打印,还能把输出安排为离线输出。实际上,期待像 ls 这样的命令作者提供如此广泛的输出选项,不仅令人意外,而且从效率角度看也并不明智。像 pr 这样把标准输入复制到标准输出(并做一定处理)的程序,称为过滤器(filter)。我们发现有用的一些过滤器可以执行字符转写、输入排序,以及加密和解密。

6.3 命令分隔符:多任务

Shell 提供的另一个特性相对直接。命令不必分布在不同的行上;它们可以用分号隔开。

ls; ed

它会先列出当前目录内容,然后进入编辑器。一个相关但更有意思的特性是:如果一个命令后面跟着“&”,Shell 就不会在提示下一条命令前等待它结束;相反,它会立即准备好接收新的命令。例如,

as source >output &

会在后台汇编 source,并把诊断输出写入 output;无论汇编花费多长时间,Shell 都会立刻返回。当 Shell 不等待某条命令完成时,会打印运行该命令的进程标识。这个标识可用于等待该命令完成,或将其终止。“&”在一行里可以使用多次:

as source >output & ls >files &

这会同时在后台执行汇编和目录列表。在前面使用“&”的例子里,都指定了一个不是打字机的输出文件;如果不这样做,不同命令的输出就会交织在一起。

Shell 还允许在上述操作中使用括号。例如,

(date; ls) >x &

会把当前日期和时间,以及当前目录的列表,一并输出到文件 x 中。Shell 也会立刻返回,等待下一条请求。

6.4 把 Shell 当作命令:命令文件

Shell 本身就是一个命令,也可以递归地调用。假设文件 tryout 中包含如下几行:

as source
mv a.out testprog
testprog

命令 mv 使文件 a.out 被重命名为 testproga.out 是汇编器输出的(二进制)结果,可以直接执行。因此,如果上面三行是从控制台键入的,那么就会先汇编 source,把生成的程序命名为 testprog,然后执行 testprog。而如果这几行位于 tryout 中,那么命令

sh <tryout

就会使 Shell sh 按顺序执行这些命令。Shell 还拥有更多能力,包括参数替换,以及从某个目录中文件名的指定子集构造参数表。它还可以根据字符串比较的结果或某些文件是否存在来有条件地执行命令,并在命令文件序列内部进行控制转移。

6.5 Shell 的实现

现在我们已经可以理解 Shell 的工作轮廓。大多数时候,Shell 都在等待用户输入命令。当标志一行结束的换行字符被键入时,Shell 的 read 调用返回。Shell 随后分析命令行,把参数整理成适于 execute 的形式。然后调用 fork。子进程当然仍然带有 Shell 的代码,它会尝试使用适当参数执行一次 execute。如果成功,这就会调入并启动执行给定名字的程序。与此同时,由 fork 产生的另一个进程,即父进程,会等待子进程结束。当这件事发生时,Shell 就知道命令已经执行完成,于是打印提示符,并从打字机读取下一条命令。

在这一框架下,后台进程的实现就很简单了:只要某条命令行中包含“&”,Shell 就只需不去等待自己所创建、用于执行该命令的那个进程即可。

令人高兴的是,这整套机制与标准输入输出文件的概念结合得非常自然。当一个进程通过 fork 原语被创建时,它不仅继承父进程的核心映像,也继承父进程中当前所有已打开文件,其中包括文件描述符 0 和 1 所对应的文件。Shell 当然使用这些文件来读取命令行和写出提示与诊断信息;在通常情况下,它的子进程,即命令程序,也会自动继承它们。然而,当给出带有“<”或“>”的参数时,子进程会在执行 execute 之前,让标准 I/O 文件描述符 0 或 1 分别指向那个被命名的文件。这件事之所以容易,是因为按照约定,打开(或创建)一个新文件时,总会分配当前最小的未使用文件描述符;因此只需要关闭文件 0(或 1),再打开所命名的文件即可。由于执行命令程序的那个进程在结束时会直接终止,因此“<”或“>”后面指定的文件与文件描述符 0 或 1 之间的关联,也会随着进程死亡而自动结束。所以,Shell 根本不需要知道它自己的标准输入和输出文件的实际名字,因为它永远不需要重新打开它们。

过滤器不过是把标准 I/O 重定向中的“文件”替换成“管道”的直接扩展。

在通常情况下,Shell 的主循环永远不会结束。(这个主循环包含了 fork 返回后属于父进程的那条分支;也就是那条执行一次 wait,然后再读取下一条命令行的分支。)唯一会让 Shell 终止的情况,是它在自己的输入文件上遇到文件结束条件。因此,

当 Shell 作为一个命令并带着给定输入文件执行时,例如

sh <comfile

comfile 中的命令会一直执行到文件结束;随后,由 sh 启动的那个 Shell 实例就会终止。由于这个 Shell 进程是另一个 Shell 实例的子进程,后者执行的 wait 就会返回,于是又可以处理下一条命令。

6.6 初始化

用户实际对其输入命令的那些 Shell 实例,本身又是另一个进程的子进程。UNIX 初始化的最后一步,是创建一个单独进程,并通过 execute 启动一个名为 init 的程序。init 的职责,是为每一条可供用户拨入的打字机通道创建一个进程。init 的各个子实例会为输入和输出打开相应的打字机。由于 init 被启动时还没有任何文件打开,因此在每个进程中,打字机键盘会获得文件描述符 0,而打印端获得文件描述符 1。每个进程都会打印一条消息,请求用户登录,然后等待并从打字机读取回复。开始时没有任何人登录,因此每个进程都只是挂起。最终,会有人键入自己的名字或其他身份标识。对应的那个 init 实例就会苏醒,接收登录行,并读取口令文件(password file)。如果找到该用户名,并且用户能够给出正确密码,init 就切换到用户的默认当前目录,把该进程的用户 ID 设置为登录者的 ID,然后执行一次 execute 来启动 Shell。此时,Shell 已经准备好接收命令,登录协议也就完成了。

与此同时,init 的主路径(它是以后将成为各个 Shell 的那些子实例的父进程)会执行 wait。如果某个子进程终止了,无论是因为某个 Shell 遇到文件结束,还是因为某个用户输入了错误的用户名或密码,这条 init 路径都会简单地重新创建那个已失效的进程;新的进程又会重新打开相应的输入输出文件,并再次打印登录消息。因此,用户只要在 Shell 中输入文件结束序列,就可以登出。

6.7 作为 Shell 的其他程序

前面描述的 Shell 之所以能让用户完整访问系统设施,是因为它会在适当保护模式下调用任何程序的执行。不过,有时人们希望使用不同的系统接口,而这一点也很容易安排。回想一下,用户成功登录、提供了自己的名字和密码之后,init 通常会启动 Shell 来解释命令行。但口令文件中该用户对应的项,也可以指定一个在登录后启动的程序,而不是 Shell。这个程序可以按它希望的任何方式解释用户消息。

例如,某个秘书编辑系统的用户,其口令文件项指定登录后调用编辑器 ed 而不是 Shell。这样,当这些编辑系统用户登录时,他们就直接处在编辑器中,可以立即开始工作;同时,也能阻止他们调用那些并非为其准备的 UNIX 程序。实践中,人们发现最好允许他们暂时跳出编辑器,去执行排版程序和其他实用工具。

UNIX 中的一些游戏(例如 chess、blackjack、3D tic-tac-toe)则展示了一个限制更为严格的环境。对于这些游戏中的每一种,口令文件中都存在一个项,指定登录后运行相应的游戏程序而不是 Shell。以游戏玩家身份登录的人会发现自己被限制在游戏之内,无法探查 UNIX 其他大概更有趣的功能。

7 陷阱

PDP-11 硬件能够检测多种程序故障,例如访问不存在的内存、执行未实现的指令,以及在要求偶地址的场合使用奇地址。这类故障会使处理器陷入某个系统例程。当捕获到非法动作时,如果没有事先作出其他安排,系统就会终止该进程,并把用户的映像写入当前目录中的文件 core。此时可以使用调试器来确定程序在发生故障时的状态。

那些陷入死循环、产生不想要的输出,或者用户后来反悔不想让它继续运行的程序,可以通过中断信号(interrupt signal)来终止;该信号由键入“delete”字符产生。如果没有采取特殊动作,这个信号只会使程序停止执行,而不会生成 core 映像文件。系统还有一个 quit 信号,用来强制生成 core 映像。因此,那些意外陷入循环的程序,可以无需事先安排就被终止,并检查它们的 core 映像。

由硬件产生的故障,以及中断和退出信号(quit signal),都可以按请求被进程忽略或捕获。例如,Shell 会忽略 quit,以防一次 quit 把用户登出。编辑器会捕获中断,并返回到它的命令层。这对于停止长时间打印而不丢失正在进行中的工作很有用(编辑器操作的是它正在编辑文件的一个副本)。在没有浮点硬件的系统中,未实现指令会被捕获,而浮点指令会被解释执行。

8 回顾

看似矛盾的是,UNIX 的成功在很大程度上恰恰来自它并不是为了满足任何预先定义的目标而设计的。第一个版本写成时,我们中的一位(Thompson)对现有的计算设施感到不满,发现了一台很少使用的 PDP-7,于是着手创造一个更宜人的环境。这个本质上属于个人的尝试相当成功,以至于引起了另一位作者以及其他人的兴趣;后来,它进一步成为购置 PDP-11/20 的理由,而后者又专门用于支持一个文本编辑和排版系统。随后,11/20 也不再够用;UNIX 已经证明自己足够有用,从而说服管理层投资购置 PDP-11/45。

我们的目标,即使有过明确表达,也始终围绕着如何建立一种与机器相处得更舒适的关系,以及如何探索操作系统中的思想和发明。我们并没有面对必须满足别人需求的压力,而对此我们心怀感激。

回过头看,影响 UNIX 设计的因素主要有三点。第一,由于我们自己就是程序员,我们自然会把系统设计得便于编写、测试和运行程序。我们追求编程便利性的最重要体现,就是系统从一开始就被安排为交互式使用,尽管最初版本实际上只支持一个用户。我们相信,一个设计得当的交互式系统,比“批处理”系统高效得多、用起来也更令人满意。而且,这样的系统比较容易适配为非交互使用;反过来则不成立。

第二,系统及其软件始终受到相当严苛的尺寸约束。在合理效率与表达能力这两种某种程度上彼此对立的愿望之下,尺寸约束所鼓励的不仅是节俭,也是一种设计上的优雅。这也许只是“通过受苦获得救赎”哲学的一种伪装,但在我们的案例里,它确实奏效了。

第三,几乎从一开始,这个系统就能够维护自己,而且也确实是在维护自己。这一点的重要性比表面看起来更大。如果系统设计者被迫使用他们自己设计的系统,那么他们就会很快意识到它在功能上和表面体验上的缺陷,并且在为时未晚之前强烈地想去纠正它们。由于所有源程序始终在线可得且易于修改,我们愿意在新的想法被发明、发现或由他人提出之后,修订并重写系统及其软件。

本文讨论的 UNIX 各个方面,至少清楚地展现了上述前两项设计考虑。以文件系统接口为例:从编程角度看,它极其方便。最低层次的接口被设计成消除不同设备与文件之间、以及直接访问(direct access)与顺序访问之间的区别。程序员无需依赖大型的“access method”例程来把自己与系统调用隔开;实际上,所有用户程序要么直接调用系统,要么使用一个很小的库程序,这个库程序只有几十条指令长,用来缓冲若干字符并一次性把它们全部读出或写入。

另一个关于编程便利性的重要方面是,系统中不存在那种结构复杂、部分由文件系统或其他系统调用维护、又反过来被其依赖的“control block”。一般来说,程序地址空间中的内容属于程序自身,我们一直努力避免对该地址空间内的数据结构施加限制。考虑到所有程序都应当能以任意文件或设备作为输入输出,从空间效率角度看,最好是把设备相关的考虑推入操作系统本身。否则,看起来只剩两种选择:要么把处理每种设备的例程都装入所有程序中,这会浪费空间;要么依赖某种动态链接机制,在真正需要时把针对每种设备的例程链接进来,而这又会在开销或硬件上付出代价。

同样,进程控制方案和命令接口也都证明了自己既方便又高效。由于 Shell 作为一个普通的、可换出的用户程序运行,它并不占用系统本体中那种必须常驻的空间,而且它可以在很小代价下变得尽可能强大。特别是,在 Shell 作为一个进程执行、并派生其他进程去完成命令的这一框架下,I/O 重定向、后台进程、命令文件,以及用户可选择的系统接口,这些概念都变得几乎不费吹灰之力就能实现。

8.1 影响

UNIX 的成功,与其说在于提出了新的发明,不如说在于充分利用了一组精心挑选且富有生殖力的思想,尤其在于证明了它们可以成为实现一个小巧而强大的操作系统的关键。fork 操作,以我们实现它的这种基本形式,早已出现在 Berkeley 的分时系统中 \[8\]。在若干方面,我们受到了 Multics 的影响:它启发了 I/O 系统调用的具体形式 \[9\],也启发了 Shell 这个名字及其总体职能。Shell 应当为每条命令创建一个进程这一想法,也来自 Multics 的早期设计,尽管该系统后来因效率原因放弃了这种做法。TENEX \[10\] 也使用了类似方案。

9 统计

下面给出的 UNIX 统计数据,一方面是为了展示这个系统的规模,另一方面也是为了说明如此规模的系统是如何被使用的。那些不参与文档准备的用户,往往主要把系统用于程序开发,特别是语言相关工作。系统中几乎没有什么重要的“应用”程序。

9.1 总体

指标数值
用户总数72
最大同时在线用户数14
目录数300
文件数4400
已使用的 512 字节辅存块数34000

9.2 每日(按每周 7 天、每天 24 小时计)

系统中有一个“后台”进程,以最低优先级运行;它用来消耗任何空闲的 CPU 时间。它曾被用于计算常数 e - 2 的百万位近似值,现在则在生成合成伪素数(以 2 为底)。

指标数值
命令数1800
CPU 小时数(不含后台)4.3
连接小时数70
不同用户数30
登录次数75

9.3 命令 CPU 使用情况(截断到 1%)

占比命令占比命令
15.7%C 编译器1.7%Fortran 编译器
15.2%用户程序1.6%删除文件
11.7%编辑器1.6%磁带归档
5.8%Shell(作为命令使用,含命令时间)1.6%文件系统一致性检查
5.3%chess1.4%库维护程序
3.3%列目录1.3%拼接/打印文件
3.1%文档格式化程序1.3%分页并打印文件
1.8%汇编器1.1%打印磁盘使用量
1.6%备份转储程序1.0%复制文件

9.4 命令访问次数(截断到 1%)

占比命令占比命令
15.3%编辑器1.6%调试器
9.6%列目录1.6%Shell(作为命令使用)
6.3%删除文件1.5%打印磁盘可用量
6.3%C 编译器1.4%列出正在执行的进程
6.0%拼接/打印文件1.4%汇编器
6.0%用户程序1.4%打印参数
3.3%列出已登录用户1.2%复制文件系统
3.2%重命名/移动文件1.1%分页并打印文件
3.1%文件状态1.1%打印当前日期/时间
1.8%库维护程序1.1%文件系统一致性检查
1.8%文档格式化程序1.0%磁带归档
1.6%有条件地执行另一条命令

9.5 可靠性

我们关于可靠性的统计比前面的那些要主观得多。以下结果在我们两人的共同记忆中,尽可能是真实的。所覆盖的时间跨度超过一年,而机器是一台很早期的 11/45。

曾经发生过一次文件系统丢失(五块磁盘中的一块),原因是软件无法应对一个硬件问题,该问题会反复触发电源故障陷阱。那块磁盘上的文件在三天前做过备份。

“崩溃”是指一次非计划的系统重启或停机。大约每隔一天就会发生一次崩溃;其中大约三分之二是由硬件相关问题造成的,例如电源波动和莫名其妙跳转到随机位置的处理器中断。其余则是软件故障。最长的一次连续正常运行时间大约是两周。服务调用平均每三周一次,但分布高度集中。总可用时间大约占我们 24 小时、365 天运行计划的 98%。

致谢

我们感谢 R.H. Canaday、L.L. Cherry 和 L.E. McMahon 对 UNIX 所做的贡献。我们尤其感激 R. Morris、M.D. McIlroy 和 J.F. Ossanna 的创造力、深思熟虑的批评以及持续不断的支持。

References

  1. Digital Equipment Corporation. PDP-11/40 Processor Handbook, 1972, and PDP-11/45 Processor Handbook. 1971.
  2. Deutsch, L.P., and Lampson, B.W. An online editor. Comm. ACM 10, 12 (Dec, 1967) 793-799, 803.
  3. Richards, M. BCPL: A tool for compiler writing and system programming. Proc. AFIPS 1969 SJCC, Vol. 34, AFIPS Press, Montvale, N.J., pp. 557-566.
  4. McClure, R.M. TMG—A syntax directed compiler. Proc. ACM 20th Nat. Conf., ACM, 1965, New York, pp. 262-274.
  5. Hall. A.D. The M6 macroprocessor. Computing Science Tech. Rep. #2, Bell Telephone Laboratories, 1969.
  6. Ritchie, D.M. C reference manual. Unpublished memorandum, Bell Telephone Laboratories, 1973.
  7. Aleph-null. Computer Recreations. Software Practice and Experience 1, 2 (Apr.-June 1971), 201-204.
  8. Deutsch, L.P., and Lampson, B.W. SDS 930 time-sharing system preliminary reference manual. Doc. 30.10.10, Project GENIE, U of California at Berkeley, Apr. 1965.
  9. Feiertag. R.J., and Organick, E.I. The Multics input-output system. Proc. Third Symp. on Oper. Syst. Princ., Oct. 18-20, 1971, ACM, New York, pp. 35-41.
  10. Bobrow, D.C., Burchfiel, J.D., Murphy, D.L., and Tomlinson, R.S. TENEX, a paged time sharing system for the PDP-10. Comm. ACM 15, 3 (Mar. 1972) 135-143.

The UNIX Time-Sharing System

1974 · Dennis M. Ritchie and Ken Thompson

lucida 翻译

Tags: 操作系统, UNIX, 经典重读